feat: recover failed CI checks from mobile (Closes #1354)
All checks were successful
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 2026-08-24 15:06:01 +00:00
parent 3e7a6c81fc
commit 8cb92c6858
10 changed files with 634 additions and 4 deletions

View File

@ -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; }

View File

@ -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;

View File

@ -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>

View File

@ -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;

View File

@ -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 };
}

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:

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)

View File

@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
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()

View File

@ -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):

View File

@ -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": ""},