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]>
387 lines
23 KiB
Python
387 lines
23 KiB
Python
from django.db import models
|
|
|
|
from insights.models.scoping.root_mixin import TeamScopedRootMixin
|
|
from insights.models.utils import UUIDModel
|
|
|
|
from products.review.backend.reviewer.artefact_content import (
|
|
ArtefactContentValidationError,
|
|
FindingOutcomeArtefact,
|
|
ReviewArtefactContent,
|
|
ReviewIssueFinding,
|
|
ReviewLogArtefactContent,
|
|
ReviewWorkingStateContent,
|
|
TaskRunArtefact,
|
|
ValidationVerdict,
|
|
artefact_type_for,
|
|
)
|
|
from products.signals.backend.artefact_attribution import ArtefactAttribution
|
|
|
|
|
|
class ReviewReport(UUIDModel, TeamScopedRootMixin):
|
|
"""The living per-target review document.
|
|
|
|
One row per `(team, repository, pr_number)` — or per `(team, repository, head_branch)` for a
|
|
PR-less branch target (`pr_number` NULL), which upgrades in place once its PR exists. Review
|
|
is loop-y — after the first pass it re-checks the target for new commits/comments and takes
|
|
another turn — so the report is updated in place across turns and the watermark records what the
|
|
latest turn already reviewed.
|
|
"""
|
|
|
|
class Status(models.TextChoices):
|
|
ACTIVE = "active"
|
|
IDLE = "idle"
|
|
CLOSED = "closed"
|
|
|
|
# db_constraint=False keeps the migration lock-free on hot insights_team.
|
|
team = models.ForeignKey("insights.Team", on_delete=models.CASCADE, related_name="+", db_constraint=False)
|
|
repository = models.CharField(max_length=255) # owner/repo
|
|
# NULL = a branch target with no PR yet; backfilled by the fetch stage once a PR opens.
|
|
pr_number = models.IntegerField(null=True, blank=True)
|
|
pr_url = models.TextField(blank=True)
|
|
head_branch = models.CharField(max_length=255)
|
|
base_branch = models.CharField(max_length=255)
|
|
# Whose configuration drives this report's reviews (the PR author on the label path) — stamped at
|
|
# acting-user resolve on every run, so it works for future non-PR triggers too. Powers "your
|
|
# recent reviews"; db_constraint=False keeps the migration lock-free on hot insights_user.
|
|
acting_user = models.ForeignKey(
|
|
"insights.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="+", db_constraint=False
|
|
)
|
|
# The PR author's GitHub login, refreshed from the fetched metadata every turn. Powers the
|
|
# "For you" scope's authored-PRs match (compared case-insensitively against the viewer's linked
|
|
# GitHub login) — a teammate-triggered review of your PR lands under THEIR acting_user, so
|
|
# without this the findings would be invisible to you. Null until a post-column turn stamps it.
|
|
author_login = models.CharField(max_length=255, null=True, blank=True)
|
|
status = models.CharField(max_length=20, choices=Status, default=Status.ACTIVE)
|
|
run_count = models.IntegerField(default=0)
|
|
last_run_at = models.DateTimeField(null=True, blank=True)
|
|
# Watermark — what the latest turn already reviewed, so a re-run knows what changed.
|
|
head_sha = models.CharField(max_length=64, null=True, blank=True)
|
|
# The head the latest COMPLETED turn reviewed — stamped at finalize, while `head_sha` advances at
|
|
# turn START. Read paths pairing stats/links with the completed turn's findings anchor here, so an
|
|
# in-flight or crashed turn's metadata never splices onto the previous turn's findings.
|
|
completed_head_sha = models.CharField(max_length=64, null=True, blank=True)
|
|
# The urgency threshold the last COMPLETED turn's body/publish gated on — stamped at finalize
|
|
# (alongside `run_count` / `completed_head_sha`) from the same resolve snapshot both consumed,
|
|
# so the detail view buckets published vs held-back findings truthfully even when the acting
|
|
# user's settings later change. Null for pre-column turns (readers fall back to the viewer's
|
|
# own setting as an approximation).
|
|
run_urgency_threshold = models.CharField(max_length=20, null=True, blank=True)
|
|
# Idempotency watermark — the head the review was last *published* to GitHub for (distinct from
|
|
# `head_sha`, what was reviewed). Publishing skips when this equals the current head, so an
|
|
# activity retry / re-trigger can't double-post the review or the one-time alpha promo comment.
|
|
published_head_sha = models.CharField(max_length=64, null=True, blank=True)
|
|
# The urgency threshold in force at each publish, keyed by the `run_index` that published, as
|
|
# `{"1": "consider", "2": "must_fix"}`. Outcome classification reconstructs the published finding
|
|
# set from this, since the user's live setting can change after publish. Keyed per turn rather
|
|
# than held as one scalar because a report republishes as new commits land and the acting user
|
|
# may change their threshold in between: each turn posts only its own findings, so a later
|
|
# tightened threshold must not retroactively hide what an earlier, looser turn already posted.
|
|
published_urgency_thresholds = models.JSONField(null=True, blank=True)
|
|
# The head each turn published at, keyed by `run_index` like the thresholds above, as
|
|
# `{"1": "abc123", "2": "def456"}`. Outcome classification compares a finding against the head its
|
|
# own turn was published at: a fix can land between two turns, and comparing every finding against
|
|
# only the latest published head hides those commits entirely, marking a finding that was fixed as
|
|
# ignored. Kept beside the thresholds rather than merged into one map because that one already
|
|
# shipped; both are written together at publish.
|
|
published_head_shas = models.JSONField(null=True, blank=True)
|
|
# Set once this report's finding-outcome events have been flushed to capture — the outcome
|
|
# sweep's completion marker. Distinct from the `finding_outcome` artefacts (the decided
|
|
# classification, persisted first): a crash between persist and flush leaves this NULL, so the
|
|
# next sweep re-emits from the stored artefacts instead of re-deciding the outcomes.
|
|
outcomes_emitted_at = models.DateTimeField(null=True, blank=True)
|
|
last_seen_comment_id = models.BigIntegerField(null=True, blank=True)
|
|
# The PR's live "review in progress" status comment (publish path only): the GitHub comment id we
|
|
# edit in place, and the last-edit watermark that debounces refreshes across concurrent activities.
|
|
status_comment_id = models.BigIntegerField(null=True, blank=True)
|
|
status_comment_edited_at = models.DateTimeField(null=True, blank=True)
|
|
report_markdown = models.TextField(default="", blank=True)
|
|
# Provenance for inbox-triggered reviews: the signals report whose implementation this review
|
|
# targets. A plain UUID, not an FK — the link must survive report deletion/reingestion on the
|
|
# signals side (the signals-side `code_review` artefact is API-deletable; this row is the durable
|
|
# link). Filled once, never overwritten by later turns from another trigger.
|
|
signal_report_id = models.UUIDField(null=True, blank=True)
|
|
# Which trigger created this report ("label" / "inbox" / "manual" / "ui"); stamped on create only.
|
|
trigger_source = models.CharField(max_length=20, default="manual")
|
|
# Reviewer-model experiment assignment, drawn once at creation and sticky for the report's life
|
|
# (see `REVIEW_EXPERIMENT_ARMS`). Adapter/model/effort/permission-mode persist as one bundle
|
|
# because a model without its adapter can't be routed. NULL on pre-experiment rows; reads
|
|
# resolve through `resolve_review_arm`, which falls back to the REVIEW_* pins.
|
|
review_runtime_adapter = models.CharField(max_length=32, null=True, blank=True)
|
|
review_model = models.CharField(max_length=128, null=True, blank=True)
|
|
review_reasoning_effort = models.CharField(max_length=32, null=True, blank=True)
|
|
review_initial_permission_mode = models.CharField(max_length=32, null=True, blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["team", "repository", "pr_number"],
|
|
name="unique_review_report_per_pr",
|
|
condition=models.Q(pr_number__isnull=False),
|
|
),
|
|
models.UniqueConstraint(
|
|
fields=["team", "repository", "head_branch"],
|
|
name="unique_review_report_per_branch",
|
|
condition=models.Q(pr_number__isnull=True),
|
|
),
|
|
]
|
|
indexes = [
|
|
models.Index(fields=["team", "status"], name="review_rpt_team_status_idx"),
|
|
models.Index(fields=["signal_report_id"], name="review_rpt_signal_rpt_idx"),
|
|
# Serves the "recent reviews" API: one user's reports, newest completed turn first.
|
|
models.Index(fields=["team", "acting_user", "-last_run_at"], name="review_rpt_recent_idx"),
|
|
# Same API's everyone scope: the whole project's reports, newest completed turn first.
|
|
models.Index(fields=["team", "-last_run_at"], name="review_rpt_team_recent_idx"),
|
|
# The outcome sweep's backlog: published, PR-bound, not yet stamped emitted. The whole
|
|
# predicate lives in the condition, so the index holds only the working set and shrinks
|
|
# as reports are classified; `team` leads it because the cross-team discovery reads
|
|
# DISTINCT team_id off this index and the per-team read filters on team first.
|
|
# `-updated_at` trails it so the per-team read's newest-first slice is an index scan
|
|
# rather than a sort over every pending row.
|
|
models.Index(
|
|
fields=["team", "-updated_at"],
|
|
name="review_rpt_unclassified_idx",
|
|
condition=models.Q(
|
|
published_head_sha__isnull=False, pr_number__isnull=False, outcomes_emitted_at__isnull=True
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
class ReviewReportArtefact(UUIDModel, TeamScopedRootMixin):
|
|
"""Append-only work log for a `ReviewReport`.
|
|
|
|
Mirrors Signals' `SignalReportArtefact` funnel — the row's type is derived from the content
|
|
model's class and attribution maps to `created_by` / `task` columns — but owns its own types
|
|
and has no auto-start side effects.
|
|
"""
|
|
|
|
class ArtefactType(models.TextChoices):
|
|
ISSUE_FINDING = "issue_finding"
|
|
VALIDATION_VERDICT = "validation_verdict"
|
|
# The classified fate of a published finding, written by the outcome-telemetry batch after
|
|
# the PR merged (one per finding); its presence marks the finding already classified.
|
|
FINDING_OUTCOME = "finding_outcome"
|
|
TASK_RUN = "task_run"
|
|
COMMIT = "commit"
|
|
CODE_REFERENCE = "code_reference"
|
|
NOTE = "note"
|
|
# Per-turn pipeline working state, read back by the DB-driven resume (head_sha-scoped).
|
|
CHUNK_SET = "chunk_set"
|
|
PERSPECTIVE_SELECTION = "perspective_selection"
|
|
PERSPECTIVE_RESULT = "perspective_result"
|
|
# The turn's fetched PR inputs, stored by reference so stage activities reload them from the
|
|
# DB instead of crossing the Temporal workflow boundary with the big pr_files payload.
|
|
PR_SNAPSHOT = "pr_snapshot"
|
|
|
|
# Log types accumulate (each call is a new row). Findings and verdicts also append, but their
|
|
# identity is `issue_key` — latest row per key wins at read time — so they get dedicated
|
|
# appenders rather than going through `add_log`.
|
|
LOG_ARTEFACT_TYPES: frozenset[str] = frozenset(
|
|
{ArtefactType.TASK_RUN, ArtefactType.COMMIT, ArtefactType.CODE_REFERENCE, ArtefactType.NOTE}
|
|
)
|
|
# Working-state types accumulate per turn; the resume reads the latest per (head_sha, key).
|
|
WORKING_STATE_ARTEFACT_TYPES: frozenset[str] = frozenset(
|
|
{
|
|
ArtefactType.CHUNK_SET,
|
|
ArtefactType.PERSPECTIVE_SELECTION,
|
|
ArtefactType.PERSPECTIVE_RESULT,
|
|
ArtefactType.PR_SNAPSHOT,
|
|
}
|
|
)
|
|
|
|
# db_constraint=False keeps the migration lock-free on hot insights_team.
|
|
team = models.ForeignKey("insights.Team", on_delete=models.CASCADE, related_name="+", db_constraint=False)
|
|
report = models.ForeignKey(ReviewReport, on_delete=models.CASCADE, related_name="artefacts")
|
|
type = models.CharField(max_length=100, choices=ArtefactType)
|
|
content = models.TextField()
|
|
# Turn scope, denormalized from content.head_sha so resume loaders can filter in SQL instead of
|
|
# parsing every historical row. Null when the content model carries no head_sha.
|
|
head_sha = models.CharField(max_length=64, null=True, blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True, null=True)
|
|
# Attribution: exactly one of (created_by, task) is set on new rows, enforced at the write
|
|
# helpers via `ArtefactAttribution`. SET_NULL so deleting a user/task degrades attribution to
|
|
# "system/unknown" rather than destroying the report's work log. db_constraint=False keeps the
|
|
# migration lock-free on hot insights_user.
|
|
created_by = models.ForeignKey(
|
|
"insights.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="+", db_constraint=False
|
|
)
|
|
task = models.ForeignKey("tasks.Task", on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
|
|
|
|
class Meta:
|
|
indexes = [
|
|
models.Index(fields=["report"], name="review_art_report_idx"),
|
|
models.Index(fields=["report", "type"], name="review_art_rpt_type_idx"),
|
|
# Latest-wins seeks: WHERE report=? AND type=? ORDER BY created_at DESC.
|
|
models.Index(fields=["report", "type", "-created_at"], name="review_art_rpt_type_ct_idx"),
|
|
]
|
|
|
|
@classmethod
|
|
def _create(
|
|
cls,
|
|
*,
|
|
team_id: int,
|
|
report_id: str,
|
|
content: ReviewArtefactContent,
|
|
attribution: ArtefactAttribution,
|
|
) -> "ReviewReportArtefact":
|
|
"""Single write funnel: derive the row's type from the content model's class, map
|
|
attribution to columns, and insert.
|
|
|
|
Goes through `for_team` so it works outside request context (the cloud/Temporal
|
|
orchestrator has no ambient team scope, and the fail-closed manager would otherwise raise).
|
|
"""
|
|
# A task_run's content.task_id is the same association as the row's `task` FK — they must
|
|
# not diverge. The FK comes from attribution, so require task attribution that matches.
|
|
if isinstance(content, TaskRunArtefact) and content.task_id != attribution.task_id:
|
|
raise ArtefactContentValidationError("task_run content.task_id must match the artefact's attributed task")
|
|
return cls.objects.for_team(team_id).create(
|
|
team_id=team_id,
|
|
report_id=report_id,
|
|
type=artefact_type_for(content),
|
|
content=content.model_dump_json(),
|
|
head_sha=getattr(content, "head_sha", None),
|
|
created_by_id=attribution.user_id,
|
|
task_id=attribution.task_id,
|
|
)
|
|
|
|
@classmethod
|
|
def append_finding(
|
|
cls, *, team_id: int, report_id: str, content: ReviewIssueFinding, attribution: ArtefactAttribution
|
|
) -> "ReviewReportArtefact":
|
|
"""Append an `issue_finding` (latest row per `issue_key` wins at read time)."""
|
|
return cls._create(team_id=team_id, report_id=report_id, content=content, attribution=attribution)
|
|
|
|
@classmethod
|
|
def append_verdict(
|
|
cls, *, team_id: int, report_id: str, content: ValidationVerdict, attribution: ArtefactAttribution
|
|
) -> "ReviewReportArtefact":
|
|
"""Append a `validation_verdict` (latest verdict per `issue_key` wins at read time)."""
|
|
return cls._create(team_id=team_id, report_id=report_id, content=content, attribution=attribution)
|
|
|
|
@classmethod
|
|
def add_finding_outcome(
|
|
cls, *, team_id: int, report_id: str, content: FindingOutcomeArtefact, attribution: ArtefactAttribution
|
|
) -> "ReviewReportArtefact":
|
|
"""Append a `finding_outcome` (one per published finding; the durable classification record).
|
|
|
|
Presence means the finding's outcome is decided and must never be re-decided; the report is
|
|
only *done* once `ReviewReport.outcomes_emitted_at` is stamped after its events flushed.
|
|
"""
|
|
return cls._create(team_id=team_id, report_id=report_id, content=content, attribution=attribution)
|
|
|
|
@classmethod
|
|
def add_log(
|
|
cls, *, team_id: int, report_id: str, content: ReviewLogArtefactContent, attribution: ArtefactAttribution
|
|
) -> "ReviewReportArtefact":
|
|
"""Append a work-log entry (`task_run` / `commit` / `code_reference` / `note`); these accumulate."""
|
|
if artefact_type_for(content) not in cls.LOG_ARTEFACT_TYPES:
|
|
raise ValueError(f"{type(content).__name__} is not a log artefact content model")
|
|
return cls._create(team_id=team_id, report_id=report_id, content=content, attribution=attribution)
|
|
|
|
@classmethod
|
|
def add_working_state(
|
|
cls, *, team_id: int, report_id: str, content: ReviewWorkingStateContent, attribution: ArtefactAttribution
|
|
) -> "ReviewReportArtefact":
|
|
"""Append per-turn pipeline working state (`chunk_set` / `perspective_result` / `pr_snapshot`).
|
|
|
|
These accumulate; the DB-driven resume reads the latest row per (head_sha, key), so a
|
|
resumed turn reuses completed sandbox work instead of re-running it.
|
|
"""
|
|
if artefact_type_for(content) not in cls.WORKING_STATE_ARTEFACT_TYPES:
|
|
raise ValueError(f"{type(content).__name__} is not a working-state artefact content model")
|
|
return cls._create(team_id=team_id, report_id=report_id, content=content, attribution=attribution)
|
|
|
|
|
|
class ReviewSkillConfig(UUIDModel, TeamScopedRootMixin):
|
|
"""Per-(team, user, review-skill) enablement for Review.
|
|
|
|
One generic table for which review skills run for a user, discriminated by the skill-name prefix:
|
|
`review-script-perspective-*` (the review perspectives — **multi-enable**, ≥1 must stay on), plus
|
|
`review-script-validation-*` (the validator) and `review-script-blind-spots-*` (the blind-spot check) —
|
|
both **single-active**: exactly one runs, the select endpoint flips the others off, falling back to
|
|
the canonical default when none is set. `enabled=True` means "active for this user" in every case;
|
|
the cardinality rules are enforced in app code, not the DB, the same way the perspective min-1
|
|
floor is.
|
|
|
|
A skill is any team `LLMSkill` carrying the prefix (canonical or custom — handled identically);
|
|
canonicals auto-seed on first resolve, customs are switched on via the config API. The skill itself
|
|
stays team-level; this row only gates whether it runs for this user's PRs. Mirrors Signals'
|
|
`SignalScoutConfig` enable/disable, minus the schedule — Review is PR-triggered, not on a clock.
|
|
`skill_name` is the identity (mirrors scouts keying on it, never `created_by`).
|
|
"""
|
|
|
|
# db_constraint=False keeps the migration lock-free on hot insights_team / insights_user.
|
|
team = models.ForeignKey("insights.Team", on_delete=models.CASCADE, related_name="+", db_constraint=False)
|
|
user = models.ForeignKey("insights.User", on_delete=models.CASCADE, related_name="+", db_constraint=False)
|
|
skill_name = models.CharField(max_length=200)
|
|
enabled = models.BooleanField(default=True, db_default=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=["team", "user", "skill_name"], name="uniq_review_skill_config_per_user"),
|
|
]
|
|
indexes = [
|
|
# The loaders seek WHERE team=? AND user=? AND enabled=true to resolve a run's skills.
|
|
models.Index(fields=["team", "user", "enabled"], name="review_skillcfg_lookup_idx"),
|
|
]
|
|
|
|
|
|
class ReviewUserSettings(UUIDModel, TeamScopedRootMixin):
|
|
"""Per-(team, user) Review settings: what gets reviewed and how strict publishing is.
|
|
|
|
One row per user per project, created with defaults on first read. `review_labeled_prs` is the
|
|
label trigger's opt-out — the workflow gates on the PR author's row (no row = the defaults).
|
|
`urgency_threshold` is the minimum priority a validated finding needs to be published; it snaps
|
|
to a run at acting-user resolution, so mid-run edits don't flip gates between body and publish.
|
|
`review_inbox_prs` is the inbox trigger's opt-in (default off — the budget gate for 100%-coverage
|
|
cost): checked cheaply at the TaskRun-completion receiver and re-checked off the resolve snapshot.
|
|
`stamp_review_inbox_prs` is the same opt-in for hosted Stamp (approve-first review with a
|
|
real GitHub approval) on those same inbox PRs. It is a cross-product preference kept here so both
|
|
toggles live on one row, and it only takes effect for teams with a synced, enabled
|
|
StampRepoConfig covering the PR's repository.
|
|
"""
|
|
|
|
class UrgencyThreshold(models.TextChoices):
|
|
# Values mirror `IssuePriority` so the threshold compares directly against finding priorities.
|
|
CONSIDER = "consider" # "All issues"
|
|
SHOULD_FIX = "should_fix"
|
|
MUST_FIX = "must_fix"
|
|
|
|
# FKs to the hot insights_team / insights_user tables use db_constraint=False so creating this
|
|
# table takes no lock on the parents (app-level enforcement only).
|
|
team = models.ForeignKey("insights.Team", on_delete=models.CASCADE, related_name="+", db_constraint=False)
|
|
user = models.ForeignKey("insights.User", on_delete=models.CASCADE, related_name="+", db_constraint=False)
|
|
review_inbox_prs = models.BooleanField(default=False, db_default=False)
|
|
stamp_review_inbox_prs = models.BooleanField(default=False, db_default=False)
|
|
review_labeled_prs = models.BooleanField(default=True, db_default=True)
|
|
urgency_threshold = models.CharField(
|
|
max_length=20,
|
|
choices=UrgencyThreshold.choices,
|
|
default=UrgencyThreshold.CONSIDER,
|
|
db_default=UrgencyThreshold.CONSIDER.value,
|
|
)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=["team", "user"], name="uniq_review_user_settings_per_user"),
|
|
]
|
|
|
|
@classmethod
|
|
def load(cls, team_id: int, user_id: int) -> "ReviewUserSettings":
|
|
"""The user's settings row, or an unsaved instance carrying the defaults when none exists."""
|
|
row = cls.objects.for_team(team_id).filter(user_id=user_id).first()
|
|
return row if row is not None else cls(team_id=team_id, user_id=user_id)
|
|
|
|
@classmethod
|
|
def load_many(cls, team_id: int, user_ids: list[int]) -> dict[int, "ReviewUserSettings"]:
|
|
"""`load` for several users in one query; users with no row get the same defaults."""
|
|
rows = {row.user_id: row for row in cls.objects.for_team(team_id).filter(user_id__in=user_ids)}
|
|
return {user_id: rows.get(user_id) or cls(team_id=team_id, user_id=user_id) for user_id in user_ids}
|