mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
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]>
184 lines
8.3 KiB
Python
184 lines
8.3 KiB
Python
import hmac
|
|
import logging
|
|
|
|
from django.conf import settings
|
|
|
|
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
|
from rest_framework import serializers, status, viewsets
|
|
from rest_framework.decorators import action
|
|
from rest_framework.request import Request
|
|
from rest_framework.response import Response
|
|
|
|
from insights.models.integration import Integration
|
|
from insights.models.organization import OrganizationMembership
|
|
from insights.models.team import Team
|
|
|
|
from products.review.backend.temporal.client import start_review_pr_workflow
|
|
from products.review.backend.temporal.types import TRIGGER_LABEL
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# v1 scope: Review only runs against the main Insights monorepo. Matched case-insensitively.
|
|
ALLOWED_REPOS = {"insights/insights"}
|
|
|
|
|
|
class ReviewTriggerRequestSerializer(serializers.Serializer):
|
|
repo = serializers.CharField(
|
|
help_text="GitHub repository to review, in 'owner/name' form; must be on the allowlist (e.g. 'Insights/insights').",
|
|
)
|
|
pr_number = serializers.IntegerField(
|
|
min_value=1,
|
|
help_text="Pull request number to review.",
|
|
)
|
|
publish = serializers.BooleanField(
|
|
required=False,
|
|
default=True,
|
|
help_text="Whether to post the review back to the PR. Defaults true (the label trigger publishes).",
|
|
)
|
|
|
|
|
|
class ReviewTriggerResponseSerializer(serializers.Serializer):
|
|
workflow_id = serializers.CharField(help_text="Temporal workflow id for the started review run.")
|
|
status = serializers.CharField(help_text="Run lifecycle marker; 'started' when the review was queued.")
|
|
|
|
|
|
class ReviewTriggerErrorSerializer(serializers.Serializer):
|
|
error = serializers.CharField(help_text="Human-readable explanation of why the trigger was rejected.")
|
|
|
|
|
|
def _bearer_token(request: Request) -> str:
|
|
"""Extract the bearer token from the Authorization header (accepts a bare token too)."""
|
|
auth = request.headers.get("Authorization", "")
|
|
if auth.startswith("Bearer "):
|
|
return auth[len("Bearer ") :].strip()
|
|
return auth.strip()
|
|
|
|
|
|
def _resolve_run_user_id(team_id: int) -> int | None:
|
|
"""The user the sandbox tasks run as: the GitHub integration creator if still an active org
|
|
member, else the oldest active org member (same semantics as signals' resolve_user_id_for_team).
|
|
|
|
A disabled run user is worse than none: every user-scoped sandbox credential 403s and the agent
|
|
hangs silently until the poll budget expires, so never return an inactive user here.
|
|
"""
|
|
team = Team.objects.select_related("organization").get(id=team_id)
|
|
integration = Integration.objects.filter(team_id=team_id, kind="github").order_by("id").first()
|
|
if integration is not None and integration.created_by_id:
|
|
creator_is_active = OrganizationMembership.objects.filter(
|
|
organization=team.organization,
|
|
user_id=integration.created_by_id,
|
|
user__is_active=True,
|
|
).exists()
|
|
if creator_is_active:
|
|
return integration.created_by_id
|
|
logger.warning(
|
|
"Review run-user fallback: integration creator %s is not an active org member",
|
|
integration.created_by_id,
|
|
)
|
|
membership = (
|
|
OrganizationMembership.objects.select_related("user")
|
|
.filter(organization=team.organization, user__is_active=True)
|
|
.order_by("id")
|
|
.first()
|
|
)
|
|
return membership.user_id if membership else None
|
|
|
|
|
|
class ReviewTriggerViewSet(viewsets.ViewSet):
|
|
"""Shared-secret-gated trigger that starts a Review review for a PR.
|
|
|
|
Unscoped (no team in the URL): the team and run user are resolved server-side from settings, and CI
|
|
authenticates with the `REVIEWFN_TRIGGER_TOKEN` shared secret — no session or API key. The first
|
|
client is the `review` label GitHub Action, but the endpoint is the durable reusable interface.
|
|
"""
|
|
|
|
authentication_classes = ()
|
|
permission_classes = ()
|
|
|
|
def _authenticate(self, request: Request) -> Response | None:
|
|
"""Return an error Response if the shared-secret check fails, else None."""
|
|
expected = settings.REVIEWFN_TRIGGER_TOKEN
|
|
if not expected:
|
|
# Fail closed in real deployments; allow locally so the endpoint is testable without a secret.
|
|
if settings.DEBUG or settings.TEST:
|
|
return None
|
|
return Response(
|
|
{"error": "Review trigger token is not configured"},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
if not hmac.compare_digest(_bearer_token(request), expected):
|
|
return Response({"error": "Invalid trigger token"}, status=status.HTTP_403_FORBIDDEN)
|
|
return None
|
|
|
|
@extend_schema(
|
|
request=ReviewTriggerRequestSerializer,
|
|
responses={
|
|
202: OpenApiResponse(response=ReviewTriggerResponseSerializer, description="Review run started"),
|
|
400: OpenApiResponse(
|
|
response=ReviewTriggerErrorSerializer, description="Invalid body or unresolved run user"
|
|
),
|
|
403: OpenApiResponse(
|
|
response=ReviewTriggerErrorSerializer, description="Missing/invalid token or disallowed repo"
|
|
),
|
|
503: OpenApiResponse(response=ReviewTriggerErrorSerializer, description="Trigger team not configured"),
|
|
},
|
|
summary="Trigger a Review PR review",
|
|
description=(
|
|
"Start a single-turn Review review for a pull request and (by default) publish it back to "
|
|
"the PR. Authenticated with the REVIEWFN_TRIGGER_TOKEN shared secret in the Authorization "
|
|
"header. Non-blocking: returns the Temporal workflow id immediately while the review runs in "
|
|
"the worker."
|
|
),
|
|
)
|
|
@action(detail=False, methods=["POST"], url_path="trigger")
|
|
def trigger(self, request: Request) -> Response:
|
|
auth_error = self._authenticate(request)
|
|
if auth_error is not None:
|
|
return auth_error
|
|
|
|
serializer = ReviewTriggerRequestSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
repo: str = serializer.validated_data["repo"]
|
|
pr_number: int = serializer.validated_data["pr_number"]
|
|
publish: bool = serializer.validated_data["publish"]
|
|
|
|
if repo.lower() not in ALLOWED_REPOS:
|
|
return Response({"error": f"Repository {repo} is not allowed"}, status=status.HTTP_403_FORBIDDEN)
|
|
|
|
team_id = settings.REVIEWFN_TEAM_ID
|
|
if not team_id:
|
|
return Response({"error": "Review team is not configured"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
|
|
|
user_id = settings.REVIEWFN_RUN_USER_ID or _resolve_run_user_id(team_id)
|
|
if not user_id:
|
|
return Response(
|
|
{"error": "No active run user found for the team (no GitHub integration creator or org member)"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
# Fail loud here rather than silently downstream: sandbox credentials are authorized against
|
|
# active org membership, so an unauthorized run user hangs the review until its poll budget expires.
|
|
run_user_is_authorized = OrganizationMembership.objects.filter(
|
|
organization__team__id=team_id,
|
|
user_id=user_id,
|
|
user__is_active=True,
|
|
).exists()
|
|
if not run_user_is_authorized:
|
|
# The Action echoes error bodies into public CI logs, so the id stays server-side.
|
|
logger.warning("Review trigger: run user %s is not an active member of the team's organization", user_id)
|
|
return Response(
|
|
{"error": "Configured run user is not an active member of the team's organization"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Forks are rejected server-side in the workflow's fetch activity (and by the Action gate); the
|
|
# endpoint stays free of GitHub I/O and returns immediately.
|
|
pr_url = f"https://github.com/{repo}/pull/{pr_number}"
|
|
workflow_id = start_review_pr_workflow(
|
|
pr_url=pr_url, team_id=team_id, user_id=user_id, publish=publish, trigger_source=TRIGGER_LABEL
|
|
)
|
|
logger.info(f"Review trigger started workflow {workflow_id} for {repo}#{pr_number} (publish={publish})")
|
|
return Response(
|
|
ReviewTriggerResponseSerializer({"workflow_id": workflow_id, "status": "started"}).data,
|
|
status=status.HTTP_202_ACCEPTED,
|
|
)
|