feat: close authored pulls with recovery (Closes #1356)
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m58s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-24 16:18:24 +00:00
1 parent fde3ba21ca
commit 77f22f3cd5
10 files changed
+442 -6

No files matched your search

+7 -2
View File
@@ -966,8 +966,13 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-actions #watch-issue-detail, .pull-sheet-actions #watch-pull-detail { min-width:0; min-height:44px; }
.detail-watch-status:not(:empty) { margin-top:8px; overflow-wrap:anywhere; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.issue-handoff, .pull-ownership, .pull-review-request { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div, .pull-ownership > div, .pull-review-request > div { display:grid; gap:8px; margin-top:10px; }
.issue-handoff, .pull-ownership, .pull-review-request, .pull-lifecycle { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div, .pull-ownership > div, .pull-review-request > div, .pull-lifecycle > div { display:grid; gap:8px; margin-top:10px; }
.pull-lifecycle { border-color:#b45353; }
.pull-lifecycle button { min-height:44px; border-color:#ef4444; background:#5f1d24; color:#fff; }
.pull-close-receipt { margin-top:14px; padding:12px; border:1px solid #4ade80; border-radius:12px; background:#10291e; }
.pull-close-receipt h3 { margin-top:0; }
.pull-close-receipt button { width:100%; min-height:44px; border-color:#4ade80; }
.issue-handoff select, .pull-ownership select, .pull-review-request select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
.issue-handoff select, .issue-handoff button { min-height:44px; }
.pull-ownership select, .pull-ownership button { min-height:44px; }
+1 -2
View File
@@ -1280,7 +1280,7 @@
trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.',
}, () => {
if (!pullController) {
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage, onState:refreshMyWorkView });
createPullSheet.bindOwnershipControls(document, pullController, () => selectedPull, async item => {
const continuing = workSession.checkpointed(item);
lastContextSnapshot = createPullSheet.removeFromSnapshot(lastContextSnapshot, item);
@@ -1289,7 +1289,6 @@
paintMyWork(lastContextSnapshot);
return false;
}, ()=>selectedPullDetail, ()=>confirmedOwnerLogin);
}
attachReleaseReceipt();
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
+14
View File
@@ -1792,6 +1792,20 @@
<div id="pull-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
</div>
</details>
<details class="pull-lifecycle" id="pull-lifecycle" hidden>
<summary>Close pull request</summary>
<div>
<p class="small">Close an obsolete pull request without deleting its branch or discussion.</p>
<button id="close-authored-pull" type="button" hidden>Close pull request</button>
<div id="pull-lifecycle-status" class="small" role="status" aria-live="assertive"></div>
</div>
</details>
<section class="pull-close-receipt" id="pull-close-receipt" aria-labelledby="pull-close-receipt-title" hidden>
<h3 id="pull-close-receipt-title" tabindex="-1">Pull request closed.</h3>
<p class="small">This pull request was removed from active work. Reopen it if the close was accidental.</p>
<button id="reopen-authored-pull" type="button">Reopen pull request</button>
<div id="pull-reopen-status" class="small" role="status" aria-live="assertive"></div>
</section>
<div class="pull-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<button id="watch-pull-detail" type="button">Watch pull request</button>
+84 -1
View File
@@ -198,6 +198,7 @@ function applyOwnershipCapabilities(doc, capabilities = null) {
function resetOwnershipControls(doc, item, checkpointed, capabilities = null) {
const qs = selector => doc.querySelector(selector);
const assigned = applyOwnershipCapabilities(doc, capabilities);
resetLifecycleControls(doc, null);
qs('#pull-ownership').open = false;
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
qs('#pull-handoff-recipient').disabled = true;
@@ -238,6 +239,7 @@ function bindReviewRequestControls(doc, controller, getSelected, getDetail, getL
controller.setReviewDetail = detail => {
resetReviewRequestControls(doc, detail);
applyOwnershipCapabilities(doc, detail?.capabilities);
resetLifecycleControls(doc, detail?.saved_at ? null : detail);
controller.edit?.setDetail(detail, Boolean(detail?.saved_at));
const publishable = detail?.state === 'open' && detail?.draft === true &&
detail?.author === getLogin?.() && detail?.head_sha;
@@ -577,6 +579,7 @@ function bindOwnershipControls(doc, controller, getSelected, finish, getDetail,
bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin);
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
bindCheckRecovery(doc, controller, getSelected, getDetail);
bindLifecycleControls(doc, controller, getSelected, getDetail, result => controller.onState?.(result));
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
const qs = selector => doc.querySelector(selector);
const load = qs('#load-pull-handoff');
@@ -642,7 +645,7 @@ function bindOwnershipControls(doc, controller, getSelected, finish, getDetail,
});
}
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function createPullSheet({ fetchJson, storage, onState, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
let checkRequest = null;
@@ -652,6 +655,7 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
let reviewRequestMutation = null;
let reviewCancelMutation = null;
let readyMutation = null;
let lifecycleMutation = null;
let editRequest = null;
let feedbackRequest = null;
const reviewRequests = new Map();
@@ -680,6 +684,7 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}
return {
onState,
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
@@ -791,6 +796,22 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}).finally(() => { readyMutation = null; });
return readyMutation;
},
transition(item, expectedHeadSha, action) {
if (lifecycleMutation) return lifecycleMutation;
lifecycleMutation = fetchJson(pathFor(item) + '/' + action, {
method:'PATCH',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({ expected_head_sha:expectedHeadSha }),
}).then(result => {
const expectedState = action === 'close' ? 'closed' : 'open';
if (result?.number !== item.number || result?.head_sha !== expectedHeadSha ||
result?.state !== expectedState || result?.merged === true) {
throw new Error('Pull request state change was not confirmed.');
}
return result;
}).finally(() => { lifecycleMutation = null; });
return lifecycleMutation;
},
loadEditDraft(item) {
try {
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
@@ -957,6 +978,66 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
};
}
function resetLifecycleControls(doc, detail) {
const authored = detail?.capabilities?.authored === true;
const lifecycle = doc.querySelector('#pull-lifecycle');
if (!lifecycle) return;
lifecycle.hidden = !authored || detail?.state !== 'open' || detail?.merged === true;
doc.querySelector('#close-authored-pull').hidden = !authored || detail?.state !== 'open' || detail?.merged === true;
doc.querySelector('#pull-close-receipt').hidden = true;
doc.querySelector('#pull-lifecycle-status').textContent = '';
doc.querySelector('#pull-reopen-status').textContent = '';
}
function bindLifecycleControls(doc, controller, selectedPull, selectedDetail, applyResult) {
const close = doc.querySelector('#close-authored-pull');
const reopen = doc.querySelector('#reopen-authored-pull');
close.addEventListener('click', async () => {
const item = selectedPull();
const detail = selectedDetail();
if (!item || !detail?.head_sha || detail?.capabilities?.authored !== true ||
!globalThis.confirm('Close ' + item.key + ' at head ' + detail.head_sha.slice(0, 12) + '?')) return;
close.disabled = true;
doc.querySelector('#pull-lifecycle-status').textContent = 'Closing pull request…';
try {
const result = await controller.transition(item, detail.head_sha, 'close');
Object.assign(detail, result);
applyResult(result);
doc.querySelector('#pull-lifecycle').hidden = true;
doc.querySelector('#pull-close-receipt').hidden = false;
doc.querySelector('#pull-lifecycle-status').textContent = '';
doc.querySelector('#pull-close-receipt-title').focus();
} catch (error) {
doc.querySelector('#pull-lifecycle-status').textContent = error.message + ' The pull request remains open; reload and retry.';
close.disabled = false;
close.focus();
}
});
reopen.addEventListener('click', async () => {
const item = selectedPull();
const detail = selectedDetail();
if (!item || !detail?.head_sha) return;
reopen.disabled = true;
doc.querySelector('#pull-reopen-status').textContent = 'Reopening pull request…';
try {
const result = await controller.transition(item, detail.head_sha, 'reopen');
Object.assign(detail, result);
applyResult(result);
doc.querySelector('#pull-close-receipt').hidden = true;
doc.querySelector('#pull-lifecycle').hidden = false;
close.hidden = false;
close.disabled = false;
doc.querySelector('#pull-lifecycle-status').textContent = 'Pull request reopened.';
doc.querySelector('#pull-reopen-status').textContent = '';
close.focus();
} catch (error) {
doc.querySelector('#pull-reopen-status').textContent = error.message + ' The pull request remains closed; reload and retry.';
reopen.disabled = false;
reopen.focus();
}
});
}
function bindCheckRecovery(
doc, controller, selectedPull, selectedDetail,
confirmRetry = message => globalThis.confirm(message)
@@ -1066,6 +1147,8 @@ createPullSheet.renderFile = renderFile;
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
createPullSheet.removeFromSnapshot = removeFromSnapshot;
createPullSheet.sameTarget = sameTarget;
createPullSheet.bindLifecycleControls = bindLifecycleControls;
createPullSheet.resetLifecycleControls = resetLifecycleControls;
createPullSheet.ownershipSelectors = ownershipSelectors;
createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
createPullSheet.ownershipExitMessage = ownershipExitMessage;