Skip to content

Commit 29caa24

Browse files
authored
feat(stamphog): trust clean ReviewHog signals
Generated-By: PostHog Code Task-Id: fa3ff167-712b-41c4-aa27-aa8ec1700f4e
1 parent 46f300a commit 29caa24

3 files changed

Lines changed: 106 additions & 3 deletions

File tree

tools/pr-approval-agent/github.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
"""
77

88
import json
9+
import re
910
import subprocess
10-
from dataclasses import dataclass
11+
from dataclasses import dataclass, field
1112
from pathlib import Path
1213

1314

@@ -29,6 +30,7 @@ class PRData:
2930
reviews: list[dict]
3031
review_comments: list[dict]
3132
check_runs: list[dict]
33+
pr_reactions: list[dict] = field(default_factory=list)
3234

3335
@property
3436
def file_paths(self) -> list[str]:
@@ -52,6 +54,8 @@ def has_new_files(self) -> bool:
5254

5355

5456
_TRUSTED_ASSOCIATIONS = {"MEMBER", "OWNER", "COLLABORATOR"}
57+
_REVIEWHOG_BOT_LOGIN = "posthog[bot]"
58+
_REVIEWHOG_STATUS_RE = re.compile(r"<!--\s*reviewhog:status:[0-9a-f-]+\s*-->")
5559

5660

5761
def _normalize_reviews_for_prompt(reviews_raw: list[dict], head_sha: str) -> list[dict]:
@@ -85,6 +89,26 @@ def _normalize_reviews_for_prompt(reviews_raw: list[dict], head_sha: str) -> lis
8589
return normalized_reviews
8690

8791

92+
def _reviewhog_clean_reactions(comments_raw: list[dict]) -> list[dict]:
93+
for comment in reversed(comments_raw):
94+
user = comment.get("user") or {}
95+
if user.get("login", "").lower() != _REVIEWHOG_BOT_LOGIN or user.get("type") != "Bot":
96+
continue
97+
body = comment.get("body") or ""
98+
if "Found no issues worth raising, so no review was posted." not in body:
99+
continue
100+
if _REVIEWHOG_STATUS_RE.search(body) is None:
101+
continue
102+
return [
103+
{
104+
"user": "reviewhog[bot]",
105+
"emoji": "👍",
106+
"created_at": comment.get("updated_at") or comment.get("created_at"),
107+
}
108+
]
109+
return []
110+
111+
88112
def _gh_api(endpoint: str, *, paginate: bool = False) -> dict | list:
89113
cmd = ["gh", "api", endpoint]
90114
if paginate:
@@ -258,6 +282,12 @@ def fetch_pr(pr_number: int, repo: str, repo_root: Path | None = None) -> PRData
258282
base_sha = pr["base"]["sha"]
259283
head_sha = pr["head"]["sha"]
260284
check_runs_resp = _gh_api(f"repos/{repo}/commits/{head_sha}/check-runs")
285+
reviewhog_reactions: list[dict] = []
286+
try:
287+
discussion_raw = _gh_api(f"repos/{repo}/issues/{pr_number}/comments", paginate=True)
288+
reviewhog_reactions = _reviewhog_clean_reactions(discussion_raw)
289+
except Exception as exc:
290+
print(f"warning: discussion fetch failed ({exc}); continuing without ReviewHog assurance")
261291

262292
git_root = repo_root or Path.cwd()
263293
ensure_commits(pr_number, head_sha, git_root)
@@ -280,6 +310,7 @@ def fetch_pr(pr_number: int, repo: str, repo_root: Path | None = None) -> PRData
280310
reviews=_normalize_reviews_for_prompt(reviews_raw, head_sha),
281311
review_comments=review_comments,
282312
check_runs=check_runs_resp.get("check_runs", []),
313+
pr_reactions=reviewhog_reactions,
283314
)
284315

285316

tools/pr-approval-agent/reviewer.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ def _validate_verdict(result: dict) -> dict:
174174
reviews as a concern and ESCALATE unless there's a strong,
175175
specific justification to APPROVE.
176176
- Bot comments with valid concerns that were ignored → ESCALATE
177+
- ReviewHog's authenticated clean status appears as 👍 @reviewhog[bot]. Treat
178+
it as the same mild positive signal as a clean Greptile or Hex reaction:
179+
useful corroboration, but never sufficient by itself to approve.
177180
178181
Tools: You have Read, Grep, and Glob (restricted to the repo directory).
179182
All PR metadata (comments, ownership) is in the prompt — do NOT fetch
@@ -392,6 +395,10 @@ def _build_review_prompt(self, pr: PRData, cl: dict, gate_context: dict, diff_pa
392395
lines.append(f" - @{safe_user}{reply}{status} on {safe_path}: {safe_body}")
393396
review_comments = "\n".join(lines)
394397

398+
pr_reactions = "\n".join(
399+
f" - {r['emoji']} @{_sanitize_untrusted(r['user'], max_len=50)}" for r in pr.pr_reactions
400+
)
401+
395402
ownership = self._format_ownership(cl)
396403

397404
gate_lines = []
@@ -419,7 +426,7 @@ def _build_review_prompt(self, pr: PRData, cl: dict, gate_context: dict, diff_pa
419426
Size: {pr.lines_total} lines ({pr.lines_added}+/{pr.lines_deleted}-), {len(pr.files)} files
420427
Scope: {cl["breadth"]}
421428
Commit type: {cl.get("commit_type") or "unknown"}
422-
Reviews: {len(pr.reviews)} top-level, {len(pr.review_comments)} inline
429+
Reviews: {len(pr.reviews)} top-level, {len(pr.review_comments)} inline, {len(pr.pr_reactions)} trusted signals
423430
424431
{ownership}
425432
@@ -443,6 +450,9 @@ def _build_review_prompt(self, pr: PRData, cl: dict, gate_context: dict, diff_pa
443450
444451
Inline comments:
445452
{review_comments}
453+
454+
Reactions on the PR:
455+
{pr_reactions}
446456
--- END UNTRUSTED CONTENT ---
447457
""")
448458

