feat: recover failed post-merge checks (Closes #1366)
This commit is contained in:
parent
d4ddac07ab
commit
966116e5ee
|
|
@ -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%; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
90
src/main.py
90
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,
|
||||
|
|
|
|||
91
tests/e2e/test_mobile_release_failure_recovery.py
Normal file
91
tests/e2e/test_mobile_release_failure_recovery.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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="<script>csrfToken: 'csrf-123'</script>")
|
||||
if request.method == "POST" and request.url.path.endswith("/actions/runs/91/jobs/3/rerun"):
|
||||
assert request.headers["x-csrf-token"] == "csrf-123"
|
||||
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("/")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user