feat: recover failed post-merge checks (Closes #1366)
This commit is contained in:
1 parent
d4ddac07ab
commit
966116e5ee
7 files changed
+694
-5
No files matched your search
@@ -38,8 +38,14 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.release-watchlist-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:min(240px,46vw); }
|
||||
.release-watchlist-actions > button { min-height:44px; width:100%; }
|
||||
.release-branch-cleanup-status { color:#cbd5e1; }
|
||||
.release-failure-summary { display:grid; gap:8px; min-width:0; margin-top:8px; padding-top:8px; border-top:1px solid #334155; overflow-x:hidden; }
|
||||
.release-failure-summary > button { min-height:44px; }
|
||||
.release-failure-recovery { display:grid; gap:8px; min-width:0; padding:10px; border:1px solid #7f1d1d; border-radius:8px; background:#180f17; overflow-x:hidden; }
|
||||
.release-failure-recovery pre { box-sizing:border-box; max-width:100%; max-height:32dvh; margin:0; padding:10px; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; background:#07101d; }
|
||||
.release-failure-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:0; }
|
||||
.release-failure-actions > button, .release-failure-actions > a { min-height:44px; width:100%; }
|
||||
@media (max-width:359px) {
|
||||
.release-receipt-actions, .release-watchlist-actions { grid-template-columns:1fr; }
|
||||
.release-receipt-actions, .release-watchlist-actions, .release-failure-actions { grid-template-columns:1fr; }
|
||||
.release-watchlist-item { grid-template-columns:1fr; }
|
||||
.release-watchlist-actions { min-width:0; width:100%; }
|
||||
}
|
||||
|
||||
+116
-3
@@ -3,6 +3,8 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
const limit = 12;
|
||||
let entries = [];
|
||||
let refreshing = null;
|
||||
const recoveries = new Map();
|
||||
const retrying = new Map();
|
||||
let timer = null;
|
||||
let bound = false;
|
||||
documentRef ||= typeof document !== 'undefined' ? document : null;
|
||||
@@ -92,10 +94,17 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
|
||||
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 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 };
|
||||
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 };
|
||||
}
|
||||
@@ -146,6 +155,58 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
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);
|
||||
recovery.append(sha, excerpt, actions);
|
||||
summary.append(recovery);
|
||||
}
|
||||
copy.append(summary);
|
||||
}
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = 'Dismiss';
|
||||
@@ -205,6 +266,58 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
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;
|
||||
}
|
||||
|
||||
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') {
|
||||
@@ -272,7 +385,7 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
||||
schedule();
|
||||
}
|
||||
|
||||
return { capture, restore, refresh, deleteBranch, dismiss, bind, fetchJson };
|
||||
return { capture, restore, refresh, reviewFailure, retryFailure, deleteBranch, dismiss, bind, fetchJson };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;
|
||||
Reference in new issue
Block a user