tools/pr-approval-agent/test_github.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from github import _normalize_reviews_for_prompt
5+
from github import _normalize_reviews_for_prompt, _reviewhog_clean_reactions
66

77

88
def test_normalize_reviews_marks_current_head_and_preserves_stale_reviews() -> None:
@@ -75,3 +75,65 @@ def test_normalize_reviews_filters_by_trust_source(
7575
)
7676

7777
assert len(normalized) == expected_count
78+
79+
80+
def test_reviewhog_clean_status_is_normalized_as_a_trusted_positive() -> None:
81+
reactions = _reviewhog_clean_reactions(
82+
[
83+
{
84+
"user": {"login": "posthog[bot]", "type": "Bot"},
85+
"body": (
86+
"Found no issues worth raising, so no review was posted.\n\n"
87+
"<!-- reviewhog:status:019f807c-68a6-7d18-b010-85409c5ed4ad -->"
88+
),
89+
"created_at": "2026-07-20T17:04:26Z",
90+
"updated_at": "2026-07-20T17:05:26Z",
91+
}
92+
]
93+
)
94+
95+
assert reactions == [
96+
{
97+
"user": "reviewhog[bot]",
98+
"emoji": "👍",
99+
"created_at": "2026-07-20T17:05:26Z",
100+
}
101+
]
102+
103+
104+
def _clean_reviewhog_body() -> str:
105+
return (
106+
"Found no issues worth raising, so no review was posted.\n\n"
107+
"<!-- reviewhog:status:019f807c-68a6-7d18-b010-85409c5ed4ad -->"
108+
)
109+
110+
111+
@pytest.mark.parametrize(
112+
"login,user_type,body",
113+
[
114+
pytest.param("posthog[bot]", "User", _clean_reviewhog_body(), id="not-app-bot"),
115+
pytest.param("someone[bot]", "Bot", _clean_reviewhog_body(), id="wrong-bot"),
116+
pytest.param(
117+
"posthog[bot]", "Bot", "Found no issues worth raising, so no review was posted.", id="missing-marker"
118+
),
119+
pytest.param(
120+
"posthog[bot]",
121+
"Bot",
122+
"Found 1 should fix.\n<!-- reviewhog:status:019f807c-68a6-7d18-b010-85409c5ed4ad -->",
123+
id="not-clean",
124+
),
125+
],
126+
)
127+
def test_reviewhog_clean_status_rejects_untrusted_or_non_clean_comments(
128+
login: str, user_type: str, body: str
129+
) -> None:
130+
reactions = _reviewhog_clean_reactions(
131+
[
132+
{
133+
"user": {"login": login, "type": user_type},
134+
"body": body,
135+
}
136+
]
137+
)
138+
139+
assert reactions == []

0 commit comments

Comments
 (0)