open-swe/agent/review/style_collector.py
Adam Moussa 62d9945df4
refactor: consolidate reviewer modules into agent/review/
Part of the domain-reorg adoption (build plan step C2): fork content,
upstream layout. Nine 1:1 module moves (reviewer_diff/eval_store/
findings/groups/publish/reconcile/trace_context + review_style_
collector/guidance) into agent/review/, with internal relative
imports re-wired to the new package depth. agent/review/__init__.py
mirrors upstream's thin re-export shim (one of the 21 verified "A"
structural adds).

Rewrote the 38 grep hits across importer files (agent/{analyzer,
ci_autofix,reviewer,webapp}.py, agent/dashboard/*, agent/middleware/
settle_review_check.py, agent/tools/*, agent/utils/github_feedback.py,
agent/webhooks/github.py, evals/reviewer/*, and the reviewer test
suite) to point at agent.review.*; 4 of the 38 hits were name
collisions (list_reviewer_findings, reviewer_outcomes,
_reviewer_thread_id, reviewer_thread_id — not the moved modules) and
were left untouched. tests/test_github_checks.py's module-alias
import (`from agent import reviewer_publish`) follows upstream's own
`from agent.review import publish as reviewer_publish` pattern so
downstream `reviewer_publish.*` call sites needed no changes.
agent/reviewer.py and agent/webapp.py stay in place per the hard
rule (fork content, import-only rewire) and are not part of this
package.

Gates: ruff check + ruff format --check, pytest --co -q (1637
collected), full unit suite (1637 passed), and the reviewer/findings
suite in isolation (pytest -k "review or finding", 421 passed).
2026-07-17 13:52:03 -04:00

325 lines
11 KiB
Python

"""Collect historical PR review samples from GitHub for style analysis."""
from __future__ import annotations
import logging
import uuid
from collections import Counter
from dataclasses import dataclass, field
from typing import Any
import httpx
logger = logging.getLogger(__name__)
DEFAULT_MAX_PRS = 20
DEFAULT_MAX_REVIEWERS = 10
DEFAULT_MAX_SAMPLES_PER_REVIEWER = 6
MIN_COMMENT_CHARS = 20
GITHUB_API = "https://api.github.com"
_BOT_SUFFIX = "[bot]"
def generate_review_style_thread_id(owner: str, repo: str) -> str:
stable_key = f"{owner}/{repo}/review-style"
return str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key))
@dataclass
class ReviewSample:
pr_number: int
reviewer_login: str
kind: str
body: str
state: str = ""
path: str | None = None
submitted_at: str | None = None
@dataclass
class ReviewStyleSamples:
full_name: str
owner: str
name: str
top_reviewers: list[str] = field(default_factory=list)
samples: list[ReviewSample] = field(default_factory=list)
prs_scanned: int = 0
reviews_scanned: int = 0
def github_headers(token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async def _paginate(
client: httpx.AsyncClient,
url: str,
*,
headers: dict[str, str],
cap: int = 500,
) -> list[Any]:
out: list[Any] = []
next_url: str | None = url
first = True
while next_url and len(out) < cap:
params = {"per_page": "100"} if first else None
r = await client.get(next_url, headers=headers, params=params)
r.raise_for_status()
page = r.json()
if isinstance(page, list):
out.extend(page)
next_url = None
link = r.headers.get("Link", "")
for part in link.split(","):
segments = [s.strip() for s in part.split(";")]
if len(segments) >= 2 and 'rel="next"' in segments[1] and segments[0].startswith("<"):
next_url = segments[0][1:-1]
break
first = False
return out
def _is_bot_login(login: str | None) -> bool:
if not login:
return True
return login.endswith(_BOT_SUFFIX) or login.endswith("-bot")
def _is_bot_user(user: dict[str, Any] | None) -> bool:
if not isinstance(user, dict):
return True
if user.get("type") == "Bot":
return True
login = user.get("login")
return _is_bot_login(login if isinstance(login, str) else None)
def _substantive_body(body: str | None) -> str | None:
text = (body or "").strip()
if len(text) < MIN_COMMENT_CHARS:
return None
return text[:4000]
async def _recent_merged_prs(
client: httpx.AsyncClient,
*,
owner: str,
repo: str,
headers: dict[str, str],
max_prs: int,
) -> list[dict[str, Any]]:
"""Return recently merged PRs via the issues search API (reliable on busy repos)."""
r = await client.get(
f"{GITHUB_API}/search/issues",
headers=headers,
params={
"q": f"repo:{owner}/{repo} is:pr is:merged",
"sort": "updated",
"order": "desc",
"per_page": min(max_prs, 100),
},
)
r.raise_for_status()
body = r.json()
items = body.get("items", []) if isinstance(body, dict) else []
merged: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
continue
number = item.get("number")
if not isinstance(number, int):
continue
merged.append({"number": number, "title": item.get("title", "")})
if not merged:
logger.warning(
"search returned 0 merged PRs for %s/%s (status=%s total_count=%s)",
owner,
repo,
r.status_code,
body.get("total_count") if isinstance(body, dict) else "?",
)
return merged
async def collect_review_samples(
token: str,
owner: str,
repo: str,
*,
max_prs: int = DEFAULT_MAX_PRS,
max_reviewers: int = DEFAULT_MAX_REVIEWERS,
max_samples_per_reviewer: int = DEFAULT_MAX_SAMPLES_PER_REVIEWER,
) -> ReviewStyleSamples:
"""Sample recent merged PR feedback to identify reviewer style."""
full_name = f"{owner}/{repo}"
headers = github_headers(token)
raw_entries: list[tuple[str, int, ReviewSample]] = []
reviewer_counts: Counter[str] = Counter()
async with httpx.AsyncClient(timeout=90.0) as client:
merged_prs = await _recent_merged_prs(
client, owner=owner, repo=repo, headers=headers, max_prs=max_prs
)
for pr in merged_prs:
pr_number = pr.get("number")
if not isinstance(pr_number, int):
continue
reviews_url = f"{GITHUB_API}/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
for review in await _paginate(client, reviews_url, headers=headers, cap=100):
if not isinstance(review, dict):
continue
user = review.get("user")
if _is_bot_user(user if isinstance(user, dict) else None):
continue
login = (user or {}).get("login") if isinstance(user, dict) else None
if not isinstance(login, str):
continue
body = _substantive_body(review.get("body"))
if not body:
continue
reviewer_counts[login] += 1
raw_entries.append(
(
login,
pr_number,
ReviewSample(
pr_number=pr_number,
reviewer_login=login,
kind="review",
state=str(review.get("state") or ""),
body=body,
submitted_at=review.get("submitted_at"),
),
)
)
comments_url = f"{GITHUB_API}/repos/{owner}/{repo}/pulls/{pr_number}/comments"
for comment in await _paginate(client, comments_url, headers=headers, cap=200):
if not isinstance(comment, dict):
continue
user = comment.get("user")
if _is_bot_user(user if isinstance(user, dict) else None):
continue
login = (user or {}).get("login") if isinstance(user, dict) else None
if not isinstance(login, str):
continue
body = _substantive_body(comment.get("body"))
if not body:
continue
path = comment.get("path")
reviewer_counts[login] += 1
raw_entries.append(
(
login,
pr_number,
ReviewSample(
pr_number=pr_number,
reviewer_login=login,
kind="inline",
body=body,
path=str(path) if isinstance(path, str) else None,
submitted_at=comment.get("created_at"),
),
)
)
issue_comments_url = f"{GITHUB_API}/repos/{owner}/{repo}/issues/{pr_number}/comments"
for comment in await _paginate(client, issue_comments_url, headers=headers, cap=100):
if not isinstance(comment, dict):
continue
user = comment.get("user")
if _is_bot_user(user if isinstance(user, dict) else None):
continue
login = (user or {}).get("login") if isinstance(user, dict) else None
if not isinstance(login, str):
continue
body = _substantive_body(comment.get("body"))
if not body:
continue
reviewer_counts[login] += 1
raw_entries.append(
(
login,
pr_number,
ReviewSample(
pr_number=pr_number,
reviewer_login=login,
kind="issue",
body=body,
submitted_at=comment.get("created_at"),
),
)
)
top_reviewers = [login for login, _ in reviewer_counts.most_common(max_reviewers)]
top_set = set(top_reviewers)
per_reviewer: Counter[str] = Counter()
samples: list[ReviewSample] = []
for login, _pr_number, sample in raw_entries:
if login not in top_set:
continue
if per_reviewer[login] >= max_samples_per_reviewer:
continue
samples.append(sample)
per_reviewer[login] += 1
return ReviewStyleSamples(
full_name=full_name,
owner=owner,
name=repo,
top_reviewers=top_reviewers,
samples=samples,
prs_scanned=len(merged_prs),
reviews_scanned=len(raw_entries),
)
def format_samples_for_analyzer(samples: ReviewStyleSamples) -> str:
"""Render collected samples as context for the style-analyzer agent."""
lines = [
f"# Recent review samples for {samples.full_name}",
"",
f"Recently merged PRs scanned: {samples.prs_scanned}",
f"Review summaries + inline comments collected: {samples.reviews_scanned}",
f"Top reviewers ({len(samples.top_reviewers)}): {', '.join(samples.top_reviewers) or '(none)'}",
"",
]
if not samples.samples:
lines.append(
"Pre-collection found no substantive review text on recent merged PRs. "
"You must browse merged PRs yourself with `GH_TOKEN=dummy gh` (reviews, "
"pull comments, and issue comments) before saving."
)
return "\n".join(lines)
by_reviewer: dict[str, list[ReviewSample]] = {}
for s in samples.samples:
by_reviewer.setdefault(s.reviewer_login, []).append(s)
for login in samples.top_reviewers:
reviewer_samples = by_reviewer.get(login, [])
if not reviewer_samples:
continue
lines.append(f"## Reviewer: @{login}")
for s in reviewer_samples:
if s.kind == "inline":
loc = f" ({s.path})" if s.path else ""
lines.append(f"### PR #{s.pr_number} inline comment{loc}")
elif s.kind == "issue":
lines.append(f"### PR #{s.pr_number} issue comment")
else:
state = f", state={s.state}" if s.state else ""
lines.append(f"### PR #{s.pr_number} review summary{state}")
lines.append(s.body)
lines.append("")
return "\n".join(lines)