open-swe/agent/utils/github_checks.py
Johannes du Plessis 7dd758f845
feat: shared GitHub HTTP helper with retries, rate-limit handling [closes OPE-45] (#1565)
* feat: shared GitHub HTTP helper with retries, rate-limit handling, and sane timeouts

Introduces agent/utils/github_http.py — a single place for GitHub API HTTP
calls with 30s/10s-connect timeouts (vs httpx's 5s default), exponential
backoff with jitter, Retry-After header support, and 429/secondary-rate-limit
detection. Migrates the reviewer publish path (reviewer_publish.py,
reviewer_diff.py, github_checks.py, github_ci.py) from one-shot
httpx.AsyncClient() calls to the shared helper.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: don't retry transport errors on non-idempotent GitHub writes

POST/DELETE/PATCH can create side effects server-side even when the client
gets a timeout or connection reset. Only retry transport errors for
idempotent methods (GET, HEAD, PUT, DELETE). 429/5xx status codes are still
retried for all methods since the server explicitly did not process the
request.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: don't retry 502/504 on non-idempotent GitHub writes

502 (bad gateway) and 504 (gateway timeout) are ambiguous — the upstream
may have processed the write before the gateway returned an error. Only
retry these for idempotent methods. 429 and 503 are still retried for all
methods since the server explicitly did not process the request.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-17 14:45:57 -07:00

175 lines
5.5 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 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(surfaced_count: int) -> tuple[CheckConclusion, str, str]:
"""Map a publish result to (conclusion, title, summary).
Always ``success`` so the check is informational and non-blocking, and so
GitHub groups it under "successful checks" rather than a confusing
"neutral check". The finding count is surfaced in the title; the findings
themselves are posted as PR comments.
"""
if surfaced_count > 0:
issue_word = "issue" if surfaced_count == 1 else "issues"
return (
"success",
f"Found {surfaced_count} potential {issue_word}",
f"Open SWE surfaced {surfaced_count} potential {issue_word} on this pull request.",
)
return (
"success",
"No issues found",
"Open SWE reviewed this pull request and found no issues.",
)