From 4060933ce4a0debd487d11443baa61b663a3fd89 Mon Sep 17 00:00:00 2001 From: "langsmith-forge-dev[bot]" <265965909+langsmith-forge-dev[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 12:19:04 -0700 Subject: [PATCH] feat: add github CI check run tools for shepherding CI (#1121) * feat: add github CI check run tools for shepherding CI Add get_pr_check_runs and rerun_failed_check_runs tools that authenticate using the GitHub App installation token so the agent can query and retry CI status on private repos without relying on GH_TOKEN or unauthenticated http_request calls. Co-Authored-By: Claude Sonnet 4.6 * fix: handle paginated GitHub CI results * refactor(github_ci): address review feedback - Rename rerun_failed_check_runs -> rerun_failed_workflow_runs and clarify in docstrings that the tool only retries GitHub Actions workflow runs (not third-party CI checks surfaced by get_pr_check_runs). - Skip action_required workflow runs when rerunning; those need manual approval, not a rerun. - Run rerun-failed-jobs requests concurrently via asyncio.gather instead of sequentially. - Fix latent pagination bug in _fetch_paginated_items where caller-supplied params could overwrite per_page/page and break the end-of-pagination check; reserved keys now always win and the threshold uses a PER_PAGE constant. - Set an explicit 30s httpx timeout so a hung GitHub call cannot stall the agent loop. - Restore alphabetical ordering of tools in agent/tools/__init__.py. - Add tests for: a 500 surfaced on a later pagination page, and action_required runs being filtered out of rerun candidates. --------- Co-authored-by: Claude Agent Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Johannes du Plessis --- agent/server.py | 4 + agent/tools/__init__.py | 7 +- agent/tools/github_ci.py | 252 +++++++++++++++++++++++ tests/test_github_ci.py | 422 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 683 insertions(+), 2 deletions(-) create mode 100644 agent/tools/github_ci.py create mode 100644 tests/test_github_ci.py diff --git a/agent/server.py b/agent/server.py index 1ff3addf..bdd3becf 100644 --- a/agent/server.py +++ b/agent/server.py @@ -39,6 +39,7 @@ from .tools import ( dismiss_pr_review, fetch_url, get_branch_name, + get_pr_check_runs, get_pr_review, get_pr_review_comments, github_comment, @@ -53,6 +54,7 @@ from .tools import ( list_pr_review_comments, list_pr_reviews, list_repos, + rerun_failed_workflow_runs, slack_read_thread_messages, slack_thread_reply, submit_pr_review, @@ -315,6 +317,8 @@ async def get_agent(config: RunnableConfig) -> Pregel: dismiss_pr_review, submit_pr_review, list_pr_review_comments, + get_pr_check_runs, + rerun_failed_workflow_runs, ], backend=sandbox_backend, middleware=[ diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index fe60b64f..07f38e32 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -2,6 +2,7 @@ from .commit_and_open_pr import commit_and_open_pr from .fetch_url import fetch_url from .get_branch_name import get_branch_name from .get_pr_review_comments import get_pr_review_comments +from .github_ci import get_pr_check_runs, rerun_failed_workflow_runs from .github_comment import github_comment from .github_review import ( create_pr_review, @@ -31,20 +32,22 @@ __all__ = [ "dismiss_pr_review", "fetch_url", "get_branch_name", + "get_pr_check_runs", "get_pr_review", "get_pr_review_comments", "github_comment", "http_request", "linear_comment", - "list_pr_review_comments", - "list_pr_reviews", "linear_create_issue", "linear_delete_issue", "linear_get_issue", "linear_get_issue_comments", "linear_list_teams", "linear_update_issue", + "list_pr_review_comments", + "list_pr_reviews", "list_repos", + "rerun_failed_workflow_runs", "slack_read_thread_messages", "slack_thread_reply", "submit_pr_review", diff --git a/agent/tools/github_ci.py b/agent/tools/github_ci.py new file mode 100644 index 00000000..75310906 --- /dev/null +++ b/agent/tools/github_ci.py @@ -0,0 +1,252 @@ +import asyncio +from typing import Any + +import httpx +from langgraph.config import get_config + +from ..utils.github_app import get_github_app_installation_token + +GITHUB_API_BASE = "https://api.github.com" +PER_PAGE = 100 +HTTP_TIMEOUT_SECONDS = 30.0 + +FAILED_CONCLUSIONS = ("failure", "timed_out", "cancelled", "action_required") +RERUNNABLE_CONCLUSIONS = ("failure", "timed_out", "cancelled") + + +def _get_repo_config() -> dict[str, str]: + config = get_config() + return config.get("configurable", {}).get("repo", {}) + + +def _github_headers(token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + +async def _get_token() -> str | None: + return await get_github_app_installation_token() + + +def _repo_url(repo_config: dict[str, str]) -> str: + owner = repo_config.get("owner", "") + name = repo_config.get("name", "") + return f"{GITHUB_API_BASE}/repos/{owner}/{name}" + + +def _http_client() -> httpx.AsyncClient: + return httpx.AsyncClient(timeout=httpx.Timeout(HTTP_TIMEOUT_SECONDS)) + + +async def _fetch_paginated_items( + client: httpx.AsyncClient, + url: str, + headers: dict[str, str], + item_key: str, + params: dict[str, str] | None = None, +) -> tuple[list[dict[str, Any]] | None, int | None, str | None]: + items: list[dict[str, Any]] = [] + total_count: int | None = None + page = 1 + + while True: + # Reserved pagination params take precedence over caller-supplied params + # so the end-of-pagination check below stays consistent with PER_PAGE. + page_params = dict(params) if params else {} + page_params["per_page"] = str(PER_PAGE) + page_params["page"] = str(page) + + response = await client.get(url, headers=headers, params=page_params) + if response.status_code != 200: + return None, None, f"GitHub API returned {response.status_code}: {response.text}" + + data = response.json() + if total_count is None and isinstance(data.get("total_count"), int): + total_count = data["total_count"] + + page_items = data.get(item_key, []) + if not isinstance(page_items, list): + return None, None, f"GitHub API response missing {item_key} list" + + items.extend(page_items) + if len(page_items) < PER_PAGE: + return items, total_count, None + + page += 1 + + +def get_pr_check_runs(pull_number: int) -> dict[str, Any]: + """Get CI check run status for a pull request. + + Returns all check runs for the PR's latest commit with their status and conclusion. + Use this to check if CI is passing before declaring a PR ready for review. + + Note: this returns all check runs (GitHub Actions plus any third-party CI like + CircleCI, Vercel, etc.). The companion `rerun_failed_workflow_runs` tool only + retries GitHub Actions workflow runs. + + Args: + pull_number: The PR number to get check runs for. + + Returns: + Dictionary with success status and check run summary per check. + """ + repo_config = _get_repo_config() + if not repo_config: + return {"success": False, "error": "No repo config found"} + + token = asyncio.run(_get_token()) + if not token: + return {"success": False, "error": "Failed to get GitHub App installation token"} + + async def _fetch() -> dict[str, Any]: + async with _http_client() as client: + # Step 1: get the PR's head commit SHA + pr_url = f"{_repo_url(repo_config)}/pulls/{pull_number}" + pr_response = await client.get(pr_url, headers=_github_headers(token)) + if pr_response.status_code != 200: + return { + "success": False, + "error": f"GitHub API returned {pr_response.status_code} fetching PR: {pr_response.text}", + } + head_sha = pr_response.json().get("head", {}).get("sha") + if not head_sha: + return {"success": False, "error": "Could not determine head SHA for PR"} + + # Step 2: get check runs for that SHA + check_runs_url = f"{_repo_url(repo_config)}/commits/{head_sha}/check-runs" + check_runs, total_count, error = await _fetch_paginated_items( + client, + check_runs_url, + _github_headers(token), + "check_runs", + ) + if error or check_runs is None: + return { + "success": False, + "error": f"Error fetching check runs: {error}", + } + + summary = [ + { + "id": run.get("id"), + "name": run.get("name"), + "status": run.get("status"), + "conclusion": run.get("conclusion"), + "html_url": run.get("html_url"), + } + for run in check_runs + ] + + has_check_runs = bool(check_runs) + all_passed = all( + run.get("conclusion") == "success" + for run in check_runs + if run.get("status") == "completed" + ) + any_failed = any(run.get("conclusion") in FAILED_CONCLUSIONS for run in check_runs) + any_pending = any(run.get("status") != "completed" for run in check_runs) + + return { + "success": True, + "head_sha": head_sha, + "total_count": total_count if total_count is not None else len(check_runs), + "check_runs": summary, + "all_passed": has_check_runs and all_passed and not any_pending, + "any_failed": any_failed, + "any_pending": any_pending, + } + + return asyncio.run(_fetch()) + + +def rerun_failed_workflow_runs(pull_number: int) -> dict[str, Any]: + """Rerun failed jobs for failed/timed-out/cancelled GitHub Actions workflow runs. + + Use this to retry flaky CI failures without human intervention. Only operates on + GitHub Actions workflow runs — third-party CI checks (CircleCI, Vercel, etc.) + surfaced by `get_pr_check_runs` are not affected. Skips runs with conclusion + `action_required`, since those need manual approval (e.g. environment protection) + rather than a rerun. + + Args: + pull_number: The PR number whose failed CI runs should be rerun. + + Returns: + Dictionary with success status and details of which runs were rerun. + """ + repo_config = _get_repo_config() + if not repo_config: + return {"success": False, "error": "No repo config found"} + + token = asyncio.run(_get_token()) + if not token: + return {"success": False, "error": "Failed to get GitHub App installation token"} + + async def _rerun() -> dict[str, Any]: + async with _http_client() as client: + # Step 1: get the PR's head commit SHA + pr_url = f"{_repo_url(repo_config)}/pulls/{pull_number}" + pr_response = await client.get(pr_url, headers=_github_headers(token)) + if pr_response.status_code != 200: + return { + "success": False, + "error": f"GitHub API returned {pr_response.status_code} fetching PR: {pr_response.text}", + } + head_sha = pr_response.json().get("head", {}).get("sha") + if not head_sha: + return {"success": False, "error": "Could not determine head SHA for PR"} + + # Step 2: get workflow runs for that SHA + runs_url = f"{_repo_url(repo_config)}/actions/runs" + workflow_runs, _, error = await _fetch_paginated_items( + client, + runs_url, + _github_headers(token), + "workflow_runs", + params={"head_sha": head_sha}, + ) + if error or workflow_runs is None: + return { + "success": False, + "error": f"Error fetching workflow runs: {error}", + } + + failed_run_ids = [ + run["id"] + for run in workflow_runs + if run.get("conclusion") in RERUNNABLE_CONCLUSIONS + ] + + if not failed_run_ids: + return { + "success": True, + "message": "No failed workflow runs found for the PR's latest commit", + "head_sha": head_sha, + "rerun_run_ids": [], + } + + # Step 3: rerun failed jobs concurrently for all failed workflow runs + async def _rerun_one(run_id: int) -> dict[str, Any]: + rerun_url = f"{_repo_url(repo_config)}/actions/runs/{run_id}/rerun-failed-jobs" + rerun_response = await client.post(rerun_url, headers=_github_headers(token)) + return { + "run_id": run_id, + "status_code": rerun_response.status_code, + "success": rerun_response.status_code in (200, 201, 204), + } + + rerun_results = await asyncio.gather(*(_rerun_one(rid) for rid in failed_run_ids)) + + all_rerun_succeeded = all(r["success"] for r in rerun_results) + return { + "success": all_rerun_succeeded, + "head_sha": head_sha, + "rerun_run_ids": failed_run_ids, + "rerun_results": list(rerun_results), + } + + return asyncio.run(_rerun()) diff --git a/tests/test_github_ci.py b/tests/test_github_ci.py new file mode 100644 index 00000000..598e8ded --- /dev/null +++ b/tests/test_github_ci.py @@ -0,0 +1,422 @@ +"""Tests for the github_ci tools.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import agent.tools.github_ci as github_ci + + +def _make_response(status_code: int, json_data: Any) -> MagicMock: + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data + resp.text = str(json_data) + return resp + + +def _check_run( + run_id: int, conclusion: str = "success", status: str = "completed" +) -> dict[str, Any]: + return { + "id": run_id, + "name": f"job-{run_id}", + "status": status, + "conclusion": conclusion, + "html_url": f"https://github.com/checks/{run_id}", + } + + +# --------------------------------------------------------------------------- +# get_pr_check_runs +# --------------------------------------------------------------------------- + + +def test_get_pr_check_runs_no_repo_config(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(github_ci, "_get_repo_config", lambda: {}) + result = github_ci.get_pr_check_runs(42) + assert result == {"success": False, "error": "No repo config found"} + + +def test_get_pr_check_runs_no_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"} + ) + monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value=None)) + result = github_ci.get_pr_check_runs(42) + assert result == {"success": False, "error": "Failed to get GitHub App installation token"} + + +def test_get_pr_check_runs_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"} + ) + monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token")) + + pr_data = {"head": {"sha": "abc123"}} + check_runs_data = { + "total_count": 2, + "check_runs": [ + { + "id": 1, + "name": "test-job", + "status": "completed", + "conclusion": "success", + "html_url": "https://github.com/checks/1", + }, + { + "id": 2, + "name": "lint-job", + "status": "completed", + "conclusion": "success", + "html_url": "https://github.com/checks/2", + }, + ], + } + + mock_client = AsyncMock() + mock_client.get = AsyncMock( + side_effect=[ + _make_response(200, pr_data), + _make_response(200, check_runs_data), + ] + ) + + mock_async_context = MagicMock() + mock_async_context.__aenter__ = AsyncMock(return_value=mock_client) + mock_async_context.__aexit__ = AsyncMock(return_value=False) + + with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context): + result = github_ci.get_pr_check_runs(42) + + assert result["success"] is True + assert result["head_sha"] == "abc123" + assert result["total_count"] == 2 + assert len(result["check_runs"]) == 2 + assert result["all_passed"] is True + assert result["any_failed"] is False + assert result["any_pending"] is False + + +def test_get_pr_check_runs_with_failure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"} + ) + monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token")) + + pr_data = {"head": {"sha": "def456"}} + check_runs_data = { + "total_count": 2, + "check_runs": [ + { + "id": 10, + "name": "test-job", + "status": "completed", + "conclusion": "failure", + "html_url": "https://github.com/checks/10", + }, + { + "id": 11, + "name": "lint-job", + "status": "in_progress", + "conclusion": None, + "html_url": "https://github.com/checks/11", + }, + ], + } + + mock_client = AsyncMock() + mock_client.get = AsyncMock( + side_effect=[ + _make_response(200, pr_data), + _make_response(200, check_runs_data), + ] + ) + + mock_async_context = MagicMock() + mock_async_context.__aenter__ = AsyncMock(return_value=mock_client) + mock_async_context.__aexit__ = AsyncMock(return_value=False) + + with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context): + result = github_ci.get_pr_check_runs(42) + + assert result["success"] is True + assert result["any_failed"] is True + assert result["any_pending"] is True + assert result["all_passed"] is False + + +def test_get_pr_check_runs_empty_checks_not_all_passed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"} + ) + monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token")) + + mock_client = AsyncMock() + mock_client.get = AsyncMock( + side_effect=[ + _make_response(200, {"head": {"sha": "empty123"}}), + _make_response(200, {"total_count": 0, "check_runs": []}), + ] + ) + + mock_async_context = MagicMock() + mock_async_context.__aenter__ = AsyncMock(return_value=mock_client) + mock_async_context.__aexit__ = AsyncMock(return_value=False) + + with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context): + result = github_ci.get_pr_check_runs(42) + + assert result["success"] is True + assert result["total_count"] == 0 + assert result["check_runs"] == [] + assert result["all_passed"] is False + assert result["any_failed"] is False + assert result["any_pending"] is False + + +def test_get_pr_check_runs_paginates_before_summarizing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"} + ) + monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token")) + + first_page_runs = [_check_run(run_id) for run_id in range(1, 101)] + second_page_runs = [_check_run(101, conclusion="failure")] + + mock_client = AsyncMock() + mock_client.get = AsyncMock( + side_effect=[ + _make_response(200, {"head": {"sha": "paged123"}}), + _make_response(200, {"total_count": 101, "check_runs": first_page_runs}), + _make_response(200, {"total_count": 101, "check_runs": second_page_runs}), + ] + ) + + mock_async_context = MagicMock() + mock_async_context.__aenter__ = AsyncMock(return_value=mock_client) + mock_async_context.__aexit__ = AsyncMock(return_value=False) + + with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context): + result = github_ci.get_pr_check_runs(42) + + assert result["success"] is True + assert result["total_count"] == 101 + assert len(result["check_runs"]) == 101 + assert result["any_failed"] is True + assert result["all_passed"] is False + + +def test_get_pr_check_runs_pr_fetch_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"} + ) + monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token")) + + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=_make_response(404, {"message": "Not Found"})) + + mock_async_context = MagicMock() + mock_async_context.__aenter__ = AsyncMock(return_value=mock_client) + mock_async_context.__aexit__ = AsyncMock(return_value=False) + + with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context): + result = github_ci.get_pr_check_runs(42) + + assert result["success"] is False + assert "404" in result["error"] + + +# --------------------------------------------------------------------------- +# rerun_failed_workflow_runs +# --------------------------------------------------------------------------- + + +def test_rerun_failed_workflow_runs_no_repo_config(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(github_ci, "_get_repo_config", lambda: {}) + result = github_ci.rerun_failed_workflow_runs(42) + assert result == {"success": False, "error": "No repo config found"} + + +def test_rerun_failed_workflow_runs_no_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"} + ) + monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value=None)) + result = github_ci.rerun_failed_workflow_runs(42) + assert result == {"success": False, "error": "Failed to get GitHub App installation token"} + + +def test_rerun_failed_workflow_runs_no_failures(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"} + ) + monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token")) + + pr_data = {"head": {"sha": "abc999"}} + workflow_runs_data = { + "workflow_runs": [ + {"id": 100, "conclusion": "success"}, + ] + } + + mock_client = AsyncMock() + mock_client.get = AsyncMock( + side_effect=[ + _make_response(200, pr_data), + _make_response(200, workflow_runs_data), + ] + ) + + mock_async_context = MagicMock() + mock_async_context.__aenter__ = AsyncMock(return_value=mock_client) + mock_async_context.__aexit__ = AsyncMock(return_value=False) + + with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context): + result = github_ci.rerun_failed_workflow_runs(42) + + assert result["success"] is True + assert result["rerun_run_ids"] == [] + assert "No failed workflow runs" in result["message"] + + +def test_rerun_failed_workflow_runs_with_failures(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"} + ) + monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token")) + + pr_data = {"head": {"sha": "bbb111"}} + workflow_runs_data = { + "workflow_runs": [ + {"id": 200, "conclusion": "failure"}, + {"id": 201, "conclusion": "timed_out"}, + ] + } + + mock_client = AsyncMock() + mock_client.get = AsyncMock( + side_effect=[ + _make_response(200, pr_data), + _make_response(200, workflow_runs_data), + ] + ) + mock_client.post = AsyncMock(return_value=_make_response(201, {})) + + mock_async_context = MagicMock() + mock_async_context.__aenter__ = AsyncMock(return_value=mock_client) + mock_async_context.__aexit__ = AsyncMock(return_value=False) + + with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context): + result = github_ci.rerun_failed_workflow_runs(42) + + assert result["success"] is True + assert set(result["rerun_run_ids"]) == {200, 201} + assert len(result["rerun_results"]) == 2 + assert all(r["success"] for r in result["rerun_results"]) + + +def test_rerun_failed_workflow_runs_paginates_workflow_runs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"} + ) + monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token")) + + first_page_runs = [{"id": run_id, "conclusion": "success"} for run_id in range(1, 101)] + second_page_runs = [{"id": 999, "conclusion": "failure"}] + + mock_client = AsyncMock() + mock_client.get = AsyncMock( + side_effect=[ + _make_response(200, {"head": {"sha": "paged-rerun"}}), + _make_response(200, {"workflow_runs": first_page_runs}), + _make_response(200, {"workflow_runs": second_page_runs}), + ] + ) + mock_client.post = AsyncMock(return_value=_make_response(201, {})) + + mock_async_context = MagicMock() + mock_async_context.__aenter__ = AsyncMock(return_value=mock_client) + mock_async_context.__aexit__ = AsyncMock(return_value=False) + + with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context): + result = github_ci.rerun_failed_workflow_runs(42) + + assert result["success"] is True + assert result["rerun_run_ids"] == [999] + assert len(result["rerun_results"]) == 1 + + +def test_get_pr_check_runs_paginated_fetch_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Surface a non-200 response that occurs on a later page of pagination.""" + monkeypatch.setattr( + github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"} + ) + monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token")) + + first_page_runs = [_check_run(run_id) for run_id in range(1, 101)] + + mock_client = AsyncMock() + mock_client.get = AsyncMock( + side_effect=[ + _make_response(200, {"head": {"sha": "paged-error"}}), + _make_response(200, {"total_count": 200, "check_runs": first_page_runs}), + _make_response(500, {"message": "server error"}), + ] + ) + + mock_async_context = MagicMock() + mock_async_context.__aenter__ = AsyncMock(return_value=mock_client) + mock_async_context.__aexit__ = AsyncMock(return_value=False) + + with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context): + result = github_ci.get_pr_check_runs(42) + + assert result["success"] is False + assert "500" in result["error"] + + +def test_rerun_failed_workflow_runs_skips_action_required( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`action_required` runs need manual approval and must not be rerun.""" + monkeypatch.setattr( + github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"} + ) + monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token")) + + pr_data = {"head": {"sha": "ccc222"}} + workflow_runs_data = { + "workflow_runs": [ + {"id": 300, "conclusion": "failure"}, + {"id": 301, "conclusion": "action_required"}, + {"id": 302, "conclusion": "success"}, + ] + } + + mock_client = AsyncMock() + mock_client.get = AsyncMock( + side_effect=[ + _make_response(200, pr_data), + _make_response(200, workflow_runs_data), + ] + ) + mock_client.post = AsyncMock(return_value=_make_response(201, {})) + + mock_async_context = MagicMock() + mock_async_context.__aenter__ = AsyncMock(return_value=mock_client) + mock_async_context.__aexit__ = AsyncMock(return_value=False) + + with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context): + result = github_ci.rerun_failed_workflow_runs(42) + + assert result["success"] is True + assert result["rerun_run_ids"] == [300] + assert mock_client.post.await_count == 1