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]>
316 lines
13 KiB
Python
316 lines
13 KiB
Python
"""Tests for the offline review entrypoint's context handling."""
|
|
|
|
import sys
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock
|
|
|
|
# review_local pulls in review_pr, whose reviewer.py imports claude_agent_sdk (installed by
|
|
# `uv run`, not the test venv). Stub it before importing.
|
|
sys.modules.setdefault("claude_agent_sdk", MagicMock())
|
|
sys.modules.setdefault("claude_agent_sdk.types", MagicMock())
|
|
|
|
import review_local # noqa: E402
|
|
from review_pr import Pipeline # noqa: E402
|
|
|
|
|
|
def _review(login: str, state: str, head_sha: str, body: str = "") -> dict:
|
|
return {
|
|
"user": {"login": login, "type": "User"},
|
|
"author_association": "MEMBER",
|
|
"state": state,
|
|
"commit_id": head_sha,
|
|
"submitted_at": "2026-07-15T00:00:00Z",
|
|
"body": body,
|
|
}
|
|
|
|
|
|
def test_commented_reviews_are_dropped_offline(monkeypatch) -> None:
|
|
# The hosted context has no inline review threads, so a bare COMMENTED review at head carries no
|
|
# readable feedback. If it reached PRData, _summarize_assurance would surface its author as a
|
|
# current-head reviewer — reading as independent assurance for feedback nobody can see. It must be
|
|
# filtered; APPROVED and CHANGES_REQUESTED (which the prerequisite gate needs) must survive.
|
|
monkeypatch.setattr(review_local, "_git_diff_files", lambda *a, **k: [])
|
|
head_sha = "abc123"
|
|
context = {
|
|
"repo": "Insights/insights",
|
|
"head_sha": head_sha,
|
|
"base_sha": "def456",
|
|
"pr": {"number": 1, "title": "t", "state": "OPEN", "user": {"login": "author", "type": "User"}},
|
|
"reviews": [
|
|
_review("carol", "COMMENTED", head_sha),
|
|
_review("dave", "APPROVED", head_sha),
|
|
_review("erin", "CHANGES_REQUESTED", head_sha),
|
|
# A comment-only "hold" carries real human feedback — it must reach the prompt, or the
|
|
# reviewer could approve without ever seeing it.
|
|
_review("frank", "COMMENTED", head_sha, body="Hold off, migration plan pending."),
|
|
],
|
|
}
|
|
|
|
pr = review_local._build_pr_data(context)
|
|
|
|
assert "carol" not in {r["user"] for r in pr.reviews}
|
|
assert "frank" in {r["user"] for r in pr.reviews}
|
|
|
|
pipeline = Pipeline(pr_number=1, repo="Insights/insights")
|
|
pipeline.pr = pr
|
|
assurance = pipeline._summarize_assurance()
|
|
# frank's comment is visible feedback, so counting him as a current-head commenter is factual;
|
|
# carol's bare state must not appear (unseen feedback never reads as assurance).
|
|
assert assurance["head_commented_users"] == ["frank"]
|
|
assert assurance["head_approvals"] == ["dave"]
|
|
|
|
|
|
def test_ownership_summary_preserves_individual_owners() -> None:
|
|
# Individuals-only ownership (team_count == 0) used to collapse to "no owned paths touched",
|
|
# hiding the owner handles from the reviewer prompt — the LLM would approve instead of routing
|
|
# to the named owner. Mirrors _summarize_ownership's emptiness rule.
|
|
assert review_local._ownership_summary({"teams": [], "individuals": ["@a-handle"]}) == "touches @a-handle"
|
|
assert review_local._ownership_summary({"teams": ["org/devex"], "individuals": ["@a-handle"]}) == (
|
|
"touches org/devex, @a-handle"
|
|
)
|
|
assert review_local._ownership_summary({"teams": [], "individuals": []}) == "no owned paths touched"
|
|
|
|
|
|
def _thread_context(review_threads: list[dict]) -> dict:
|
|
return {
|
|
"repo": "Insights/insights",
|
|
"head_sha": "abc123",
|
|
"base_sha": "def456",
|
|
"pr": {"number": 1, "title": "t", "state": "OPEN", "user": {"login": "author", "type": "User"}},
|
|
"review_threads": review_threads,
|
|
}
|
|
|
|
|
|
def _thread_comment(author: str, body: str, *, association: str = "MEMBER", is_bot: bool = False) -> dict:
|
|
return {"author": author, "author_association": association, "author_is_bot": is_bot, "body": body}
|
|
|
|
|
|
def test_unresolved_review_threads_reach_the_prompt_and_resolved_are_dropped(monkeypatch) -> None:
|
|
# The hosted context now carries inline review threads. Only UNRESOLVED threads must flow into
|
|
# review_comments (which the reviewer prompt renders) — an unresolved inline "do not merge" is the
|
|
# blocker the reviewer must see; a resolved thread is settled noise that would dilute the signal.
|
|
monkeypatch.setattr(review_local, "_git_diff_files", lambda *a, **k: [])
|
|
context = _thread_context(
|
|
[
|
|
{
|
|
"is_resolved": False,
|
|
"is_outdated": False,
|
|
"path": "insights/api/insight.py",
|
|
"line": 42,
|
|
"comments": [_thread_comment("maintainer", "this is wrong, do not merge")],
|
|
},
|
|
{
|
|
"is_resolved": True,
|
|
"is_outdated": False,
|
|
"path": "insights/api/other.py",
|
|
"line": 7,
|
|
"comments": [_thread_comment("maintainer", "was wrong, now fixed")],
|
|
},
|
|
]
|
|
)
|
|
|
|
pr = review_local._build_pr_data(context)
|
|
|
|
assert [c["user"] for c in pr.review_comments] == ["maintainer"]
|
|
assert pr.review_comments[0]["body"] == "this is wrong, do not merge"
|
|
assert pr.review_comments[0]["path"] == "insights/api/insight.py"
|
|
assert pr.review_comments[0]["line"] == 42
|
|
assert all("now fixed" not in c["body"] for c in pr.review_comments)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"author,association,is_bot,expect_kept",
|
|
[
|
|
pytest.param("alice", "MEMBER", False, True, id="trusted-member-kept"),
|
|
pytest.param("greptile-apps[bot]", "NONE", True, True, id="bot-reviewer-kept"),
|
|
# A drive-by external commenter must not reach the prompt: a fake maintainer hold is both
|
|
# griefable and forgeable — the same trust gate as reviews and discussion.
|
|
pytest.param("outsider", "NONE", False, False, id="untrusted-external-dropped"),
|
|
# Stamp's own prior inline comments feed back as third-party claims about a stale
|
|
# snapshot — later runs suspect impersonation and refuse forever.
|
|
pytest.param("stamp[bot]", "NONE", True, False, id="own-comment-dropped"),
|
|
],
|
|
)
|
|
def test_review_thread_comments_pass_the_author_trust_gate(
|
|
monkeypatch, author: str, association: str, is_bot: bool, expect_kept: bool
|
|
) -> None:
|
|
monkeypatch.setattr(review_local, "_git_diff_files", lambda *a, **k: [])
|
|
context = _thread_context(
|
|
[
|
|
{
|
|
"is_resolved": False,
|
|
"is_outdated": False,
|
|
"path": "a.py",
|
|
"line": 1,
|
|
"comments": [_thread_comment(author, "a comment", association=association, is_bot=is_bot)],
|
|
}
|
|
]
|
|
)
|
|
|
|
pr = review_local._build_pr_data(context)
|
|
|
|
assert ([c["user"] for c in pr.review_comments] == [author]) is expect_kept
|
|
|
|
|
|
def test_multi_comment_thread_counts_as_one_unresolved_thread(monkeypatch) -> None:
|
|
# _summarize_assurance counts unresolved THREADS as comments with in_reply_to_id None — a single
|
|
# chatty 3-comment thread must read as one unresolved thread, not three. Only the true thread
|
|
# root (index 0) may carry in_reply_to_id None.
|
|
monkeypatch.setattr(review_local, "_git_diff_files", lambda *a, **k: [])
|
|
context = _thread_context(
|
|
[
|
|
{
|
|
"is_resolved": False,
|
|
"is_outdated": False,
|
|
"path": "a.py",
|
|
"line": 1,
|
|
"comments": [
|
|
_thread_comment("alice", "root concern"),
|
|
_thread_comment("author", "pushed a fix"),
|
|
_thread_comment("alice", "still wrong"),
|
|
],
|
|
}
|
|
]
|
|
)
|
|
|
|
pr = review_local._build_pr_data(context)
|
|
|
|
assert len(pr.review_comments) == 3
|
|
assert [c["in_reply_to_id"] for c in pr.review_comments] == [None, -1, -1]
|
|
|
|
pipeline = Pipeline(pr_number=1, repo="Insights/insights")
|
|
pipeline.pr = pr
|
|
assert pipeline._summarize_assurance()["unresolved_threads"] == 1
|
|
|
|
|
|
def test_filtered_root_thread_counts_zero_unresolved_threads(monkeypatch) -> None:
|
|
# Parity with the Action: when the true thread root is filtered (untrusted author, or stamp's
|
|
# own inline finding), the survivors are replies — the thread contributes 0 to unresolved_threads,
|
|
# exactly as the Action's real replyTo ids make it. Treating the first survivor as a root would
|
|
# make hosted stricter than the Action on every maintainer reply to a stamp finding.
|
|
monkeypatch.setattr(review_local, "_git_diff_files", lambda *a, **k: [])
|
|
context = _thread_context(
|
|
[
|
|
{
|
|
"is_resolved": False,
|
|
"is_outdated": False,
|
|
"path": "a.py",
|
|
"line": 1,
|
|
"comments": [
|
|
_thread_comment("rando", "drive-by root", association="NONE"),
|
|
_thread_comment("maintainer", "actually a fair point"),
|
|
],
|
|
}
|
|
]
|
|
)
|
|
|
|
pr = review_local._build_pr_data(context)
|
|
|
|
assert [c["user"] for c in pr.review_comments] == ["maintainer"]
|
|
assert pr.review_comments[0]["in_reply_to_id"] == -1
|
|
|
|
pipeline = Pipeline(pr_number=1, repo="Insights/insights")
|
|
pipeline.pr = pr
|
|
assert pipeline._summarize_assurance()["unresolved_threads"] == 0
|
|
|
|
|
|
def test_absent_review_threads_key_is_a_clean_no_op(monkeypatch) -> None:
|
|
# The Action runtime doesn't pass review_threads yet, so a context without the key must default to
|
|
# no inline comments rather than crash — the engine parity contract.
|
|
monkeypatch.setattr(review_local, "_git_diff_files", lambda *a, **k: [])
|
|
context = {
|
|
"repo": "Insights/insights",
|
|
"head_sha": "abc123",
|
|
"base_sha": "def456",
|
|
"pr": {"number": 1, "title": "t", "state": "OPEN", "user": {"login": "author", "type": "User"}},
|
|
}
|
|
|
|
pr = review_local._build_pr_data(context)
|
|
|
|
assert pr.review_comments == []
|
|
|
|
|
|
def test_fresh_trusted_bot_eyes_reach_pr_data_and_flag_in_flight(monkeypatch) -> None:
|
|
# The hosted context now carries raw PR reactions; a fresh 👀 from an allowlisted reviewer bot
|
|
# must reach PRData so the offline WAIT check can fire — hard-coding pr_reactions=[] meant
|
|
# stamp could approve while another required reviewer was still mid-review. Untrusted
|
|
# reactors must be dropped (anyone can react on a public PR).
|
|
monkeypatch.setattr(review_local, "_git_diff_files", lambda *a, **k: [])
|
|
now_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
context = {
|
|
"repo": "Insights/insights",
|
|
"head_sha": "abc123",
|
|
"base_sha": "def456",
|
|
"pr": {"number": 1, "title": "t", "state": "OPEN", "user": {"login": "author", "type": "User"}},
|
|
"pr_reactions": [
|
|
{"user": "greptile-apps[bot]", "content": "eyes", "created_at": now_iso},
|
|
{"user": "random-account", "content": "eyes", "created_at": now_iso},
|
|
],
|
|
}
|
|
|
|
pr = review_local._build_pr_data(context)
|
|
|
|
assert [r["user"] for r in pr.pr_reactions] == ["greptile-apps[bot]"]
|
|
assert pr.pr_reactions[0]["emoji"] == "👀"
|
|
|
|
pipeline = Pipeline(pr_number=1, repo="Insights/insights")
|
|
pipeline.pr = pr
|
|
assert pipeline._in_flight_bot_reviewers() == ["greptile-apps[bot]"]
|
|
|
|
|
|
def _selfdriving_context(self_driving: bool) -> dict:
|
|
context: dict[str, Any] = {
|
|
"repo": "Insights/insights",
|
|
"head_sha": "abc123",
|
|
"base_sha": "def456",
|
|
"pr": {
|
|
"number": 9,
|
|
"title": "feat: self-driving fix",
|
|
"state": "OPEN",
|
|
"draft": True,
|
|
"user": {"login": "insights-code[bot]", "type": "Bot"},
|
|
},
|
|
}
|
|
if self_driving:
|
|
context["self_driving_review"] = True
|
|
return context
|
|
|
|
|
|
def test_bot_authored_context_without_the_flag_is_refused(monkeypatch) -> None:
|
|
# An Action-shaped context (no self_driving_review key) must keep today's hard refusal for bot
|
|
# authors — the flag defaulting open would auto-approve every dependabot/renovate PR the hosted
|
|
# runtime sees.
|
|
monkeypatch.setattr(review_local, "_git_diff_files", lambda *a, **k: [])
|
|
|
|
result = review_local.run(_selfdriving_context(False))
|
|
|
|
assert result["final_verdict"] == "REFUSED"
|
|
assert "bot" in result["reviewer"]["reasoning"]
|
|
|
|
|
|
def test_self_driving_flag_reviews_the_bot_authored_draft(monkeypatch) -> None:
|
|
# The carve-out's engine half: with the flag set, the run must get PAST the bot-author refusal
|
|
# AND the draft prerequisite (both would otherwise fire for a self-driving PR, which is a
|
|
# bot-authored draft by construction) and reach the review stage. Classification and gates run
|
|
# for real; only the LLM boundary is stubbed.
|
|
monkeypatch.setattr(review_local, "_git_diff_files", lambda *a, **k: [])
|
|
seen: dict = {}
|
|
|
|
def fake_llm(self, gate_verdict: str) -> None:
|
|
seen["gate_verdict"] = gate_verdict
|
|
self.final_verdict = "APPROVED"
|
|
self.reviewer_output = {"verdict": "APPROVE", "reasoning": "ok", "risk": "low", "issues": []}
|
|
|
|
monkeypatch.setattr(Pipeline, "_llm_review", fake_llm)
|
|
|
|
result = review_local.run(_selfdriving_context(True))
|
|
|
|
assert result["final_verdict"] == "APPROVED"
|
|
assert seen["gate_verdict"] != "DENIED"
|
|
prerequisites = next(g for g in result["gates"] if g["gate"] == "prerequisites")
|
|
assert prerequisites["passed"] is True # the draft issue is carved out for this run
|
|
assert result["classification"]["self_driving"] is True # provenance rides into the output contract
|