feat: plan search batches into releases (Closes #815)
This commit is contained in:
1 parent
2e62eb09f5
commit
43769460fc
4 files changed
+251
-10
No files matched your search
@@ -129,7 +129,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.cmd-select-result { min-height:44px; width:100%; display:grid; grid-template-columns:28px 1fr auto; gap:10px; align-items:center; text-align:left; }
|
||||
.cmd-select-result input { width:22px; height:22px; }
|
||||
.cmd-select-result[disabled] { cursor:not-allowed; opacity:.62; }
|
||||
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||
.search-batch-actions[hidden] { display:none; }
|
||||
.search-batch-actions span { grid-column:1 / -1; }
|
||||
.search-batch-actions button, .search-batch-recovery { min-height:44px; }
|
||||
@@ -142,6 +142,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.search-batch-estimate-review input { min-height:44px; width:76px; }
|
||||
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-batch-estimate-actions button { min-height:44px; }
|
||||
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.search-release-review[hidden] { display:none; }
|
||||
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
|
||||
.search-release-review label { display:grid; gap:6px; }
|
||||
.search-release-review select, .search-release-review input { min-height:44px; width:100%; }
|
||||
.search-release-row { padding:8px 0; border-bottom:1px solid #2a496e; }
|
||||
.search-release-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-release-actions button { min-height:44px; }
|
||||
|
||||
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
|
||||
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
|
||||
<button id="cancel-search-selection" type="button">Cancel</button>
|
||||
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
|
||||
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
|
||||
<button id="queue-selected-search-results" type="button" disabled>Assign & add to Today</button>
|
||||
</div>
|
||||
<section class="search-batch-estimate-review" id="search-batch-estimate-review"
|
||||
@@ -471,6 +472,19 @@
|
||||
<button id="confirm-search-batch-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="search-release-review" id="search-release-review"
|
||||
aria-labelledby="search-release-heading" hidden>
|
||||
<div><strong id="search-release-heading">Plan selected issues into a release</strong>
|
||||
<span class="small" id="search-release-summary" aria-live="polite"></span></div>
|
||||
<label>Open milestone<select id="search-release-milestone" required></select></label>
|
||||
<label>Shared due date <span class="small">(optional)</span>
|
||||
<input id="search-release-due-date" type="date" /></label>
|
||||
<div id="search-release-list"></div>
|
||||
<div class="search-release-actions">
|
||||
<button id="cancel-search-release" type="button">Back</button>
|
||||
<button id="confirm-search-release" type="button">Assign & plan</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
|
||||
|
||||
@@ -57,7 +57,11 @@
|
||||
if (detail?.claimable) return claim(detail);
|
||||
throw new Error('This issue is no longer available to assign.');
|
||||
}
|
||||
return { identity, eligible, start, cancel, toggle, snapshot, prepare, limit:maximum };
|
||||
function releaseRepository() {
|
||||
const repositories = new Set(Array.from(selected.values()).map(item => item.repository));
|
||||
return repositories.size === 1 ? repositories.values().next().value : null;
|
||||
}
|
||||
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
|
||||
}
|
||||
|
||||
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
|
||||
@@ -68,6 +72,8 @@
|
||||
const get = selector => document.querySelector(selector);
|
||||
let processor;
|
||||
let laterProcessor;
|
||||
let releaseProcessor;
|
||||
let releaseContext = null;
|
||||
const plan = createSearchBatchPlan({
|
||||
resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}),
|
||||
claim:detail => fetchJson(
|
||||
@@ -80,6 +86,7 @@
|
||||
get('#select-search-results').hidden = state.active;
|
||||
get('#queue-selected-search-results').disabled = state.count === 0;
|
||||
get('#defer-selected-search-results').disabled = state.count === 0;
|
||||
get('#plan-selected-search-results').disabled = state.count === 0;
|
||||
get('#search-selection-status').textContent = state.count ?
|
||||
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
|
||||
render();
|
||||
@@ -152,6 +159,48 @@
|
||||
}
|
||||
},
|
||||
});
|
||||
releaseProcessor = batchFactory({
|
||||
capacity:() => plan.limit,
|
||||
owner:getOwner,
|
||||
journalName:'search-release-batch',
|
||||
queueFailureReason:'assigned but release planning could not be confirmed',
|
||||
autoMount:false,
|
||||
claim:item => plan.prepare(item),
|
||||
queue:async (confirmed, context) => {
|
||||
const issuePath = 'api/v1/repos/' + confirmed.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(confirmed.number);
|
||||
const milestone = await fetchJson(issuePath + '/milestone', {
|
||||
method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'},
|
||||
body:JSON.stringify({milestone_id:context.milestone_id}),
|
||||
});
|
||||
if (milestone?.number !== confirmed.number || milestone?.milestone?.id !== context.milestone_id) {
|
||||
throw new Error('Issue milestone was not confirmed.');
|
||||
}
|
||||
if (context.due_date) {
|
||||
const deadline = await fetchJson(issuePath + '/due-date', {
|
||||
method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'},
|
||||
body:JSON.stringify({due_date:context.due_date}),
|
||||
});
|
||||
if (deadline?.number !== confirmed.number || deadline?.due_date !== context.due_date) {
|
||||
throw new Error('Issue due date was not confirmed.');
|
||||
}
|
||||
}
|
||||
return 'queued';
|
||||
},
|
||||
onProgress:progress => {
|
||||
get('#confirm-search-release').disabled = progress.status === 'running';
|
||||
if (progress.status === 'running') {
|
||||
get('#search-release-summary').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
} else if (progress.status === 'complete') {
|
||||
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||
progress.queued.length + ' planned · ' + progress.failed.length + ' need retry.' :
|
||||
progress.queued.length + ' planned for ' + (releaseContext?.milestone_title || 'release') + '.';
|
||||
get('#resume-search-batch').hidden = releaseProcessor.pending() === 0;
|
||||
closeReleaseReview();
|
||||
if (!progress.failed.length) plan.cancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
function estimateValues() {
|
||||
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
|
||||
[input.dataset.searchBatchEstimate, Number(input.value)]
|
||||
@@ -180,6 +229,42 @@
|
||||
get('#search-batch-estimate-review').hidden = false;
|
||||
document.querySelector('[data-search-batch-estimate]')?.focus();
|
||||
}
|
||||
function closeReleaseReview() {
|
||||
get('#search-release-review').hidden = true;
|
||||
get('#search-batch-actions').hidden = !plan.snapshot().active;
|
||||
}
|
||||
async function openReleaseReview() {
|
||||
const items = plan.snapshot().items;
|
||||
const repository = plan.releaseRepository();
|
||||
if (!items.length) return;
|
||||
if (!repository) {
|
||||
get('#search-selection-status').textContent = 'Select issues from one repository to plan a release.';
|
||||
return;
|
||||
}
|
||||
get('#plan-selected-search-results').disabled = true;
|
||||
get('#search-selection-status').textContent = 'Loading release milestones…';
|
||||
try {
|
||||
const milestones = await fetchJson(
|
||||
'api/v1/repos/' + repository.split('/').map(encodeURIComponent).join('/') + '/milestones',
|
||||
{headers:{Accept:'application/json'}}
|
||||
);
|
||||
const open = (Array.isArray(milestones) ? milestones : []).filter(item => item?.state !== 'closed');
|
||||
get('#search-release-milestone').innerHTML = '<option value="">Choose milestone</option>' + open.map(item =>
|
||||
'<option value="' + Number(item.id) + '">' + escapeHtml(item.title) + '</option>'
|
||||
).join('');
|
||||
get('#search-release-list').innerHTML = items.map(item => '<div class="search-release-row"><span>' +
|
||||
escapeHtml(item.repository + ' #' + item.number) + '</span><strong>' +
|
||||
escapeHtml(item.title || 'Untitled work') + '</strong></div>').join('');
|
||||
get('#search-release-summary').textContent = items.length + ' issues in ' + repository + '. Assignment starts only after confirmation.';
|
||||
get('#search-batch-actions').hidden = true;
|
||||
get('#search-release-review').hidden = false;
|
||||
get('#search-release-milestone').focus();
|
||||
} catch (error) {
|
||||
get('#search-selection-status').textContent = error?.message || 'Milestones could not be loaded. Try again.';
|
||||
} finally {
|
||||
get('#plan-selected-search-results').disabled = false;
|
||||
}
|
||||
}
|
||||
get('#select-search-results').addEventListener('click', () => plan.start());
|
||||
get('#cancel-search-selection').addEventListener('click', () => plan.cancel());
|
||||
get('#queue-selected-search-results').addEventListener('click', () => {
|
||||
@@ -195,13 +280,32 @@
|
||||
items, {}, { until:new Date(until).toISOString() }
|
||||
) }, event.currentTarget, 'search-batch');
|
||||
});
|
||||
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
|
||||
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
|
||||
get('#confirm-search-release').addEventListener('click', () => {
|
||||
const milestoneSelect = get('#search-release-milestone');
|
||||
const milestoneId = Number(milestoneSelect.value);
|
||||
if (!Number.isInteger(milestoneId) || milestoneId < 1) {
|
||||
get('#search-release-summary').textContent = 'Choose an open milestone.';
|
||||
return;
|
||||
}
|
||||
const selectedOption = milestoneSelect.options?.[milestoneSelect.selectedIndex];
|
||||
const milestoneTitle = selectedOption?.textContent || selectedOption?.text || 'release';
|
||||
const dueDay = get('#search-release-due-date').value;
|
||||
releaseContext = {
|
||||
milestone_id:milestoneId, milestone_title:milestoneTitle,
|
||||
due_date:dueDay ? dueDay + 'T23:59:59Z' : null,
|
||||
};
|
||||
releaseProcessor.run(plan.snapshot().items, {}, releaseContext);
|
||||
});
|
||||
get('#cancel-search-batch-estimates').addEventListener('click', closeEstimateReview);
|
||||
get('#confirm-search-batch-estimates').addEventListener('click', () =>
|
||||
processor.run(plan.snapshot().items, estimateValues())
|
||||
);
|
||||
get('#resume-search-batch').addEventListener('click', () => {
|
||||
if (processor.pending()) return processor.resume();
|
||||
return laterProcessor.resume();
|
||||
if (laterProcessor.pending()) return laterProcessor.resume();
|
||||
return releaseProcessor.resume();
|
||||
});
|
||||
get('#open-palette').addEventListener('click', () => restore());
|
||||
get('#cmd-results').addEventListener('change', event => {
|
||||
@@ -211,7 +315,7 @@
|
||||
if (result) plan.toggle(result);
|
||||
});
|
||||
function restore() {
|
||||
const pending = processor.pending() + laterProcessor.pending();
|
||||
const pending = processor.pending() + laterProcessor.pending() + releaseProcessor.pending();
|
||||
get('#resume-search-batch').hidden = pending === 0;
|
||||
get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
|
||||
}
|
||||
@@ -225,7 +329,7 @@
|
||||
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
|
||||
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
|
||||
}
|
||||
return { plan, processor, laterProcessor, restore, resultHtml };
|
||||
return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
|
||||
};
|
||||
|
||||
return createSearchBatchPlan;
|
||||
|
||||
Reference in new issue
Block a user