Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion tools/pr-approval-agent/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import json
import subprocess
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path


Expand All @@ -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]:
Expand All @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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,
)


Expand Down
12 changes: 11 additions & 1 deletion tools/pr-approval-agent/reviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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}

Expand All @@ -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 ---
""")

Expand Down
44 changes: 43 additions & 1 deletion tools/pr-approval-agent/test_github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 == []
Loading