From 6db0802461fe4e4d8242cde717905c9b5659c9f7 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 18 Aug 2026 19:41:00 +0000 Subject: [PATCH] feat: track exact post-merge release progress (Closes #1092) --- frontend/dashboard.css | 8 +++ frontend/dashboard.js | 31 +++++++++++- frontend/index.html | 11 ++++ frontend/release-receipt.js | 95 +++++++++++++++++++++++++++++++++++ frontend/service-worker.js | 1 + src/frontend_bundle.py | 2 +- src/gitea_proxy.py | 53 ++++++++++++++++++- src/main.py | 6 +++ tests/test_pull_api.py | 93 ++++++++++++++++++++++++++++++++++ tests/test_release_receipt.py | 76 ++++++++++++++++++++++++++++ tests/test_service_worker.py | 1 + 11 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 frontend/release-receipt.js create mode 100644 tests/test_release_receipt.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 5435ea8..cc4d601 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -14,6 +14,14 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex .live-data-status-header button, .live-data-status-actions button { min-height:44px; } .issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); } .issue-filing-receipt[hidden] { display:none; } +.release-receipt-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; } +.release-receipt-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); } +.release-receipt-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; } +.release-receipt-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } +.release-receipt-launcher { width:100%; min-height:44px; border-color:#4ade80; } +.release-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; } +.release-receipt-panel button, .release-receipt-panel .button-link { box-sizing:border-box; display:flex; align-items:center; justify-content:center; min-width:0; min-height:44px; width:100%; text-align:center; } +@media (max-width:359px) { .release-receipt-actions { grid-template-columns:1fr; } } .issue-filing-receipt-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; } .issue-filing-receipt-panel h2, .issue-filing-receipt-panel h3 { margin:.25rem 0; } .issue-filing-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 22619ac..a4bb73f 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -334,6 +334,23 @@ let confirmedOwnerLogin = ''; let planningOwnerLogin = ''; let activeFlushLogin = ''; + let releaseReceipt = null; + function attachReleaseReceipt() { + if (releaseReceipt) return releaseReceipt; + releaseReceipt = createReleaseReceipt({ + storage:localStorage, getLogin:()=>confirmedOwnerLogin, fetchJson:fetchReviewJson, + launcher:qs('#release-receipt-launcher'), dialog:qs('#release-receipt-sheet'), + statusNode:qs('#release-receipt-status'), checksNode:qs('#release-receipt-checks'), + releaseNode:qs('#release-receipt-link'), + }); + releaseReceipt.bind(); + qs('#close-release-receipt').addEventListener('click', () => qs('#release-receipt-sheet').close()); + qs('#refresh-release-receipt').addEventListener('click', () => releaseReceipt.refresh().catch(error => { + qs('#release-receipt-status').textContent = error.message + ' Retry when connected.'; + })); + qs('#dismiss-release-receipt').addEventListener('click', () => releaseReceipt.dismiss()); + return releaseReceipt; + } const completedFiledReview = createCompletedFiledReview({ storage: localStorage, getLogin() { return planningOwnerLogin; }, @@ -1136,6 +1153,7 @@ return false; }); } + attachReleaseReceipt(); if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage }); if (!wrapPreference) { wrapPreference = createReviewController.createWrapPreference({ @@ -1145,6 +1163,14 @@ } }); } + function restoreReleaseReceipt() { + const account = String(confirmedOwnerLogin || '').trim().toLowerCase(); + if (!account) return; + try { + if (!localStorage.getItem('stackchain.release-receipt.v1:' + account)) return; + } catch (_error) { return; } + ensurePullWorkflow().then(() => releaseReceipt.restore()).catch(() => {}); + } const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); outboxCoordinator.subscribe(() => refreshMyWorkView()); const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB }); @@ -5246,6 +5272,7 @@ activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : ''; if (activeFlushLogin) { confirmedOwnerLogin = activeFlushLogin; + restoreReleaseReceipt(); updateDeliveryReceiptControls(); interruptionPrompt.restore(); } @@ -7019,7 +7046,8 @@ button.disabled = true; qs('#pull-sheet-status').textContent = 'Merging pull request…'; try { - await pullController.merge(selectedPull, selectedPullDetail.head_sha); + const mergeResult = await pullController.merge(selectedPull, selectedPullDetail.head_sha); + releaseReceipt.capture(merging, mergeResult); closePullSheet(); lastMyWork = lastMyWork.filter(item => !(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number) @@ -7493,6 +7521,7 @@ if (!saved) return false; const outage = mode === 'outage'; confirmedOwnerLogin = String(saved.user?.login || '').trim(); + restoreReleaseReceipt(); planningOwnerLogin = confirmedOwnerLogin; interruptionPrompt.restore(); updatePlanningAvailability(); diff --git a/frontend/index.html b/frontend/index.html index fa3c9ae..0e5f48a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -302,6 +302,7 @@
+
Today is saved on this device.
@@ -319,6 +320,15 @@ + +
+

Exact merge evidence

Release progress

+

Checking the exact merge commit…

+

+ +
+
+
@@ -1875,6 +1885,7 @@ + diff --git a/frontend/release-receipt.js b/frontend/release-receipt.js new file mode 100644 index 0000000..ccba86d --- /dev/null +++ b/frontend/release-receipt.js @@ -0,0 +1,95 @@ +function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null }) { + const prefix = 'stackchain.release-receipt.v1:'; + let receipt = null; + + const login = () => String(getLogin?.() || '').trim().toLowerCase(); + const key = () => prefix + login(); + const path = value => 'api/v1/repos/' + String(value.repository || '').split('/') + .map(encodeURIComponent).join('/') + '/release-receipt/' + encodeURIComponent(value.commit_sha); + + function restore() { + const account = login(); + if (!account) return null; + try { + const parsed = JSON.parse(storage?.getItem(prefix + account) || 'null'); + receipt = parsed && parsed.account === account && parsed.repository && parsed.commit_sha ? parsed : null; + } catch (_error) { receipt = null; } + render(receipt?.status || null); + return receipt; + } + + function capture(item, mergeResult) { + const account = login(); + const commitSha = String(mergeResult?.merge_commit_sha || '').trim(); + if (!account || !item?.repository || !commitSha) throw new Error('The exact merge commit is unavailable.'); + receipt = { + account, + repository: item.repository, + number: Number(item.number), + key: item.key || item.repository + '#' + item.number, + commit_sha: commitSha, + status: null, + }; + storage?.setItem(key(), JSON.stringify(receipt)); + render(null); + return receipt; + } + + 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 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 (payload?.ci_state === 'success') return { ...payload, label: 'Checks passed · waiting for release', checks: [] }; + return { ...payload, label: 'Checks running', checks: pending }; + } + + function render(status) { + if (launcher) { + launcher.hidden = !receipt; + launcher.textContent = status?.label || (receipt ? 'Merged · tracking release' : ''); + } + if (statusNode) statusNode.textContent = status?.label || 'Checking the exact merge commit…'; + if (checksNode) checksNode.textContent = (status?.checks || []).join(', '); + if (releaseNode) { + releaseNode.hidden = !status?.release?.url; + if (status?.release?.url) { + releaseNode.href = status.release.url; + releaseNode.textContent = 'Open release ' + status.release.tag; + } + } + } + + async function refresh() { + if (!receipt) restore(); + if (!receipt) return null; + const payload = await fetchJson(path(receipt), { headers: { Accept: 'application/json' } }); + if (payload?.commit_sha !== receipt.commit_sha) throw new Error('Release evidence did not match the merged commit.'); + const status = summarize(payload); + receipt.status = status; + storage?.setItem(key(), JSON.stringify(receipt)); + render(status); + return status; + } + + function dismiss() { + const accountKey = key(); + storage?.removeItem(accountKey); + receipt = null; + render(null); + if (dialog?.open) dialog.close(); + } + + function bind() { + launcher?.addEventListener('click', async () => { + dialog?.showModal?.(); + try { await refresh(); } + catch (error) { if (statusNode) statusNode.textContent = error.message + ' Retry when connected.'; } + }); + } + + return { capture, restore, refresh, dismiss, bind, fetchJson }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index bd05cff..b853de3 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -105,6 +105,7 @@ const SHELL = [ BASE + 'static/queue-today.js', BASE + 'static/pull-sheet.js', BASE + 'static/review-sheet.js', + BASE + 'static/release-receipt.js', BASE + 'static/work-route.js', BASE + 'static/task-overlay-history.js', BASE + 'static/context-poller.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index bb2d004..8b448cb 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -25,7 +25,7 @@ FEATURE_SOURCES = { "issue-capture": ( "static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js", ), - "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"), + "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"), "push-notifications": ("static/push-notifications.js",), "device-setup": ( "static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js", diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index f82c356..aa3793d 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -2674,6 +2674,48 @@ async def pull_completion_review(repository: str, number: int) -> dict: } +async def release_receipt_status(repository: str, commit_sha: str) -> dict: + """Return CI and release evidence for one exact merge commit.""" + status, releases = await asyncio.gather( + fetch(f"repos/{repository}/commits/{commit_sha}/status"), + fetch(f"repos/{repository}/releases?limit=20"), + ) + normalized_checks = _normalize_commit_checks(status) + checks = [ + {"name": check["name"], "state": check["state"], "url": check["url"]} + for check in normalized_checks + ] + matching = next( + ( + release + for release in (releases if isinstance(releases, list) else []) + if isinstance(release, dict) and release.get("target_commitish") == commit_sha + ), + None, + ) + normalized_release = None + if matching is not None: + assets = matching.get("assets") + normalized_release = { + "tag": matching.get("tag_name") if isinstance(matching.get("tag_name"), str) else "", + "url": _safe_gitea_web_url(matching.get("html_url")), + "assets": [ + { + "name": asset.get("name") if isinstance(asset.get("name"), str) else "", + "url": _safe_gitea_web_url(asset.get("browser_download_url")), + } + for asset in (assets if isinstance(assets, list) else [])[:20] + if isinstance(asset, dict) + ], + } + return { + "commit_sha": commit_sha, + "ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown", + "checks": checks, + "release": normalized_release, + } + + async def pull_check_status(repository: str, number: int) -> dict: """Load only mutable pull and CI state, without immutable review data.""" base = f"repos/{repository}/pulls/{number}" @@ -2741,7 +2783,16 @@ async def merge_assigned_pull( json={"Do": "merge", "head_commit_id": current_sha}, ) response.raise_for_status() - return {"number": number, "merged": True, "state": "closed"} + payload = response.json() + merge_commit_sha = payload.get("sha") if isinstance(payload, dict) else None + if not isinstance(merge_commit_sha, str) or not merge_commit_sha: + raise ValueError("Gitea merge response did not include the merge commit") + return { + "number": number, + "merged": True, + "state": "closed", + "merge_commit_sha": merge_commit_sha, + } async def pull_review_detail(repository: str, number: int) -> dict: diff --git a/src/main.py b/src/main.py index 45be49b..1eeba24 100644 --- a/src/main.py +++ b/src/main.py @@ -6012,6 +6012,12 @@ async def merge_assigned_pull( ) +@app.get("/api/v1/repos/{owner}/{repo}/release-receipt/{commit_sha}") +async def release_receipt(owner: str, repo: str, commit_sha: str): + result = await gitea_proxy.release_receipt_status(f"{owner}/{repo}", commit_sha) + return JSONResponse(result, 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/test_pull_api.py b/tests/test_pull_api.py index 4f566b0..b597484 100644 --- a/tests/test_pull_api.py +++ b/tests/test_pull_api.py @@ -733,3 +733,96 @@ async def test_gitea_pull_release_removes_current_login_case_insensitively(): await gitea_proxy.stop_client() assert released["assignees"] == ["sam"] + + +@pytest.mark.anyio +async def test_gitea_merge_returns_the_exact_merge_commit_for_release_tracking(): + async def handler(request): + if request.url.path.endswith("/pulls/7"): + return httpx.Response(200, json={ + "number": 7, "state": "open", "draft": False, "mergeable": True, + "merged": False, "head": {"sha": "abc123"}, + }) + if request.url.path.endswith("/commits/abc123/status"): + return httpx.Response(200, json={"state": "success"}) + if request.url.path.endswith("/pulls/7/merge"): + return httpx.Response(200, json={"merged": True, "sha": "merge456"}) + raise AssertionError(request.url.path) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.merge_assigned_pull("stackchain/api", 7, "abc123") + finally: + await gitea_proxy.stop_client() + + assert result == { + "number": 7, "merged": True, "state": "closed", "merge_commit_sha": "merge456" + } + + +@pytest.mark.anyio +async def test_release_receipt_matches_only_the_captured_commit_and_reports_checks(monkeypatch): + async def receipt(repository, commit_sha): + assert (repository, commit_sha) == ("stackchain/api", "merge456") + return { + "commit_sha": "merge456", + "ci_state": "pending", + "checks": [{"name": "browser", "state": "pending", "url": ""}], + "release": None, + } + + monkeypatch.setattr(main.gitea_proxy, "release_receipt_status", receipt, 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/release-receipt/merge456" + ) + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json()["checks"] == [ + {"name": "browser", "state": "pending", "url": ""} + ] + assert response.json()["release"] is None + + +@pytest.mark.anyio +async def test_gitea_release_receipt_ignores_other_commits_and_normalizes_matching_assets(): + forge = gitea_proxy.GITEA_URL.rstrip("/") + + async def handler(request): + if request.url.path.endswith("/commits/merge456/status"): + return httpx.Response(200, json={ + "state": "success", + "statuses": [{"context": "browser", "status": "success", "target_url": ""}], + }) + if request.url.path.endswith("/releases"): + return httpx.Response(200, json=[ + {"tag_name": "newer", "target_commitish": "other789", "html_url": f"{forge}/stackchain/api/releases/tag/newer"}, + { + "tag_name": "rc-42", "target_commitish": "merge456", + "html_url": f"{forge}/stackchain/api/releases/tag/rc-42", + "assets": [ + {"name": "manifest.json", "browser_download_url": f"{forge}/stackchain/api/releases/download/rc-42/manifest.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["commit_sha"] == "merge456" + assert result["ci_state"] == "success" + assert result["checks"] == [{"name": "browser", "state": "success", "url": ""}] + assert result["release"] == { + "tag": "rc-42", + "url": f"{forge}/stackchain/api/releases/tag/rc-42", + "assets": [{ + "name": "manifest.json", + "url": f"{forge}/stackchain/api/releases/download/rc-42/manifest.json", + }], + } diff --git a/tests/test_release_receipt.py b/tests/test_release_receipt.py new file mode 100644 index 0000000..07efbb1 --- /dev/null +++ b/tests/test_release_receipt.py @@ -0,0 +1,76 @@ +import json +import subprocess +from pathlib import Path + + +SOURCE = Path(__file__).parents[1] / "frontend" / "release-receipt.js" + + +def run_node(body: str) -> dict: + script = f"const createReleaseReceipt=require({json.dumps(str(SOURCE))});\n" + body + completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(completed.stdout) + + +def test_receipt_survives_reload_for_confirmed_account_only_and_tracks_exact_commit(): + output = run_node(r""" +const values=new Map(); +const storage={getItem:key=>values.has(key)?values.get(key):null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +let login='timmy'; const calls=[]; +const first=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:async path=>{calls.push(path);return {commit_sha:'merge456',ci_state:'pending',checks:[{name:'browser',state:'pending',url:''}],release:null};}}); +const captured=first.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'}); +const restored=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:first.fetchJson}).restore(); +restored && first.refresh().then(status=>{ + login='alex'; + const other=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:first.fetchJson}).restore(); + process.stdout.write(JSON.stringify({captured,restored,status,other,calls,keys:[...values.keys()]})); +}); +""") + + assert output["captured"]["commit_sha"] == "merge456" + assert output["restored"]["repository"] == "stackchain/api" + assert output["status"]["label"] == "Checks running" + assert output["status"]["checks"] == ["browser"] + assert output["other"] is None + assert output["calls"] == ["api/v1/repos/stackchain/api/release-receipt/merge456"] + assert output["keys"] == ["stackchain.release-receipt.v1:timmy"] + + +def test_receipt_distinguishes_failed_checks_waiting_for_release_and_exact_release(): + output = run_node(r""" +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 responses=[ + {commit_sha:'merge456',ci_state:'failure',checks:[{name:'unit',state:'success'},{name:'browser',state:'failure'}],release:null}, + {commit_sha:'merge456',ci_state:'success',checks:[{name:'browser',state:'success'}],release:null}, + {commit_sha:'merge456',ci_state:'success',checks:[{name:'browser',state:'success'}],release:{tag:'rc-42',url:'https://forge.example/git/x/releases/tag/rc-42',assets:[{name:'manifest.json',url:'https://forge.example/manifest'}]}}, +]; +const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async()=>responses.shift()}); +receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'}); +(async()=>{ + const failed=await receipt.refresh(); const waiting=await receipt.refresh(); const released=await receipt.refresh(); + receipt.dismiss(); + process.stdout.write(JSON.stringify({failed,waiting,released,restored:receipt.restore()})); +})(); +""") + + assert output["failed"]["label"] == "Checks failed" + assert output["failed"]["checks"] == ["browser"] + assert output["waiting"]["label"] == "Checks passed · waiting for release" + assert output["released"]["label"] == "Released · rc-42" + assert output["released"]["release"]["assets"][0]["name"] == "manifest.json" + assert output["restored"] is None + + +def test_mobile_release_receipt_is_wired_into_the_merge_flow_and_phone_safe(): + root = Path(__file__).parents[1] + html = (root / "frontend" / "index.html").read_text() + css = (root / "frontend" / "dashboard.css").read_text() + dashboard = (root / "frontend" / "dashboard.js").read_text() + + assert '' in html + assert 'id="release-receipt-launcher"' in html + assert 'id="release-receipt-sheet"' in html + assert 'releaseReceipt.capture(merging, mergeResult)' in dashboard + assert "min-height:44px" in css[css.index(".release-receipt-sheet"):] + assert "overflow-x:hidden" in css[css.index(".release-receipt-sheet"):] diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index e7362c4..9475cbb 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -1206,6 +1206,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/queue-today.js", "/dashboard/static/pull-sheet.js", "/dashboard/static/review-sheet.js", + "/dashboard/static/release-receipt.js", "/dashboard/static/work-route.js", "/dashboard/static/task-overlay-history.js", "/dashboard/static/context-poller.js", -- 2.43.0