feat: recover failed CI checks from mobile (Closes #1354)
This commit is contained in:
parent
3e7a6c81fc
commit
8cb92c6858
|
|
@ -792,6 +792,14 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.ci-check-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
|
||||
.ci-check-copy strong, .ci-check-copy span { min-width:0; overflow-wrap:anywhere; }
|
||||
.ci-check-link { min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid #60a5fa; border-radius:8px; white-space:nowrap; }
|
||||
.ci-check-actions { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:6px; min-height:44px; }
|
||||
.ci-check-inspect { min-height:44px; white-space:nowrap; }
|
||||
.ci-check-recovery { max-width:100%; min-width:0; margin:0 8px 8px; padding:10px; border:1px solid #dc2626; border-radius:8px; background:#101b2e; }
|
||||
.ci-check-recovery[hidden] { display:none; }
|
||||
.ci-check-recovery h3 { margin:0 0 6px; overflow-wrap:anywhere; }
|
||||
.ci-check-log { max-width:100%; max-height:42vh; margin:8px 0; padding:10px; overflow-x:auto; overflow-y:auto; border:1px solid #2a496e; border-radius:6px; background:#07101d; color:#dbeafe; font-size:.78rem; white-space:pre; }
|
||||
.ci-check-recovery-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-top:6px; background:#101b2e; }
|
||||
.ci-check-recovery-actions button { min-height:44px; }
|
||||
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.update-sheet.open { display:flex; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
|
|
|
|||
|
|
@ -368,6 +368,7 @@
|
|||
let selectedPullDetail = null;
|
||||
let pullConversation = null;
|
||||
let pullReviewState = null;
|
||||
|
||||
let creatingIssue = false;
|
||||
let createAndStartRequested = false;
|
||||
let pendingIssueFilingIntent = 'create-and-assign';
|
||||
|
|
@ -4605,6 +4606,7 @@
|
|||
async function openPullSheet(item, trigger, offlineDetail = null) {
|
||||
if (!item) return;
|
||||
if (!await ensurePullWorkflow(trigger)) return;
|
||||
|
||||
pullDetailPosition.open(workDetailIdentity('pull', item));
|
||||
qs('#pull-review').inert = false;
|
||||
qs('#pull-ownership').inert = false;
|
||||
|
|
|
|||
|
|
@ -1756,6 +1756,15 @@
|
|||
<summary><span>Checks</span><span class="small" id="pull-checks-summary">Not loaded</span></summary>
|
||||
<button class="ci-check-refresh" id="refresh-pull-checks" type="button">Refresh checks</button>
|
||||
<div class="ci-check-list" id="pull-check-list"></div>
|
||||
<section class="ci-check-recovery" id="pull-check-recovery" aria-labelledby="pull-check-recovery-title" hidden>
|
||||
<h3 id="pull-check-recovery-title" tabindex="-1">Failed check</h3>
|
||||
<div id="pull-check-recovery-status" class="small" role="status" aria-live="assertive"></div>
|
||||
<pre class="ci-check-log" id="pull-check-log" tabindex="0"></pre>
|
||||
<div class="ci-check-recovery-actions">
|
||||
<button id="retry-pull-check" type="button" disabled>Retry failed job</button>
|
||||
<button id="close-pull-check-recovery" type="button">Close details</button>
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
|
||||
<div id="pull-files"></div>
|
||||
|
|
|
|||
|
|
@ -576,6 +576,7 @@ function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
|
|||
function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) {
|
||||
bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
bindCheckRecovery(doc, controller, getSelected, getDetail);
|
||||
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-handoff');
|
||||
|
|
@ -722,6 +723,21 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
|||
}).finally(() => { checkRequest = null; });
|
||||
return checkRequest;
|
||||
},
|
||||
loadCheckFailure(item, expectedHeadSha, runId, jobIndex) {
|
||||
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
|
||||
'/jobs/' + encodeURIComponent(jobIndex) + '/failure?expected_head_sha=' +
|
||||
encodeURIComponent(expectedHeadSha), {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
retryCheck(item, expectedHeadSha, runId, jobIndex) {
|
||||
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
|
||||
'/jobs/' + encodeURIComponent(jobIndex) + '/retry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
|
||||
});
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
if (candidateRequest) return candidateRequest;
|
||||
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
|
||||
|
|
@ -941,6 +957,108 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
|||
};
|
||||
}
|
||||
|
||||
function bindCheckRecovery(
|
||||
doc, controller, selectedPull, selectedDetail,
|
||||
confirmRetry = message => globalThis.confirm(message)
|
||||
) {
|
||||
let current = null;
|
||||
const get = selector => doc.querySelector(selector);
|
||||
function reset({ restoreFocus = true } = {}) {
|
||||
const recovery = current;
|
||||
current = null;
|
||||
get('#pull-check-recovery').hidden = true;
|
||||
get('#pull-check-log').textContent = '';
|
||||
get('#pull-check-recovery-status').textContent = '';
|
||||
get('#retry-pull-check').disabled = true;
|
||||
if (restoreFocus) (recovery?.trigger?.isConnected ? recovery.trigger : get('#refresh-pull-checks'))?.focus();
|
||||
}
|
||||
async function poll(recovery, attempt = 0) {
|
||||
if (current !== recovery || selectedPull() !== recovery.item) return;
|
||||
if (attempt >= 12) {
|
||||
get('#pull-check-recovery-status').textContent = 'Retry is still queued. Use Refresh checks to continue monitoring.';
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
if (current !== recovery || selectedPull() !== recovery.item) return;
|
||||
try {
|
||||
const status = await controller.loadChecks(recovery.item);
|
||||
if (current !== recovery || selectedPull() !== recovery.item) return;
|
||||
if (status.head_sha !== recovery.headSha) {
|
||||
get('#pull-check-recovery-status').textContent = 'New commits arrived. Reload the pull request before retrying again.';
|
||||
return;
|
||||
}
|
||||
const check = (status.checks || []).find(candidate =>
|
||||
candidate?.recovery?.run_id === recovery.runId && candidate?.recovery?.job_index === recovery.jobIndex
|
||||
);
|
||||
if (!check || !['failure', 'error', 'pending'].includes(check.state)) {
|
||||
get('#refresh-pull-checks').click();
|
||||
get('#pull-check-recovery-status').textContent = 'Retried check completed. Merge readiness refreshed.';
|
||||
return;
|
||||
}
|
||||
get('#pull-check-recovery-status').textContent = check.state === 'pending'
|
||||
? 'Retried check is running…' : 'Retry queued; waiting for a fresh run…';
|
||||
} catch (_error) {
|
||||
get('#pull-check-recovery-status').textContent = 'Waiting for check status…';
|
||||
}
|
||||
return poll(recovery, attempt + 1);
|
||||
}
|
||||
get('#pull-check-list').addEventListener('click', async event => {
|
||||
const trigger = event.target.closest('.ci-check-inspect');
|
||||
const item = selectedPull();
|
||||
const detail = selectedDetail();
|
||||
if (!trigger || !item || !detail?.head_sha) return;
|
||||
const runId = Number(trigger.dataset.runId);
|
||||
const jobIndex = Number(trigger.dataset.jobIndex);
|
||||
if (!Number.isInteger(runId) || !Number.isInteger(jobIndex)) return;
|
||||
const recovery = {
|
||||
item, headSha: detail.head_sha, runId, jobIndex, trigger,
|
||||
name: trigger.closest('.ci-check')?.querySelector('strong')?.textContent || 'Failed check',
|
||||
};
|
||||
current = recovery;
|
||||
get('#pull-check-recovery').hidden = false;
|
||||
get('#pull-check-recovery-title').textContent = recovery.name;
|
||||
get('#pull-check-log').textContent = 'Loading failure details…';
|
||||
get('#pull-check-recovery-status').textContent = 'Loading failure details…';
|
||||
get('#retry-pull-check').disabled = true;
|
||||
get('#pull-check-recovery-title').focus();
|
||||
try {
|
||||
const result = await controller.loadCheckFailure(item, recovery.headSha, runId, jobIndex);
|
||||
if (current !== recovery || selectedPull() !== item) return;
|
||||
get('#pull-check-log').textContent = result.excerpt || 'No failure output was reported.';
|
||||
get('#pull-check-recovery-status').textContent = 'Failure details loaded.';
|
||||
get('#retry-pull-check').disabled = false;
|
||||
} catch (error) {
|
||||
if (current !== recovery || selectedPull() !== item) return;
|
||||
get('#pull-check-log').textContent = '';
|
||||
get('#pull-check-recovery-status').textContent = error.message;
|
||||
}
|
||||
});
|
||||
get('#close-pull-check-recovery').addEventListener('click', () => reset());
|
||||
get('#pull-checks').addEventListener('toggle', () => {
|
||||
if (current && selectedPull() !== current.item) reset({ restoreFocus: false });
|
||||
});
|
||||
get('#retry-pull-check').addEventListener('click', async () => {
|
||||
const recovery = current;
|
||||
if (!recovery || selectedPull() !== recovery.item) return;
|
||||
if (!confirmRetry('Retry ' + recovery.name + ' for head ' + recovery.headSha.slice(0, 12) + '?')) return;
|
||||
const button = get('#retry-pull-check');
|
||||
button.disabled = true;
|
||||
get('#pull-check-recovery-status').textContent = 'Queueing retry…';
|
||||
try {
|
||||
await controller.retryCheck(recovery.item, recovery.headSha, recovery.runId, recovery.jobIndex);
|
||||
if (current !== recovery) return;
|
||||
get('#pull-check-recovery-status').textContent = 'Retry queued; waiting for a fresh run…';
|
||||
poll(recovery);
|
||||
} catch (error) {
|
||||
if (current !== recovery) return;
|
||||
get('#pull-check-recovery-status').textContent = error.message;
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
return { reset };
|
||||
}
|
||||
|
||||
createPullSheet.mergeEligibility = mergeEligibility;
|
||||
createPullSheet.review = reviewEligibility;
|
||||
createPullSheet.renderReviewerStatuses = renderReviewerStatuses;
|
||||
|
|
@ -957,4 +1075,5 @@ createPullSheet.bindContextEditor = bindContextEditor;
|
|||
createPullSheet.resetReviewRequestControls = resetReviewRequestControls;
|
||||
createPullSheet.bindReviewRequestControls = bindReviewRequestControls;
|
||||
createPullSheet.bindFeedbackControls = bindFeedbackControls;
|
||||
createPullSheet.bindCheckRecovery = bindCheckRecovery;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||
|
|
|
|||
|
|
@ -384,10 +384,20 @@ function renderChecks(checks, escapeHtml, { offline = false } = {}) {
|
|||
const url = typeof check.url === 'string' ? check.url : '';
|
||||
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
|
||||
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
|
||||
const recovery = check.recovery;
|
||||
const inspect = !offline && ['failure', 'error'].includes(check.state) &&
|
||||
Number.isInteger(recovery?.run_id) && recovery.run_id > 0 &&
|
||||
Number.isInteger(recovery?.job_index) && recovery.job_index >= 0
|
||||
? '<button type="button" class="ci-check-inspect" data-run-id="' + recovery.run_id +
|
||||
'" data-job-index="' + recovery.job_index + '">Inspect failure</button>'
|
||||
: '';
|
||||
const actions = inspect || link
|
||||
? '<div class="ci-check-actions">' + inspect + link + '</div>'
|
||||
: '';
|
||||
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
|
||||
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
|
||||
'<span class="small">' + escapeHtml(check.state || 'unknown') +
|
||||
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
|
||||
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + actions + '</article>';
|
||||
}).join('');
|
||||
return { summary, html, expanded: failed > 0 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
src/main.py
100
src/main.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -8703,6 +8703,76 @@ process.stdout.write(JSON.stringify({{result, offline}}));
|
|||
assert output["offline"]["summary"].startswith("Last known · ")
|
||||
|
||||
|
||||
def test_failed_actions_check_renders_in_app_recovery_control_only_when_supported():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const escapeHtml = value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
||||
const supported = reviewSheet.renderChecks([
|
||||
{{name:'CI / lint', state:'failure', url:'https://forge.example/jobs/3', recovery:{{run_id:91, job_index:3}}}},
|
||||
], escapeHtml);
|
||||
const external = reviewSheet.renderChecks([
|
||||
{{name:'Vendor scan', state:'failure', url:'https://forge.example/jobs/4'}},
|
||||
], escapeHtml);
|
||||
process.stdout.write(JSON.stringify({{supported, external}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert 'class="ci-check-inspect"' in output["supported"]["html"]
|
||||
assert 'data-run-id="91"' in output["supported"]["html"]
|
||||
assert 'data-job-index="3"' in output["supported"]["html"]
|
||||
assert "Inspect failure" in output["supported"]["html"]
|
||||
assert "ci-check-inspect" not in output["external"]["html"]
|
||||
assert "Open job" in output["external"]["html"]
|
||||
|
||||
|
||||
def test_pull_controller_loads_failure_and_retries_exact_head():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const calls = [];
|
||||
const controller = createPullSheet({{
|
||||
fetchJson: async (url, options) => {{ calls.push({{url, options}}); return {{status:'queued'}}; }},
|
||||
storage: null,
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:7}};
|
||||
controller.loadCheckFailure(item, 'abc1234', 91, 3).then(() =>
|
||||
controller.retryCheck(item, 'abc1234', 91, 3)
|
||||
).then(() => process.stdout.write(JSON.stringify(calls)));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
calls = json.loads(result.stdout)
|
||||
|
||||
assert calls[0]["url"].endswith(
|
||||
"/pulls/7/checks/91/jobs/3/failure?expected_head_sha=abc1234"
|
||||
)
|
||||
assert calls[0]["options"]["headers"]["Accept"] == "application/json"
|
||||
assert calls[1]["url"].endswith("/pulls/7/checks/91/jobs/3/retry")
|
||||
assert calls[1]["options"]["method"] == "POST"
|
||||
assert json.loads(calls[1]["options"]["body"]) == {"expected_head_sha": "abc1234"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_pull_workspace_packages_accessible_ci_recovery_panel():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="pull-check-recovery"' in html
|
||||
assert 'id="pull-check-recovery-title"' in html
|
||||
assert 'id="pull-check-log"' in html
|
||||
assert 'id="retry-pull-check"' in html
|
||||
assert 'id="close-pull-check-recovery"' in html
|
||||
assert 'aria-live="assertive"' in html
|
||||
assert "createPullSheet.bindOwnershipControls" in html
|
||||
assert "#pull-check-list" in html and "ci-check-inspect" in html
|
||||
assert ".ci-check-recovery" in html and "max-width:100%" in html
|
||||
assert ".ci-check-log" in html and "overflow-x:auto" in html
|
||||
assert ".ci-check-actions" in html and "min-height:44px" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_pull_review_sheets_refresh_checks_without_reloading_diffs():
|
||||
html = await dashboard()
|
||||
|
|
|
|||
|
|
@ -650,6 +650,201 @@ async def test_assigned_pull_checks_endpoint_authorizes_and_returns_status_only(
|
|||
]
|
||||
|
||||
|
||||
def test_commit_checks_expose_only_same_forge_actions_recovery_metadata(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gitea_proxy, "GITEA_URL", "https://forge.example/git"
|
||||
)
|
||||
|
||||
checks = gitea_proxy._normalize_commit_checks({
|
||||
"statuses": [
|
||||
{
|
||||
"context": "CI / lint (pull_request)",
|
||||
"status": "failure",
|
||||
"description": "Failed",
|
||||
"target_url": "/git/acme/mobile/actions/runs/91/jobs/3",
|
||||
},
|
||||
{
|
||||
"context": "external",
|
||||
"status": "failure",
|
||||
"target_url": "https://checks.example/jobs/4",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
assert checks[0] == {
|
||||
"name": "CI / lint (pull_request)",
|
||||
"state": "failure",
|
||||
"description": "Failed",
|
||||
"url": "https://forge.example/git/acme/mobile/actions/runs/91/jobs/3",
|
||||
"recovery": {"run_id": 91, "job_index": 3},
|
||||
}
|
||||
assert checks[1]["url"] == ""
|
||||
assert "recovery" not in checks[1]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_action_failure_excerpt_is_bounded_and_redacts_credentials(monkeypatch):
|
||||
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "http://test/git")
|
||||
requests = []
|
||||
noisy_log = "old output\n" * 4_000 + (
|
||||
"Authorization: token super-secret\n"
|
||||
"GITEA_TOKEN=another-secret\n"
|
||||
"AssertionError: expected ready, got blocked\n"
|
||||
)
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={"head": {"sha": "abc1234"}})
|
||||
if request.url.path.endswith("/commits/abc1234/status"):
|
||||
return httpx.Response(200, json={"statuses": [{
|
||||
"context": "CI / lint (pull_request)",
|
||||
"status": "failure",
|
||||
"target_url": "/git/stackchain/api/actions/runs/91/jobs/3",
|
||||
}]})
|
||||
if request.url.path.endswith("/actions/runs/91/jobs/3/logs"):
|
||||
return httpx.Response(200, text=noisy_log)
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.action_failure_excerpt(
|
||||
"stackchain/api", 7, "abc1234", 91, 3
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["head_sha"] == "abc1234"
|
||||
assert result["run_id"] == 91
|
||||
assert result["job_index"] == 3
|
||||
assert len(result["excerpt"].encode()) <= 24 * 1024
|
||||
assert "AssertionError: expected ready, got blocked" in result["excerpt"]
|
||||
assert "super-secret" not in result["excerpt"]
|
||||
assert "another-secret" not in result["excerpt"]
|
||||
assert "Authorization: [redacted]" in result["excerpt"]
|
||||
assert requests == [
|
||||
("GET", "/git/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("GET", "/git/api/v1/repos/stackchain/api/commits/abc1234/status"),
|
||||
("GET", "/git/stackchain/api/actions/runs/91/jobs/3/logs"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_action_failure_endpoint_authorizes_and_returns_no_store(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def capabilities(repository, number):
|
||||
calls.append(("access", repository, number))
|
||||
return {"authored": True, "assigned": False}
|
||||
|
||||
async def excerpt(repository, number, head_sha, run_id, job_index):
|
||||
calls.append(("excerpt", repository, number, head_sha, run_id, job_index))
|
||||
return {
|
||||
"head_sha": head_sha,
|
||||
"run_id": run_id,
|
||||
"job_index": job_index,
|
||||
"name": "CI / lint",
|
||||
"excerpt": "AssertionError: failed",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "_pull_workspace_capabilities", capabilities)
|
||||
monkeypatch.setattr(main.gitea_proxy, "action_failure_excerpt", excerpt, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/checks/91/jobs/3/failure",
|
||||
params={"expected_head_sha": "abc1234"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["excerpt"] == "AssertionError: failed"
|
||||
assert calls == [
|
||||
("access", "stackchain/api", 7),
|
||||
("excerpt", "stackchain/api", 7, "abc1234", 91, 3),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_retry_failed_action_job_validates_head_and_uses_gitea_csrf(monkeypatch):
|
||||
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "http://test/git")
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path, request.headers.get("x-csrf-token")))
|
||||
if request.url.path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={"head": {"sha": "abc1234"}})
|
||||
if request.url.path.endswith("/commits/abc1234/status"):
|
||||
return httpx.Response(200, json={"statuses": [{
|
||||
"context": "CI / lint (pull_request)",
|
||||
"status": "failure",
|
||||
"target_url": "/git/stackchain/api/actions/runs/91/jobs/3",
|
||||
}]})
|
||||
if request.method == "GET" and request.url.path.endswith("/actions/runs/91/jobs/3"):
|
||||
return httpx.Response(200, text="<script>csrfToken: 'csrf-123'</script>")
|
||||
if request.method == "POST" and request.url.path.endswith("/actions/runs/91/jobs/3/rerun"):
|
||||
assert request.headers["x-csrf-token"] == "csrf-123"
|
||||
assert b"_csrf=csrf-123" in request.content
|
||||
return httpx.Response(303, headers={"location": "/git/stackchain/api/actions/runs/92"})
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.retry_action_job(
|
||||
"stackchain/api", 7, "abc1234", 91, 3
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"head_sha": "abc1234",
|
||||
"run_id": 91,
|
||||
"job_index": 3,
|
||||
"status": "queued",
|
||||
}
|
||||
assert requests == [
|
||||
("GET", "/git/api/v1/repos/stackchain/api/pulls/7", None),
|
||||
("GET", "/git/api/v1/repos/stackchain/api/commits/abc1234/status", None),
|
||||
("GET", "/git/stackchain/api/actions/runs/91/jobs/3", None),
|
||||
("POST", "/git/stackchain/api/actions/runs/91/jobs/3/rerun", "csrf-123"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_retry_action_endpoint_requires_workspace_access_and_exact_head(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def capabilities(repository, number):
|
||||
calls.append(("access", repository, number))
|
||||
return {"authored": True, "assigned": False}
|
||||
|
||||
async def retry(repository, number, head_sha, run_id, job_index):
|
||||
calls.append(("retry", repository, number, head_sha, run_id, job_index))
|
||||
return {
|
||||
"head_sha": head_sha,
|
||||
"run_id": run_id,
|
||||
"job_index": job_index,
|
||||
"status": "queued",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "_pull_workspace_capabilities", capabilities)
|
||||
monkeypatch.setattr(main.gitea_proxy, "retry_action_job", retry, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/checks/91/jobs/3/retry",
|
||||
json={"expected_head_sha": "abc1234"},
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["status"] == "queued"
|
||||
assert calls == [
|
||||
("access", "stackchain/api", 7),
|
||||
("retry", "stackchain/api", 7, "abc1234", 91, 3),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
|
||||
async def handler(request):
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ async def test_gitea_review_detail_returns_bounded_actionable_checks(monkeypatch
|
|||
"name": "lint/<unsafe>", "state": "failure",
|
||||
"description": "Formatting failed " + "x" * 222,
|
||||
"url": "https://forge.example/git/stackchain/api/actions/runs/9/jobs/4",
|
||||
"recovery": {"run_id": 9, "job_index": 4},
|
||||
},
|
||||
{"name": "external", "state": "pending", "description": "Waiting", "url": ""},
|
||||
{"name": "build-release", "state": "success", "description": "Passed", "url": ""},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user