stackchain-dashboard/frontend/release-receipt.js
timmy 07f5770aa8
Some checks failed
CI / lint (pull_request) Successful in 3m33s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 5m42s
CI / release-candidate (pull_request) Has been skipped
feat: delete merged source branches safely (Closes #1364)
2026-08-24 20:29:32 +00:00

279 lines
12 KiB
JavaScript

function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, confirmAction = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
const prefix = 'stackchain.release-receipt.v1:';
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 [];
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 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() {
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.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));
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; }
}
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, deleteBranch, dismiss, bind, fetchJson };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;