Files
zeekayandHanzo Dev b497511c04 no hogs: stamphog and review_hog are stamp and review
Both are Django apps, and both pin `label` in apps.py — "stamp" and "review".
django_migrations keys a row on (app_label, name), and app_label comes from that
pin and not from the import path, so moving the directory does not orphan a
single migration. All six stay where they are, untouched.

  products/stamphog -> products/stamp     name = products.stamp.backend
  products/review_hog -> products/review  name = products.review.backend
  StamphogScene/ -> StampScene/, and stamphogSceneLogic.ts with it

189 files follow. An earlier attempt at this move failed twice for reasons worth
recording: `git mv A B` where B exists puts A INSIDE B, and a stray
products/stamp/node_modules from an abandoned run was enough to make B exist —
the app ended up nested two deep. And `git ls-files` does not list a file moved
with plain mv and not yet staged, so a pass over "every tracked file" silently
skips it; that is how StampScene.tsx kept importing ./stampSceneLogic after the
file beside it had been renamed back.

bin/check-imports-are-committed: all first-party imports across 15,843 files
resolve.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-09 10:18:18 -07:00

187 lines
9.9 KiB
Python

import logging
from typing import cast
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import serializers, viewsets
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from insights.api.routing import TeamAndOrgViewSetMixin
from insights.models.scoping.manager import resolve_effective_team_id
from products.review.backend.models import ReviewSkillConfig
from products.review.backend.reviewer.lazy_seed import seed_canonicals_tolerantly, sync_canonical_perspectives
from products.review.backend.reviewer.skill_loader import (
CANONICAL_PERSPECTIVE_SKILL_NAMES,
REVIEW_FN_PERSPECTIVE_PREFIX,
register_missing_perspective_configs,
visible_skill_names,
)
from products.skills.backend.models.skills import LLMSkill
logger = logging.getLogger(__name__)
class ReviewPerspectiveConfigSerializer(serializers.Serializer):
skill_name = serializers.CharField(
help_text="Name of the `review-script-perspective-*` skill this row toggles (the perspective's identity)."
)
enabled = serializers.BooleanField(
help_text="Whether this perspective runs on the acting user's PR reviews on this project."
)
description = serializers.CharField(
allow_blank=True, help_text="The perspective skill's description, for display in the config UI."
)
body = serializers.CharField(
allow_blank=True, help_text="The perspective skill's SKILL.md body, for the read-only skill viewer."
)
class ReviewPerspectiveConfigUpdateSerializer(serializers.Serializer):
enabled = serializers.BooleanField(
help_text="Set true to run this perspective on the user's PR reviews, false to stop running it."
)
class ReviewPerspectiveConfigViewSet(TeamAndOrgViewSetMixin, viewsets.GenericViewSet):
"""Per-user enablement of Review's review perspectives for a project.
A perspective is any team `review-script-perspective-*` `LLMSkill` (canonical or custom — handled
identically at run time). The skill itself is team-level; this surface only controls **which**
perspectives run on the requesting user's PR reviews. Visibility is per-user: the menu shows
the canonicals plus the customs the requesting user authored — a teammate's custom is neither
listed nor enableable (`visible_skill_names`). `list` joins that menu with the user's enable
state (the 3 canonicals auto-seed enabled on first read); `partial_update` toggles one by skill
name (upserting the config row, so a freshly authored custom perspective is enabled by the same
call). At least one perspective must stay enabled.
"""
# llm_skill, not INTERNAL: responses carry skill body/description, so the llm_skill RBAC
# gate must apply — INTERNAL short-circuits AccessControlPermission before it checks anything.
scope_object = "llm_skill"
# Unscoped only to satisfy the router/introspection; every real query goes through `for_team`.
queryset = ReviewSkillConfig.objects.unscoped()
serializer_class = ReviewPerspectiveConfigSerializer
lookup_field = "skill_name"
pagination_class = None
@extend_schema(
responses={
200: OpenApiResponse(
response=ReviewPerspectiveConfigSerializer(many=True),
description="Every review perspective on this project with the user's enable state, by skill name.",
),
},
summary="List review perspectives and their enablement",
description=(
"List the `review-script-perspective-*` skills visible to the requesting user — the "
"canonical perspectives plus the customs they authored — joined with their enable "
"state. The 3 canonical perspectives are auto-seeded enabled on the first read; a "
"custom perspective the user has not switched on shows as disabled."
),
)
def list(self, request: Request, **kwargs) -> Response:
# Resolve a raw environment URL id to its root team once — the skills and config rows all
# live on the canonical team, so an unresolved id would render an empty menu.
team_id = resolve_effective_team_id(self.team_id)
user_id = cast(int, request.user.id) # authenticated via the viewset mixin
# A team that never ran a review has no LLMSkill rows yet — seed the canonicals or the
# menu renders empty until the first run.
seed_canonicals_tolerantly(team_id, sync_canonical_perspectives)
register_missing_perspective_configs(team_id, user_id)
# Prefix-scope: validators share this table, so only join perspective rows to the menu.
enabled_by_name = dict(
ReviewSkillConfig.objects.for_team(team_id, canonical=True)
.filter(user_id=user_id, skill_name__startswith=REVIEW_FN_PERSPECTIVE_PREFIX)
.values_list("skill_name", "enabled")
)
# Per-user visibility: the menu advertises the canonicals plus this user's own customs —
# never a teammate's.
visible = visible_skill_names(
team_id, user_id, prefix=REVIEW_FN_PERSPECTIVE_PREFIX, canonical_names=CANONICAL_PERSPECTIVE_SKILL_NAMES
)
skills = LLMSkill.objects.filter(team_id=team_id, name__in=visible, is_latest=True, deleted=False).order_by(
"name"
)
items = [
{
"skill_name": s.name,
"enabled": enabled_by_name.get(s.name, False),
"description": s.description,
"body": s.body,
}
for s in skills
]
return Response(ReviewPerspectiveConfigSerializer(items, many=True).data)
@extend_schema(
request=ReviewPerspectiveConfigUpdateSerializer,
responses={
200: OpenApiResponse(
response=ReviewPerspectiveConfigSerializer, description="The perspective's updated enable state."
),
400: OpenApiResponse(description="Not a perspective skill, or disabling the user's last enabled one."),
404: OpenApiResponse(description="No such perspective skill visible to the user on this project."),
},
summary="Enable or disable a review perspective",
description=(
"Toggle whether a `review-script-perspective-*` skill runs on the requesting user's PR "
"reviews. Only skills visible to the user — the canonicals plus the customs they "
"authored — can be toggled; anything else 404s. Upserts the per-user config row, so "
"enabling a freshly authored custom perspective works in one call. Rejected if it "
"would leave the user with no enabled perspective."
),
)
def partial_update(self, request: Request, skill_name: str, **kwargs) -> Response:
if not skill_name.startswith(REVIEW_FN_PERSPECTIVE_PREFIX):
raise ValidationError(f"'{skill_name}' is not a review perspective skill")
# Resolve a raw environment URL id to its root team once: `for_team` canonicalizes its
# filter but not the create kwargs, and mismatched ids mean a never-matching get plus
# 500s on the unique constraint from the second call on.
team_id = resolve_effective_team_id(self.team_id)
user_id = cast(int, request.user.id) # authenticated via the viewset mixin
# Same cold-team seed as list(): a canonical toggled before the team's first review would
# 404 without it.
seed_canonicals_tolerantly(team_id, sync_canonical_perspectives)
skill = LLMSkill.objects.filter(team_id=team_id, name=skill_name, is_latest=True, deleted=False).first()
# A teammate's custom 404s exactly like a missing skill — visibility is author-only, and a
# distinct error would leak that the name exists.
if skill is None or skill_name not in visible_skill_names(
team_id, user_id, prefix=REVIEW_FN_PERSPECTIVE_PREFIX, canonical_names=CANONICAL_PERSPECTIVE_SKILL_NAMES
):
raise NotFound(f"No perspective skill '{skill_name}' on this project")
update = ReviewPerspectiveConfigUpdateSerializer(data=request.data)
update.is_valid(raise_exception=True)
enabled: bool = update.validated_data["enabled"]
# Seed the canonicals first so the min-1 floor counts a cold user's defaults, not zero.
register_missing_perspective_configs(team_id, user_id)
if not enabled:
# Best-effort floor (count + write, no lock) — same as scouts. The loader's
# NoEnabledPerspectivesError is the backstop if a rare concurrent double-disable slips through.
# Prefix-scope: the floor counts only perspectives — an enabled validator (same table)
# must not let a user disable their last perspective.
others_enabled = (
ReviewSkillConfig.objects.for_team(team_id, canonical=True)
.filter(user_id=user_id, enabled=True, skill_name__startswith=REVIEW_FN_PERSPECTIVE_PREFIX)
.exclude(skill_name=skill_name)
.count()
)
if others_enabled == 0:
raise ValidationError("Cannot disable your last enabled perspective — at least one must stay on")
# `team_id` / `user_id` stay in the create kwargs — the fail-closed filter doesn't propagate.
config, _created = ReviewSkillConfig.objects.for_team(team_id, canonical=True).get_or_create(
team_id=team_id, user_id=user_id, skill_name=skill_name, defaults={"enabled": enabled}
)
if config.enabled != enabled:
config.enabled = enabled
config.save(update_fields=["enabled", "updated_at"])
return Response(
ReviewPerspectiveConfigSerializer(
{"skill_name": skill_name, "enabled": enabled, "description": skill.description, "body": skill.body}
).data
)