(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 '