feat: recover failed CI checks from mobile (Closes #1354)
This commit is contained in:
1 parent
3e7a6c81fc
commit
8cb92c6858
10 files changed
+634
-4
No files matched your search
@@ -792,6 +792,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.ci-check-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
|
||||
.ci-check-copy strong, .ci-check-copy span { min-width:0; overflow-wrap:anywhere; }
|
||||
.ci-check-link { min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid #60a5fa; border-radius:8px; white-space:nowrap; }
|
||||
.ci-check-actions { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:6px; min-height:44px; }
|
||||
.ci-check-inspect { min-height:44px; white-space:nowrap; }
|
||||
.ci-check-recovery { max-width:100%; min-width:0; margin:0 8px 8px; padding:10px; border:1px solid #dc2626; border-radius:8px; background:#101b2e; }
|
||||
.ci-check-recovery[hidden] { display:none; }
|
||||
.ci-check-recovery h3 { margin:0 0 6px; overflow-wrap:anywhere; }
|
||||
.ci-check-log { max-width:100%; max-height:42vh; margin:8px 0; padding:10px; overflow-x:auto; overflow-y:auto; border:1px solid #2a496e; border-radius:6px; background:#07101d; color:#dbeafe; font-size:.78rem; white-space:pre; }
|
||||
.ci-check-recovery-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-top:6px; background:#101b2e; }
|
||||
.ci-check-recovery-actions button { min-height:44px; }
|
||||
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.update-sheet.open { display:flex; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
let selectedPullDetail = null;
|
||||
let pullConversation = null;
|
||||
let pullReviewState = null;
|
||||
|
||||
let creatingIssue = false;
|
||||
let createAndStartRequested = false;
|
||||
let pendingIssueFilingIntent = 'create-and-assign';
|
||||
@@ -4605,6 +4606,7 @@
|
||||
async function openPullSheet(item, trigger, offlineDetail = null) {
|
||||
if (!item) return;
|
||||
if (!await ensurePullWorkflow(trigger)) return;
|
||||
|
||||
pullDetailPosition.open(workDetailIdentity('pull', item));
|
||||
qs('#pull-review').inert = false;
|
||||
qs('#pull-ownership').inert = false;
|
||||
|
||||
@@ -1756,6 +1756,15 @@
|
||||
<summary><span>Checks</span><span class="small" id="pull-checks-summary">Not loaded</span></summary>
|
||||
<button class="ci-check-refresh" id="refresh-pull-checks" type="button">Refresh checks</button>
|
||||
<div class="ci-check-list" id="pull-check-list"></div>
|
||||
<section class="ci-check-recovery" id="pull-check-recovery" aria-labelledby="pull-check-recovery-title" hidden>
|
||||
<h3 id="pull-check-recovery-title" tabindex="-1">Failed check</h3>
|
||||
<div id="pull-check-recovery-status" class="small" role="status" aria-live="assertive"></div>
|
||||
<pre class="ci-check-log" id="pull-check-log" tabindex="0"></pre>
|
||||
<div class="ci-check-recovery-actions">
|
||||
<button id="retry-pull-check" type="button" disabled>Retry failed job</button>
|
||||
<button id="close-pull-check-recovery" type="button">Close details</button>
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
|
||||
<div id="pull-files"></div>
|
||||
|
||||
@@ -576,6 +576,7 @@ function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
|
||||
function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) {
|
||||
bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
bindCheckRecovery(doc, controller, getSelected, getDetail);
|
||||
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-handoff');
|
||||
@@ -722,6 +723,21 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
}).finally(() => { checkRequest = null; });
|
||||
return checkRequest;
|
||||
},
|
||||
loadCheckFailure(item, expectedHeadSha, runId, jobIndex) {
|
||||
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
|
||||
'/jobs/' + encodeURIComponent(jobIndex) + '/failure?expected_head_sha=' +
|
||||
encodeURIComponent(expectedHeadSha), {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
retryCheck(item, expectedHeadSha, runId, jobIndex) {
|
||||
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
|
||||
'/jobs/' + encodeURIComponent(jobIndex) + '/retry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
|
||||
});
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
if (candidateRequest) return candidateRequest;
|
||||
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
|
||||
@@ -941,6 +957,108 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
};
|
||||
}
|
||||
|
||||
function bindCheckRecovery(
|
||||
doc, controller, selectedPull, selectedDetail,
|
||||
confirmRetry = message => globalThis.confirm(message)
|
||||
) {
|
||||
let current = null;
|
||||
const get = selector => doc.querySelector(selector);
|
||||
function reset({ restoreFocus = true } = {}) {
|
||||
const recovery = current;
|
||||
current = null;
|
||||
get('#pull-check-recovery').hidden = true;
|
||||
get('#pull-check-log').textContent = '';
|
||||
get('#pull-check-recovery-status').textContent = '';
|
||||
get('#retry-pull-check').disabled = true;
|
||||
if (restoreFocus) (recovery?.trigger?.isConnected ? recovery.trigger : get('#refresh-pull-checks'))?.focus();
|
||||
}
|
||||
async function poll(recovery, attempt = 0) {
|
||||
if (current !== recovery || selectedPull() !== recovery.item) return;
|
||||
if (attempt >= 12) {
|
||||
get('#pull-check-recovery-status').textContent = 'Retry is still queued. Use Refresh checks to continue monitoring.';
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
if (current !== recovery || selectedPull() !== recovery.item) return;
|
||||
try {
|
||||
const status = await controller.loadChecks(recovery.item);
|
||||
if (current !== recovery || selectedPull() !== recovery.item) return;
|
||||
if (status.head_sha !== recovery.headSha) {
|
||||
get('#pull-check-recovery-status').textContent = 'New commits arrived. Reload the pull request before retrying again.';
|
||||
return;
|
||||
}
|
||||
const check = (status.checks || []).find(candidate =>
|
||||
candidate?.recovery?.run_id === recovery.runId && candidate?.recovery?.job_index === recovery.jobIndex
|
||||
);
|
||||
if (!check || !['failure', 'error', 'pending'].includes(check.state)) {
|
||||
get('#refresh-pull-checks').click();
|
||||
get('#pull-check-recovery-status').textContent = 'Retried check completed. Merge readiness refreshed.';
|
||||
return;
|
||||
}
|
||||
get('#pull-check-recovery-status').textContent = check.state === 'pending'
|
||||
? 'Retried check is running…' : 'Retry queued; waiting for a fresh run…';
|
||||
} catch (_error) {
|
||||
get('#pull-check-recovery-status').textContent = 'Waiting for check status…';
|
||||
}
|
||||
return poll(recovery, attempt + 1);
|
||||
}
|
||||
get('#pull-check-list').addEventListener('click', async event => {
|
||||
const trigger = event.target.closest('.ci-check-inspect');
|
||||
const item = selectedPull();
|
||||
const detail = selectedDetail();
|
||||
if (!trigger || !item || !detail?.head_sha) return;
|
||||
const runId = Number(trigger.dataset.runId);
|
||||
const jobIndex = Number(trigger.dataset.jobIndex);
|
||||
if (!Number.isInteger(runId) || !Number.isInteger(jobIndex)) return;
|
||||
const recovery = {
|
||||
item, headSha: detail.head_sha, runId, jobIndex, trigger,
|
||||
name: trigger.closest('.ci-check')?.querySelector('strong')?.textContent || 'Failed check',
|
||||
};
|
||||
current = recovery;
|
||||
get('#pull-check-recovery').hidden = false;
|
||||
get('#pull-check-recovery-title').textContent = recovery.name;
|
||||
get('#pull-check-log').textContent = 'Loading failure details…';
|
||||
get('#pull-check-recovery-status').textContent = 'Loading failure details…';
|
||||
get('#retry-pull-check').disabled = true;
|
||||
get('#pull-check-recovery-title').focus();
|
||||
try {
|
||||
const result = await controller.loadCheckFailure(item, recovery.headSha, runId, jobIndex);
|
||||
if (current !== recovery || selectedPull() !== item) return;
|
||||
get('#pull-check-log').textContent = result.excerpt || 'No failure output was reported.';
|
||||
get('#pull-check-recovery-status').textContent = 'Failure details loaded.';
|
||||
get('#retry-pull-check').disabled = false;
|
||||
} catch (error) {
|
||||
if (current !== recovery || selectedPull() !== item) return;
|
||||
get('#pull-check-log').textContent = '';
|
||||
get('#pull-check-recovery-status').textContent = error.message;
|
||||
}
|
||||
});
|
||||
get('#close-pull-check-recovery').addEventListener('click', () => reset());
|
||||
get('#pull-checks').addEventListener('toggle', () => {
|
||||
if (current && selectedPull() !== current.item) reset({ restoreFocus: false });
|
||||
});
|
||||
get('#retry-pull-check').addEventListener('click', async () => {
|
||||
const recovery = current;
|
||||
if (!recovery || selectedPull() !== recovery.item) return;
|
||||
if (!confirmRetry('Retry ' + recovery.name + ' for head ' + recovery.headSha.slice(0, 12) + '?')) return;
|
||||
const button = get('#retry-pull-check');
|
||||
button.disabled = true;
|
||||
get('#pull-check-recovery-status').textContent = 'Queueing retry…';
|
||||
try {
|
||||
await controller.retryCheck(recovery.item, recovery.headSha, recovery.runId, recovery.jobIndex);
|
||||
if (current !== recovery) return;
|
||||
get('#pull-check-recovery-status').textContent = 'Retry queued; waiting for a fresh run…';
|
||||
poll(recovery);
|
||||
} catch (error) {
|
||||
if (current !== recovery) return;
|
||||
get('#pull-check-recovery-status').textContent = error.message;
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
return { reset };
|
||||
}
|
||||
|
||||
createPullSheet.mergeEligibility = mergeEligibility;
|
||||
createPullSheet.review = reviewEligibility;
|
||||
createPullSheet.renderReviewerStatuses = renderReviewerStatuses;
|
||||
@@ -957,4 +1075,5 @@ createPullSheet.bindContextEditor = bindContextEditor;
|
||||
createPullSheet.resetReviewRequestControls = resetReviewRequestControls;
|
||||
createPullSheet.bindReviewRequestControls = bindReviewRequestControls;
|
||||
createPullSheet.bindFeedbackControls = bindFeedbackControls;
|
||||
createPullSheet.bindCheckRecovery = bindCheckRecovery;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||
@@ -384,10 +384,20 @@ function renderChecks(checks, escapeHtml, { offline = false } = {}) {
|
||||
const url = typeof check.url === 'string' ? check.url : '';
|
||||
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
|
||||
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
|
||||
const recovery = check.recovery;
|
||||
const inspect = !offline && ['failure', 'error'].includes(check.state) &&
|
||||
Number.isInteger(recovery?.run_id) && recovery.run_id > 0 &&
|
||||
Number.isInteger(recovery?.job_index) && recovery.job_index >= 0
|
||||
? '<button type="button" class="ci-check-inspect" data-run-id="' + recovery.run_id +
|
||||
'" data-job-index="' + recovery.job_index + '">Inspect failure</button>'
|
||||
: '';
|
||||
const actions = inspect || link
|
||||
? '<div class="ci-check-actions">' + inspect + link + '</div>'
|
||||
: '';
|
||||
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
|
||||
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
|
||||
'<span class="small">' + escapeHtml(check.state || 'unknown') +
|
||||
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
|
||||
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + actions + '</article>';
|
||||
}).join('');
|
||||
return { summary, html, expanded: failed > 0 };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user