mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 09:13:14 +00:00
* 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>
271 lines
8.5 KiB
Python
271 lines
8.5 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from agent import reviewer_publish
|
|
from agent.utils import github_checks
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(
|
|
self,
|
|
payload: dict[str, Any] | None = None,
|
|
error: bool = False,
|
|
status_code: int = 200,
|
|
) -> None:
|
|
self._payload = payload or {}
|
|
self._error = error
|
|
self.status_code = status_code
|
|
self.headers: dict[str, str] = {}
|
|
|
|
def raise_for_status(self) -> None:
|
|
if self._error:
|
|
raise httpx.HTTPStatusError("forbidden", request=None, response=None) # type: ignore[arg-type]
|
|
|
|
def json(self) -> dict[str, Any]:
|
|
return self._payload
|
|
|
|
|
|
class _FakeAsyncClient:
|
|
last_post: dict[str, Any] | None = None
|
|
last_patch: dict[str, Any] | None = None
|
|
post_response: _FakeResponse = _FakeResponse({"id": 42})
|
|
patch_response: _FakeResponse = _FakeResponse({})
|
|
|
|
def __init__(self, **kwargs: Any) -> None:
|
|
pass
|
|
|
|
async def __aenter__(self) -> _FakeAsyncClient:
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
return None
|
|
|
|
async def post(self, url: str, **kwargs: Any) -> _FakeResponse:
|
|
type(self).last_post = {"url": url, **kwargs}
|
|
return type(self).post_response
|
|
|
|
async def patch(self, url: str, **kwargs: Any) -> _FakeResponse:
|
|
type(self).last_patch = {"url": url, **kwargs}
|
|
return type(self).patch_response
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_fake_client() -> None:
|
|
_FakeAsyncClient.last_post = None
|
|
_FakeAsyncClient.last_patch = None
|
|
_FakeAsyncClient.post_response = _FakeResponse({"id": 42})
|
|
_FakeAsyncClient.patch_response = _FakeResponse({})
|
|
|
|
|
|
async def test_create_review_check_run_posts_in_progress(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(github_checks.httpx, "AsyncClient", _FakeAsyncClient)
|
|
|
|
check_run_id = await github_checks.create_review_check_run(
|
|
owner="acme",
|
|
repo="widgets",
|
|
head_sha="abc123",
|
|
token="tok",
|
|
details_url="https://example.com/thread",
|
|
)
|
|
|
|
assert check_run_id == 42
|
|
assert _FakeAsyncClient.last_post is not None
|
|
assert _FakeAsyncClient.last_post["url"].endswith("/repos/acme/widgets/check-runs")
|
|
body = _FakeAsyncClient.last_post["json"]
|
|
assert body["name"] == github_checks.REVIEW_CHECK_RUN_NAME
|
|
assert body["head_sha"] == "abc123"
|
|
assert body["status"] == "in_progress"
|
|
assert body["details_url"] == "https://example.com/thread"
|
|
|
|
|
|
async def test_create_review_check_run_returns_none_on_http_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(github_checks.httpx, "AsyncClient", _FakeAsyncClient)
|
|
_FakeAsyncClient.post_response = _FakeResponse(error=True)
|
|
|
|
check_run_id = await github_checks.create_review_check_run(
|
|
owner="acme", repo="widgets", head_sha="abc123", token="tok"
|
|
)
|
|
|
|
assert check_run_id is None
|
|
|
|
|
|
async def test_complete_review_check_run_patches_completed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(github_checks.httpx, "AsyncClient", _FakeAsyncClient)
|
|
|
|
ok = await github_checks.complete_review_check_run(
|
|
owner="acme",
|
|
repo="widgets",
|
|
check_run_id=42,
|
|
token="tok",
|
|
conclusion="neutral",
|
|
title="Found 2 potential issues",
|
|
summary="…",
|
|
)
|
|
|
|
assert ok is True
|
|
assert _FakeAsyncClient.last_patch is not None
|
|
assert _FakeAsyncClient.last_patch["url"].endswith("/repos/acme/widgets/check-runs/42")
|
|
body = _FakeAsyncClient.last_patch["json"]
|
|
assert body["status"] == "completed"
|
|
assert body["conclusion"] == "neutral"
|
|
assert body["output"]["title"] == "Found 2 potential issues"
|
|
|
|
|
|
async def test_post_autofix_status_check_completes_neutral(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(github_checks.httpx, "AsyncClient", _FakeAsyncClient)
|
|
|
|
ok = await github_checks.post_autofix_status_check(
|
|
owner="acme",
|
|
repo="widgets",
|
|
head_sha="abc123",
|
|
token="tok",
|
|
title="Auto-fixing 1 failing check(s)",
|
|
summary="working on it",
|
|
details_url="https://example.com/thread",
|
|
)
|
|
|
|
assert ok is True
|
|
assert _FakeAsyncClient.last_post is not None
|
|
assert _FakeAsyncClient.last_post["url"].endswith("/repos/acme/widgets/check-runs")
|
|
body = _FakeAsyncClient.last_post["json"]
|
|
assert body["name"] == github_checks.AUTOFIX_CHECK_RUN_NAME
|
|
assert body["status"] == "completed"
|
|
assert body["conclusion"] == "neutral"
|
|
assert body["details_url"] == "https://example.com/thread"
|
|
|
|
|
|
def test_review_check_conclusion_mapping() -> None:
|
|
conclusion, title, _ = github_checks.review_check_conclusion(0)
|
|
assert conclusion == "success"
|
|
assert title == "No issues found"
|
|
|
|
conclusion, title, _ = github_checks.review_check_conclusion(1)
|
|
assert conclusion == "success"
|
|
assert "1 potential issue" in title
|
|
|
|
conclusion, title, _ = github_checks.review_check_conclusion(3)
|
|
assert conclusion == "success"
|
|
assert "3 potential issues" in title
|
|
|
|
|
|
async def test_settle_review_check_run_noop_without_tracked_id(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_get_thread_metadata(thread_id: str) -> dict[str, Any]:
|
|
return {}
|
|
|
|
completed: list[dict[str, Any]] = []
|
|
|
|
async def fake_complete(**kwargs: Any) -> bool:
|
|
completed.append(kwargs)
|
|
return True
|
|
|
|
monkeypatch.setattr(reviewer_publish, "get_thread_metadata", fake_get_thread_metadata)
|
|
monkeypatch.setattr(reviewer_publish, "complete_review_check_run", fake_complete)
|
|
|
|
await reviewer_publish.settle_review_check_run(
|
|
thread_id="t1",
|
|
owner="acme",
|
|
repo="widgets",
|
|
token="tok",
|
|
conclusion="success",
|
|
title="t",
|
|
summary="s",
|
|
)
|
|
|
|
assert completed == []
|
|
|
|
|
|
async def test_settle_review_check_run_completes_and_clears(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_get_thread_metadata(thread_id: str) -> dict[str, Any]:
|
|
return {"review_check_run_id": 42}
|
|
|
|
completed: list[dict[str, Any]] = []
|
|
metadata_writes: list[dict[str, Any]] = []
|
|
|
|
async def fake_complete(**kwargs: Any) -> bool:
|
|
completed.append(kwargs)
|
|
return True
|
|
|
|
async def fake_set_metadata(thread_id: str, **kwargs: Any) -> None:
|
|
metadata_writes.append({"thread_id": thread_id, **kwargs})
|
|
|
|
monkeypatch.setattr(reviewer_publish, "get_thread_metadata", fake_get_thread_metadata)
|
|
monkeypatch.setattr(reviewer_publish, "complete_review_check_run", fake_complete)
|
|
monkeypatch.setattr(reviewer_publish, "set_reviewer_thread_metadata", fake_set_metadata)
|
|
|
|
await reviewer_publish.settle_review_check_run(
|
|
thread_id="t1",
|
|
owner="acme",
|
|
repo="widgets",
|
|
token="tok",
|
|
conclusion="neutral",
|
|
title="t",
|
|
summary="s",
|
|
)
|
|
|
|
assert len(completed) == 1
|
|
assert completed[0]["check_run_id"] == 42
|
|
assert completed[0]["conclusion"] == "neutral"
|
|
assert metadata_writes == [
|
|
{
|
|
"thread_id": "t1",
|
|
"extra": {"review_check_run_id": None, "review_check_pending_result": None},
|
|
}
|
|
]
|
|
|
|
|
|
async def test_settle_review_check_run_keeps_id_on_patch_failure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_get_thread_metadata(thread_id: str) -> dict[str, Any]:
|
|
return {"review_check_run_id": 42}
|
|
|
|
metadata_writes: list[dict[str, Any]] = []
|
|
|
|
async def fake_complete(**kwargs: Any) -> bool:
|
|
return False
|
|
|
|
async def fake_set_metadata(thread_id: str, **kwargs: Any) -> None:
|
|
metadata_writes.append({"thread_id": thread_id, **kwargs})
|
|
|
|
monkeypatch.setattr(reviewer_publish, "get_thread_metadata", fake_get_thread_metadata)
|
|
monkeypatch.setattr(reviewer_publish, "complete_review_check_run", fake_complete)
|
|
monkeypatch.setattr(reviewer_publish, "set_reviewer_thread_metadata", fake_set_metadata)
|
|
|
|
await reviewer_publish.settle_review_check_run(
|
|
thread_id="t1",
|
|
owner="acme",
|
|
repo="widgets",
|
|
token="tok",
|
|
conclusion="success",
|
|
title="t",
|
|
summary="s",
|
|
)
|
|
|
|
assert metadata_writes == [
|
|
{
|
|
"thread_id": "t1",
|
|
"extra": {
|
|
"review_check_pending_result": {
|
|
"conclusion": "success",
|
|
"title": "t",
|
|
"summary": "s",
|
|
}
|
|
},
|
|
}
|
|
]
|