diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 211b362..15fb7b9 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -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; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index c0a5183..4b10202 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -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;
diff --git a/frontend/index.html b/frontend/index.html
index 6c3eb20..bd60580 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1756,6 +1756,15 @@
ChecksNot loaded
+
+ Failed check
+
+
+
+
+
+
+
Review progress unavailable.
diff --git a/frontend/pull-sheet.js b/frontend/pull-sheet.js
index 9dfb6d6..e2ecae1 100644
--- a/frontend/pull-sheet.js
+++ b/frontend/pull-sheet.js
@@ -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;
diff --git a/frontend/review-sheet.js b/frontend/review-sheet.js
index 63401a1..4e5db4d 100644
--- a/frontend/review-sheet.js
+++ b/frontend/review-sheet.js
@@ -384,10 +384,20 @@ function renderChecks(checks, escapeHtml, { offline = false } = {}) {
const url = typeof check.url === 'string' ? check.url : '';
const link = url ? 'Open job' : '';
+ 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
+ ? ''
+ : '';
+ const actions = inspect || link
+ ? '' + inspect + link + '
'
+ : '';
return '' +
'' + escapeHtml(check.name) + '' +
'' + escapeHtml(check.state || 'unknown') +
- (check.description ? ' · ' + escapeHtml(check.description) : '') + '
' + link + '';
+ (check.description ? ' · ' + escapeHtml(check.description) : '') + '' + actions + '';
}).join('');
return { summary, html, expanded: failed > 0 };
}
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index 829f7de..090fa5d 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -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:
diff --git a/src/main.py b/src/main.py
index bc9f337..5cbc12f 100644
--- a/src/main.py
+++ b/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)
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 95d0f85..dc487f0 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -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()
diff --git a/tests/test_pull_api.py b/tests/test_pull_api.py
index bcdc1bb..38b2f69 100644
--- a/tests/test_pull_api.py
+++ b/tests/test_pull_api.py
@@ -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="")
+ 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):
diff --git a/tests/test_review_api.py b/tests/test_review_api.py
index 5caa03b..39a5289 100644
--- a/tests/test_review_api.py
+++ b/tests/test_review_api.py
@@ -108,6 +108,7 @@ async def test_gitea_review_detail_returns_bounded_actionable_checks(monkeypatch
"name": "lint/", "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": ""},