mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 11:33:14 +00:00
* feat: AI-sorted PR review view with diff grouping Group a PR's changed files into a logical top-to-bottom walkthrough via a best-effort structured-output LLM pass, kicked off concurrently with the reviewer run (~0 added latency) and persisted on reviewer thread metadata. Results render in the review UI behind an AI sorted / file tree toggle that persists across PRs; the view falls back to the file tree when groups are absent or stale. Adds a grouping-model team default (inherits the Reviewer subagent model when unset) and the admin RolePicker for it. * feat(reviews): richer AI-sorted explanations + sidebar polish Sidebar group rows get Devin-style spacing (dividers, padding), a per-group file list (click a file to jump to its diff), inline-code chips in titles, and an accent Read explanation link. Group explanations are now rich markdown: the grouping prompt feeds per-hunk line ranges and asks for inline code, a short code block, and [path:line](#loc=...) references. The Markdown renderer turns those #loc= links into in-page buttons that scroll the diff to the hunk and highlight the range, reusing the existing selectedLines path. * fix(reviews): drop stale diff groups from the AI-sorted view When groups were generated for a previous head, a persisted "ai" view in localStorage still rendered the outdated walkthrough. groupedView now returns null on diff_groups_stale, so the file-tree fallback is used and the view toggle hides until fresh groups arrive. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
423 lines
15 KiB
Python
423 lines
15 KiB
Python
"""Read API for the PR review UI.
|
|
|
|
Reviewer threads (``metadata.kind == "reviewer"``) hold the durable review
|
|
state for a PR: identity (``pr``), findings, watch flag, and head SHA. These
|
|
endpoints surface that state plus live PR details/diff fetched from GitHub
|
|
with the App installation token.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Any, Literal
|
|
|
|
import httpx
|
|
from fastapi import HTTPException
|
|
|
|
from ..reviewer_findings import REVIEWER_THREAD_KIND
|
|
from ..utils.github_app import get_github_app_installation_token
|
|
from ..utils.github_checks import github_headers
|
|
from ..utils.thread_ops import langgraph_client
|
|
from .pr_diff import build_pr_diff_files
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_GITHUB_API = "https://api.github.com"
|
|
_GITHUB_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
|
|
|
|
|
|
async def _require_app_token() -> str:
|
|
token = await get_github_app_installation_token()
|
|
if not token:
|
|
raise HTTPException(503, "GitHub App token unavailable")
|
|
return token
|
|
|
|
|
|
async def _github_get(
|
|
path: str, token: str, *, accept: str | None = None, params: dict[str, Any] | None = None
|
|
) -> Any:
|
|
headers = github_headers(token)
|
|
if accept:
|
|
headers["Accept"] = accept
|
|
async with httpx.AsyncClient(timeout=_GITHUB_TIMEOUT) as client:
|
|
response = await client.get(f"{_GITHUB_API}{path}", headers=headers, params=params)
|
|
if response.status_code == 404:
|
|
raise HTTPException(404, "not found on GitHub")
|
|
if response.status_code >= 400:
|
|
logger.warning("GitHub GET %s failed: %s", path, response.status_code)
|
|
raise HTTPException(502, f"GitHub request failed ({response.status_code})")
|
|
if accept and "json" not in accept:
|
|
return response.text
|
|
return response.json()
|
|
|
|
|
|
def reviewer_thread_id(owner: str, repo: str, pr_number: int) -> str:
|
|
import uuid
|
|
|
|
stable_key = f"{owner}/{repo}/pr/{pr_number}/reviewer"
|
|
return str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key))
|
|
|
|
|
|
def _findings_list(metadata: dict[str, Any]) -> list[dict[str, Any]]:
|
|
findings = metadata.get("findings")
|
|
if not isinstance(findings, list):
|
|
return []
|
|
return [f for f in findings if isinstance(f, dict) and isinstance(f.get("id"), str)]
|
|
|
|
|
|
def _serialize_finding(finding: dict[str, Any], head_sha: str | None) -> dict[str, Any]:
|
|
last_confirmed = finding.get("last_confirmed_sha")
|
|
outdated = bool(
|
|
head_sha
|
|
and isinstance(last_confirmed, str)
|
|
and last_confirmed
|
|
and last_confirmed != head_sha
|
|
)
|
|
interactions = finding.get("interactions")
|
|
return {
|
|
"id": finding.get("id"),
|
|
"severity": finding.get("severity", "low"),
|
|
"confidence": finding.get("confidence", "medium"),
|
|
"category": finding.get("category", ""),
|
|
"title": finding.get("title") or "",
|
|
"description": finding.get("description", ""),
|
|
"suggestion": finding.get("suggestion"),
|
|
"file": finding.get("file", ""),
|
|
"start_line": finding.get("start_line"),
|
|
"end_line": finding.get("end_line"),
|
|
"side": finding.get("side", "RIGHT"),
|
|
"in_diff": bool(finding.get("in_diff", True)),
|
|
"status": finding.get("status", "open"),
|
|
"outdated": outdated,
|
|
"resolution_note": finding.get("resolution_note"),
|
|
"diff_hunk": finding.get("diff_hunk"),
|
|
"github_thread_resolved": bool(finding.get("github_thread_resolved")),
|
|
"github_review_comment_id": (
|
|
finding["github_review_comment_id"]
|
|
if isinstance(finding.get("github_review_comment_id"), int)
|
|
else None
|
|
),
|
|
"interactions": interactions if isinstance(interactions, list) else [],
|
|
}
|
|
|
|
|
|
_BUG_SEVERITIES = frozenset({"high", "critical"})
|
|
|
|
|
|
def classify_finding(finding: dict[str, Any]) -> Literal["bug", "investigate", "informational"]:
|
|
"""Map our severity/confidence model onto the UI's Bugs/Flags split."""
|
|
severity = finding.get("severity", "low")
|
|
confidence = finding.get("confidence", "medium")
|
|
if severity in _BUG_SEVERITIES and confidence == "high":
|
|
return "bug"
|
|
if severity != "low":
|
|
return "investigate"
|
|
return "informational"
|
|
|
|
|
|
def _serialize_diff_groups(
|
|
metadata: dict[str, Any], head_sha: str
|
|
) -> tuple[list[dict[str, Any]], bool]:
|
|
"""Serialize the persisted ``diff_groups`` for the AI sorted view.
|
|
|
|
Returns ``(groups, stale)`` where each group carries a 1-based ``index``
|
|
and validated fields, and ``stale`` is True when the groups were generated
|
|
for a different head SHA than the one currently being rendered.
|
|
"""
|
|
raw = metadata.get("diff_groups")
|
|
if not isinstance(raw, dict):
|
|
return [], False
|
|
raw_groups = raw.get("groups")
|
|
if not isinstance(raw_groups, list):
|
|
return [], False
|
|
groups: list[dict[str, Any]] = []
|
|
for group in raw_groups:
|
|
if not isinstance(group, dict):
|
|
continue
|
|
title = group.get("title")
|
|
if not isinstance(title, str) or not title.strip():
|
|
continue
|
|
files = [f for f in (group.get("files") or []) if isinstance(f, str) and f]
|
|
if not files:
|
|
continue
|
|
summary = group.get("summary")
|
|
groups.append(
|
|
{
|
|
"index": len(groups) + 1,
|
|
"title": title.strip(),
|
|
"summary": summary.strip() if isinstance(summary, str) else "",
|
|
"files": files,
|
|
}
|
|
)
|
|
groups_head = raw.get("head_sha")
|
|
stale = bool(
|
|
head_sha and isinstance(groups_head, str) and groups_head and groups_head != head_sha
|
|
)
|
|
return groups, stale
|
|
|
|
|
|
def _finding_counts(findings: list[dict[str, Any]]) -> dict[str, int]:
|
|
counts = {"open": 0, "resolved": 0, "dismissed": 0, "bugs": 0, "flags": 0}
|
|
for finding in findings:
|
|
status = finding.get("status", "open")
|
|
if status in counts:
|
|
counts[status] += 1
|
|
if status == "open":
|
|
if classify_finding(finding) == "bug":
|
|
counts["bugs"] += 1
|
|
else:
|
|
counts["flags"] += 1
|
|
return counts
|
|
|
|
|
|
def _run_status(thread: dict[str, Any], metadata: dict[str, Any]) -> str:
|
|
if thread.get("status") == "busy":
|
|
return "running"
|
|
latest = metadata.get("latest_run_status")
|
|
if latest in {"pending", "running"}:
|
|
return "running"
|
|
if latest in {"error", "failed", "timeout", "interrupted"}:
|
|
return "error"
|
|
return "idle"
|
|
|
|
|
|
def _thread_review_summary(thread: dict[str, Any]) -> dict[str, Any] | None:
|
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
|
pr = metadata.get("pr")
|
|
if not isinstance(pr, dict):
|
|
return None
|
|
owner = pr.get("owner")
|
|
name = pr.get("name")
|
|
number = pr.get("number")
|
|
if not (isinstance(owner, str) and isinstance(name, str) and isinstance(number, int)):
|
|
return None
|
|
findings = _findings_list(metadata)
|
|
updated_at = thread.get("updated_at")
|
|
return {
|
|
"thread_id": thread.get("thread_id"),
|
|
"owner": owner,
|
|
"repo": name,
|
|
"full_name": f"{owner}/{name}",
|
|
"number": number,
|
|
"title": pr.get("title") or f"PR #{number}",
|
|
"url": pr.get("url") or f"https://github.com/{owner}/{name}/pull/{number}",
|
|
"head_ref": pr.get("head_ref") or "",
|
|
"base_ref": pr.get("base_ref") or "",
|
|
"author": pr.get("author") if isinstance(pr.get("author"), str) else "",
|
|
"head_sha": metadata.get("head_sha") or "",
|
|
"watch": bool(metadata.get("watch")),
|
|
"status": _run_status(thread, metadata),
|
|
"counts": _finding_counts(findings),
|
|
"updated_at": updated_at if isinstance(updated_at, str) else None,
|
|
}
|
|
|
|
|
|
async def list_reviews(
|
|
limit: int = 20,
|
|
*,
|
|
offset: int = 0,
|
|
author: str | None = None,
|
|
is_accessible: Callable[[dict[str, Any]], Awaitable[bool]] | None = None,
|
|
page_size: int = 100,
|
|
max_scan: int = 1000,
|
|
) -> tuple[list[dict[str, Any]], bool]:
|
|
"""List review summaries, newest first.
|
|
|
|
Returns ``(summaries, has_more)`` where the summaries are the page at
|
|
``offset`` (counted in accessible, filter-matching records) and
|
|
``has_more`` says whether at least one more record exists past it.
|
|
|
|
``author`` is pushed into the ``threads.search`` metadata filter
|
|
(``pr.author`` containment), so the "My PRs" tab only fetches the user's
|
|
own reviewer threads instead of scanning the whole population in Python.
|
|
|
|
When ``is_accessible`` is given, keeps paging through reviewer threads
|
|
until enough accessible summaries are collected (or ``max_scan`` threads
|
|
have been examined), so inaccessible records don't crowd accessible ones
|
|
out of a single fixed-size page.
|
|
"""
|
|
client = langgraph_client()
|
|
search_metadata: dict[str, Any] = {"kind": REVIEWER_THREAD_KIND}
|
|
if author is not None:
|
|
search_metadata["pr"] = {"author": author}
|
|
needed = offset + limit + 1
|
|
summaries: list[dict[str, Any]] = []
|
|
scan_offset = 0
|
|
while len(summaries) < needed and scan_offset < max_scan:
|
|
threads = await client.threads.search(
|
|
metadata=search_metadata,
|
|
limit=page_size,
|
|
offset=scan_offset,
|
|
sort_by="updated_at",
|
|
sort_order="desc",
|
|
)
|
|
if not threads:
|
|
break
|
|
for thread in threads:
|
|
if not isinstance(thread, dict):
|
|
continue
|
|
summary = _thread_review_summary(thread)
|
|
if not summary:
|
|
continue
|
|
if is_accessible is not None and not await is_accessible(summary):
|
|
continue
|
|
summaries.append(summary)
|
|
if len(summaries) >= needed:
|
|
break
|
|
if len(threads) < page_size:
|
|
break
|
|
scan_offset += page_size
|
|
page = summaries[offset : offset + limit]
|
|
return page, len(summaries) > offset + limit
|
|
|
|
|
|
def _user_ref(value: Any) -> dict[str, Any] | None:
|
|
if not isinstance(value, dict):
|
|
return None
|
|
login = value.get("login")
|
|
if not isinstance(login, str):
|
|
return None
|
|
return {"login": login, "avatar_url": value.get("avatar_url")}
|
|
|
|
|
|
def _serialize_pr_details(payload: dict[str, Any]) -> dict[str, Any]:
|
|
labels = payload.get("labels")
|
|
state = payload.get("state")
|
|
if payload.get("merged"):
|
|
state = "merged"
|
|
elif payload.get("draft"):
|
|
state = "draft"
|
|
return {
|
|
"state": state if isinstance(state, str) else "open",
|
|
"title": payload.get("title") or "",
|
|
"body": payload.get("body") or "",
|
|
"additions": payload.get("additions") or 0,
|
|
"deletions": payload.get("deletions") or 0,
|
|
"changed_files": payload.get("changed_files") or 0,
|
|
"commits": payload.get("commits") or 0,
|
|
"head_sha": (payload.get("head") or {}).get("sha") or "",
|
|
"head_ref": (payload.get("head") or {}).get("ref") or "",
|
|
"base_ref": (payload.get("base") or {}).get("ref") or "",
|
|
"author": _user_ref(payload.get("user")),
|
|
"assignees": [
|
|
user
|
|
for user in (_user_ref(value) for value in payload.get("assignees") or [])
|
|
if user is not None
|
|
],
|
|
"requested_reviewers": [
|
|
user
|
|
for user in (_user_ref(value) for value in payload.get("requested_reviewers") or [])
|
|
if user is not None
|
|
],
|
|
"labels": [
|
|
{"name": label.get("name"), "color": label.get("color")}
|
|
for label in (labels if isinstance(labels, list) else [])
|
|
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
|
],
|
|
}
|
|
|
|
|
|
async def _fetch_check_runs(owner: str, repo: str, sha: str, token: str) -> list[dict[str, Any]]:
|
|
if not sha:
|
|
return []
|
|
try:
|
|
payload = await _github_get(
|
|
f"/repos/{owner}/{repo}/commits/{sha}/check-runs",
|
|
token,
|
|
params={"per_page": 50},
|
|
)
|
|
except HTTPException:
|
|
return []
|
|
runs = payload.get("check_runs") if isinstance(payload, dict) else None
|
|
out: list[dict[str, Any]] = []
|
|
for run in runs if isinstance(runs, list) else []:
|
|
if not isinstance(run, dict):
|
|
continue
|
|
out.append(
|
|
{
|
|
"name": run.get("name") or "",
|
|
"status": run.get("status") or "",
|
|
"conclusion": run.get("conclusion"),
|
|
"url": run.get("html_url"),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
async def get_review(owner: str, repo: str, pr_number: int) -> dict[str, Any]:
|
|
thread_id = reviewer_thread_id(owner, repo, pr_number)
|
|
client = langgraph_client()
|
|
try:
|
|
thread = await client.threads.get(thread_id)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(404, "review not found") from exc
|
|
if not isinstance(thread, dict):
|
|
raise HTTPException(404, "review not found")
|
|
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
|
|
summary = _thread_review_summary(thread)
|
|
if not summary:
|
|
raise HTTPException(404, "review not found")
|
|
|
|
token = await _require_app_token()
|
|
pr_payload = await _github_get(f"/repos/{owner}/{repo}/pulls/{pr_number}", token)
|
|
details = _serialize_pr_details(pr_payload if isinstance(pr_payload, dict) else {})
|
|
head_sha = details["head_sha"] or summary["head_sha"]
|
|
checks = await _fetch_check_runs(owner, repo, head_sha, token)
|
|
|
|
findings = [_serialize_finding(finding, head_sha) for finding in _findings_list(metadata)]
|
|
findings.sort(
|
|
key=lambda f: (
|
|
f["status"] != "open",
|
|
{"bug": 0, "investigate": 1, "informational": 2}[classify_finding(f)],
|
|
f["file"],
|
|
f["start_line"] or 0,
|
|
)
|
|
)
|
|
for finding in findings:
|
|
finding["group"] = classify_finding(finding)
|
|
|
|
diff_groups, diff_groups_stale = _serialize_diff_groups(metadata, head_sha)
|
|
|
|
return {
|
|
**summary,
|
|
"pr": details,
|
|
"checks": checks,
|
|
"findings": findings,
|
|
"diff_groups": diff_groups,
|
|
"diff_groups_stale": diff_groups_stale,
|
|
}
|
|
|
|
|
|
async def get_review_diff(owner: str, repo: str, pr_number: int) -> dict[str, Any]:
|
|
"""Return the PR's changed files with full original/modified contents.
|
|
|
|
Uses the App installation token so the diff is available regardless of who
|
|
is viewing the review. The client renders these with pierre's MultiFileDiff.
|
|
"""
|
|
token = await _require_app_token()
|
|
async with httpx.AsyncClient(headers=github_headers(token), timeout=_GITHUB_TIMEOUT) as client:
|
|
diff = await build_pr_diff_files(client, f"{owner}/{repo}", pr_number)
|
|
files = diff["files"]
|
|
return {
|
|
"files": files,
|
|
"total_additions": sum(f["additions"] for f in files),
|
|
"total_deletions": sum(f["deletions"] for f in files),
|
|
"truncated": diff["truncated"],
|
|
}
|
|
|
|
|
|
async def trigger_re_review(owner: str, repo: str, pr_number: int, login: str) -> dict[str, Any]:
|
|
from ..utils.slack import GitHubPrRef
|
|
from ..webapp import trigger_pr_review_from_ref
|
|
|
|
pr_ref = GitHubPrRef(
|
|
owner=owner,
|
|
repo=repo,
|
|
number=pr_number,
|
|
url=f"https://github.com/{owner}/{repo}/pull/{pr_number}",
|
|
)
|
|
result = await trigger_pr_review_from_ref(pr_ref, source="dashboard", github_login=login)
|
|
if not result.get("success"):
|
|
raise HTTPException(502, str(result.get("error") or "could not trigger review"))
|
|
return result
|