mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 19:43:15 +00:00
* feat: CI auto-fix and PR babysitting for agent PRs Watch CI failures and review feedback on PRs Open SWE opened, then dispatch confidence-gated fix runs on the originating agent thread. Adds CI webhook ingestion (check_run/check_suite/workflow_run/status), a per-PR @open-swe autofix on|off toggle, auto-response to review comments, and a polling ci_monitor graph that also flags merge conflicts. Gated by the existing autofix_mode/trigger_mode settings, the enabled-repos opt-in, base-branch and human-commit skip rules, dedupe, and a per-PR attempt cap. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: address review feedback on CI auto-fix - Security: gate the no-mention review-feedback path on author trust — require a trusted author_association (OWNER/MEMBER/COLLABORATOR) plus a GitHub write/maintain/admin permission check before dispatching a write-capable agent run, preventing privilege escalation from read/triage/outside reviewers. - Auth: reuse the originating PR thread's source + login/email when dispatching fix runs so the GitHub-token resolver authenticates them in non-bot-token deployments (bespoke github_ci source failed to resolve). - Docs: document the Commit statuses: Read-only permission required for the Status webhook event. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
156 lines
5.2 KiB
Python
156 lines
5.2 KiB
Python
"""Unit tests for GitHub CI read helpers used by the auto-fix flow."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from agent.utils import github_ci
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, payload: Any = None, error: bool = False) -> None:
|
|
self._payload = payload if payload is not None else {}
|
|
self._error = error
|
|
|
|
def raise_for_status(self) -> None:
|
|
if self._error:
|
|
raise httpx.HTTPError("boom")
|
|
|
|
def json(self) -> Any:
|
|
return self._payload
|
|
|
|
|
|
class _FakeClient:
|
|
response: _FakeResponse = _FakeResponse({})
|
|
|
|
async def __aenter__(self) -> _FakeClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *_: object) -> None:
|
|
return None
|
|
|
|
async def get(self, url: str, **_: Any) -> _FakeResponse:
|
|
return type(self).response
|
|
|
|
|
|
def _patch(monkeypatch: pytest.MonkeyPatch, payload: Any, error: bool = False) -> None:
|
|
_FakeClient.response = _FakeResponse(payload, error=error)
|
|
monkeypatch.setattr(github_ci.httpx, "AsyncClient", _FakeClient)
|
|
|
|
|
|
def test_branch_and_sha_from_check_run() -> None:
|
|
payload = {
|
|
"check_run": {
|
|
"head_sha": "deadbeef",
|
|
"check_suite": {"head_branch": "feat/x"},
|
|
}
|
|
}
|
|
assert github_ci.branch_from_check_payload(payload, "check_run") == "feat/x"
|
|
assert github_ci.head_sha_from_check_payload(payload, "check_run") == "deadbeef"
|
|
|
|
|
|
def test_branch_and_sha_from_workflow_run() -> None:
|
|
payload = {"workflow_run": {"head_sha": "abc", "head_branch": "main"}}
|
|
assert github_ci.branch_from_check_payload(payload, "workflow_run") == "main"
|
|
assert github_ci.head_sha_from_check_payload(payload, "workflow_run") == "abc"
|
|
|
|
|
|
def test_sha_from_status_event() -> None:
|
|
payload = {"sha": "sha1", "branches": [{"name": "b1"}]}
|
|
assert github_ci.head_sha_from_check_payload(payload, "status") == "sha1"
|
|
assert github_ci.branch_from_check_payload(payload, "status") == "b1"
|
|
|
|
|
|
def test_is_failing_ci_payload() -> None:
|
|
assert github_ci.is_failing_ci_payload(
|
|
{"check_run": {"status": "completed", "conclusion": "failure"}}, "check_run"
|
|
)
|
|
assert not github_ci.is_failing_ci_payload(
|
|
{"check_run": {"status": "completed", "conclusion": "success"}}, "check_run"
|
|
)
|
|
assert not github_ci.is_failing_ci_payload(
|
|
{"check_run": {"status": "in_progress", "conclusion": None}}, "check_run"
|
|
)
|
|
assert github_ci.is_failing_ci_payload({"state": "failure"}, "status")
|
|
assert not github_ci.is_failing_ci_payload({"state": "pending"}, "status")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_failing_check_runs_filters(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_patch(
|
|
monkeypatch,
|
|
{
|
|
"check_runs": [
|
|
{"name": "lint", "status": "completed", "conclusion": "failure"},
|
|
{"name": "test", "status": "completed", "conclusion": "success"},
|
|
{"name": "build", "status": "in_progress", "conclusion": None},
|
|
{"name": "Open SWE Auto-fix", "status": "completed", "conclusion": "failure"},
|
|
]
|
|
},
|
|
)
|
|
failing = await github_ci.list_failing_check_runs(owner="o", repo="r", ref="sha", token="t")
|
|
assert failing is not None
|
|
names = {c["name"] for c in failing}
|
|
assert names == {"lint"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_failing_check_runs_returns_none_on_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_patch(monkeypatch, {}, error=True)
|
|
assert await github_ci.list_failing_check_runs(owner="o", repo="r", ref="s", token="t") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_names_failing_on_base(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# Both check-runs and statuses calls return the same fake payload here;
|
|
# only the check_runs shape is populated, statuses empty.
|
|
_patch(
|
|
monkeypatch,
|
|
{
|
|
"check_runs": [
|
|
{"name": "flaky", "status": "completed", "conclusion": "failure"},
|
|
],
|
|
"statuses": [],
|
|
},
|
|
)
|
|
names = await github_ci.names_failing_on_base(owner="o", repo="r", base_sha="base", token="t")
|
|
assert "flaky" in names
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_names_failing_on_base_empty_when_no_base() -> None:
|
|
assert (
|
|
await github_ci.names_failing_on_base(owner="o", repo="r", base_sha="", token="t") == set()
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_has_repo_write_permission_true(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_patch(monkeypatch, {"permission": "write"})
|
|
assert await github_ci.has_repo_write_permission(
|
|
owner="o", repo="r", username="alice", token="t"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_has_repo_write_permission_false_for_read(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_patch(monkeypatch, {"permission": "read"})
|
|
assert not await github_ci.has_repo_write_permission(
|
|
owner="o", repo="r", username="bob", token="t"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_has_repo_write_permission_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_patch(monkeypatch, {}, error=True)
|
|
assert not await github_ci.has_repo_write_permission(
|
|
owner="o", repo="r", username="bob", token="t"
|
|
)
|
|
assert not await github_ci.has_repo_write_permission(
|
|
owner="o", repo="r", username="", token="t"
|
|
)
|