diff --git a/tools/pr-approval-agent/github.py b/tools/pr-approval-agent/github.py index 2a8adeb199..9d7f403dc9 100644 --- a/tools/pr-approval-agent/github.py +++ b/tools/pr-approval-agent/github.py @@ -7,7 +7,7 @@ import json import subprocess -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path @@ -29,6 +29,7 @@ class PRData: reviews: list[dict] review_comments: list[dict] check_runs: list[dict] + pr_reactions: list[dict] = field(default_factory=list) @property def file_paths(self) -> list[str]: @@ -52,6 +53,14 @@ def has_new_files(self) -> bool: _TRUSTED_ASSOCIATIONS = {"MEMBER", "OWNER", "COLLABORATOR"} +TRUSTED_REACTOR_BOTS = { + "chatgpt-codex-connector[bot]", + "copilot-pull-request-reviewer[bot]", + "greptile-apps[bot]", + "hex-security-app[bot]", + "posthog[bot]", + "veria-ai[bot]", +} def _normalize_reviews_for_prompt(reviews_raw: list[dict], head_sha: str) -> list[dict]: @@ -85,6 +94,22 @@ def _normalize_reviews_for_prompt(reviews_raw: list[dict], head_sha: str) -> lis return normalized_reviews +def _normalize_pr_reactions(reactions_raw: list[dict], author: str) -> list[dict]: + normalized = [] + for reaction in reactions_raw: + login = (reaction.get("user") or {}).get("login", "") + if login == author or login.lower() not in TRUSTED_REACTOR_BOTS: + continue + normalized.append( + { + "user": login, + "emoji": {"+1": "👍", "-1": "👎", "eyes": "👀"}.get(reaction.get("content"), reaction.get("content")), + "created_at": reaction.get("created_at"), + } + ) + return normalized + + def _gh_api(endpoint: str, *, paginate: bool = False) -> dict | list: cmd = ["gh", "api", endpoint] if paginate: @@ -258,6 +283,12 @@ def fetch_pr(pr_number: int, repo: str, repo_root: Path | None = None) -> PRData base_sha = pr["base"]["sha"] head_sha = pr["head"]["sha"] check_runs_resp = _gh_api(f"repos/{repo}/commits/{head_sha}/check-runs") + pr_reactions: list[dict] = [] + try: + reactions_raw = _gh_api(f"repos/{repo}/issues/{pr_number}/reactions", paginate=True) + pr_reactions = _normalize_pr_reactions(reactions_raw, pr["user"]["login"]) + except Exception as exc: + print(f"warning: reaction fetch failed ({exc}); continuing without reaction context") git_root = repo_root or Path.cwd() ensure_commits(pr_number, head_sha, git_root) @@ -280,6 +311,7 @@ def fetch_pr(pr_number: int, repo: str, repo_root: Path | None = None) -> PRData reviews=_normalize_reviews_for_prompt(reviews_raw, head_sha), review_comments=review_comments, check_runs=check_runs_resp.get("check_runs", []), + pr_reactions=pr_reactions, ) diff --git a/tools/pr-approval-agent/reviewer.py b/tools/pr-approval-agent/reviewer.py index ea935011a6..814869da95 100644 --- a/tools/pr-approval-agent/reviewer.py +++ b/tools/pr-approval-agent/reviewer.py @@ -174,6 +174,9 @@ def _validate_verdict(result: dict) -> dict: reviews as a concern and ESCALATE unless there's a strong, specific justification to APPROVE. - Bot comments with valid concerns that were ignored → ESCALATE + - Trusted reviewer reactions are included. A 👍 is mild positive evidence and + a 👎 is mild negative evidence; neither is sufficient by itself. An 👀 means + a review is still in flight, so do not approve until it finishes. Tools: You have Read, Grep, and Glob (restricted to the repo directory). 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 lines.append(f" - @{safe_user}{reply}{status} on {safe_path}: {safe_body}") review_comments = "\n".join(lines) + pr_reactions = "\n".join( + f" - {r['emoji']} @{_sanitize_untrusted(r['user'], max_len=50)}" for r in pr.pr_reactions + ) + ownership = self._format_ownership(cl) gate_lines = [] @@ -419,7 +426,7 @@ def _build_review_prompt(self, pr: PRData, cl: dict, gate_context: dict, diff_pa Size: {pr.lines_total} lines ({pr.lines_added}+/{pr.lines_deleted}-), {len(pr.files)} files Scope: {cl["breadth"]} Commit type: {cl.get("commit_type") or "unknown"} - Reviews: {len(pr.reviews)} top-level, {len(pr.review_comments)} inline + Reviews: {len(pr.reviews)} top-level, {len(pr.review_comments)} inline, {len(pr.pr_reactions)} trusted signals {ownership} @@ -443,6 +450,9 @@ def _build_review_prompt(self, pr: PRData, cl: dict, gate_context: dict, diff_pa Inline comments: {review_comments} + + Reactions on the PR: + {pr_reactions} --- END UNTRUSTED CONTENT --- """) diff --git a/tools/pr-approval-agent/test_github.py b/tools/pr-approval-agent/test_github.py index 87aaa5a461..8068a753e6 100644 --- a/tools/pr-approval-agent/test_github.py +++ b/tools/pr-approval-agent/test_github.py @@ -2,7 +2,7 @@ import pytest -from github import _normalize_reviews_for_prompt +from github import _normalize_pr_reactions, _normalize_reviews_for_prompt def test_normalize_reviews_marks_current_head_and_preserves_stale_reviews() -> None: @@ -75,3 +75,45 @@ def test_normalize_reviews_filters_by_trust_source( ) assert len(normalized) == expected_count + + +def test_normalize_pr_reactions_keeps_trusted_reviewer_bots() -> None: + reactions = _normalize_pr_reactions( + [ + { + "user": {"login": "posthog[bot]"}, + "content": "+1", + "created_at": "2026-07-20T17:04:26Z", + } + ], + "author", + ) + + assert reactions == [ + { + "user": "posthog[bot]", + "emoji": "👍", + "created_at": "2026-07-20T17:04:26Z", + } + ] + + +@pytest.mark.parametrize( + "login,author", + [ + pytest.param("someone[bot]", "author", id="untrusted-bot"), + pytest.param("posthog[bot]", "posthog[bot]", id="author-self-reaction"), + ], +) +def test_normalize_pr_reactions_rejects_untrusted_or_author_reactions(login: str, author: str) -> None: + reactions = _normalize_pr_reactions( + [ + { + "user": {"login": login}, + "content": "+1", + } + ], + author, + ) + + assert reactions == []