open-swe/agent/utils/github_checks.py

233 lines
7.4 KiB
Python

"""GitHub Checks API helpers for the reviewer's PR check run.
A check run named ``Open SWE Review`` is created on the PR head SHA when an
auto-review is dispatched, so the PR's checks section shows the review as
in-progress. ``publish_review`` (or the after-agent fallback) completes it.
All calls are best-effort: check runs require the GitHub App's
``Checks: Read & write`` permission, and a missing permission must never
break review dispatch or publish.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Literal
import httpx
from .github_http import (
GITHUB_API_BASE,
github_client,
github_headers, # re-exported for backward compatibility
github_request,
)
__all__ = ["github_headers"]
logger = logging.getLogger(__name__)
REVIEW_CHECK_RUN_NAME = "Open SWE Review"
AUTOFIX_CHECK_RUN_NAME = "Open SWE Auto-fix"
_GITHUB_API_BASE = GITHUB_API_BASE
CheckConclusion = Literal["success", "neutral", "failure"]
def _utc_now_iso() -> str:
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
async def create_review_check_run(
*,
owner: str,
repo: str,
head_sha: str,
token: str,
details_url: str | None = None,
) -> int | None:
"""Create an in-progress ``Open SWE Review`` check run on ``head_sha``.
Returns the check run id, or ``None`` on any failure (most commonly the
App lacking the Checks permission).
"""
payload: dict[str, object] = {
"name": REVIEW_CHECK_RUN_NAME,
"head_sha": head_sha,
"status": "in_progress",
"started_at": _utc_now_iso(),
"output": {
"title": "Review in progress",
"summary": "Open SWE is reviewing this pull request…",
},
}
if details_url:
payload["details_url"] = details_url
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/check-runs"
try:
async with github_client(token=token) as client:
response = await github_request(client, "POST", url, json=payload)
response.raise_for_status()
except httpx.HTTPError:
logger.exception(
"Failed to create review check run for %s/%s@%s "
"(does the GitHub App have Checks: Read & write?)",
owner,
repo,
head_sha,
)
return None
data = response.json()
check_run_id = data.get("id") if isinstance(data, dict) else None
return check_run_id if isinstance(check_run_id, int) else None
async def complete_review_check_run(
*,
owner: str,
repo: str,
check_run_id: int,
token: str,
conclusion: CheckConclusion,
title: str,
summary: str,
) -> bool:
"""Mark a review check run as completed. Returns True on success."""
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/check-runs/{check_run_id}"
payload = {
"status": "completed",
"conclusion": conclusion,
"completed_at": _utc_now_iso(),
"output": {"title": title, "summary": summary},
}
try:
async with github_client(token=token) as client:
response = await github_request(client, "PATCH", url, json=payload)
response.raise_for_status()
except httpx.HTTPError:
logger.exception(
"Failed to complete review check run %s on %s/%s", check_run_id, owner, repo
)
return False
return True
async def post_autofix_status_check(
*,
owner: str,
repo: str,
head_sha: str,
token: str,
title: str,
summary: str,
details_url: str | None = None,
) -> bool:
"""Post an informational, completed ``Open SWE Auto-fix`` check on ``head_sha``.
Completed immediately as ``neutral`` so it's non-blocking and never leaves a
dangling in-progress check that could gate branch protection. Used as the
auto-fix status channel instead of a PR comment (PR comments can trigger
``issue_comment`` automation like Atlantis/Terraform).
"""
payload: dict[str, object] = {
"name": AUTOFIX_CHECK_RUN_NAME,
"head_sha": head_sha,
"status": "completed",
"conclusion": "neutral",
"started_at": _utc_now_iso(),
"completed_at": _utc_now_iso(),
"output": {"title": title, "summary": summary},
}
if details_url:
payload["details_url"] = details_url
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/check-runs"
try:
async with github_client(token=token) as client:
response = await github_request(client, "POST", url, json=payload)
response.raise_for_status()
except httpx.HTTPError:
logger.warning("Failed to post auto-fix status check for %s/%s@%s", owner, repo, head_sha)
return False
return True
def review_check_conclusion(
publish_outcome: Mapping[str, object],
) -> tuple[CheckConclusion, str, str]:
"""Map the authoritative publish outcome to a check conclusion."""
verdict_submitted = publish_outcome.get("verdict_submitted") is True
verdict_event = publish_outcome.get("verdict_event")
if verdict_submitted and verdict_event == "APPROVE":
return (
"success",
"Review approved",
"Open SWE recorded an approving review on this pull request.",
)
if verdict_submitted and verdict_event == "REQUEST_CHANGES":
return (
"failure",
"Changes requested",
"Open SWE recorded a request-changes review on this pull request.",
)
blocking_count_raw = publish_outcome.get("blocking_finding_count")
blocking_count = (
blocking_count_raw
if isinstance(blocking_count_raw, int) and not isinstance(blocking_count_raw, bool)
else None
)
ignored_reason = publish_outcome.get("verdict_ignored_reason")
verdict_authorization = publish_outcome.get("verdict_authorization")
if (
blocking_count is not None
and blocking_count > 0
and verdict_authorization
in {
"requested",
"consistent",
}
and ignored_reason
in {
None,
"approve_with_open_findings",
"self_review",
}
):
issue_word = "issue" if blocking_count == 1 else "issues"
return (
"failure",
f"Found {blocking_count} blocking {issue_word}",
f"Open SWE found {blocking_count} blocking {issue_word} on this pull request.",
)
if ignored_reason and ignored_reason != "self_review":
return (
"neutral",
"Verdict withheld",
f"Open SWE published without a verdict ({str(ignored_reason).replace('_', ' ')}).",
)
if blocking_count == 0:
return (
"success",
"No issues found",
"Open SWE reviewed this pull request and found no blocking issues.",
)
surfaced_count_raw = publish_outcome.get("surfaced_count")
surfaced_count = (
surfaced_count_raw
if isinstance(surfaced_count_raw, int) and not isinstance(surfaced_count_raw, bool)
else 0
)
if surfaced_count > 0:
issue_word = "issue" if surfaced_count == 1 else "issues"
title = f"Found {surfaced_count} potential {issue_word}"
else:
title = "Review completed without verdict"
return (
"neutral",
title,
"Open SWE completed the review without an authoritative verdict.",
)