From 966116e5eee72f681eb0fb7a74926e216b666350 Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 24 Aug 2026 21:36:16 +0000 Subject: [PATCH] feat: recover failed post-merge checks (Closes #1366) --- frontend/dashboard.css | 8 +- frontend/release-receipt.js | 119 ++++++++++- src/gitea_proxy.py | 113 +++++++++- src/main.py | 90 ++++++++ .../test_mobile_release_failure_recovery.py | 91 ++++++++ tests/test_pull_api.py | 194 ++++++++++++++++++ tests/test_release_receipt.py | 84 ++++++++ 7 files changed, 694 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/test_mobile_release_failure_recovery.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 3637e72..d398c7c 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -38,8 +38,14 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex .release-watchlist-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:min(240px,46vw); } .release-watchlist-actions > button { min-height:44px; width:100%; } .release-branch-cleanup-status { color:#cbd5e1; } +.release-failure-summary { display:grid; gap:8px; min-width:0; margin-top:8px; padding-top:8px; border-top:1px solid #334155; overflow-x:hidden; } +.release-failure-summary > button { min-height:44px; } +.release-failure-recovery { display:grid; gap:8px; min-width:0; padding:10px; border:1px solid #7f1d1d; border-radius:8px; background:#180f17; overflow-x:hidden; } +.release-failure-recovery pre { box-sizing:border-box; max-width:100%; max-height:32dvh; margin:0; padding:10px; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; background:#07101d; } +.release-failure-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:0; } +.release-failure-actions > button, .release-failure-actions > a { min-height:44px; width:100%; } @media (max-width:359px) { - .release-receipt-actions, .release-watchlist-actions { grid-template-columns:1fr; } + .release-receipt-actions, .release-watchlist-actions, .release-failure-actions { grid-template-columns:1fr; } .release-watchlist-item { grid-template-columns:1fr; } .release-watchlist-actions { min-width:0; width:100%; } } diff --git a/frontend/release-receipt.js b/frontend/release-receipt.js index d6a2dd8..87b53d3 100644 --- a/frontend/release-receipt.js +++ b/frontend/release-receipt.js @@ -3,6 +3,8 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d const limit = 12; let entries = []; let refreshing = null; + const recoveries = new Map(); + const retrying = new Map(); let timer = null; let bound = false; documentRef ||= typeof document !== 'undefined' ? document : null; @@ -92,10 +94,17 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d function summarize(payload) { const checks = Array.isArray(payload?.checks) ? payload.checks : []; - const failing = checks.filter(check => ['failure', 'error'].includes(check.state)).map(check => check.name).filter(Boolean); + const failedChecks = checks.filter(check => ['failure', 'error'].includes(check.state)); + const failing = failedChecks.map(check => check.name).filter(Boolean); + const failures = failedChecks.filter(check => check?.recovery).map(check => ({ + name: check.name || 'Failed check', + description: check.description || '', + url: check.url || '', + recovery: check.recovery, + })); const pending = checks.filter(check => check.state === 'pending').map(check => check.name).filter(Boolean); if (payload?.release) return { ...payload, label: 'Released · ' + payload.release.tag, checks: [] }; - if (failing.length) return { ...payload, label: 'Checks failed', checks: failing }; + if (failing.length) return { ...payload, label: 'Checks failed', checks: failing, failures }; if (payload?.ci_state === 'success') return { ...payload, label: 'Checks passed · waiting for release', checks: [] }; return { ...payload, label: 'Checks running', checks: pending }; } @@ -146,6 +155,58 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d link.textContent = 'Open release ' + entry.status.release.tag; copy.append(link); } + for (const failure of (entry.status?.failures || [])) { + const runId = failure.recovery?.run_id; + const jobIndex = failure.recovery?.job_index; + if (!Number.isInteger(runId) || !Number.isInteger(jobIndex)) continue; + const key = identity(entry) + '@' + runId + ':' + jobIndex; + const summary = document.createElement('section'); + summary.className = 'release-failure-summary'; + const failureName = document.createElement('span'); + failureName.className = 'small'; + failureName.textContent = failure.name + (failure.description ? ' · ' + failure.description : ''); + const review = document.createElement('button'); + review.type = 'button'; + review.textContent = 'Review failure'; + review.setAttribute('aria-label', 'Review failed check ' + failure.name + ' for ' + entry.commit_sha.slice(0, 8)); + review.addEventListener('click', () => reviewFailure( + entry.repository, entry.commit_sha, runId, jobIndex + ).catch(error => { + if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.'; + })); + summary.append(failureName, review); + const detail = recoveries.get(key); + if (detail) { + const recovery = document.createElement('div'); + recovery.className = 'release-failure-recovery'; + const sha = document.createElement('strong'); + sha.textContent = 'Merge ' + entry.commit_sha.slice(0, 8); + const excerpt = document.createElement('pre'); + excerpt.textContent = detail.excerpt || 'No log excerpt was returned.'; + const actions = document.createElement('div'); + actions.className = 'release-failure-actions'; + if (failure.url) { + const job = document.createElement('a'); + job.href = failure.url; + job.textContent = 'Open job'; + actions.append(job); + } + const retry = document.createElement('button'); + retry.type = 'button'; + retry.textContent = retrying.has(key) ? 'Retrying…' : 'Retry failed check'; + retry.disabled = retrying.has(key); + retry.addEventListener('click', () => retryFailure( + entry.repository, entry.commit_sha, runId, jobIndex + ).catch(error => { + if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.'; + render(); + })); + actions.append(retry); + recovery.append(sha, excerpt, actions); + summary.append(recovery); + } + copy.append(summary); + } const button = document.createElement('button'); button.type = 'button'; button.textContent = 'Dismiss'; @@ -205,6 +266,58 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d finally { refreshing = null; } } + function recoveryPath(entry, runId, jobIndex) { + return 'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/') + + '/pulls/' + encodeURIComponent(entry.number) + + '/release-receipt/' + encodeURIComponent(entry.commit_sha) + + '/checks/' + encodeURIComponent(runId) + '/jobs/' + encodeURIComponent(jobIndex); + } + + async function reviewFailure(repository, commitSha, runId, jobIndex) { + const entry = entries.find(value => identity(value) === repository + '@' + commitSha); + if (!entry) throw new Error('Tracked release is unavailable.'); + const key = identity(entry) + '@' + runId + ':' + jobIndex; + const detail = await fetchJson(recoveryPath(entry, runId, jobIndex) + '/failure', { + headers: { Accept: 'application/json' }, + }); + if (detail?.commit_sha !== entry.commit_sha) { + throw new Error('Release failure evidence did not match the merged commit.'); + } + recoveries.set(key, detail); + render(); + return detail; + } + + function retryFailure(repository, commitSha, runId, jobIndex) { + const entry = entries.find(value => identity(value) === repository + '@' + commitSha); + if (!entry) return Promise.reject(new Error('Tracked release is unavailable.')); + const key = identity(entry) + '@' + runId + ':' + jobIndex; + if (retrying.has(key)) return retrying.get(key); + const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false); + const failure = entry.status?.failures?.find(value => + value.recovery?.run_id === runId && value.recovery?.job_index === jobIndex + ); + if (!approve('Retry ' + (failure?.name || 'failed release check') + ' for ' + commitSha.slice(0, 8) + '?')) { + return Promise.resolve(false); + } + const operation = fetchJson(recoveryPath(entry, runId, jobIndex) + '/retry', { + method: 'POST', + headers: { Accept: 'application/json' }, + }).then(result => { + if (result?.commit_sha !== entry.commit_sha) { + throw new Error('Release retry did not match the merged commit.'); + } + entry.status = { ...entry.status, ci_state: 'pending', label: 'Checks running', checks: [], failures: [] }; + recoveries.delete(key); + persist(); + render(); + schedule(0); + return true; + }).finally(() => retrying.delete(key)); + retrying.set(key, operation); + return operation; + } + async function deleteBranch(repository, commitSha) { const entry = entries.find(value => identity(value) === repository + '@' + commitSha); if (!entry?.source_branch || !entry?.source_head_sha || entry.cleanup?.state === 'deleted') { @@ -272,7 +385,7 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d schedule(); } - return { capture, restore, refresh, deleteBranch, dismiss, bind, fetchJson }; + return { capture, restore, refresh, reviewFailure, retryFailure, deleteBranch, dismiss, bind, fetchJson }; } if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index e7aefb0..b060a81 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -3093,6 +3093,26 @@ async def is_requested_review(repository: str, number: int) -> bool: ) +async def can_recover_merged_release( + repository: str, number: int, commit_sha: str +) -> bool: + """Confirm the current operator participated in the exact merged pull.""" + login, pull = await _current_login_and_target( + f"repos/{repository}/pulls/{number}" + ) + author = pull.get("user") if isinstance(pull.get("user"), dict) else {} + participant = ( + author.get("login", "").casefold() == login.casefold() + or _login_in_users(login, pull.get("assignees")) + ) + return ( + participant + and pull.get("state") == "closed" + and pull.get("merged") is True + and pull.get("merge_commit_sha") == commit_sha + ) + + async def pull_workspace_capabilities(repository: str, number: int) -> dict[str, bool]: login, pull = await _current_login_and_target( f"repos/{repository}/pulls/{number}" @@ -3399,7 +3419,21 @@ async def release_receipt_status(repository: str, commit_sha: str) -> dict: ) normalized_checks = _normalize_commit_checks(status) checks = [ - {"name": check["name"], "state": check["state"], "url": check["url"]} + { + "name": check["name"], + "state": check["state"], + "url": check["url"], + **( + {"description": check["description"]} + if check.get("description") + else {} + ), + **( + {"recovery": check["recovery"]} + if isinstance(check.get("recovery"), dict) + else {} + ), + } for check in normalized_checks ] matching = next( @@ -3478,6 +3512,39 @@ def _bounded_action_log_excerpt(value: str, max_bytes: int = 24 * 1024) -> str: return encoded[-max_bytes:].decode("utf-8", errors="ignore") +async def release_action_failure_excerpt( + repository: str, + commit_sha: str, + run_id: int, + job_index: int, +) -> dict: + """Return a bounded failed-job excerpt only when it still belongs to a merge commit.""" + status = await fetch(f"repos/{repository}/commits/{commit_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 { + "commit_sha": commit_sha, + "run_id": run_id, + "job_index": job_index, + "name": matching["name"], + "excerpt": _bounded_action_log_excerpt(response.text), + } + + async def action_failure_excerpt( repository: str, number: int, @@ -3516,6 +3583,50 @@ async def action_failure_excerpt( } +async def retry_release_action_job( + repository: str, + commit_sha: str, + run_id: int, + job_index: int, +) -> dict: + """Re-run a failed Actions job only while it belongs to the tracked merge commit.""" + status = await fetch(f"repos/{repository}/commits/{commit_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 { + "commit_sha": commit_sha, + "run_id": run_id, + "job_index": job_index, + "status": "queued", + } + + async def retry_action_job( repository: str, number: int, diff --git a/src/main.py b/src/main.py index ac987d7..d3540f7 100644 --- a/src/main.py +++ b/src/main.py @@ -7497,6 +7497,96 @@ async def release_receipt(owner: str, repo: str, commit_sha: str): return JSONResponse(result, headers={"Cache-Control": "no-store"}) +@app.get( + "/api/v1/repos/{owner}/{repo}/pulls/{number}/release-receipt/{commit_sha}" + "/checks/{run_id}/jobs/{job_index}/failure" +) +async def release_action_failure( + owner: str, + repo: str, + number: int = PathParam(gt=0), + commit_sha: str = PathParam(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"), + run_id: int = PathParam(gt=0), + job_index: int = PathParam(ge=0), +) -> JSONResponse: + repository = f"{owner}/{repo}" + + async def load_excerpt(): + if not await gitea_proxy.can_recover_merged_release( + repository, number, commit_sha + ): + raise HTTPException(status_code=404, detail="Merged pull request not found") + return await gitea_proxy.release_action_failure_excerpt( + repository, commit_sha, run_id, job_index + ) + + try: + result = await asyncio.wait_for( + load_excerpt(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS + ) + except HTTPException: + raise + except ValueError: + return JSONResponse( + {"error": "This failed release check is no longer recoverable."}, + status_code=404, + headers={"Cache-Control": "no-store"}, + ) + except Exception: + return JSONResponse( + {"error": "The release 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}/release-receipt/{commit_sha}" + "/checks/{run_id}/jobs/{job_index}/retry" +) +async def retry_release_action_job( + owner: str, + repo: str, + number: int = PathParam(gt=0), + commit_sha: str = PathParam(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"), + run_id: int = PathParam(gt=0), + job_index: int = PathParam(ge=0), +) -> JSONResponse: + repository = f"{owner}/{repo}" + + async def retry_job(): + if not await gitea_proxy.can_recover_merged_release( + repository, number, commit_sha + ): + raise HTTPException(status_code=404, detail="Merged pull request not found") + return await gitea_proxy.retry_release_action_job( + repository, commit_sha, run_id, job_index + ) + + try: + result = await asyncio.wait_for( + retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS + ) + except HTTPException: + raise + except ValueError: + return JSONResponse( + {"error": "This release check is no longer failed or cannot be retried."}, + status_code=409, + headers={"Cache-Control": "no-store"}, + ) + except Exception: + return JSONResponse( + {"error": "The failed release 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.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201) async def submit_review( submission: PullReviewSubmission, diff --git a/tests/e2e/test_mobile_release_failure_recovery.py b/tests/e2e/test_mobile_release_failure_recovery.py new file mode 100644 index 0000000..6787d62 --- /dev/null +++ b/tests/e2e/test_mobile_release_failure_recovery.py @@ -0,0 +1,91 @@ +import os +from pathlib import Path + +import pytest + +if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1": + pytest.skip("packaged release-failure recovery runs only in the browser gate", allow_module_level=True) +pytest.importorskip("playwright.sync_api") +from playwright.sync_api import expect, sync_playwright + + +ROOT = Path(__file__).parents[2] +FRONTEND = ROOT / "frontend" + + +@pytest.mark.parametrize("viewport", [ + {"width": 320, "height": 568}, + {"width": 390, "height": 844}, +]) +def test_failed_release_check_is_diagnosable_and_retryable_on_phone(viewport): + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport=viewport) + page.set_content((FRONTEND / "index.html").read_text()) + page.add_style_tag(path=FRONTEND / "dashboard.css") + page.add_script_tag(path=FRONTEND / "release-receipt.js") + result = page.evaluate("""async () => { + const calls=[]; + const values=new Map(); + const storage={ + getItem:key=>values.get(key)||null, + setItem:(key,value)=>values.set(key,value), + removeItem:key=>values.delete(key), + }; + const receipt=createReleaseReceipt({ + storage, + getLogin:()=> 'timmy', + listNode:document.querySelector('#release-watchlist'), + confirmAction:()=>true, + fetchJson:async(path, options={})=>{ + calls.push({path, method:options.method || 'GET'}); + if (path.endsWith('/failure')) return { + commit_sha:'abc1234', run_id:91, job_index:3, + name:'CI / browser', excerpt:'AssertionError: mobile viewport overflow', + }; + if (path.endsWith('/retry')) return {commit_sha:'abc1234', status:'queued'}; + return { + commit_sha:'abc1234', ci_state:'failure', release:null, + checks:[{ + name:'CI / browser with a deliberately long workflow name', + state:'failure', description:'Playwright failed at the narrow viewport', + url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3', + recovery:{run_id:91, job_index:3}, + }], + }; + }, + }); + receipt.capture( + {repository:'stackchain/api-with-a-long-mobile-name', number:7, key:'stackchain/api-with-a-long-mobile-name#7'}, + {merge_commit_sha:'abc1234'}, + ); + document.querySelector('#release-receipt-sheet').showModal(); + await receipt.refresh(); + const review=document.querySelector('.release-failure-summary button'); + const reviewHeight=review.getBoundingClientRect().height; + review.click(); + await new Promise(resolve=>setTimeout(resolve, 0)); + const recovery=document.querySelector('.release-failure-recovery'); + const retry=recovery.querySelector('button'); + const retryHeight=retry.getBoundingClientRect().height; + const excerpt=recovery.querySelector('pre').textContent; + const sha=recovery.querySelector('strong').textContent; + const job=recovery.querySelector('a').href; + const overflow=document.documentElement.scrollWidth > document.documentElement.clientWidth; + retry.click(); + await new Promise(resolve=>setTimeout(resolve, 0)); + return { + calls, reviewHeight, retryHeight, excerpt, sha, job, overflow, + state:receipt.restore()[0].status.label, + }; + }""") + expect(page.locator("#release-receipt-sheet")).to_be_visible() + assert result["reviewHeight"] >= 44 + assert result["retryHeight"] >= 44 + assert result["excerpt"] == "AssertionError: mobile viewport overflow" + assert result["sha"] == "Merge abc1234" + assert result["job"].endswith("/actions/runs/91/jobs/3") + assert result["overflow"] is False + assert result["state"] == "Checks running" + assert [call["method"] for call in result["calls"]] == ["GET", "GET", "POST"] + browser.close() diff --git a/tests/test_pull_api.py b/tests/test_pull_api.py index d0a22ed..a72cc49 100644 --- a/tests/test_pull_api.py +++ b/tests/test_pull_api.py @@ -2051,6 +2051,200 @@ async def test_release_receipt_matches_only_the_captured_commit_and_reports_chec assert response.json()["release"] is None +@pytest.mark.anyio +async def test_release_receipt_preserves_only_safe_actions_recovery_for_failed_exact_commit(monkeypatch): + monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example/git") + + async def handler(request): + if request.url.path.endswith("/commits/merge456/status"): + return httpx.Response(200, json={ + "state": "failure", + "statuses": [ + { + "context": "CI / browser (push)", + "status": "failure", + "description": "Playwright failed", + "target_url": "/git/stackchain/api/actions/runs/91/jobs/3", + }, + { + "context": "external", + "status": "failure", + "description": "External check failed", + "target_url": "https://checks.example/jobs/4", + }, + ], + }) + if request.url.path.endswith("/releases"): + return httpx.Response(200, json=[]) + raise AssertionError(request.url.path) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.release_receipt_status("stackchain/api", "merge456") + finally: + await gitea_proxy.stop_client() + + assert result["checks"] == [ + { + "name": "CI / browser (push)", + "state": "failure", + "description": "Playwright failed", + "url": "https://forge.example/git/stackchain/api/actions/runs/91/jobs/3", + "recovery": {"run_id": 91, "job_index": 3}, + }, + { + "name": "external", + "state": "failure", + "description": "External check failed", + "url": "", + }, + ] + + +@pytest.mark.anyio +async def test_release_action_failure_excerpt_is_bound_to_exact_merge_commit(monkeypatch): + monkeypatch.setattr(gitea_proxy, "GITEA_URL", "http://test/git") + requests = [] + + async def handler(request): + requests.append((request.method, request.url.path)) + if request.url.path.endswith("/commits/merge456/status"): + return httpx.Response(200, json={"statuses": [{ + "context": "CI / browser (push)", + "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="GITEA_TOKEN=secret\nAssertionError: mobile journey failed") + raise AssertionError(request.url.path) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.release_action_failure_excerpt( + "stackchain/api", "merge456", 91, 3 + ) + finally: + await gitea_proxy.stop_client() + + assert result == { + "commit_sha": "merge456", + "run_id": 91, + "job_index": 3, + "name": "CI / browser (push)", + "excerpt": "GITEA_TOKEN=[redacted]\nAssertionError: mobile journey failed", + } + assert requests == [ + ("GET", "/git/api/v1/repos/stackchain/api/commits/merge456/status"), + ("GET", "/git/stackchain/api/actions/runs/91/jobs/3/logs"), + ] + + +@pytest.mark.anyio +async def test_retry_release_action_job_revalidates_failed_exact_commit(monkeypatch): + monkeypatch.setattr(gitea_proxy, "GITEA_URL", "http://test/git") + requests = [] + + async def handler(request): + requests.append((request.method, request.url.path)) + if request.url.path.endswith("/commits/merge456/status"): + return httpx.Response(200, json={"statuses": [{ + "context": "CI / browser (push)", + "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" + return httpx.Response(303) + raise AssertionError(request.url.path) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.retry_release_action_job( + "stackchain/api", "merge456", 91, 3 + ) + finally: + await gitea_proxy.stop_client() + + assert result == { + "commit_sha": "merge456", "run_id": 91, "job_index": 3, "status": "queued" + } + assert requests == [ + ("GET", "/git/api/v1/repos/stackchain/api/commits/merge456/status"), + ("GET", "/git/stackchain/api/actions/runs/91/jobs/3"), + ("POST", "/git/stackchain/api/actions/runs/91/jobs/3/rerun"), + ] + + +@pytest.mark.anyio +async def test_release_failure_recovery_endpoints_authorize_pull_and_bind_merge_commit(monkeypatch): + calls = [] + + async def release_access(repository, number, commit_sha): + calls.append(("access", repository, number, commit_sha)) + return True + + async def excerpt(repository, commit_sha, run_id, job_index): + calls.append(("excerpt", repository, commit_sha, run_id, job_index)) + return { + "commit_sha": commit_sha, "run_id": run_id, "job_index": job_index, + "name": "CI / browser", "excerpt": "AssertionError: failed", + } + + async def retry(repository, commit_sha, run_id, job_index): + calls.append(("retry", repository, commit_sha, run_id, job_index)) + return { + "commit_sha": commit_sha, "run_id": run_id, "job_index": job_index, + "status": "queued", + } + + monkeypatch.setattr(main.gitea_proxy, "can_recover_merged_release", release_access, raising=False) + monkeypatch.setattr(main.gitea_proxy, "release_action_failure_excerpt", excerpt, raising=False) + monkeypatch.setattr(main.gitea_proxy, "retry_release_action_job", retry, raising=False) + path = "/api/v1/repos/stackchain/api/pulls/7/release-receipt/abc1234/checks/91/jobs/3" + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + failure = await client.get(path + "/failure") + retried = await client.post(path + "/retry") + + assert failure.status_code == 200 + assert failure.headers["cache-control"] == "no-store" + assert failure.json()["commit_sha"] == "abc1234" + assert retried.status_code == 202 + assert retried.headers["cache-control"] == "no-store" + assert retried.json()["status"] == "queued" + assert calls == [ + ("access", "stackchain/api", 7, "abc1234"), + ("excerpt", "stackchain/api", "abc1234", 91, 3), + ("access", "stackchain/api", 7, "abc1234"), + ("retry", "stackchain/api", "abc1234", 91, 3), + ] + + +@pytest.mark.anyio +async def test_merged_release_recovery_access_requires_exact_commit_and_participant(): + async def handler(request): + if request.url.path.endswith("/user"): + return httpx.Response(200, json={"login": "timmy"}) + if request.url.path.endswith("/pulls/7"): + return httpx.Response(200, json={ + "state": "closed", "merged": True, "merge_commit_sha": "abc1234", + "user": {"login": "timmy"}, "assignees": [], + }) + raise AssertionError(request.url.path) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + exact = await gitea_proxy.can_recover_merged_release("stackchain/api", 7, "abc1234") + other = await gitea_proxy.can_recover_merged_release("stackchain/api", 7, "def5678") + finally: + await gitea_proxy.stop_client() + + assert exact is True + assert other is False + + @pytest.mark.anyio async def test_gitea_release_receipt_ignores_other_commits_and_normalizes_matching_assets(): forge = gitea_proxy.GITEA_URL.rstrip("/") diff --git a/tests/test_release_receipt.py b/tests/test_release_receipt.py index b3a8e8c..fd62cc3 100644 --- a/tests/test_release_receipt.py +++ b/tests/test_release_receipt.py @@ -96,6 +96,84 @@ receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, { assert output["restored"] == [] +def test_failed_release_check_loads_exact_commit_diagnostics_and_retries_single_flight(): + output = run_node(r""" +const values=new Map(), calls=[]; +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +let finishRetry; +const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',confirmAction:()=>true,fetchJson:async(path,options={})=>{ + calls.push({path,method:options.method||'GET'}); + if (path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'AssertionError: failed'}; + if (path.endsWith('/retry')) return await new Promise(resolve=>{finishRetry=()=>resolve({commit_sha:'abc1234',status:'queued'});}); + return {commit_sha:'abc1234',ci_state:'failure',checks:[{name:'CI / browser',state:'failure',description:'Playwright failed',url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',recovery:{run_id:91,job_index:3}}],release:null}; +}}); +receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'abc1234'}); +(async()=>{ + const failed=(await receipt.refresh())[0]; + const detail=await receipt.reviewFailure('stackchain/api','abc1234',91,3); + const first=receipt.retryFailure('stackchain/api','abc1234',91,3); + const second=receipt.retryFailure('stackchain/api','abc1234',91,3); + await new Promise(resolve=>setTimeout(resolve,0)); finishRetry(); + const results=await Promise.all([first,second]); + process.stdout.write(JSON.stringify({failed,detail,results,calls,after:receipt.restore()[0].status})); +})(); +""") + + assert output["failed"]["failures"] == [{ + "name": "CI / browser", + "description": "Playwright failed", + "url": "https://forge.example/git/stackchain/api/actions/runs/91/jobs/3", + "recovery": {"run_id": 91, "job_index": 3}, + }] + assert output["detail"]["commit_sha"] == "abc1234" + assert output["detail"]["excerpt"] == "AssertionError: failed" + assert output["results"] == [True, True] + assert [call["method"] for call in output["calls"]] == ["GET", "GET", "POST"] + assert output["calls"][1]["path"].endswith( + "/pulls/7/release-receipt/abc1234/checks/91/jobs/3/failure" + ) + assert output["calls"][2]["path"].endswith("/checks/91/jobs/3/retry") + assert output["after"]["label"] == "Checks running" + assert output["after"]["checks"] == [] + + +def test_failed_release_row_renders_phone_recovery_with_exact_sha_log_and_job_fallback(): + output = run_node(r""" +function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',className:'',disabled:false,append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; } +global.document={createElement:tag=>node(tag)}; +const values=new Map(), listNode=node(); +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',listNode,confirmAction:()=>true,fetchJson:async path=>{ + if (path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'AssertionError: mobile viewport overflow'}; + return {commit_sha:'abc1234',ci_state:'failure',checks:[{name:'CI / browser',state:'failure',description:'Playwright failed',url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',recovery:{run_id:91,job_index:3}}]}; +}}); +const flatten=root=>[root,...root.children.flatMap(flatten)]; +receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'abc1234'}); +(async()=>{ + await receipt.refresh(); + const review=flatten(listNode).find(value=>value.textContent==='Review failure'); + await review.click(); + const nodes=flatten(listNode); + process.stdout.write(JSON.stringify({ + reviewLabel:review['aria-label'], + texts:nodes.map(value=>value.textContent).filter(Boolean), + links:nodes.filter(value=>value.tag==='a').map(value=>({text:value.textContent,href:value.href})), + classes:nodes.map(value=>value.className).filter(Boolean), + })); +})(); +""") + + assert output["reviewLabel"] == "Review failed check CI / browser for abc1234" + assert "Merge abc1234" in output["texts"] + assert "AssertionError: mobile viewport overflow" in output["texts"] + assert "Retry failed check" in output["texts"] + assert output["links"] == [{ + "text": "Open job", + "href": "https://forge.example/git/stackchain/api/actions/runs/91/jobs/3", + }] + assert "release-failure-recovery" in output["classes"] + + def test_watchlist_preserves_distinct_merges_deduplicates_and_dismisses_one(): output = run_node(r""" const values=new Map(); @@ -276,3 +354,9 @@ def test_mobile_release_receipt_is_wired_into_the_merge_flow_and_phone_safe(): cleanup_css = css[css.index(".release-watchlist-actions"):] assert "min-height:44px" in cleanup_css assert "grid-template-columns:1fr" in cleanup_css + recovery_css = css[css.index(".release-failure-summary"):] + assert "overflow-x:hidden" in recovery_css + assert "white-space:pre-wrap" in recovery_css + assert "min-height:44px" in recovery_css + assert ".release-failure-actions" in recovery_css + assert "grid-template-columns:1fr" in recovery_css