feat: hand off assigned pull requests (Closes #567)
This commit is contained in:
1 parent
50d51ed159
commit
0304f5ab2c
9 files changed
+627
-30
No files matched your search
@@ -356,10 +356,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff, .pull-ownership { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select, .pull-ownership 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; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
|
||||
+20
-25
@@ -448,7 +448,17 @@
|
||||
return await pullWorkflowFeatures.run('pull-workflow', {
|
||||
trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.',
|
||||
}, () => {
|
||||
if (!pullController) pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
if (!pullController) {
|
||||
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
createPullSheet.bindOwnershipControls(document, pullController, () => selectedPull, async item => {
|
||||
const continuing = workSession.checkpointed(item);
|
||||
lastContextSnapshot = createPullSheet.removeFromSnapshot(lastContextSnapshot, item);
|
||||
closePullSheet();
|
||||
if (continuing) return await completeOwnershipExitToday(item);
|
||||
paintMyWork(lastContextSnapshot);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
if (!wrapPreference) {
|
||||
wrapPreference = createReviewController.createWrapPreference({
|
||||
@@ -1700,9 +1710,9 @@
|
||||
'#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date',
|
||||
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
|
||||
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
|
||||
] : [
|
||||
] : createPullSheet.ownershipSelectors().concat([
|
||||
'#merge-pull', '#pull-review-retry', '#next-unreviewed-pull-file', '#load-older-pull-comments',
|
||||
];
|
||||
]);
|
||||
selectors.forEach(selector => {
|
||||
const control = qs(selector);
|
||||
if (control) control.disabled = true;
|
||||
@@ -1712,6 +1722,7 @@
|
||||
qs('#issue-handoff').inert = true;
|
||||
} else {
|
||||
qs('#pull-review').inert = true;
|
||||
qs('#pull-ownership').inert = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2594,21 +2605,6 @@
|
||||
qs('#pull-review-status').textContent = 'Checks refreshed for the current head.';
|
||||
}
|
||||
|
||||
function focusNextUnreviewedPullFile() {
|
||||
if (!selectedPullDetail || !pullReviewState) return;
|
||||
const filename = pullController.nextUnreviewed(selectedPullDetail, pullReviewState);
|
||||
const article = Array.from(qs('#pull-files').querySelectorAll('.pull-file'))
|
||||
.find(file => file.dataset.pullFilename === filename);
|
||||
const toggle = article?.querySelector('.pull-file-toggle');
|
||||
const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
|
||||
if (toggle && panel) {
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
panel.hidden = false;
|
||||
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
toggle.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPullReview({ refresh = false } = {}) {
|
||||
if (!selectedPull || !selectedPullDetail?.head_sha) return;
|
||||
const item = selectedPull;
|
||||
@@ -2633,16 +2629,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function sameWorkTarget(left, right) {
|
||||
return Boolean(left && right && left.repository === right.repository &&
|
||||
Number(left.number) === Number(right.number));
|
||||
}
|
||||
|
||||
async function openPullSheet(item, trigger, offlineDetail = null) {
|
||||
if (!item) return;
|
||||
if (!await ensurePullWorkflow(trigger)) return;
|
||||
if (!sameWorkTarget(selectedPull, item)) pullAttachmentController.clear();
|
||||
if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();
|
||||
qs('#pull-review').inert = false;
|
||||
qs('#pull-ownership').inert = false;
|
||||
selectedPull = item;
|
||||
pullMentions.dismiss();
|
||||
pullTrigger = trigger;
|
||||
@@ -2672,6 +2664,7 @@
|
||||
qs('#pull-merge-state').textContent = 'Review data not loaded';
|
||||
qs('#merge-pull').disabled = true;
|
||||
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
|
||||
createPullSheet.resetOwnershipControls(document, item, candidate => workSession.checkpointed(candidate));
|
||||
qs('#retry-pull-load').hidden = true;
|
||||
qs('#open-pull-gitea').href = item.url || '#';
|
||||
setCommentNextVisibility('pull');
|
||||
@@ -4571,7 +4564,9 @@
|
||||
if (selectedPull === item) qs('#pull-checks-summary').textContent = 'Refresh failed · retry';
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
qs('#next-unreviewed-pull-file').addEventListener('click', focusNextUnreviewedPullFile);
|
||||
qs('#next-unreviewed-pull-file').addEventListener('click', () => {
|
||||
createPullSheet.focusNextUnreviewed(document, selectedPullDetail, pullReviewState, pullController);
|
||||
});
|
||||
qs('#load-older-pull-comments').addEventListener('click', async () => {
|
||||
if (!pullConversation) return;
|
||||
const button = qs('#load-older-pull-comments');
|
||||
|
||||
@@ -655,8 +655,19 @@
|
||||
<div id="pull-files"></div>
|
||||
<button id="merge-pull" type="button" disabled>Merge</button>
|
||||
</details>
|
||||
<details class="pull-ownership" id="pull-ownership">
|
||||
<summary>Ownership</summary>
|
||||
<div>
|
||||
<button id="load-pull-handoff" type="button">Choose teammate</button>
|
||||
<label for="pull-handoff-recipient" class="small">Eligible repository assignee</label>
|
||||
<select id="pull-handoff-recipient" disabled><option value="">Select a teammate</option></select>
|
||||
<button id="confirm-pull-handoff" type="button" disabled>Confirm handoff</button>
|
||||
<div id="pull-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="pull-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="release-pull" type="button">Release assignment</button>
|
||||
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
|
||||
@@ -41,10 +41,137 @@ function renderFile(file, index, reviewed, escapeHtml) {
|
||||
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
|
||||
}
|
||||
|
||||
function focusNextUnreviewed(doc, detail, state, controller) {
|
||||
if (!detail || !state) return;
|
||||
const filename = controller.nextUnreviewed(detail, state);
|
||||
const article = Array.from(doc.querySelectorAll('#pull-files .pull-file'))
|
||||
.find(file => file.dataset.pullFilename === filename);
|
||||
const toggle = article?.querySelector('.pull-file-toggle');
|
||||
const panel = toggle && doc.getElementById(toggle.getAttribute('aria-controls'));
|
||||
if (!toggle || !panel) return;
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
panel.hidden = false;
|
||||
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
toggle.focus();
|
||||
}
|
||||
|
||||
function removeFromSnapshot(data, item) {
|
||||
return { ...data, pulls:(data.pulls || []).filter(candidate =>
|
||||
candidate.repository !== item.repository || candidate.number !== item.number) };
|
||||
}
|
||||
|
||||
function sameTarget(left, right) {
|
||||
return Boolean(left && right && left.repository === right.repository && Number(left.number) === Number(right.number));
|
||||
}
|
||||
|
||||
function ownershipSelectors() {
|
||||
return ['#release-pull', '#load-pull-handoff', '#pull-handoff-recipient', '#confirm-pull-handoff'];
|
||||
}
|
||||
|
||||
function renderHandoffCandidates(select, candidates, doc) {
|
||||
select.textContent = '';
|
||||
const placeholder = doc.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
|
||||
select.appendChild(placeholder);
|
||||
candidates.forEach(candidate => {
|
||||
const option = doc.createElement('option');
|
||||
option.value = candidate.login;
|
||||
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
|
||||
select.appendChild(option);
|
||||
});
|
||||
return candidates.length;
|
||||
}
|
||||
|
||||
function ownershipExitMessage(item, action, transitionResult) {
|
||||
return item.key + ' ' + action + (transitionResult === 'opened' ?
|
||||
'. Next work item opened.' : '. Choose the next ready Today item.');
|
||||
}
|
||||
|
||||
function resetOwnershipControls(doc, item, checkpointed) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
qs('#pull-ownership').open = false;
|
||||
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
||||
qs('#pull-handoff-recipient').disabled = true;
|
||||
qs('#confirm-pull-handoff').disabled = true;
|
||||
qs('#confirm-pull-handoff').textContent = checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
|
||||
qs('#load-pull-handoff').disabled = false;
|
||||
qs('#release-pull').disabled = false;
|
||||
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
|
||||
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
|
||||
}
|
||||
|
||||
function bindOwnershipControls(doc, controller, getSelected, finish) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-handoff');
|
||||
if (load.dataset.ownershipBound === 'true') return;
|
||||
load.dataset.ownershipBound = 'true';
|
||||
load.addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
if (!selected) return;
|
||||
const select = qs('#pull-handoff-recipient');
|
||||
load.disabled = true;
|
||||
qs('#pull-handoff-status').textContent = 'Loading eligible teammates…';
|
||||
try {
|
||||
const count = renderHandoffCandidates(select, await controller.loadHandoffCandidates(selected), doc);
|
||||
select.disabled = !count;
|
||||
qs('#confirm-pull-handoff').disabled = true;
|
||||
qs('#pull-handoff-status').textContent = count ?
|
||||
'Choose who should own this pull request next.' : 'No other eligible assignees were found.';
|
||||
if (count) select.focus();
|
||||
} catch (error) {
|
||||
qs('#pull-handoff-status').textContent = error.message + ' Retry loading teammates.';
|
||||
load.disabled = false;
|
||||
load.focus();
|
||||
}
|
||||
});
|
||||
qs('#pull-handoff-recipient').addEventListener('change', event => {
|
||||
qs('#confirm-pull-handoff').disabled = !event.target.value;
|
||||
});
|
||||
qs('#confirm-pull-handoff').addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
const recipient = qs('#pull-handoff-recipient').value;
|
||||
if (!selected || !recipient || !globalThis.confirm('Hand off ' + selected.key + ' to @' + recipient + '?')) return;
|
||||
const button = qs('#confirm-pull-handoff');
|
||||
button.disabled = true;
|
||||
qs('#pull-handoff-status').textContent = 'Confirming handoff…';
|
||||
try {
|
||||
await controller.handoff(selected, recipient);
|
||||
const transition = await finish(selected);
|
||||
qs('#my-work-action-status').textContent = transition === false ?
|
||||
selected.key + ' handed off to @' + recipient + '.' :
|
||||
ownershipExitMessage(selected, 'handed off to @' + recipient, transition);
|
||||
} catch (error) {
|
||||
qs('#pull-handoff-status').textContent = error.message + ' The pull request remains in My Work; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#release-pull').addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
if (!selected || !globalThis.confirm('Release ' + selected.key + ' from your My Work?')) return;
|
||||
const button = qs('#release-pull');
|
||||
button.disabled = true;
|
||||
qs('#pull-sheet-status').textContent = 'Releasing assignment…';
|
||||
try {
|
||||
await controller.release(selected);
|
||||
const transition = await finish(selected);
|
||||
qs('#my-work-action-status').textContent = transition === false ?
|
||||
selected.key + ' released.' : ownershipExitMessage(selected, 'released', transition);
|
||||
} catch (error) {
|
||||
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let mergeRequest = null;
|
||||
let checkRequest = null;
|
||||
let ownershipRequest = null;
|
||||
let candidateRequest = null;
|
||||
const reviewRequests = new Map();
|
||||
const reviewCache = new Map();
|
||||
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
|
||||
@@ -90,6 +217,29 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
}).finally(() => { checkRequest = null; });
|
||||
return checkRequest;
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
if (candidateRequest) return candidateRequest;
|
||||
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).finally(() => { candidateRequest = null; });
|
||||
return candidateRequest;
|
||||
},
|
||||
handoff(item, recipient) {
|
||||
if (ownershipRequest) return ownershipRequest;
|
||||
ownershipRequest = fetchJson(pathFor(item) + '/handoff', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient }),
|
||||
}).finally(() => { ownershipRequest = null; });
|
||||
return ownershipRequest;
|
||||
},
|
||||
release(item) {
|
||||
if (ownershipRequest) return ownershipRequest;
|
||||
ownershipRequest = fetchJson(pathFor(item) + '/release', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
}).finally(() => { ownershipRequest = null; });
|
||||
return ownershipRequest;
|
||||
},
|
||||
conversation(item, initialPage) {
|
||||
const pager = createConversationPager({
|
||||
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||
@@ -165,4 +315,12 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
|
||||
createPullSheet.mergeEligibility = mergeEligibility;
|
||||
createPullSheet.renderFile = renderFile;
|
||||
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
|
||||
createPullSheet.removeFromSnapshot = removeFromSnapshot;
|
||||
createPullSheet.sameTarget = sameTarget;
|
||||
createPullSheet.ownershipSelectors = ownershipSelectors;
|
||||
createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
|
||||
createPullSheet.ownershipExitMessage = ownershipExitMessage;
|
||||
createPullSheet.resetOwnershipControls = resetOwnershipControls;
|
||||
createPullSheet.bindOwnershipControls = bindOwnershipControls;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||
Reference in new issue
Block a user