feat: recover failed CI checks from mobile (Closes #1354)
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 5m44s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-24 15:06:01 +00:00
1 parent 3e7a6c81fc
commit 8cb92c6858
10 files changed
+634 -4

No files matched your search

+119 -3
View File
@@ -793,13 +793,25 @@ def _normalize_commit_checks(status: Any) -> list[dict]:
if state not in rank:
state = "unknown"
description = entry.get("description")
checks.append({
url = _safe_gitea_web_url(entry.get("target_url"))
check = {
"name": name.strip()[:120],
"state": state,
"description": description.strip()[:240] if isinstance(description, str) else "",
"url": _safe_gitea_web_url(entry.get("target_url")),
"url": url,
"_index": index,
})
}
configured_path = urlsplit(GITEA_URL).path.rstrip("/")
action_match = re.fullmatch(
re.escape(configured_path) + r"/[^/]+/[^/]+/actions/runs/(\d+)/jobs/(\d+)",
urlsplit(url).path,
)
if action_match:
check["recovery"] = {
"run_id": int(action_match.group(1)),
"job_index": int(action_match.group(2)),
}
checks.append(check)
checks.sort(key=lambda check: (rank.get(check["state"], 2), check["_index"]))
return [{key: value for key, value in check.items() if key != "_index"} for check in checks[:20]]
@@ -3192,6 +3204,110 @@ async def pull_check_status(repository: str, number: int) -> dict:
}
def _bounded_action_log_excerpt(value: str, max_bytes: int = 24 * 1024) -> str:
redacted = re.sub(
r"(?im)^(authorization\s*:)\s*[^\r\n]*$",
r"\1 [redacted]",
value,
)
redacted = re.sub(
r"(?im)\b(gitea_token|github_token|access_token)=\S+",
r"\1=[redacted]",
redacted,
)
encoded = redacted.encode("utf-8")
if len(encoded) <= max_bytes:
return redacted
return encoded[-max_bytes:].decode("utf-8", errors="ignore")
async def action_failure_excerpt(
repository: str,
number: int,
expected_head_sha: str,
run_id: int,
job_index: int,
) -> dict:
pull = await fetch(f"repos/{repository}/pulls/{number}")
head = pull.get("head") if isinstance(pull, dict) else None
sha = head.get("sha") if isinstance(head, dict) else None
if sha != expected_head_sha:
raise StalePullError("Pull request head changed")
status = await fetch(f"repos/{repository}/commits/{sha}/status")
matching = next(
(
check
for check in _normalize_commit_checks(status)
if check.get("state") in {"failure", "error"}
and check.get("recovery") == {"run_id": run_id, "job_index": job_index}
),
None,
)
if matching is None:
raise ValueError("Failed Gitea Actions job is unavailable")
response = await _get_client().get(
f"{GITEA_URL}/{quote(repository, safe='/')}/actions/runs/{run_id}/jobs/{job_index}/logs",
headers=_auth(),
)
response.raise_for_status()
return {
"head_sha": sha,
"run_id": run_id,
"job_index": job_index,
"name": matching["name"],
"excerpt": _bounded_action_log_excerpt(response.text),
}
async def retry_action_job(
repository: str,
number: int,
expected_head_sha: str,
run_id: int,
job_index: int,
) -> dict:
pull = await fetch(f"repos/{repository}/pulls/{number}")
head = pull.get("head") if isinstance(pull, dict) else None
sha = head.get("sha") if isinstance(head, dict) else None
if sha != expected_head_sha:
raise StalePullError("Pull request head changed")
status = await fetch(f"repos/{repository}/commits/{sha}/status")
matching = next(
(
check
for check in _normalize_commit_checks(status)
if check.get("state") in {"failure", "error"}
and check.get("recovery") == {"run_id": run_id, "job_index": job_index}
),
None,
)
if matching is None:
raise ValueError("Failed Gitea Actions job is unavailable")
job_url = (
f"{GITEA_URL}/{quote(repository, safe='/')}"
f"/actions/runs/{run_id}/jobs/{job_index}"
)
page = await _get_client().get(job_url, headers=_auth())
page.raise_for_status()
csrf_match = re.search(r"\bcsrfToken:\s*'([^']+)'", page.text)
if not csrf_match:
raise ValueError("Gitea did not provide a retry authorization token")
csrf_token = csrf_match.group(1)
response = await _get_client().post(
f"{job_url}/rerun",
headers={**_auth(), "X-Csrf-Token": csrf_token},
data={"_csrf": csrf_token},
)
if response.is_error:
response.raise_for_status()
return {
"head_sha": sha,
"run_id": run_id,
"job_index": job_index,
"status": "queued",
}
async def is_pull_merged_at_head(
repository: str, number: int, expected_head_sha: str
) -> bool:
+100
View File
@@ -6784,6 +6784,106 @@ async def assigned_pull_checks(
)
@app.get(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/checks/{run_id}/jobs/{job_index}/failure"
)
async def pull_action_failure(
owner: str,
repo: str,
number: int = PathParam(gt=0),
run_id: int = PathParam(gt=0),
job_index: int = PathParam(ge=0),
expected_head_sha: str = Query(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
) -> JSONResponse:
repository = f"{owner}/{repo}"
async def load_excerpt():
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.action_failure_excerpt(
repository, number, expected_head_sha, run_id, job_index
)
try:
result = await asyncio.wait_for(
load_excerpt(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except gitea_proxy.StalePullError:
return JSONResponse(
{"error": "New commits arrived. Reload checks before diagnosing this job."},
status_code=409,
headers={"Cache-Control": "no-store"},
)
except ValueError:
return JSONResponse(
{"error": "This failed check does not expose a recoverable Actions job."},
status_code=404,
headers={"Cache-Control": "no-store"},
)
except Exception:
return JSONResponse(
{"error": "The failure log is temporarily unavailable. Open the job or retry."},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.post(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/checks/{run_id}/jobs/{job_index}/retry"
)
async def retry_pull_action_job(
retry: PullReadyRequest,
owner: str,
repo: str,
number: int = PathParam(gt=0),
run_id: int = PathParam(gt=0),
job_index: int = PathParam(ge=0),
) -> JSONResponse:
repository = f"{owner}/{repo}"
async def retry_job():
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.retry_action_job(
repository, number, retry.expected_head_sha, run_id, job_index
)
try:
result = await asyncio.wait_for(
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except gitea_proxy.StalePullError:
return JSONResponse(
{"error": "New commits arrived. Reload checks before retrying this job."},
status_code=409,
headers={"Cache-Control": "no-store"},
)
except ValueError:
return JSONResponse(
{"error": "This check is no longer failed or cannot be retried."},
status_code=409,
headers={"Cache-Control": "no-store"},
)
except Exception:
return JSONResponse(
{"error": "The failed job could not be queued. Open the job or retry."},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
return JSONResponse(
result, status_code=202, headers={"Cache-Control": "no-store"}
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/handoff-candidates")
async def pull_handoff_candidates(
owner: str, repo: str, number: int = PathParam(gt=0)