diff --git a/frontend/dashboard.css b/frontend/dashboard.css index cc4d601..2c78e86 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -21,6 +21,10 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex .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; } +.release-watchlist { display:grid; gap:8px; margin-top:14px; } +.release-watchlist-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:10px; border:1px solid #334155; border-radius:10px; } +.release-watchlist-item > div { display:grid; min-width:0; gap:3px; overflow-wrap:anywhere; } +.release-watchlist-item > button { width:auto; min-width:88px; } @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; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a4bb73f..a26fcc6 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -342,6 +342,7 @@ 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'), + listNode:qs('#release-watchlist'), }); releaseReceipt.bind(); qs('#close-release-receipt').addEventListener('click', () => qs('#release-receipt-sheet').close()); diff --git a/frontend/index.html b/frontend/index.html index 0e5f48a..2d22dc7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -322,11 +322,12 @@ - Exact merge evidenceRelease progressClose + Exact merge evidenceRelease watchlistClose Checking the exact merge commit… Open release - RefreshDismiss + + Refresh allClear all diff --git a/frontend/release-receipt.js b/frontend/release-receipt.js index ccba86d..2d40116 100644 --- a/frontend/release-receipt.js +++ b/frontend/release-receipt.js @@ -1,38 +1,82 @@ -function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null }) { +function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) { const prefix = 'stackchain.release-receipt.v1:'; - let receipt = null; + const limit = 12; + let entries = []; + let refreshing = null; + let timer = null; + let bound = false; + documentRef ||= typeof document !== 'undefined' ? document : null; + windowRef ||= typeof window !== 'undefined' ? window : null; const login = () => String(getLogin?.() || '').trim().toLowerCase(); const key = () => prefix + login(); + const identity = value => String(value.repository || '') + '@' + String(value.commit_sha || ''); const path = value => 'api/v1/repos/' + String(value.repository || '').split('/') .map(encodeURIComponent).join('/') + '/release-receipt/' + encodeURIComponent(value.commit_sha); + function valid(value, account) { + return value && value.account === account && value.repository && value.commit_sha; + } + + function persist() { + if (!entries.length) storage?.removeItem(key()); + else storage?.setItem(key(), JSON.stringify({ version: 2, account: login(), entries })); + } + + const hasPending = () => entries.some(entry => !entry.status?.release && entry.status?.label !== 'Checks failed'); + + function schedule(delay = pollMs) { + if (!bound || documentRef?.hidden || !hasPending()) return; + if (timer !== null) clearTimer(timer); + timer = setTimer(async () => { + timer = null; + try { await refresh(); } + catch (error) { if (statusNode) statusNode.textContent = error.message + ' Retry when connected.'; } + schedule(); + }, delay); + } + function restore() { const account = login(); - if (!account) return null; + if (!account) return []; 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; + const stored = parsed?.version === 2 && parsed.account === account + ? parsed.entries : (valid(parsed, account) ? [parsed] : []); + entries = Array.isArray(stored) ? stored.filter(value => valid(value, account)).slice(-limit) : []; + if (entries.length && parsed?.version !== 2) persist(); + } catch (_error) { entries = []; } + render(); + schedule(); + return entries.map(value => ({ ...value })); } 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 = { + if (!entries.length) restore(); + const entry = { account, repository: item.repository, number: Number(item.number), key: item.key || item.repository + '#' + item.number, commit_sha: commitSha, status: null, + captured_at: new Date().toISOString(), }; - storage?.setItem(key(), JSON.stringify(receipt)); - render(null); - return receipt; + const existing = entries.findIndex(value => identity(value) === identity(entry)); + if (existing >= 0) { + entry.status = entries[existing].status || null; + entries.splice(existing, 1, entry); + } else { + entries.push(entry); + entries = entries.slice(-limit); + } + persist(); + render(); + schedule(); + return { ...entry }; } function summarize(payload) { @@ -45,48 +89,110 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d return { ...payload, label: 'Checks running', checks: pending }; } - function render(status) { + function render() { + const visible = entries.map((entry, index) => ({ entry, index })) + .sort((a, b) => Number(b.entry.status?.label === 'Checks failed') - Number(a.entry.status?.label === 'Checks failed') || a.index - b.index) + .map(value => value.entry); + const first = visible[0] || null; + const failed = visible.filter(entry => entry.status?.label === 'Checks failed').length; if (launcher) { - launcher.hidden = !receipt; - launcher.textContent = status?.label || (receipt ? 'Merged · tracking release' : ''); + launcher.hidden = !entries.length; + launcher.textContent = failed + ? failed + ' release ' + (failed === 1 ? 'failure' : 'failures') + ' · ' + visible.length + ' tracked' + : visible.length + ' ' + (visible.length === 1 ? 'merge' : 'merges') + ' · tracking release'; } - if (statusNode) statusNode.textContent = status?.label || 'Checking the exact merge commit…'; - if (checksNode) checksNode.textContent = (status?.checks || []).join(', '); + if (statusNode) statusNode.textContent = first?.status?.label || 'Checking the exact merge commit…'; + if (checksNode) checksNode.textContent = (first?.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; + releaseNode.hidden = !first?.status?.release?.url; + if (first?.status?.release?.url) { + releaseNode.href = first.status.release.url; + releaseNode.textContent = 'Open release ' + first.status.release.tag; } } + if (listNode) { + const rows = visible.map(entry => { + const row = document.createElement('article'); + row.className = 'release-watchlist-item'; + const copy = document.createElement('div'); + const title = document.createElement('strong'); + title.textContent = entry.key; + const state = document.createElement('span'); + state.className = 'small'; + state.textContent = entry.status?.label || 'Checking the exact merge commit…'; + copy.append(title, state); + if (entry.status?.release?.url) { + const link = document.createElement('a'); + link.href = entry.status.release.url; + link.textContent = 'Open release ' + entry.status.release.tag; + copy.append(link); + } + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = 'Dismiss'; + button.setAttribute('aria-label', 'Dismiss ' + entry.key + ' from release tracking'); + button.addEventListener('click', () => dismiss(entry.repository, entry.commit_sha)); + row.append(copy, button); + return row; + }); + listNode.replaceChildren(...rows); + } + + } + + async function refreshEntry(entry) { + const payload = await fetchJson(path(entry), { headers: { Accept: 'application/json' } }); + if (payload?.commit_sha !== entry.commit_sha) throw new Error('Release evidence did not match the merged commit.'); + entry.status = summarize(payload); + return entry.status; } 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; + if (refreshing) return refreshing; + if (!entries.length) restore(); + if (!entries.length) return []; + refreshing = (async () => { + const statuses = []; + for (const entry of entries) { + try { statuses.push(await refreshEntry(entry)); } + catch (error) { + entry.status = { label: 'Status unavailable', checks: [], error: String(error?.message || error) }; + statuses.push(entry.status); + } + } + persist(); + render(); + return statuses; + })(); + try { return await refreshing; } + finally { refreshing = null; } } - function dismiss() { - const accountKey = key(); - storage?.removeItem(accountKey); - receipt = null; - render(null); - if (dialog?.open) dialog.close(); + function dismiss(repository, commitSha) { + if (repository && commitSha) entries = entries.filter(entry => identity(entry) !== repository + '@' + commitSha); + else entries = []; + persist(); + render(); + schedule(); + if (!entries.length && dialog?.open) dialog.close(); + return entries.map(value => ({ ...value })); } function bind() { + bound = true; launcher?.addEventListener('click', async () => { dialog?.showModal?.(); try { await refresh(); } catch (error) { if (statusNode) statusNode.textContent = error.message + ' Retry when connected.'; } }); + documentRef?.addEventListener('visibilitychange', () => { + if (documentRef.hidden) { + if (timer !== null) clearTimer(timer); + timer = null; + } else schedule(0); + }); + windowRef?.addEventListener('online', () => schedule(0)); + schedule(); } return { capture, restore, refresh, dismiss, bind, fetchJson }; diff --git a/tests/test_release_receipt.py b/tests/test_release_receipt.py index 07efbb1..f636115 100644 --- a/tests/test_release_receipt.py +++ b/tests/test_release_receipt.py @@ -28,10 +28,10 @@ restored && first.refresh().then(status=>{ """) 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["restored"][0]["repository"] == "stackchain/api" + assert output["status"][0]["label"] == "Checks running" + assert output["status"][0]["checks"] == ["browser"] + assert output["other"] == [] assert output["calls"] == ["api/v1/repos/stackchain/api/release-receipt/merge456"] assert output["keys"] == ["stackchain.release-receipt.v1:timmy"] @@ -54,12 +54,154 @@ receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, { })(); """) - 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 + assert output["failed"][0]["label"] == "Checks failed" + assert output["failed"][0]["checks"] == ["browser"] + assert output["waiting"][0]["label"] == "Checks passed · waiting for release" + assert output["released"][0]["label"] == "Released · rc-42" + assert output["released"][0]["release"]["assets"][0]["name"] == "manifest.json" + assert output["restored"] == [] + + +def test_watchlist_preserves_distinct_merges_deduplicates_and_dismisses_one(): + 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 receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async()=>{throw new Error('unused')}}); +receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'}); +receipt.capture({repository:'stackchain/web',number:8,key:'stackchain/web#8'}, {merge_commit_sha:'merge789'}); +receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'}); +const before=receipt.restore(); +receipt.dismiss('stackchain/api', 'merge456'); +const after=receipt.restore(); +process.stdout.write(JSON.stringify({before,after,stored:JSON.parse([...values.values()][0])})); +""") + + assert [item["commit_sha"] for item in output["before"]] == ["merge456", "merge789"] + assert [item["commit_sha"] for item in output["after"]] == ["merge789"] + assert output["stored"]["version"] == 2 + assert len(output["stored"]["entries"]) == 1 + + +def test_watchlist_refreshes_every_exact_commit_in_one_single_flight(): + 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 calls=[]; +const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async path=>{ + calls.push(path); await new Promise(resolve=>setTimeout(resolve, 5)); + const sha=path.split('/').pop(); + return sha === 'merge456' + ? {commit_sha:sha,ci_state:'failure',checks:[{name:'browser',state:'failure'}],release:null} + : {commit_sha:sha,ci_state:'success',checks:[],release:{tag:'rc-9',url:'https://forge.example/rc-9'}}; +}}); +receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'}); +receipt.capture({repository:'stackchain/web',number:8,key:'stackchain/web#8'}, {merge_commit_sha:'merge789'}); +(async()=>{ + const [first,second]=await Promise.all([receipt.refresh(),receipt.refresh()]); + process.stdout.write(JSON.stringify({first,second,calls,restored:receipt.restore()})); +})(); +""") + + assert output["calls"] == [ + "api/v1/repos/stackchain/api/release-receipt/merge456", + "api/v1/repos/stackchain/web/release-receipt/merge789", + ] + assert [status["label"] for status in output["first"]] == ["Checks failed", "Released · rc-9"] + assert output["second"] == output["first"] + assert [item["status"]["commit_sha"] for item in output["restored"]] == ["merge456", "merge789"] + + +def test_watchlist_renders_failed_merges_first_and_dismisses_individually(): + output = run_node(r""" +function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',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(); +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const launcher=node('button'), listNode=node(); +const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',launcher,listNode,fetchJson:async path=>{ + const sha=path.split('/').pop(); + return sha === 'failed' ? {commit_sha:sha,ci_state:'failure',checks:[{name:'browser',state:'failure'}]} + : {commit_sha:sha,ci_state:'pending',checks:[{name:'unit',state:'pending'}]}; +}}); +receipt.capture({repository:'stackchain/api',number:1,key:'stackchain/api#1'}, {merge_commit_sha:'pending'}); +receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'failed'}); +(async()=>{ + await receipt.refresh(); + const before=listNode.children.map(row=>({key:row.children[0].children[0].textContent,state:row.children[0].children[1].textContent,label:row.children[1]['aria-label']})); + const beforeLauncher=launcher.textContent; + listNode.children[0].children[1].click(); + process.stdout.write(JSON.stringify({before,launcher:beforeLauncher,after:receipt.restore()})); +})(); +""") + + assert output["before"] == [ + {"key": "stackchain/web#2", "state": "Checks failed", "label": "Dismiss stackchain/web#2 from release tracking"}, + {"key": "stackchain/api#1", "state": "Checks running", "label": "Dismiss stackchain/api#1 from release tracking"}, + ] + assert output["launcher"] == "1 release failure · 2 tracked" + assert [item["commit_sha"] for item in output["after"]] == ["pending"] + + +def test_watchlist_polls_pending_merges_only_while_foregrounded(): + output = run_node(r""" +const values=new Map(), scheduled=[], calls=[]; +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const documentRef={hidden:false,addEventListener(type,fn){this[type]=fn}}; +const windowRef={addEventListener(type,fn){this[type]=fn}}; +const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',documentRef,windowRef,pollMs:25000, + setTimer:(fn,ms)=>{scheduled.push({fn,ms});return scheduled.length},clearTimer:()=>{}, + fetchJson:async path=>{calls.push(path);const sha=path.split('/').pop();return {commit_sha:sha,ci_state:'success',checks:[],release:{tag:'rc-'+sha,url:'https://forge.example/'+sha}};}}); +receipt.bind(); +receipt.capture({repository:'stackchain/api',number:1,key:'stackchain/api#1'}, {merge_commit_sha:'one'}); +receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'two'}); +(async()=>{ + const first=scheduled.at(-1); await first.fn(); + documentRef.hidden=true; documentRef.visibilitychange(); + process.stdout.write(JSON.stringify({delay:first.ms,calls,scheduled:scheduled.length,restored:receipt.restore()})); +})(); +""") + + assert output["delay"] == 25000 + assert output["calls"] == [ + "api/v1/repos/stackchain/api/release-receipt/one", + "api/v1/repos/stackchain/web/release-receipt/two", + ] + assert output["scheduled"] == 2 + assert all(item["status"]["release"] for item in output["restored"]) + + +def test_watchlist_keeps_refreshing_other_merges_when_one_status_request_fails(): + 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 receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async path=>{ + const sha=path.split('/').pop(); + if (sha === 'offline') throw new Error('Network unavailable'); + return {commit_sha:sha,ci_state:'success',checks:[],release:{tag:'rc-ok',url:'https://forge.example/rc-ok'}}; +}}); +receipt.capture({repository:'stackchain/api',number:1,key:'stackchain/api#1'}, {merge_commit_sha:'offline'}); +receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'released'}); +(async()=>{ const statuses=await receipt.refresh(); process.stdout.write(JSON.stringify({statuses,entries:receipt.restore()})); })(); +""") + + assert output["statuses"][0]["label"] == "Status unavailable" + assert output["statuses"][1]["label"] == "Released · rc-ok" + assert output["entries"][0]["status"]["error"] == "Network unavailable" + assert output["entries"][1]["status"]["release"]["url"] == "https://forge.example/rc-ok" + + +def test_watchlist_exposes_release_link_on_the_matching_merge_row(): + output = run_node(r""" +function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(){}}; } +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,fetchJson:async()=>({commit_sha:'released',ci_state:'success',checks:[],release:{tag:'rc-12',url:'https://forge.example/rc-12'}})}); +receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'released'}); +(async()=>{ await receipt.refresh(); const link=listNode.children[0].children[0].children[2]; process.stdout.write(JSON.stringify({tag:link.tag,href:link.href,text:link.textContent})); })(); +""") + + assert output == {"tag": "a", "href": "https://forge.example/rc-12", "text": "Open release rc-12"} def test_mobile_release_receipt_is_wired_into_the_merge_flow_and_phone_safe(): @@ -71,6 +213,9 @@ def test_mobile_release_receipt_is_wired_into_the_merge_flow_and_phone_safe(): assert '' in html assert 'id="release-receipt-launcher"' in html assert 'id="release-receipt-sheet"' in html + assert 'id="release-watchlist"' in html assert 'releaseReceipt.capture(merging, mergeResult)' in dashboard + assert "listNode:qs('#release-watchlist')" in dashboard assert "min-height:44px" in css[css.index(".release-receipt-sheet"):] assert "overflow-x:hidden" in css[css.index(".release-receipt-sheet"):] + assert ".release-watchlist-item" in css
Exact merge evidence
Checking the exact merge commit…