mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 08:03:15 +00:00
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 <noreply@anthropic.com> * 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 <agent@anthropic.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
This commit is contained in:
parent
28027d5295
commit
4060933ce4
4 changed files with 683 additions and 2 deletions
|
|
@ -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=[
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
252
agent/tools/github_ci.py
Normal file
252
agent/tools/github_ci.py
Normal file
|
|
@ -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())
|
||||
422
tests/test_github_ci.py
Normal file
422
tests/test_github_ci.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue