feat: recover failed post-merge checks (Closes #1366)
This commit is contained in:
1 parent
d4ddac07ab
commit
966116e5ee
7 files changed
+694
-5
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user