mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
.stamphog/ was a config directory at the repo root — policy.yml, review-guidance.md — read by the PR review agent. It is .stamp/ now, and the 34 files that name it follow: the workflows, tools/pr-approval-agent, tach.toml, the sandbox Dockerfile, bin/debrand. One behaviour change worth naming rather than burying: the agent finds its own previous PR comment by an HTML marker, `<!-- stamphog:v1 ... -->`, and that marker is now `<!-- stamp:v1 ... -->`. Open PRs will get one new comment instead of an edit, once, and then match again. insights/test/setup_receivers_baseline.txt records registered signal receivers by module path, so it names products.review.backend now. stamphog: zero occurrences anywhere in the tree. review_hog: only inside migration files, whose names are their identity in django_migrations. Co-authored-by: Hanzo Dev <[email protected]>
409 lines
18 KiB
Python
409 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = [
|
|
# "claude-agent-sdk==0.2.113",
|
|
# "anthropic==0.80.0",
|
|
# "hanzo_insights==7.20.4",
|
|
# "pyyaml==6.0.3",
|
|
# ]
|
|
# ///
|
|
# ruff: noqa: T201
|
|
"""Offline PR review entrypoint — the sandbox runs this instead of review_pr.py.
|
|
|
|
review_pr.py fetches everything over the network (`gh`, GraphQL, git) and posts
|
|
the verdict. This script runs the SAME engine (gates, tier classification,
|
|
git-blame familiarity, the LLM reviewer with the same prompt/version) against a
|
|
LOCAL checkout, with NO GitHub access and NO token. All GitHub-sourced data the
|
|
engine needs is handed in via a `--context` JSON file that the server (which
|
|
holds the token) assembles from the API; the only thing that flows back out is
|
|
the JSON on the last stdout line — the same `to_dict()` contract review_pr.py
|
|
emits with `--output-json`.
|
|
|
|
Reuse: it drives review_pr.Pipeline's own steps (classify, gates, the LLM
|
|
review, to_dict) so the engine logic stays byte-identical to the Action. Only
|
|
the two steps that touch the network are replaced with injected data:
|
|
- _fetch (gh) → a PRData built from the context.
|
|
- the two `gh` calls the Action makes inside gate/familiarity — the author-team
|
|
membership lookup (advisory ownership enrichment) and the author's merged-PR
|
|
set — are skipped / injected. Familiarity's blame math is mirrored here with
|
|
the injected PR set (see _familiarity_offline) because Pipeline._compute_familiarity
|
|
hardcodes the `gh` fetch and must not be modified (the Action stays additive-only).
|
|
|
|
Trusted policy (`.stamp/policy.yml`, `.stamp/review-guidance.md`) is read
|
|
by the engine from the checkout at import time; the server overwrites those paths
|
|
in the checkout with the default-branch versions before this runs, so a PR head
|
|
can't substitute its own gate. The reviewer key comes from the environment
|
|
(ANTHROPIC_API_KEY), same as before.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import time
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
from familiarity import (
|
|
AuthorFamiliarity,
|
|
_band,
|
|
_blame_overlap,
|
|
_files_previously_modified,
|
|
_merge_base,
|
|
_parse_diff,
|
|
_prior_prs_in_paths,
|
|
_read_diff,
|
|
_select_considered_files,
|
|
)
|
|
from gates import POLICY
|
|
from github import (
|
|
TRUSTED_REACTOR_BOTS,
|
|
PRData,
|
|
_git_diff_files,
|
|
_normalize_discussion_for_prompt,
|
|
_normalize_reviews_for_prompt,
|
|
_prompt_worthy_author,
|
|
_reaction_emoji,
|
|
is_bot_author,
|
|
)
|
|
from policy import FamiliarityPolicy
|
|
from review_pr import REPO_ROOT, GateResult, Pipeline, flush_analytics
|
|
from version import STAMPFN_VERSION
|
|
|
|
|
|
def _api_file_status(status: str) -> str:
|
|
"""Map a get_pr_files status string onto the single-letter code the engine expects.
|
|
|
|
github._git_diff_files uses git's --name-status letters (A/M/D/R/C); the
|
|
GitHub files API spells them out. Only used on the fallback path when the
|
|
local `git diff` produced nothing.
|
|
"""
|
|
return {
|
|
"added": "A",
|
|
"modified": "M",
|
|
"removed": "D",
|
|
"renamed": "R",
|
|
"copied": "C",
|
|
"changed": "M",
|
|
}.get(status, "M")
|
|
|
|
|
|
def _convert_api_file(f: dict) -> dict:
|
|
"""Convert a get_pr_files object into github._git_diff_files' file dict shape."""
|
|
additions = int(f.get("additions", 0) or 0)
|
|
deletions = int(f.get("deletions", 0) or 0)
|
|
# The files API omits an explicit binary flag; a changed file with no patch
|
|
# and no line counts is binary (or too large for GitHub to inline).
|
|
is_binary = f.get("patch") is None and additions == 0 and deletions == 0
|
|
return {
|
|
"filename": f.get("filename", ""),
|
|
"additions": additions,
|
|
"deletions": deletions,
|
|
"binary": is_binary,
|
|
"status": _api_file_status(str(f.get("status", "modified"))),
|
|
}
|
|
|
|
|
|
def _build_pr_data(context: dict) -> PRData:
|
|
"""Build the engine's PRData from the injected context.
|
|
|
|
File stats are recomputed locally with the exact function the Action uses
|
|
(`git diff --numstat` over base...head) so PRData.files is identical to a
|
|
real run; the context's file list is only a fallback if the local diff is
|
|
empty (e.g. a sha failed to fetch). Reviews, top-level discussion comments,
|
|
and head-commit check runs are carried in the context (reviews/discussion
|
|
normalized with the same helpers the Action uses, check runs passed through
|
|
raw as the Action does) — so the prerequisite gate blocks on an active
|
|
CHANGES_REQUESTED, the agent sees maintainer discussion, and the migration
|
|
gate can see a passing "Migration risk" check. Inline review-thread comments
|
|
(a GraphQL-only surface with thread-resolution state) are carried when the
|
|
hosted context supplies "review_threads"; only UNRESOLVED threads flow into
|
|
the prompt, since an unresolved inline "do not merge" is the blocker the
|
|
reviewer must see and resolved threads are noise. An absent key (the Action
|
|
runtime doesn't pass it) means an empty list — a clean no-op, never a crash.
|
|
Reactions on those inline comments are not carried, so they default empty.
|
|
"""
|
|
pr = context.get("pr") or {}
|
|
user = pr.get("user") or {}
|
|
base_sha = context.get("base_sha") or (pr.get("base") or {}).get("sha") or ""
|
|
head_sha = context.get("head_sha") or (pr.get("head") or {}).get("sha") or ""
|
|
|
|
files = _git_diff_files(base_sha, head_sha, REPO_ROOT)
|
|
if not files:
|
|
files = [_convert_api_file(f) for f in context.get("files") or []]
|
|
|
|
# Drop only EMPTY COMMENTED reviews from the offline reviews. A bare COMMENTED top-level review
|
|
# carries no readable body — yet _summarize_assurance would surface its author as a current-head
|
|
# reviewer ("head_commented"), reading as independent assurance. Unseen feedback must reduce to "no
|
|
# assurance," never a positive vouch. (Any inline feedback that review left still reaches the prompt
|
|
# via review_comments below, so dropping the empty top-level state loses nothing the reviewer needs.)
|
|
# A COMMENTED review WITH a body is different: that text is
|
|
# a maintainer's visible feedback (possibly a comment-only "hold"), and dropping it would let
|
|
# the reviewer approve without ever seeing it — it flows through, body and all.
|
|
reviews = [
|
|
r for r in (context.get("reviews") or []) if r.get("state") != "COMMENTED" or (r.get("body") or "").strip()
|
|
]
|
|
|
|
# Trusted-bot reactions only: the offline path has no token for the org-membership check the
|
|
# Action's reactor predicate performs, and the in-flight wait consumes only allowlisted bot 👀
|
|
# anyway — human reactions are never waited on. REST content values lowercase-match the mapper.
|
|
author_login = user.get("login") or ""
|
|
pr_reactions = [
|
|
{"user": login, "emoji": _reaction_emoji(r.get("content", "")), "created_at": r.get("created_at")}
|
|
for r in context.get("pr_reactions") or []
|
|
if (login := r.get("user") or "") and login.lower() in TRUSTED_REACTOR_BOTS and login != author_login
|
|
]
|
|
|
|
# Flatten the injected inline review threads into the review_comments shape the reviewer prompt
|
|
# consumes. Only UNRESOLVED threads reach the prompt (see _build_pr_data's docstring); the full
|
|
# list stays in the context. Each comment passes the same author-trust gate as reviews and
|
|
# discussion — an untrusted external commenter must not plant a fake maintainer hold, and
|
|
# stamp's own prior comments must not feed back as third-party claims. Absent key -> empty, so
|
|
# the Action runtime (which doesn't pass it) is unaffected and the prompt renders no
|
|
# inline-comments section, exactly as before.
|
|
review_comments: list[dict] = []
|
|
for thread in context.get("review_threads") or []:
|
|
if thread.get("is_resolved"):
|
|
continue
|
|
for index, comment in enumerate(thread.get("comments") or []):
|
|
if not _prompt_worthy_author(
|
|
comment.get("author"), comment.get("author_association"), bool(comment.get("author_is_bot"))
|
|
):
|
|
continue
|
|
# Real reply ids aren't carried in the lean shape; a truthy placeholder on the replies
|
|
# only marks reply-ness — the prompt's "(reply)" label and _summarize_assurance's
|
|
# "count threads (in_reply_to_id is None), not comments" semantic both key off it.
|
|
# Parity with the Action (github.py): only the TRUE thread root (index 0) may carry None.
|
|
# When the root is filtered (untrusted author, or stamp's own finding), every survivor
|
|
# is a reply, so the thread contributes 0 to unresolved_threads — exactly like the Action,
|
|
# whose surviving replies keep their real non-None replyTo ids.
|
|
review_comments.append(
|
|
{
|
|
"user": comment.get("author") or "ghost",
|
|
"body": comment.get("body", ""),
|
|
"path": thread.get("path", ""),
|
|
"line": thread.get("line"),
|
|
"in_reply_to_id": None if index == 0 else -1,
|
|
"is_resolved": False,
|
|
"is_outdated": bool(thread.get("is_outdated")),
|
|
"reactions": [],
|
|
}
|
|
)
|
|
|
|
return PRData(
|
|
number=int(pr.get("number") or 0),
|
|
repo=context.get("repo") or "",
|
|
title=pr.get("title") or "",
|
|
state=pr.get("state") or "",
|
|
draft=bool(pr.get("draft")),
|
|
mergeable_state=pr.get("mergeable_state") or "unknown",
|
|
author=user.get("login") or "",
|
|
labels=[label.get("name", "") for label in pr.get("labels") or []],
|
|
base_sha=base_sha,
|
|
head_sha=head_sha,
|
|
files=files,
|
|
reviews=_normalize_reviews_for_prompt(reviews, head_sha),
|
|
review_comments=review_comments,
|
|
check_runs=context.get("check_runs") or [],
|
|
author_is_bot=is_bot_author(user),
|
|
pr_reactions=pr_reactions,
|
|
body=pr.get("body") or "",
|
|
discussion=_normalize_discussion_for_prompt(context.get("discussion") or []),
|
|
)
|
|
|
|
|
|
def _ownership_summary(ownership: dict) -> str:
|
|
"""Mirror _summarize_ownership's emptiness rule: individual owners count as ownership context
|
|
even with zero teams, or the prompt would claim "no owned paths" while hiding the handles the
|
|
reviewer should route escalations to."""
|
|
owners = [*ownership.get("teams", []), *ownership.get("individuals", [])]
|
|
if owners:
|
|
return f"touches {', '.join(owners)}"
|
|
return "no owned paths touched"
|
|
|
|
|
|
def _run_gates_offline(pipeline: Pipeline) -> None:
|
|
"""Run the four deterministic gate checks — the same ones _run_gates runs.
|
|
|
|
Mirrors Pipeline._run_gates minus its `_summarize_ownership` call, which
|
|
shells out to `gh` for author-team membership. Membership is advisory
|
|
reviewer context (never a gate), so it is simply omitted here; the ownership
|
|
teams themselves are still resolved locally from the checkout.
|
|
"""
|
|
gates = [
|
|
("prerequisites", pipeline._check_prerequisites),
|
|
("deny-list", pipeline._check_deny_list),
|
|
("size", pipeline._check_size),
|
|
("tier", pipeline._check_tier),
|
|
]
|
|
for name, check in gates:
|
|
passed, message = check()
|
|
pipeline.gate_results.append(GateResult(name, passed, message))
|
|
|
|
pipeline.classification["ownership_summary"] = _ownership_summary(pipeline.classification.get("ownership", {}))
|
|
|
|
|
|
def _familiarity_offline(
|
|
author_prs: set[int],
|
|
diff_path: Path,
|
|
base_sha: str,
|
|
head_sha: str,
|
|
thresholds: FamiliarityPolicy,
|
|
*,
|
|
now: float | None = None,
|
|
) -> AuthorFamiliarity:
|
|
"""Mirror of familiarity.compute_familiarity with the author-PR set injected.
|
|
|
|
compute_familiarity fetches the author's merged-PR numbers with one `gh`
|
|
call, which is impossible in the tokenless sandbox — the server fetches them
|
|
and hands them in via the context instead. Everything else (blame overlap,
|
|
prior PRs, previously-modified files, banding) is the Action's own bounded
|
|
git logic, called here unchanged.
|
|
"""
|
|
now = time.time() if now is None else now
|
|
file_diffs = _parse_diff(_read_diff(diff_path))
|
|
considered, capped = _select_considered_files(file_diffs)
|
|
considered_paths = [f.path for f in considered if f.path]
|
|
|
|
blame_sha = _merge_base(base_sha, head_sha, REPO_ROOT)
|
|
if blame_sha is not None:
|
|
owned, total, top_authors = _blame_overlap(considered, blame_sha, author_prs, REPO_ROOT)
|
|
else:
|
|
owned, total, top_authors = 0, 0, ()
|
|
blame_overlap_pct = (100.0 * owned / total) if total else 0.0
|
|
|
|
prior_prs, days_since = _prior_prs_in_paths(considered_paths, author_prs, REPO_ROOT, now)
|
|
files_prev_count, files_total = _files_previously_modified(considered, author_prs, REPO_ROOT)
|
|
band = _band(blame_overlap_pct, prior_prs, days_since, thresholds)
|
|
|
|
return AuthorFamiliarity(
|
|
band=band,
|
|
blame_overlap_pct=blame_overlap_pct,
|
|
modified_lines_owned=owned,
|
|
modified_lines_total=total,
|
|
prior_prs_in_paths=prior_prs,
|
|
days_since_last_touch=days_since,
|
|
files_prev_count=files_prev_count,
|
|
files_total=files_total,
|
|
capped=capped,
|
|
top_prior_authors=top_authors,
|
|
)
|
|
|
|
|
|
def _attach_familiarity(pipeline: Pipeline, context: dict) -> None:
|
|
"""Attach the author-familiarity signal for the T1-agent path only.
|
|
|
|
Same gating as Pipeline._maybe_compute_familiarity (T0 skips the LLM, T2 is a
|
|
deny, so neither benefits). Absent injected PR numbers leaves the signal None,
|
|
exactly as a failed `gh` call would in the Action — a one-way ratchet.
|
|
"""
|
|
if pipeline.classification.get("tier") != "T1-agent":
|
|
return
|
|
raw_prs = context.get("author_pr_numbers")
|
|
if not raw_prs:
|
|
return
|
|
author_prs = {int(n) for n in raw_prs}
|
|
diff_path = pipeline._ensure_diff_path()
|
|
try:
|
|
pipeline.classification["familiarity"] = _familiarity_offline(
|
|
author_prs, diff_path, pipeline.pr.base_sha, pipeline.pr.head_sha, POLICY.familiarity
|
|
)
|
|
except Exception as exc:
|
|
print(f"warning: familiarity computation failed ({exc}); continuing without the signal")
|
|
|
|
|
|
def run(context: dict) -> dict:
|
|
"""Run the full offline review and return the to_dict() contract."""
|
|
# The hosted server sets self_driving_review only for PRs it verified came from a self-driving
|
|
# Inbox implementation run. Action contexts never carry it, so bot authors are refused as before.
|
|
pipeline = Pipeline(0, context.get("repo") or "", self_driving=bool(context.get("self_driving_review")))
|
|
pipeline.pr = _build_pr_data(context)
|
|
|
|
if pipeline.pr.author_is_bot and not pipeline.self_driving:
|
|
pipeline._refuse_bot_author()
|
|
return pipeline.to_dict()
|
|
|
|
try:
|
|
pipeline._classify()
|
|
_run_gates_offline(pipeline)
|
|
gate_verdict = pipeline._gate_verdict()
|
|
|
|
# Mirror the Action's in-flight reviewer-bot handling, minus the wait: there is no token in
|
|
# the sandbox to poll with, and the SERVER already waited out the race (workflow bot-wait
|
|
# loop) and refreshed this snapshot before provisioning. Bots still showing fresh 👀 here
|
|
# mean the server's budget expired — WAIT, never approve over an unfinished review. Gate
|
|
# denials skip the check, same as the Action: a refusal can't approve over anything.
|
|
if gate_verdict != "DENIED" and (in_flight := pipeline._in_flight_bot_reviewers()):
|
|
bot_list = ", ".join(f"@{b}" for b in in_flight)
|
|
pipeline.final_verdict = "WAIT"
|
|
pipeline.reviewer_output = {
|
|
"verdict": "WAIT",
|
|
"reasoning": (
|
|
f"{bot_list} still {'have' if len(in_flight) > 1 else 'has'} a review in flight (👀) — "
|
|
"not approving over an unfinished review. The review re-runs on the next push, or "
|
|
"re-request one once the reviewer finishes."
|
|
),
|
|
"risk": "unknown",
|
|
"issues": [],
|
|
}
|
|
return pipeline.to_dict()
|
|
|
|
_attach_familiarity(pipeline, context)
|
|
pipeline._llm_review(gate_verdict)
|
|
finally:
|
|
if pipeline._diff_path is not None:
|
|
pipeline._diff_path.unlink(missing_ok=True)
|
|
|
|
return pipeline.to_dict()
|
|
|
|
|
|
def _escalate_result(context: dict, exc: Exception) -> dict:
|
|
"""A minimal, parseable escalate outcome for an unexpected internal failure.
|
|
|
|
Keeps the last-line contract intact so the server parses a defined verdict
|
|
(escalate, never a silent approval) rather than choking on a stack trace.
|
|
"""
|
|
pr = context.get("pr") or {}
|
|
return {
|
|
"stamp_version": STAMPFN_VERSION,
|
|
"pr_number": pr.get("number"),
|
|
"repo": context.get("repo") or "",
|
|
"classification": {},
|
|
"gates": [],
|
|
"policy": {},
|
|
"reviewer": {
|
|
"verdict": "ESCALATE",
|
|
"reasoning": "The review agent could not complete its analysis — escalating for a human.",
|
|
"risk": "high",
|
|
"issues": [str(exc)],
|
|
},
|
|
"review_body": None,
|
|
"final_verdict": "ESCALATE",
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Offline PR review (sandbox entrypoint)")
|
|
parser.add_argument("--context", required=True, help="Path to the review context JSON")
|
|
parser.add_argument("--repo-dir", default=None, help="Checkout directory (defaults to cwd)")
|
|
args = parser.parse_args()
|
|
|
|
if args.repo_dir:
|
|
os.chdir(args.repo_dir)
|
|
|
|
context = json.loads(Path(args.context).read_text())
|
|
try:
|
|
result = run(context)
|
|
except Exception as exc: # never let a crash become a silent non-verdict
|
|
result = _escalate_result(context, exc)
|
|
|
|
# Flush BEFORE the final line: batched capture events are dropped at process exit otherwise,
|
|
# and any client noise the flush prints must stay above the machine-readable line.
|
|
flush_analytics()
|
|
|
|
# The single machine-readable line the server parses — always last on stdout.
|
|
print(json.dumps(result), flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|