mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 17:23:15 +00:00
Some checks failed
CI / Lint (push) Has been cancelled
CI / Format check (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Unit tests (push) Has been cancelled
CI / Playwright E2E (push) Has been cancelled
CI / Docker build smoke (push) Has been cancelled
CI / Triage ledger up to date (push) Has been cancelled
CI / ui bun.lock in sync (push) Has been cancelled
* feat(reviewer): explicit-request verdicts + shell verdict guard Mention-triggered reviews that explicitly ask for a verdict now submit a real APPROVE/REQUEST_CHANGES through publish_review; auto-reviews stay advisory (COMMENT). Authorization is enforced in code: publish_review honors a verdict only when the dispatching webhook set verdict_requested, which only the explicit-mention path does. - request_pr_review gains instructions (forwarded verbatim into an escaped requester_instructions data block) and request_verdict - self-review guard downgrades verdicts on Open SWE-authored PRs; stale APPROVEs are best-effort dismissed when later findings land - new PullRequestVerdictGuardMiddleware blocks gh pr review --approve/-a/--request-changes/-r, gh api, and curl verdict fallbacks on both the coding-agent and reviewer graphs - shared escape helper moved to agent/utils/prompt_data.py * fix(reviewer): harden verdict path against security-review findings Adversarial security review (detector fan-out + proof-or-kill verifier) of the verdict feature surfaced several verdict-integrity gaps; resolve the confirmed ones: - head-drift (high): a mid-run push moves the resolved head, so an APPROVE could anchor to an unreviewed commit. Downgrade any verdict to a comment when the resolved head differs from the reviewed head (verdict_ignored reason head_moved); the push's own re-review submits a fresh verdict. - self-review fail-open: downgrade to comment when the PR author cannot be confirmed (author_unknown), and compare bot logins case-insensitively. - verdict_submitted now reflects GitHub's returned review state, not just the event we asked for, so a coerced APPROVE isn't reported as submitted. - an authorized verdict whose findings all anchor outside the diff now posts as a bodied review with zero inline comments instead of failing. - add finding_reply to the shared data-block escape tag superset.
1009 lines
36 KiB
Python
1009 lines
36 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).
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Any, TypedDict
|
|
|
|
import httpx
|
|
|
|
from ..utils.dashboard_links import dashboard_thread_url
|
|
from ..utils.github_checks import CheckConclusion, complete_review_check_run
|
|
from ..utils.github_http import (
|
|
GITHUB_API_BASE,
|
|
GITHUB_GRAPHQL,
|
|
github_client,
|
|
github_request,
|
|
)
|
|
from ..utils.github_token import GitHubAuthError
|
|
from .findings import (
|
|
DiffSide,
|
|
Finding,
|
|
get_thread_metadata,
|
|
normalize_finding_title,
|
|
set_reviewer_thread_metadata,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_GITHUB_API_BASE = GITHUB_API_BASE
|
|
_GITHUB_GRAPHQL = GITHUB_GRAPHQL
|
|
_OPEN_SWE_REVIEW_COMMENT_MARKER_RE = re.compile(
|
|
r"<!--\s*open-swe-review-comment\s+(\{.*?\})\s*-->",
|
|
re.DOTALL,
|
|
)
|
|
|
|
|
|
class ReviewCommentMarker(TypedDict):
|
|
id: str
|
|
file_path: str
|
|
start_line: int | None
|
|
end_line: int | None
|
|
side: DiffSide
|
|
|
|
|
|
def _optional_int(value: Any) -> int | None:
|
|
return value if isinstance(value, int) else None
|
|
|
|
|
|
def parse_review_comment_marker(body: str) -> ReviewCommentMarker | None:
|
|
match = _OPEN_SWE_REVIEW_COMMENT_MARKER_RE.search(body)
|
|
if match is None:
|
|
return None
|
|
try:
|
|
payload = json.loads(match.group(1))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
|
|
finding_id = payload.get("id")
|
|
file_path = payload.get("file_path")
|
|
side_raw = payload.get("side", "RIGHT")
|
|
if not isinstance(finding_id, str) or not finding_id:
|
|
return None
|
|
if not isinstance(file_path, str) or not file_path:
|
|
return None
|
|
if side_raw not in {"LEFT", "RIGHT"}:
|
|
return None
|
|
side: DiffSide = "LEFT" if side_raw == "LEFT" else "RIGHT"
|
|
return {
|
|
"id": finding_id,
|
|
"file_path": file_path,
|
|
"start_line": _optional_int(payload.get("start_line")),
|
|
"end_line": _optional_int(payload.get("end_line")),
|
|
"side": side,
|
|
}
|
|
|
|
|
|
def render_inline_comment_body(finding: Finding) -> str:
|
|
"""Render the body of one inline review comment.
|
|
|
|
Format:
|
|
|
|
<!-- metadata marker -->
|
|
|
|
🟡 **Generated finding title**
|
|
|
|
<finding description detail>
|
|
|
|
*(Refers to lines X-Y)*
|
|
|
|
---
|
|
*Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.*
|
|
|
|
```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 "").strip()
|
|
severity = finding.get("severity") or "medium"
|
|
marker_payload = {
|
|
"id": finding.get("id", ""),
|
|
"file_path": finding.get("file", ""),
|
|
"start_line": finding.get("start_line"),
|
|
"end_line": finding.get("end_line"),
|
|
"side": finding.get("side", "RIGHT"),
|
|
}
|
|
marker = f"<!-- open-swe-review-comment {json.dumps(marker_payload, separators=(',', ':'))} -->"
|
|
|
|
title, detail = _split_title_and_detail(description, finding.get("title"))
|
|
line_ref = _format_line_reference(finding.get("start_line"), finding.get("end_line"))
|
|
|
|
body_parts = [marker, "", f"{_severity_emoji(severity)} **{title}**"]
|
|
if detail:
|
|
body_parts.extend(["", detail])
|
|
if line_ref:
|
|
body_parts.extend(["", line_ref])
|
|
body_parts.extend(
|
|
[
|
|
"",
|
|
"---",
|
|
"*Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.*",
|
|
]
|
|
)
|
|
body = "\n".join(body_parts)
|
|
|
|
suggestion = finding.get("suggestion")
|
|
if suggestion:
|
|
body = f"{body}\n\n```suggestion\n{suggestion}\n```"
|
|
return body
|
|
|
|
|
|
def _severity_emoji(severity: str) -> str:
|
|
return {
|
|
"critical": "🔴",
|
|
"high": "🟠",
|
|
"medium": "🟡",
|
|
"low": "🔵",
|
|
}.get(severity, "🟡")
|
|
|
|
|
|
def _split_title_and_detail(description: str, title: object = None) -> tuple[str, str]:
|
|
"""Split a generated finding title from the review comment detail."""
|
|
if isinstance(title, str) and title.strip():
|
|
normalized_title = normalize_finding_title(title)
|
|
detail = description.strip()
|
|
if detail:
|
|
lines = description.split("\n")
|
|
if normalize_finding_title(lines[0]) == normalized_title:
|
|
detail = "\n".join(lines[1:]).strip()
|
|
return normalized_title, detail
|
|
|
|
if not description:
|
|
return normalize_finding_title(None), ""
|
|
lines = description.split("\n")
|
|
first_line = lines[0].strip()
|
|
detail = "\n".join(lines[1:]).strip()
|
|
normalized_title = normalize_finding_title(first_line)
|
|
if not detail and normalized_title != " ".join(first_line.split()):
|
|
return normalized_title, description
|
|
return normalized_title, detail
|
|
|
|
|
|
def _format_line_reference(start_line: int | None, end_line: int | None) -> str:
|
|
"""Format the line reference footer."""
|
|
if end_line is None:
|
|
return ""
|
|
if start_line is None or start_line == end_line:
|
|
return f"*(Refers to line {end_line})*"
|
|
return f"*(Refers to lines {start_line}-{end_line})*"
|
|
|
|
|
|
def render_resolution_comment(
|
|
finding: Finding,
|
|
status: str,
|
|
note: str | None = None,
|
|
) -> str | None:
|
|
"""Render the agent-provided reply for a review thread."""
|
|
return _resolution_body(finding, note)
|
|
|
|
|
|
def _resolution_body(finding: Finding, note: str | None) -> str | None:
|
|
candidates = [note, finding.get("resolution_note"), finding.get("last_update_note")]
|
|
for candidate in candidates:
|
|
if isinstance(candidate, str) and candidate.strip():
|
|
return candidate.strip()
|
|
return None
|
|
|
|
|
|
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 review_summary_marker(pr_number: int) -> str:
|
|
"""The hidden marker embedded in every Open SWE review summary body.
|
|
|
|
Used both to stamp the summary (``render_review_body``) and to detect
|
|
(``open_swe_review_exists``) whether Open SWE has already reviewed a PR.
|
|
"""
|
|
return f"<!-- open-swe-reviewer pr={pr_number} -->"
|
|
|
|
|
|
def render_out_of_diff_section(findings: list[Finding]) -> str:
|
|
"""Render findings anchored outside the PR diff as a collapsed dropdown.
|
|
|
|
These can't be posted as inline comments (GitHub rejects off-diff lines), so
|
|
they live in the review summary body inside a ``<details>`` block — visible
|
|
on demand without adding noise to the changed-line review.
|
|
"""
|
|
count = len(findings)
|
|
noun = "finding" if count == 1 else "findings"
|
|
items: list[str] = []
|
|
for f in findings:
|
|
title, detail = _split_title_and_detail(
|
|
(f.get("description") or "").strip(), f.get("title")
|
|
)
|
|
location = f.get("file") or "?"
|
|
line_ref = _format_line_reference(f.get("start_line"), f.get("end_line"))
|
|
if line_ref:
|
|
location += f" {line_ref.strip('*()')}".replace("Refers to ", "")
|
|
item = f"- {_severity_emoji(f.get('severity') or 'medium')} **{title}** — `{location}`"
|
|
if detail:
|
|
item += f"\n {detail}"
|
|
items.append(item)
|
|
return (
|
|
f"<details>\n<summary>🔍 {count} out-of-diff {noun}</summary>\n\n"
|
|
"These relate to code outside this PR's changed lines.\n\n"
|
|
+ "\n".join(items)
|
|
+ "\n</details>"
|
|
)
|
|
|
|
|
|
def render_review_body(
|
|
*,
|
|
pr_number: int,
|
|
surfaced_count: int,
|
|
trace_url: str | None = None,
|
|
ui_url: str | None = None,
|
|
out_of_diff_findings: list[Finding] | None = None,
|
|
additional_findings_count: int = 0,
|
|
) -> str:
|
|
"""Compose the top-level review body.
|
|
|
|
When ``surfaced_count`` is 0 but ``additional_findings_count`` is > 0,
|
|
the headline says "No issues found" and a second line directs the reader
|
|
to the web app for the remaining sub-threshold findings.
|
|
"""
|
|
out_of_diff_findings = out_of_diff_findings or []
|
|
has_additional = additional_findings_count > 0
|
|
if surfaced_count == 0 and not out_of_diff_findings:
|
|
headline = (
|
|
"## ✅ Open SWE Review: No issues found\n\n"
|
|
"Open SWE reviewed this PR and found no potential bugs to report."
|
|
)
|
|
elif surfaced_count == 0:
|
|
headline = "**Open SWE Review** found no issues in the changed lines."
|
|
else:
|
|
issue_word = "issue" if surfaced_count == 1 else "issues"
|
|
headline = f"**Open SWE Review** found {surfaced_count} potential {issue_word}."
|
|
|
|
parts = [headline]
|
|
if has_additional:
|
|
noun = "finding" if additional_findings_count == 1 else "findings"
|
|
parts.append(f"{additional_findings_count} additional {noun} can be viewed in the web app.")
|
|
if out_of_diff_findings:
|
|
parts.append(render_out_of_diff_section(out_of_diff_findings))
|
|
links = []
|
|
if ui_url:
|
|
links.append(f"[Open in Web]({ui_url})")
|
|
if trace_url:
|
|
links.append(f"[View Open SWE trace]({trace_url})")
|
|
if links:
|
|
parts.append(" • ".join(links))
|
|
parts.append(review_summary_marker(pr_number))
|
|
return "\n\n".join(parts)
|
|
|
|
|
|
def status_comment_marker(pr_number: int) -> str:
|
|
"""Hidden marker stamped on the live status comment for a PR."""
|
|
return f"<!-- open-swe-reviewer-status pr={pr_number} -->"
|
|
|
|
|
|
def render_status_comment(
|
|
*,
|
|
pr_number: int,
|
|
thread_id: str | None = None,
|
|
trace_url: str | None = None,
|
|
) -> str:
|
|
"""Compose the transient "review in progress" comment.
|
|
|
|
Posted when a review starts so the PR shows activity and a clickable
|
|
"Open in Web" link while the run is live; deleted once ``publish_review``
|
|
posts the review (which carries the same link).
|
|
"""
|
|
parts = ["## 🔍 Open SWE Review: in progress\n\nOpen SWE is reviewing this PR…"]
|
|
links = []
|
|
ui_url = dashboard_thread_url(thread_id) if thread_id else None
|
|
if ui_url:
|
|
links.append(f"[Open in Web]({ui_url})")
|
|
if trace_url:
|
|
links.append(f"[View Open SWE trace]({trace_url})")
|
|
if links:
|
|
parts.append(" • ".join(links))
|
|
parts.append(status_comment_marker(pr_number))
|
|
return "\n\n".join(parts)
|
|
|
|
|
|
async def post_status_comment(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
body: str,
|
|
token: str,
|
|
) -> int | None:
|
|
"""POST the live status comment to a PR. Returns its comment id or None."""
|
|
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/issues/{pr_number}/comments"
|
|
async with github_client(token=token) as client:
|
|
try:
|
|
response = await github_request(client, "POST", url, json={"body": body})
|
|
response.raise_for_status()
|
|
except httpx.HTTPError:
|
|
logger.exception("Failed to post status comment for %s/%s#%s", owner, repo, pr_number)
|
|
return None
|
|
data = response.json()
|
|
comment_id = data.get("id") if isinstance(data, dict) else None
|
|
return comment_id if isinstance(comment_id, int) else None
|
|
|
|
|
|
async def delete_status_comment(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
comment_id: int,
|
|
token: str,
|
|
) -> bool:
|
|
"""DELETE a status comment by id. Returns True on success (or if gone)."""
|
|
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/issues/comments/{comment_id}"
|
|
async with github_client(token=token) as client:
|
|
try:
|
|
response = await github_request(client, "DELETE", url)
|
|
if response.status_code == 404: # noqa: PLR2004
|
|
return True
|
|
response.raise_for_status()
|
|
except httpx.HTTPError:
|
|
logger.exception("Failed to delete status comment %s on %s/%s", comment_id, owner, repo)
|
|
return False
|
|
return True
|
|
|
|
|
|
async def post_review_started_comment(
|
|
*,
|
|
thread_id: str,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
token: str,
|
|
trace_url: str | None = None,
|
|
) -> int | None:
|
|
"""Post (or refresh) the transient "review in progress" comment.
|
|
|
|
Reuses the ``status_comment_id`` persisted in reviewer thread metadata when
|
|
one already lingers (a prior run that never settled), otherwise posts a
|
|
fresh comment and stores its id so ``clear_review_started_comment`` can
|
|
delete it once the review lands.
|
|
"""
|
|
metadata = await get_thread_metadata(thread_id)
|
|
existing_id = metadata.get("status_comment_id")
|
|
if isinstance(existing_id, int):
|
|
await delete_status_comment(owner=owner, repo=repo, comment_id=existing_id, token=token)
|
|
body = render_status_comment(pr_number=pr_number, thread_id=thread_id, trace_url=trace_url)
|
|
new_id = await post_status_comment(
|
|
owner=owner, repo=repo, pr_number=pr_number, body=body, token=token
|
|
)
|
|
await set_reviewer_thread_metadata(thread_id, extra={"status_comment_id": new_id})
|
|
return new_id
|
|
|
|
|
|
async def clear_review_started_comment(
|
|
*,
|
|
thread_id: str,
|
|
owner: str,
|
|
repo: str,
|
|
token: str,
|
|
) -> None:
|
|
"""Delete the transient "review in progress" comment, if one is tracked."""
|
|
metadata = await get_thread_metadata(thread_id)
|
|
comment_id = metadata.get("status_comment_id")
|
|
if not isinstance(comment_id, int):
|
|
return
|
|
await delete_status_comment(owner=owner, repo=repo, comment_id=comment_id, token=token)
|
|
await set_reviewer_thread_metadata(thread_id, extra={"status_comment_id": None})
|
|
|
|
|
|
async def settle_review_check_run(
|
|
*,
|
|
thread_id: str,
|
|
owner: str,
|
|
repo: str,
|
|
token: str,
|
|
conclusion: CheckConclusion,
|
|
title: str,
|
|
summary: str,
|
|
) -> None:
|
|
"""Complete the tracked ``Open SWE Review`` check run, if one is open.
|
|
|
|
The dispatching webhook stores ``review_check_run_id`` in reviewer thread
|
|
metadata when it creates the check. No-op when none is tracked. The id is
|
|
only cleared after a successful PATCH so a transient failure (timeout,
|
|
5xx, rate limit) leaves it in place for the after-agent hook or a later
|
|
publish to retry — otherwise the check would hang in-progress forever.
|
|
On failure the intended result is persisted as
|
|
``review_check_pending_result`` so the retry reports this conclusion, not
|
|
a generic failure that would misreport a published review.
|
|
"""
|
|
metadata = await get_thread_metadata(thread_id)
|
|
check_run_id = metadata.get("review_check_run_id")
|
|
if not isinstance(check_run_id, int):
|
|
return
|
|
ok = await complete_review_check_run(
|
|
owner=owner,
|
|
repo=repo,
|
|
check_run_id=check_run_id,
|
|
token=token,
|
|
conclusion=conclusion,
|
|
title=title,
|
|
summary=summary,
|
|
)
|
|
if ok:
|
|
await set_reviewer_thread_metadata(
|
|
thread_id,
|
|
extra={"review_check_run_id": None, "review_check_pending_result": None},
|
|
)
|
|
else:
|
|
await set_reviewer_thread_metadata(
|
|
thread_id,
|
|
extra={
|
|
"review_check_pending_result": {
|
|
"conclusion": conclusion,
|
|
"title": title,
|
|
"summary": summary,
|
|
}
|
|
},
|
|
)
|
|
|
|
|
|
async def open_swe_review_exists(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
token: str,
|
|
) -> bool | None:
|
|
"""Return whether Open SWE has already posted a review summary on this PR.
|
|
|
|
Detected via the ``review_summary_marker`` that ``render_review_body``
|
|
embeds in every Open SWE review body. The reviewer uses this to avoid
|
|
posting a duplicate "No issues found" summary when the ``re_review`` config
|
|
flag is stale — a push that lands mid-run is delivered as a queued message
|
|
into the still-running first-review run, whose configurable still says
|
|
``re_review=False``, so the empty-review guard can't trust that flag alone.
|
|
|
|
Tri-state on purpose:
|
|
- ``True`` — an Open SWE review summary was found.
|
|
- ``False`` — the full review list was paginated successfully and carried
|
|
no Open SWE summary.
|
|
- ``None`` — the answer is unknown because an API call (or a page partway
|
|
through pagination) failed. Callers must not treat ``None`` as "no review
|
|
exists": the old fail-open-as-False behaviour double-posted "no issues"
|
|
summaries whenever pagination failed mid-walk.
|
|
"""
|
|
marker = review_summary_marker(pr_number)
|
|
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
|
|
params: dict[str, Any] = {"per_page": 100, "page": 1}
|
|
async with github_client(token=token) as client:
|
|
while True:
|
|
try:
|
|
response = await github_request(client, "GET", url, params=params)
|
|
response.raise_for_status()
|
|
except httpx.HTTPError:
|
|
logger.exception(
|
|
"Failed to list PR reviews for %s/%s#%s",
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
)
|
|
return None
|
|
data = response.json()
|
|
if not isinstance(data, list):
|
|
return None
|
|
if not data:
|
|
return False
|
|
for review in data:
|
|
if isinstance(review, dict) and marker in (review.get("body") or ""):
|
|
return True
|
|
if len(data) < 100: # noqa: PLR2004
|
|
return False
|
|
params["page"] += 1
|
|
|
|
|
|
_REVIEW_EVENTS = {"COMMENT", "APPROVE", "REQUEST_CHANGES"}
|
|
|
|
|
|
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,
|
|
event: str = "COMMENT",
|
|
) -> dict[str, Any] | None:
|
|
"""POST one GitHub PR Review with inline comments. Returns the API response or None."""
|
|
if event not in _REVIEW_EVENTS:
|
|
logger.warning(
|
|
"Invalid PR review event %r for %s/%s#%s; coercing to COMMENT",
|
|
event,
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
)
|
|
event = "COMMENT"
|
|
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}/reviews"
|
|
payload: dict[str, Any] = {
|
|
"commit_id": head_sha,
|
|
"event": event,
|
|
"body": body,
|
|
"comments": inline_comments,
|
|
}
|
|
async with github_client(token=token) as client:
|
|
try:
|
|
response = await github_request(client, "POST", url, json=payload)
|
|
if response.status_code == 401:
|
|
raise GitHubAuthError(
|
|
f"GitHub returned 401 posting PR review for {owner}/{repo}#{pr_number}"
|
|
)
|
|
response.raise_for_status()
|
|
except GitHubAuthError:
|
|
raise
|
|
except httpx.HTTPStatusError as e:
|
|
body = (e.response.text or "")[:500]
|
|
logger.exception(
|
|
"Failed to POST PR review for %s/%s#%s: %s %s",
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
e.response.status_code,
|
|
body,
|
|
)
|
|
# GitHub returns 422 with errors like "Path could not be resolved"
|
|
# or "Line could not be resolved" when an inline comment's anchor
|
|
# is not part of the PR diff. Surface that as a structured signal
|
|
# so the tool layer can prune the offending findings and retry
|
|
# once, instead of the agent retrying with byte-identical args.
|
|
error_kind: str | None = None
|
|
raw_errors: list[Any] = []
|
|
if e.response.status_code == 422:
|
|
try:
|
|
parsed = e.response.json()
|
|
if isinstance(parsed, dict):
|
|
candidate = parsed.get("errors", [])
|
|
if isinstance(candidate, list):
|
|
raw_errors = candidate
|
|
except Exception: # noqa: BLE001 — body may not be JSON
|
|
raw_errors = []
|
|
if any(
|
|
isinstance(err, str)
|
|
and ("Path could not be resolved" in err or "Line could not be resolved" in err)
|
|
for err in raw_errors
|
|
):
|
|
error_kind = "unresolved_anchor"
|
|
return {
|
|
"_error": f"HTTP {e.response.status_code}: {body}",
|
|
"_error_kind": error_kind,
|
|
"_raw_errors": raw_errors,
|
|
"_status": e.response.status_code,
|
|
}
|
|
except httpx.HTTPError as e:
|
|
logger.exception("Failed to POST PR review for %s/%s#%s", owner, repo, pr_number)
|
|
return {"_error": f"{type(e).__name__}: {e}"}
|
|
data = response.json()
|
|
if isinstance(data, dict):
|
|
return data
|
|
body_excerpt = (response.text or "")[:500]
|
|
logger.error(
|
|
"POST PR review for %s/%s#%s returned non-dict body: %s",
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
body_excerpt,
|
|
)
|
|
return {"_error": (f"HTTP {response.status_code}: non-dict response body: {body_excerpt}")}
|
|
|
|
|
|
async def dismiss_pull_request_review(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
review_id: int,
|
|
message: str,
|
|
token: str,
|
|
) -> bool:
|
|
"""Dismiss a previously submitted PR review (e.g. a stale APPROVE).
|
|
|
|
Best-effort: returns True on success, False on any failure. Callers treat
|
|
dismissal as advisory cleanup, never a publish blocker.
|
|
"""
|
|
url = (
|
|
f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}/dismissals"
|
|
)
|
|
async with github_client(token=token) as client:
|
|
try:
|
|
response = await github_request(client, "PUT", url, json={"message": message})
|
|
response.raise_for_status()
|
|
except httpx.HTTPError:
|
|
logger.exception(
|
|
"Failed to dismiss PR review %s for %s/%s#%s",
|
|
review_id,
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
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"
|
|
out: list[dict[str, Any]] = []
|
|
params: dict[str, Any] = {"per_page": 100, "page": 1}
|
|
async with github_client(token=token) as client:
|
|
while True:
|
|
try:
|
|
response = await github_request(client, "GET", url, params=params)
|
|
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_pr_review_threads(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
token: str,
|
|
max_threads: int = 100,
|
|
max_comments_per_thread: int = 20,
|
|
) -> list[dict[str, Any]]:
|
|
"""Fetch all inline review threads on a PR (across reviewers, with replies).
|
|
|
|
Returned shape per thread:
|
|
{
|
|
"path": str,
|
|
"line": int | None,
|
|
"original_line": int | None,
|
|
"is_resolved": bool,
|
|
"is_outdated": bool,
|
|
"comments": [{"author": str, "body": str, "created_at": str}, ...],
|
|
}
|
|
|
|
Used to give the reviewer agent comment-awareness: it should not re-file a
|
|
finding that already appears as an open thread (its own or another
|
|
reviewer's), and should treat a thread as addressed when a human reply
|
|
explains the code or the thread is resolved.
|
|
"""
|
|
query = """
|
|
query Threads($owner: String!, $repo: String!, $pr: Int!, $cursor: String, $perThread: Int!) {
|
|
repository(owner: $owner, name: $repo) {
|
|
pullRequest(number: $pr) {
|
|
reviewThreads(first: 50, after: $cursor) {
|
|
pageInfo { hasNextPage endCursor }
|
|
nodes {
|
|
id
|
|
isResolved
|
|
isOutdated
|
|
path
|
|
line
|
|
originalLine
|
|
comments(first: $perThread) {
|
|
nodes {
|
|
databaseId
|
|
author { login }
|
|
authorAssociation
|
|
body
|
|
createdAt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
out: list[dict[str, Any]] = []
|
|
cursor: str | None = None
|
|
async with github_client(token=token) as client:
|
|
while len(out) < max_threads:
|
|
try:
|
|
response = await github_request(
|
|
client,
|
|
"POST",
|
|
_GITHUB_GRAPHQL,
|
|
json={
|
|
"query": query,
|
|
"variables": {
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"pr": pr_number,
|
|
"cursor": cursor,
|
|
"perThread": max_comments_per_thread,
|
|
},
|
|
},
|
|
)
|
|
response.raise_for_status()
|
|
except httpx.HTTPError:
|
|
logger.exception(
|
|
"Failed to fetch PR review threads for %s/%s#%s",
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
)
|
|
return out
|
|
data = response.json()
|
|
data_root = data.get("data") if isinstance(data, dict) else None
|
|
repository = data_root.get("repository") if isinstance(data_root, dict) else None
|
|
if not isinstance(repository, dict):
|
|
logger.warning(
|
|
"Null repository in review-threads response for %s/%s#%s "
|
|
"(token likely lacks access: SAML, expired token, or private/deleted repo)",
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
)
|
|
return out
|
|
pull_request = repository.get("pullRequest")
|
|
threads = pull_request.get("reviewThreads") if isinstance(pull_request, dict) else None
|
|
if not isinstance(threads, dict):
|
|
return out
|
|
for thread in threads.get("nodes", []) or []:
|
|
if not isinstance(thread, dict):
|
|
continue
|
|
comments_block = thread.get("comments") or {}
|
|
comments_nodes = comments_block.get("nodes") or []
|
|
comments: list[dict[str, Any]] = []
|
|
for c in comments_nodes:
|
|
if not isinstance(c, dict):
|
|
continue
|
|
author_block = c.get("author") or {}
|
|
login = author_block.get("login") if isinstance(author_block, dict) else None
|
|
comments.append(
|
|
{
|
|
"id": c.get("databaseId")
|
|
if isinstance(c.get("databaseId"), int)
|
|
else None,
|
|
"author": login if isinstance(login, str) else "unknown",
|
|
"author_association": c.get("authorAssociation", "")
|
|
if isinstance(c.get("authorAssociation"), str)
|
|
else "",
|
|
"body": c.get("body", "") if isinstance(c.get("body"), str) else "",
|
|
"created_at": c.get("createdAt", "")
|
|
if isinstance(c.get("createdAt"), str)
|
|
else "",
|
|
}
|
|
)
|
|
out.append(
|
|
{
|
|
"id": thread.get("id") if isinstance(thread.get("id"), str) else "",
|
|
"path": thread.get("path", "")
|
|
if isinstance(thread.get("path"), str)
|
|
else "",
|
|
"line": thread.get("line") if isinstance(thread.get("line"), int) else None,
|
|
"original_line": thread.get("originalLine")
|
|
if isinstance(thread.get("originalLine"), int)
|
|
else None,
|
|
"is_resolved": bool(thread.get("isResolved")),
|
|
"is_outdated": bool(thread.get("isOutdated")),
|
|
"comments": comments,
|
|
}
|
|
)
|
|
if len(out) >= max_threads:
|
|
break
|
|
page_info = threads.get("pageInfo") or {}
|
|
if not page_info.get("hasNextPage"):
|
|
break
|
|
cursor = page_info.get("endCursor")
|
|
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 github_client(token=token) as client:
|
|
while True:
|
|
try:
|
|
response = await github_request(
|
|
client,
|
|
"POST",
|
|
_GITHUB_GRAPHQL,
|
|
json={
|
|
"query": query,
|
|
"variables": {
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"pr": pr_number,
|
|
"cursor": cursor,
|
|
},
|
|
},
|
|
)
|
|
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()
|
|
if not isinstance(data, dict):
|
|
return None
|
|
data_root = data.get("data")
|
|
if not isinstance(data_root, dict):
|
|
return None
|
|
repository = data_root.get("repository")
|
|
if not isinstance(repository, dict):
|
|
return None
|
|
pull_request = repository.get("pullRequest")
|
|
if not isinstance(pull_request, dict):
|
|
return None
|
|
threads = pull_request.get("reviewThreads")
|
|
if not isinstance(threads, dict):
|
|
return None
|
|
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 github_client(token=token) as client:
|
|
try:
|
|
response = await github_request(
|
|
client,
|
|
"POST",
|
|
_GITHUB_GRAPHQL,
|
|
json={"query": mutation, "variables": {"threadId": thread_node_id}},
|
|
)
|
|
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
|
|
data_root = data.get("data") if isinstance(data, dict) else None
|
|
resolved = data_root.get("resolveReviewThread") if isinstance(data_root, dict) else None
|
|
thread = resolved.get("thread") if isinstance(resolved, dict) else None
|
|
return bool(thread.get("isResolved")) if isinstance(thread, dict) else False
|
|
|
|
|
|
async def reply_to_review_comment(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
review_comment_id: int,
|
|
body: str,
|
|
token: str,
|
|
) -> dict[str, Any] | None:
|
|
"""Reply to an existing pull request review comment thread."""
|
|
url = (
|
|
f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/"
|
|
f"{pr_number}/comments/{review_comment_id}/replies"
|
|
)
|
|
async with github_client(token=token) as client:
|
|
try:
|
|
response = await github_request(client, "POST", url, json={"body": body})
|
|
if response.status_code == 401:
|
|
raise GitHubAuthError(
|
|
f"GitHub returned 401 replying to review comment {review_comment_id}"
|
|
)
|
|
response.raise_for_status()
|
|
except GitHubAuthError:
|
|
raise
|
|
except httpx.HTTPError:
|
|
logger.exception(
|
|
"Failed to reply to review comment %s on %s/%s#%s",
|
|
review_comment_id,
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
)
|
|
return None
|
|
data = response.json()
|
|
return data if isinstance(data, dict) else None
|