feat: recover failed CI checks from mobile (Closes #1354)
This commit is contained in:
1 parent
3e7a6c81fc
commit
8cb92c6858
10 files changed
+634
-4
No files matched your search
+119
-3
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user