From 8cb92c6858e15e8ff8a2f282851982f67b35e7e5 Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 24 Aug 2026 15:06:01 +0000 Subject: [PATCH] feat: recover failed CI checks from mobile (Closes #1354) --- frontend/dashboard.css | 8 ++ frontend/dashboard.js | 2 + frontend/index.html | 9 ++ frontend/pull-sheet.js | 119 ++++++++++++++++++++++++ frontend/review-sheet.js | 12 ++- src/gitea_proxy.py | 122 +++++++++++++++++++++++- src/main.py | 100 ++++++++++++++++++++ tests/test_my_work.py | 70 ++++++++++++++ tests/test_pull_api.py | 195 +++++++++++++++++++++++++++++++++++++++ tests/test_review_api.py | 1 + 10 files changed, 634 insertions(+), 4 deletions(-) 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
+
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": ""},