Track multiple mobile merges through release #1095

Merged
rockachopa merged 1 commits from timmy/1094-release-watchlist into main 2026-08-18 20:49:39 +00:00
5 changed files with 304 additions and 47 deletions

View File

@ -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; }

View File

@ -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());

View File

@ -322,11 +322,12 @@
</section>
<dialog class="release-receipt-sheet" id="release-receipt-sheet" aria-labelledby="release-receipt-title">
<section class="release-receipt-panel">
<header><div><p class="small muted">Exact merge evidence</p><h2 id="release-receipt-title">Release progress</h2></div><button id="close-release-receipt" type="button">Close</button></header>
<header><div><p class="small muted">Exact merge evidence</p><h2 id="release-receipt-title">Release watchlist</h2></div><button id="close-release-receipt" type="button">Close</button></header>
<p id="release-receipt-status" role="status" aria-live="polite">Checking the exact merge commit…</p>
<p class="small" id="release-receipt-checks"></p>
<a class="button-link" id="release-receipt-link" href="" hidden>Open release</a>
<div class="release-receipt-actions"><button id="refresh-release-receipt" type="button">Refresh</button><button id="dismiss-release-receipt" type="button">Dismiss</button></div>
<div class="release-watchlist" id="release-watchlist" aria-label="Tracked merges"></div>
<div class="release-receipt-actions"><button id="refresh-release-receipt" type="button">Refresh all</button><button id="dismiss-release-receipt" type="button">Clear all</button></div>
</section>
</dialog>
<dialog class="agenda-export-sheet" id="agenda-export-sheet" aria-labelledby="agenda-export-title">

View File

@ -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 };

View File

@ -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 '<script src="static/release-receipt.js"></script>' 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