(function (root, factory) { const createSearchBatchPlan = factory(); if (typeof module === 'object' && module.exports) module.exports = createSearchBatchPlan; if (root) { root.createSearchBatchPlan = createSearchBatchPlan; root.mountSearchBatchPlanning = createSearchBatchPlan.mount; } })(typeof globalThis !== 'undefined' ? globalThis : this, function () { function createSearchBatchPlan({ limit = 50, resolve = item => Promise.resolve(item), claim = item => Promise.resolve(item), onChange = () => {}, } = {}) { const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50; const selected = new Map(); let active = false; const identity = item => [item?.kind || '', item?.repository || '', item?.number || ''].join(':'); const eligible = item => item?.kind === 'issue' && item?.state === 'open' && Boolean(item.repository) && Number.isInteger(item.number); function snapshot() { return { active, count:selected.size, items:Array.from(selected.values()) }; } function changed() { const state = snapshot(); onChange(state); return state; } function start() { active = true; selected.clear(); return changed(); } function cancel() { active = false; selected.clear(); return changed(); } function toggle(item) { if (!active) return 'inactive'; if (!eligible(item)) return 'ineligible'; const key = identity(item); if (selected.has(key)) { selected.delete(key); changed(); return 'removed'; } if (selected.size >= maximum) return 'limit'; selected.set(key, { ...item }); changed(); return 'selected'; } async function prepare(item) { const detail = await resolve(item); if (detail?.assigned_to_me) return detail; if (detail?.claimable) return claim(detail); throw new Error('This issue is no longer available to assign.'); } 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( document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday, todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute, laterWork = null, laterPicker = null ) { 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( 'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') + '/issues/' + encodeURIComponent(detail.number) + '/claim', {method:'PATCH', headers:{Accept:'application/json'}} ), onChange:state => { get('#search-batch-actions').hidden = !state.active; 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(); }, }); processor = batchFactory({ capacity:() => Math.max(0, todayWork.limit - todayWork.read().length), owner:getOwner, timeBudget:() => { const planning = todayWork.planning(); return { capacity_minutes:planning.capacity_minutes, planned_minutes:Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0), }; }, journalName:'search-today-batch', autoMount:false, claim:item => plan.prepare(item), queue:confirmed => queueToday(acceptIssue(confirmed)), persistEstimate:(confirmed, minutes) => { const planning = todayWork.planning(); planning.estimates[todayWork.identity(confirmed)] = minutes; todayWork.replacePlanning(planning); todaySync.enqueueConfiguration(planning.capacity_minutes, planning.estimates); todaySync.flush(); }, onProgress:progress => { get('#queue-selected-search-results').disabled = progress.status === 'running'; if (progress.status === 'running') { get('#search-selection-status').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…'; } else if (progress.status === 'full') { get('#search-selection-status').textContent = 'Today has ' + progress.available + ' remaining slot' + (progress.available === 1 ? '.' : 's.'); } else if (progress.status === 'estimates-required') { get('#search-batch-estimate-summary').textContent = 'Estimate every selected issue.'; } else if (progress.status === 'over-budget') { get('#search-batch-estimate-summary').textContent = progress.requested_minutes + ' min selected · ' + progress.remaining_minutes + ' min remain.'; } else if (progress.status === 'complete') { get('#cmd-search-action-status').textContent = progress.failed.length ? progress.queued.length + ' queued · ' + progress.failed.length + ' need retry.' : progress.queued.length + ' added to Today.'; get('#resume-search-batch').hidden = processor.pending() === 0; closeEstimateReview(); if (!progress.failed.length) plan.cancel(); } }, }); laterProcessor = batchFactory({ capacity:() => plan.limit, owner:getOwner, journalName:'search-later-batch', queueFailureReason:'assigned but Later could not be saved', autoMount:false, claim:item => plan.prepare(item), queue:(confirmed, context) => { const outcome = laterWork?.defer(acceptIssue(confirmed), context?.until); return outcome === 'deferred' ? 'queued' : outcome; }, onProgress:progress => { get('#defer-selected-search-results').disabled = progress.status === 'running'; if (progress.status === 'running') { get('#search-selection-status').textContent = 'Deferring ' + progress.processed + ' of ' + progress.selected + '…'; } else if (progress.status === 'complete') { get('#cmd-search-action-status').textContent = progress.failed.length ? progress.queued.length + ' deferred · ' + progress.failed.length + ' need retry.' : progress.queued.length + ' added to Later.'; restore(); if (!progress.failed.length) plan.cancel(); } }, }); 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 releasePlan = await fetchJson(issuePath + '/release-plan', { method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'}, body:JSON.stringify({milestone_id:context.milestone_id, due_date:context.due_date || null}), }); if (releasePlan?.number !== confirmed.number || releasePlan?.milestone?.id !== context.milestone_id || (context.due_date && releasePlan?.due_date !== context.due_date)) { throw new Error('Issue release plan 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)] )); } function closeEstimateReview() { get('#search-batch-estimate-review').hidden = true; get('#search-batch-actions').hidden = !plan.snapshot().active; } function openEstimateReview(items) { const planning = todayWork.planning(); const planned = Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0); const remaining = Math.max(0, planning.capacity_minutes - planned); get('#search-batch-estimate-summary').textContent = remaining + ' min remain. Estimate before assigning.'; get('#search-batch-estimate-list').innerHTML = items.map(item => { const id = String(item.repository || '') + '#' + String(item.number || ''); const estimate = planning.estimates?.[id] || ''; return '
' + escapeHtml(id) + '' + escapeHtml(item.title || 'Untitled work') + '
'; }).join(''); get('#search-batch-actions').hidden = true; 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 = '' + open.map(item => '' ).join(''); get('#search-release-list').innerHTML = items.map(item => '
' + escapeHtml(item.repository + ' #' + item.number) + '' + escapeHtml(item.title || 'Untitled work') + '
').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', () => { const items = plan.snapshot().items; const capacity = todayWork.planning().capacity_minutes; if (Number.isInteger(capacity) && capacity > 0) openEstimateReview(items); else processor.run(items); }); get('#defer-selected-search-results').addEventListener('click', event => { const items = plan.snapshot().items; if (!items.length) return; laterPicker?.open({ items, confirm:until => () => laterProcessor.run( 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(); if (laterProcessor.pending()) return laterProcessor.resume(); return releaseProcessor.resume(); }); get('#open-palette').addEventListener('click', () => restore()); get('#cmd-results').addEventListener('change', event => { const index = event.target?.dataset?.searchSelect; if (index === undefined) return; const result = lookup(Number(index)); if (result) plan.toggle(result); }); function restore() { const pending = processor.pending() + laterProcessor.pending() + releaseProcessor.pending(); get('#resume-search-batch').hidden = pending === 0; get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted'; } function resultHtml(result, index) { const allowed = result.kind === 'issue' && result.state === 'open'; const selected = plan.snapshot().items.some(item => plan.identity(item) === plan.identity(result)); return ''; } return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml }; }; return createSearchBatchPlan; });