mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 09:13:14 +00:00
* feat: PR review page * feat: manual review trigger card on admin page * feat: finding focus mode with floating card and dimmed backdrop * fix: dim only info panel, subtle block highlight for focused finding * refactor: move PR reviews into agents page with file-tree sidebar * fix: address review feedback - paginate list_reviews until enough accessible records collected - include rename/metadata-only files in diff API with empty hunks - remount ReviewBody on head_sha change so viewed-files state resets * fix: review tree background + finding card anchoring * fix: remove no-op PR size chip and dead check links * fix: parallel diff fetch, complete reReview type, shared github_headers --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
325 lines
11 KiB
Python
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)
|