mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 20:53:15 +00:00
The reviewer agent was writing a 1–2 sentence "top-level take" as the
review body, which produced noisy paragraph-style summaries on PRs
("Reviewed the PR. The new ALLOWED_GITHUB_REPOS allowlist…"). Devin's
review comment is just a one-liner (`✅ No Issues Found` or
`**Devin Review** found N potential issue.`), and was preferred in the
internal A/B vs Graphite.
Drop the `summary` parameter from `publish_review`; render a fixed,
host-formatted body in `render_review_body` instead. Update the reviewer
prompt to forbid prose summaries.
282 lines
9.6 KiB
Python
282 lines
9.6 KiB
Python
"""GitHub Reviews API + GraphQL resolveReviewThread for the reviewer agent.
|
|
|
|
The reviewer agent calls ``publish_review`` at the end of a run. That tool
|
|
batches eligible findings (severity ≥ threshold, status=open, capped) into a
|
|
single GitHub PR Review:
|
|
|
|
- Review body: a fixed, host-formatted summary line. The agent never writes
|
|
prose here — it's either "no issues found" or "found N potential issue(s)".
|
|
- Inline comments: one per surfaced finding, anchored to ``path`` + ``line``
|
|
(+ ``start_line`` for ranges) + ``side``.
|
|
- Suggestion: when ``finding.suggestion`` is set, appended to the comment body
|
|
as a fenced ```suggestion``` block — gives the user the "Commit suggestion"
|
|
button on GitHub.
|
|
|
|
After publish, the returned per-comment IDs get stored back on each Finding as
|
|
``github_review_comment_id``. On a re-review run, when a finding moves
|
|
``open`` → ``resolved``, ``resolve_review_thread`` is called for that ID via
|
|
the GraphQL ``resolveReviewThread`` mutation (REST doesn't expose this).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from .reviewer_findings import Finding
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_GITHUB_API_BASE = "https://api.github.com"
|
|
_GITHUB_GRAPHQL = "https://api.github.com/graphql"
|
|
_GITHUB_HEADERS_VERSION = "2022-11-28"
|
|
|
|
|
|
def render_inline_comment_body(finding: Finding) -> str:
|
|
"""Render the body of one inline review comment.
|
|
|
|
Format:
|
|
|
|
<description>
|
|
|
|
```suggestion
|
|
<replacement>
|
|
```
|
|
|
|
The suggestion block is only included when ``finding.suggestion`` is set.
|
|
Multi-line suggestions just become multi-line ```suggestion``` blocks.
|
|
"""
|
|
description = finding.get("description", "") or ""
|
|
suggestion = finding.get("suggestion")
|
|
if not suggestion:
|
|
return description
|
|
return f"{description}\n\n```suggestion\n{suggestion}\n```"
|
|
|
|
|
|
def render_inline_comment_payload(finding: Finding) -> dict[str, Any] | None:
|
|
"""Render one finding into the payload shape GitHub's Reviews API expects.
|
|
|
|
Returns ``None`` for file-level findings (no line range), since the Reviews
|
|
API requires inline comments to be anchored to a line.
|
|
"""
|
|
file = finding.get("file")
|
|
start_line = finding.get("start_line")
|
|
end_line = finding.get("end_line")
|
|
side = finding.get("side", "RIGHT")
|
|
if not file or end_line is None:
|
|
return None
|
|
payload: dict[str, Any] = {
|
|
"path": file,
|
|
"line": end_line,
|
|
"side": side,
|
|
"body": render_inline_comment_body(finding),
|
|
}
|
|
if start_line is not None and start_line != end_line:
|
|
payload["start_line"] = start_line
|
|
payload["start_side"] = side
|
|
return payload
|
|
|
|
|
|
def render_review_body(*, pr_number: int, surfaced_count: int) -> str:
|
|
"""Compose the top-level review body.
|
|
|
|
Two fixed shapes — no agent prose:
|
|
|
|
- 0 surfaced findings: a single "no issues" line.
|
|
- N surfaced findings: a single "found N potential issue(s)" line.
|
|
"""
|
|
if surfaced_count == 0:
|
|
headline = (
|
|
"## ✅ Open SWE Review: No issues found\n\n"
|
|
"Open SWE reviewed this PR and found no potential bugs to report."
|
|
)
|
|
else:
|
|
issue_word = "issue" if surfaced_count == 1 else "issues"
|
|
headline = f"**Open SWE Review** found {surfaced_count} potential {issue_word}."
|
|
return f"{headline}\n\n<!-- open-swe-reviewer pr={pr_number} -->"
|
|
|
|
|
|
async def post_pull_request_review(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
head_sha: str,
|
|
body: str,
|
|
inline_comments: list[dict[str, Any]],
|
|
token: str,
|
|
) -> dict[str, Any] | None:
|
|
"""POST one GitHub PR Review with inline comments. Returns the API response or None."""
|
|
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
|
|
payload: dict[str, Any] = {
|
|
"commit_id": head_sha,
|
|
"event": "COMMENT",
|
|
"body": body,
|
|
"comments": inline_comments,
|
|
}
|
|
headers = _github_headers(token)
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.post(url, headers=headers, json=payload, timeout=30)
|
|
response.raise_for_status()
|
|
except httpx.HTTPError:
|
|
logger.exception("Failed to POST PR review for %s/%s#%s", owner, repo, pr_number)
|
|
return None
|
|
data = response.json()
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
async def fetch_review_comments(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
review_id: int,
|
|
token: str,
|
|
) -> list[dict[str, Any]]:
|
|
"""List the inline comments for a posted review.
|
|
|
|
GitHub's review-creation response includes a ``comments`` count but not the
|
|
per-comment IDs in all paths; this paginates the canonical list endpoint.
|
|
"""
|
|
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}/comments"
|
|
headers = _github_headers(token)
|
|
out: list[dict[str, Any]] = []
|
|
params: dict[str, Any] = {"per_page": 100, "page": 1}
|
|
async with httpx.AsyncClient() as client:
|
|
while True:
|
|
try:
|
|
response = await client.get(url, headers=headers, params=params, timeout=30)
|
|
response.raise_for_status()
|
|
except httpx.HTTPError:
|
|
logger.exception(
|
|
"Failed to list review comments for review %s on %s/%s",
|
|
review_id,
|
|
owner,
|
|
repo,
|
|
)
|
|
break
|
|
data = response.json()
|
|
if not isinstance(data, list) or not data:
|
|
break
|
|
out.extend(item for item in data if isinstance(item, dict))
|
|
if len(data) < 100: # noqa: PLR2004
|
|
break
|
|
params["page"] += 1
|
|
return out
|
|
|
|
|
|
async def fetch_review_thread_id_for_comment(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
review_comment_id: int,
|
|
token: str,
|
|
) -> str | None:
|
|
"""Resolve the GraphQL review-thread node id for a REST review-comment id.
|
|
|
|
GitHub's GraphQL API resolves "threads" rather than individual comments; to
|
|
resolve a thread we need its node id. The REST review-comment id is mapped
|
|
to the thread by walking the PR's review threads.
|
|
"""
|
|
query = """
|
|
query Threads($owner: String!, $repo: String!, $pr: Int!, $cursor: String) {
|
|
repository(owner: $owner, name: $repo) {
|
|
pullRequest(number: $pr) {
|
|
reviewThreads(first: 50, after: $cursor) {
|
|
pageInfo { hasNextPage endCursor }
|
|
nodes {
|
|
id
|
|
comments(first: 50) { nodes { databaseId } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
cursor: str | None = None
|
|
async with httpx.AsyncClient() as client:
|
|
while True:
|
|
try:
|
|
response = await client.post(
|
|
_GITHUB_GRAPHQL,
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={
|
|
"query": query,
|
|
"variables": {
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"pr": pr_number,
|
|
"cursor": cursor,
|
|
},
|
|
},
|
|
timeout=30,
|
|
)
|
|
response.raise_for_status()
|
|
except httpx.HTTPError:
|
|
logger.exception(
|
|
"Failed to fetch review threads for %s/%s#%s",
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
)
|
|
return None
|
|
data = response.json()
|
|
threads = (
|
|
data.get("data", {})
|
|
.get("repository", {})
|
|
.get("pullRequest", {})
|
|
.get("reviewThreads", {})
|
|
)
|
|
for thread in threads.get("nodes", []) or []:
|
|
comment_ids = {
|
|
c.get("databaseId") for c in (thread.get("comments", {}).get("nodes") or [])
|
|
}
|
|
if review_comment_id in comment_ids:
|
|
node_id = thread.get("id")
|
|
return node_id if isinstance(node_id, str) else None
|
|
page_info = threads.get("pageInfo") or {}
|
|
if not page_info.get("hasNextPage"):
|
|
return None
|
|
cursor = page_info.get("endCursor")
|
|
|
|
|
|
async def resolve_review_thread(*, thread_node_id: str, token: str) -> bool:
|
|
"""Mark a review thread as resolved via the GraphQL ``resolveReviewThread`` mutation."""
|
|
mutation = """
|
|
mutation Resolve($threadId: ID!) {
|
|
resolveReviewThread(input: {threadId: $threadId}) {
|
|
thread { id isResolved }
|
|
}
|
|
}
|
|
"""
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.post(
|
|
_GITHUB_GRAPHQL,
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={"query": mutation, "variables": {"threadId": thread_node_id}},
|
|
timeout=30,
|
|
)
|
|
response.raise_for_status()
|
|
except httpx.HTTPError:
|
|
logger.exception("Failed to resolve review thread %s", thread_node_id)
|
|
return False
|
|
data = response.json()
|
|
if data.get("errors"):
|
|
logger.warning("resolveReviewThread errors: %s", data["errors"])
|
|
return False
|
|
thread = data.get("data", {}).get("resolveReviewThread", {}).get("thread", {})
|
|
return bool(thread.get("isResolved"))
|
|
|
|
|
|
def _github_headers(token: str) -> dict[str, str]:
|
|
return {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": _GITHUB_HEADERS_VERSION,
|
|
}
|