465 lines
20 KiB
JavaScript
465 lines
20 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, 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;
|