function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, confirmAction = null, openPull = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) { const prefix = 'stackchain.release-receipt.v1:'; const limit = 12; let entries = []; let refreshing = null; const recoveries = new Map(); const retrying = new Map(); const rollingBack = new Map(); 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 []; try { const parsed = JSON.parse(storage?.getItem(prefix + account) || 'null'); 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.'); if (!entries.length) restore(); const entry = { account, repository: item.repository, number: Number(item.number), key: item.key || item.repository + '#' + item.number, commit_sha: commitSha, ...( mergeResult?.source_repository === item.repository && mergeResult?.source_branch && mergeResult?.source_head_sha ? { source_branch: String(mergeResult.source_branch), source_head_sha: String(mergeResult.source_head_sha), cleanup: { state: 'available', message: 'Merged branch retained.' }, } : {} ), status: null, captured_at: new Date().toISOString(), }; 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) { const checks = Array.isArray(payload?.checks) ? payload.checks : []; 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, failures }; if (payload?.ci_state === 'success') return { ...payload, label: 'Checks passed · waiting for release', checks: [] }; return { ...payload, label: 'Checks running', checks: pending }; } 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 = !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 = first?.status?.label || 'Checking the exact merge commit…'; if (checksNode) checksNode.textContent = (first?.status?.checks || []).join(', '); if (releaseNode) { 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.source_branch && entry.source_head_sha) { const branch = document.createElement('span'); branch.className = 'small release-branch-cleanup-status'; branch.textContent = entry.cleanup?.state === 'deleted' ? 'Branch ' + entry.source_branch + ' · deleted' : 'Branch ' + entry.source_branch + ' · ' + entry.source_head_sha.slice(0, 8); copy.append(branch); } if (entry.rollback?.state === 'prepared') { const rollbackStatus = document.createElement('span'); rollbackStatus.className = 'small release-rollback-status'; rollbackStatus.textContent = entry.rollback.message; copy.append(rollbackStatus); } 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); } 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); const rollback = document.createElement('button'); rollback.type = 'button'; rollback.textContent = entry.rollback?.state === 'prepared' ? 'Open rollback #' + entry.rollback.number : (rollingBack.has(identity(entry)) ? 'Preparing rollback…' : 'Prepare rollback PR'); rollback.disabled = rollingBack.has(identity(entry)); rollback.setAttribute( 'aria-label', 'Prepare rollback pull request for ' + entry.key + ' at ' + entry.commit_sha.slice(0, 8), ); rollback.addEventListener('click', () => prepareRollback(entry.repository, entry.commit_sha).catch(error => { if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.'; render(); })); actions.append(rollback); recovery.append(sha, excerpt, actions); summary.append(recovery); } copy.append(summary); } 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)); if (entry.source_branch && entry.cleanup?.state !== 'deleted') { const actions = document.createElement('div'); actions.className = 'release-watchlist-actions'; const cleanup = document.createElement('button'); cleanup.type = 'button'; cleanup.textContent = entry.cleanup?.state === 'deleting' ? 'Deleting…' : 'Delete source branch'; cleanup.disabled = entry.cleanup?.state === 'deleting' || entry.cleanup?.state === 'advanced'; cleanup.setAttribute( 'aria-label', 'Delete merged source branch ' + entry.source_branch + ' at ' + entry.source_head_sha.slice(0, 8), ); cleanup.addEventListener('click', () => deleteBranch(entry.repository, entry.commit_sha).catch(error => { if (statusNode) statusNode.textContent = String(error?.message || error); })); actions.append(cleanup, button); row.append(copy, actions); } else { 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 (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 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; } function prepareRollback(repository, commitSha) { const entry = entries.find(value => identity(value) === repository + '@' + commitSha); if (!entry) return Promise.reject(new Error('Tracked release is unavailable.')); if (entry.rollback?.state === 'prepared') { openPull?.(entry.rollback.pull); return Promise.resolve(entry.rollback.pull); } const key = identity(entry); if (rollingBack.has(key)) return rollingBack.get(key); const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false); if (!approve('Prepare a draft rollback PR for ' + entry.key + ' at ' + commitSha.slice(0, 8) + '?')) { return Promise.resolve(false); } const operation = fetchJson( 'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(entry.number) + '/release-receipt/' + encodeURIComponent(entry.commit_sha) + '/rollback', { method: 'POST', headers: { Accept: 'application/json', 'Idempotency-Key': 'release-rollback-' + entry.number + '-' + entry.commit_sha, }, }, ).then(result => { if (result?.rollback_of !== entry.commit_sha || !Number.isInteger(result?.number)) { throw new Error('Rollback preparation did not match the failed release.'); } const pull = { ...result, kind: 'pull', key: result.repository + '#' + result.number, state: 'open', }; entry.rollback = { state: 'prepared', number: result.number, message: 'Draft rollback #' + result.number + ' is ready for review.', pull, }; persist(); render(); if (dialog?.open) dialog.close(); openPull?.(pull); return result; }).finally(() => rollingBack.delete(key)); rollingBack.set(key, operation); render(); 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') { throw new Error('Source branch cleanup is unavailable.'); } const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false); if (!approve('Delete ' + entry.source_branch + ' at ' + entry.source_head_sha.slice(0, 8) + '?')) return false; entry.cleanup = { state: 'deleting', message: 'Deleting source branch…' }; persist(); render(); try { await fetchJson( 'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(entry.number) + '/source-branch', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ source_branch: entry.source_branch, expected_head_sha: entry.source_head_sha, }), }, ); entry.cleanup = { state: 'deleted', message: 'Source branch deleted.' }; persist(); render(); return true; } catch (error) { entry.cleanup = { state: error?.status === 409 ? 'advanced' : 'available', message: error?.status === 409 ? 'Source branch has newer commits and was retained.' : 'Branch retained. Retry when connected.', }; persist(); render(); throw error; } } 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, reviewFailure, retryFailure, prepareRollback, deleteBranch, dismiss, bind, fetchJson }; } if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;