function createPlanningLoader({ loadLabels, loadMilestones }) { const requests = new Map(); const keyFor = item => item.repository + '#' + item.number; return { open(item) { const key = keyFor(item); if (requests.has(key)) return requests.get(key); const request = Promise.all([loadLabels(item), loadMilestones(item)]) .then(([labels, milestones]) => ({ labels, milestones })) .catch(error => { if (requests.get(key) === request) requests.delete(key); throw error; }); requests.set(key, request); return request; }, }; } function escapeOptionHtml(value) { return String(value || '').replace(/[&<>"']/g, character => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''', })[character]); } function appendChecklistTask(body, label) { const normalized = String(label || '').trim().replace(/\s+/g, ' '); if (!normalized) throw new Error('Enter a checklist step.'); const key = normalized.toLocaleLowerCase(); const duplicate = String(body || '').split('\n').some(line => { const match = line.match(/^\s*[-*+]\s+\[[ xX]\]\s+(.*)$/); return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key; }); if (duplicate) throw new Error('That checklist step already exists.'); const prefix = body ? String(body).replace(/\s+$/, '') + '\n' : ''; return prefix + '- [ ] ' + normalized; } function manageChecklistTask(raw, targetIndex, operation = {}) { const parts = String(raw || '').split(/(\r\n|\n|\r)/); let fenced = false; let taskIndex = 0; for (let index = 0; index < parts.length; index += 2) { const line = parts[index]; if (/^\s*```/.test(line)) { fenced = !fenced; continue; } if (fenced) continue; const task = line.match(/^([-*+]\s+\[[ xX]\]\s+)(.*)$/); if (!task) continue; if (taskIndex === Number(targetIndex)) { if (operation.action === 'rename') { const label = String(operation.label || '').trim().replace(/\s+/g, ' '); if (!label) throw new Error('Enter a checklist step.'); const key = label.toLocaleLowerCase(); let inFence = false; const duplicate = parts.some((candidate, candidateIndex) => { if (candidateIndex % 2) return false; if (/^\s*```/.test(candidate)) { inFence = !inFence; return false; } if (inFence || candidateIndex === index) return false; const match = candidate.match(/^[-*+]\s+\[[ xX]\]\s+(.*)$/); return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key; }); if (duplicate) throw new Error('That checklist step already exists.'); parts[index] = task[1] + label; } else if (operation.action === 'remove') { if (index + 1 < parts.length) parts.splice(index, 2); else if (index > 0) parts.splice(index - 1, 2); } else if (operation.action === 'move-earlier' && index >= 2 && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index - 2])) { [parts[index - 2], parts[index]] = [parts[index], parts[index - 2]]; } else if (operation.action === 'move-later' && index + 2 < parts.length && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index + 2])) { [parts[index], parts[index + 2]] = [parts[index + 2], parts[index]]; } return parts.join(''); } taskIndex += 1; } return parts.join(''); } function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, manageTask: manageTaskTransform = manageChecklistTask, enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) { let commentRequest = null; let closeRequest = null; let releaseRequest = null; let handoffRequest = null; let reassignRequest = null; let labelRequest = null; let editRequest = null; let dueDateRequest = null; let milestoneRequest = null; let blockerRequest = null; const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') + '/issues/' + encodeURIComponent(item.number); const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number; const operationKey = item => draftKey(item) + ':operation'; const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number; const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number; const dueDateDraftKey = item => 'stackchain.issue-due-date.v1:' + item.repository + '#' + item.number; const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number; return { readOnly(item) { return Boolean(item?.is_completed || (item?.is_filed && !item?.is_assigned)); }, load(item) { const access = this.readOnly(item) ? '?access=filed' : ''; return fetchJson(issuePath(item) + '/detail' + access, { headers: { Accept: 'application/json' }, }); }, conversation(item, initialPage) { const access = this.readOnly(item) ? '&access=filed' : ''; const pager = createConversationPager({ loadPage: page => fetchJson( issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20' + access, { headers: { Accept: 'application/json' } }, ), }); pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 }); return pager; }, loadLabels(item) { return fetchJson(issuePath(item) + '/labels', { headers: { Accept: 'application/json' }, }); }, loadMilestones(item) { const repository = item.repository.split('/').map(encodeURIComponent).join('/'); return fetchJson('api/v1/repos/' + repository + '/milestones', { headers: { Accept: 'application/json' }, }); }, loadHandoffCandidates(item) { const access = item?.is_filed ? '?access=filed' : ''; return fetchJson(issuePath(item) + '/handoff-candidates' + access, { headers: { Accept: 'application/json' }, }); }, updateBlocker(item, blocker, remove = false) { if (blockerRequest) return blockerRequest; blockerRequest = fetchJson(issuePath(item) + '/blockers', { method: remove ? 'DELETE' : 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ repository: blocker.repository, number: blocker.number }), }).then(result => { const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : []; const present = dependencies.some(candidate => candidate?.repository === blocker.repository && Number(candidate?.number) === Number(blocker.number) ); if (result?.number !== item.number || result?.dependencies_available !== true || present === remove) { throw new Error('Blocker change was not confirmed.'); } return result; }).finally(() => { blockerRequest = null; }); return blockerRequest; }, loadDraft(item) { try { return storage?.getItem(draftKey(item)) || ''; } catch (_error) { return ''; } }, saveDraft(item, body) { try { if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item)); storage?.setItem(draftKey(item), body); } catch (_error) { /* The textarea remains the fallback. */ } }, loadEditDraft(item) { try { const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null'); return value && typeof value.title === 'string' && typeof value.body === 'string' && typeof value.expectedUpdatedAt === 'string' ? value : null; } catch (_error) { return null; } }, saveEditDraft(item, draft) { try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); } catch (_error) { /* The edit fields remain the fallback. */ } }, updateContent(item, draft) { if (editRequest) return editRequest; this.saveEditDraft(item, draft); const access = this.readOnly(item) ? '?access=filed' : ''; editRequest = fetchJson(issuePath(item) + '/content' + access, { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ title: draft.title, body: draft.body, expected_updated_at: draft.expectedUpdatedAt, }), }).then(result => { if (result?.number !== item.number || result?.title !== draft.title || result?.body !== draft.body) { throw new Error('Issue update was not confirmed.'); } try { storage?.removeItem(editDraftKey(item)); } catch (_error) { /* Confirmed upstream content is authoritative. */ } return result; }).finally(() => { editRequest = null; }); return editRequest; }, toggleTask(item, detail, taskIndex, checked) { if (typeof toggleTask !== 'function') return Promise.reject(new Error('Checklist updates are unavailable.')); return this.updateContent(item, { title: detail.title, body: toggleTask(detail.body, taskIndex, checked), expectedUpdatedAt: detail.updated_at, }); }, manageTask(item, detail, taskIndex, operation) { if (typeof manageTaskTransform !== 'function') return Promise.reject(new Error('Checklist management is unavailable.')); return this.updateContent(item, { title: detail.title, body: manageTaskTransform(detail.body, taskIndex, operation), expectedUpdatedAt: detail.updated_at, }); }, async addTask(item, detail, label) { const body = appendChecklistTask(detail.body, label); return this.updateContent(item, { title: detail.title, body, expectedUpdatedAt: detail.updated_at, }); }, async queueAddedTask(item, detail, label) { if (typeof enqueueDurably !== 'function') { throw new Error('Offline checklist updates are unavailable.'); } const body = appendChecklistTask(detail.body, label); await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number, title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at }); return { queued:true, detail:{ ...detail, body, checklist_pending:true } }; }, async queueManagedTask(item, detail, taskIndex, operation) { if (typeof manageTaskTransform !== 'function' || typeof enqueueDurably !== 'function') { throw new Error('Offline checklist management is unavailable.'); } const body = manageTaskTransform(detail.body, taskIndex, operation); const checklistOperation = { action:String(operation?.action || ''), index:Number(taskIndex), ...(operation?.label ? { label:String(operation.label) } : {}), }; await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number, title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at, checklistOperation }); return { queued:true, detail:{ ...detail, body, checklist_pending:true } }; }, async queueTask(item, detail, taskIndex, checked) { if (typeof toggleTask !== 'function' || typeof enqueueDurably !== 'function') { throw new Error('Offline checklist updates are unavailable.'); } const body = toggleTask(detail.body, taskIndex, checked); await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number, title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at }); return { queued:true, detail:{ ...detail, body, checklist_pending:true } }; }, pendingTask(item, detail, items, ownerLogin) { const pending = [...(items || [])].reverse().find(candidate => candidate?.kind === 'issue-content' && candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) && candidate.ownerLogin === ownerLogin && ['queued', 'sending', 'attention'].includes(candidate.status)); if (!pending) return { ...detail }; return { ...detail, title:pending.title, body:pending.body, checklist_pending:true }; }, bindTaskToggles({ container, status, retry, current, confirmed, restore }) { container.addEventListener('change', async event => { const control = event.target.closest('input.task-list-toggle'); const state = current(); if (!control || !state?.item || !state?.detail?.updated_at) return; container.classList.add('checklist-pending'); container.querySelectorAll('input.task-list-toggle').forEach(input => { input.disabled = true; }); status.textContent = 'Updating checklist…'; try { const result = await (state.offline ? this.queueTask : this.toggleTask).call( this, state.item, state.detail, Number(control.dataset.taskIndex), control.checked ); const latest = current(); if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) { confirmed(state.item, state.detail, state.offline ? result.detail : result); if (state.offline) status.textContent = 'Checklist queued. Pending sync.'; else status.textContent = 'Checklist updated.'; } } catch (error) { const latest = current(); if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) { restore(state.detail); status.textContent = error.message + ' Checklist was not changed; reload latest or use Edit issue.'; retry.hidden = false; } } }); }, bindTaskManagement({ container, editor, label, earlier, later, remove, cancel, status, sheetStatus, retry, current, confirmed }) { let taskIndex = null; let trigger = null; const reset = (returnFocus = false) => { editor.hidden = true; status.textContent = ''; if (returnFocus && trigger?.isConnected) trigger.focus(); taskIndex = null; trigger = null; }; container.addEventListener('click', event => { const control = event.target.closest('button.task-list-manage'); if (!control || !current()?.detail?.updated_at) return; taskIndex = Number(control.dataset.taskIndex); trigger = control; label.value = control.dataset.taskLabel || ''; earlier.disabled = control.dataset.taskFirst === 'true'; later.disabled = control.dataset.taskLast === 'true'; editor.hidden = false; status.textContent = 'Rename, reorder, or remove this step.'; label.focus(); }); const run = async operation => { const state = current(); const selectedIndex = taskIndex; if (!state?.item || !state.detail?.updated_at || selectedIndex === null) return; if (operation.action === 'remove' && !globalThis.confirm('Remove this checklist step?')) return; const controls = editor.querySelectorAll('button,input'); controls.forEach(control => { control.disabled = true; }); status.textContent = state.offline ? 'Queueing checklist change…' : 'Updating checklist…'; try { const result = await (state.offline ? this.queueManagedTask : this.manageTask).call( this, state.item, state.detail, selectedIndex, operation ); if (current()?.item !== state.item) return; confirmed(state.item, state.detail, state.offline ? result.detail : result); reset(); sheetStatus.textContent = state.offline ? 'Checklist change queued. Pending sync.' : 'Checklist step updated.'; const focusIndex = operation.action === 'remove' ? Math.max(0, selectedIndex - 1) : selectedIndex; container.querySelector('button.task-list-manage[data-task-index="' + focusIndex + '"]')?.focus(); } catch (error) { if (current()?.item === state.item) { status.textContent = error.message + ' Your change is still here; retry.'; retry.hidden = false; label.focus(); } } finally { if (!editor.hidden) controls.forEach(control => { control.disabled = false; }); } }; editor.addEventListener('submit', event => { event.preventDefault(); run({ action:'rename', label:label.value }); }); earlier.addEventListener('click', () => run({ action:'move-earlier' })); later.addEventListener('click', () => run({ action:'move-later' })); remove.addEventListener('click', () => run({ action:'remove' })); cancel.addEventListener('click', () => reset(true)); return { reset }; }, renderTasks(container, detail, interactive) { container.classList.toggle('checklist-pending', detail.checklist_pending === true); container.innerHTML = renderMarkdown( detail.body || 'No description provided.', { interactiveTasks: Boolean(interactive), manageTasks: Boolean(interactive), } ); }, checklistCompletion(container, { interactive, today, offline, dismissed = false }) { const tasks = Array.from(container.querySelectorAll('input.task-list-toggle')); const visible = Boolean(interactive && !dismissed && tasks.length && tasks.every(task => task.checked)); return { visible, status: offline ? 'Checklist complete · pending sync' : 'Checklist complete', label: today ? (offline ? 'Queue close & next' : 'Close & next') : (offline ? 'Queue issue closure' : 'Close issue'), }; }, mergeContent(snapshot, item, detail, confirmed, replace) { return { snapshot: snapshot ? replace(snapshot, item.repository, item.number, confirmed) : snapshot, item: { ...item, ...confirmed, key:item.key }, detail: { ...detail, ...confirmed }, }; }, renderMilestoneEditor(item, confirmed, milestones, document = globalThis.document) { const select = document.querySelector('#issue-milestone'); const status = document.querySelector('#issue-milestone-status'); select.innerHTML = '' + milestones.map(milestone => '' ).join(''); select.value = String(this.loadMilestoneDraft(item) ?? confirmed?.id ?? ''); select.disabled = false; document.querySelector('#save-issue-milestone').disabled = false; status.textContent = confirmed ? 'Planned for ' + confirmed.title + '.' : 'No milestone set.'; }, loadDueDateDraft(item) { try { const raw = storage?.getItem(dueDateDraftKey(item)); if (raw === null || raw === undefined) return null; const value = JSON.parse(raw); return typeof value === 'string' ? value : null; } catch (_error) { return null; } }, updateDueDate(item, dueDate) { if (dueDateRequest) return dueDateRequest; try { storage?.setItem(dueDateDraftKey(item), JSON.stringify(dueDate)); } catch (_error) { /* The date input remains the fallback. */ } dueDateRequest = fetchJson(issuePath(item) + '/due-date', { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ due_date: dueDate }), }).then(result => { if (result?.number !== item.number || result?.due_date !== dueDate) { throw new Error('Issue due date was not confirmed.'); } try { storage?.removeItem(dueDateDraftKey(item)); } catch (_error) { /* Confirmed upstream deadline is authoritative. */ } return result; }).finally(() => { dueDateRequest = null; }); return dueDateRequest; }, loadMilestoneDraft(item) { try { const raw = storage?.getItem(milestoneDraftKey(item)); if (raw === null || raw === undefined) return null; const value = JSON.parse(raw); return value === null || Number.isInteger(value) ? value : null; } catch (_error) { return null; } }, updateMilestone(item, milestoneId) { if (milestoneRequest) return milestoneRequest; try { storage?.setItem(milestoneDraftKey(item), JSON.stringify(milestoneId)); } catch (_error) { /* The select remains the fallback. */ } milestoneRequest = fetchJson(issuePath(item) + '/milestone', { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ milestone_id: milestoneId }), }).then(result => { if (result?.number !== item.number || (milestoneId === null ? result?.milestone !== null : result?.milestone?.id !== milestoneId)) { throw new Error('Issue milestone was not confirmed.'); } try { storage?.removeItem(milestoneDraftKey(item)); } catch (_error) { /* Confirmed upstream milestone is authoritative. */ } return result; }).finally(() => { milestoneRequest = null; }); return milestoneRequest; }, loadLabelDraft(item) { try { const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]'); return Array.isArray(value) ? value.filter(Number.isInteger) : []; } catch (_error) { return []; } }, updateLabels(item, labelIds) { if (labelRequest) return labelRequest; try { storage?.setItem(labelDraftKey(item), JSON.stringify(labelIds)); } catch (_error) { /* The checked controls remain the fallback. */ } labelRequest = fetchJson(issuePath(item) + '/labels', { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ label_ids: labelIds }), }).then(result => { if (result?.number !== item.number || !Array.isArray(result.labels)) { throw new Error('Issue labels were not confirmed.'); } try { storage?.removeItem(labelDraftKey(item)); } catch (_error) { /* The upstream label set is authoritative. */ } return result; }).finally(() => { labelRequest = null; }); return labelRequest; }, close(item) { if (closeRequest) return closeRequest; const access = item?.is_filed && !item?.is_assigned ? '?access=filed' : ''; closeRequest = fetchJson(issuePath(item) + '/close' + access, { method: 'PATCH', headers: { Accept: 'application/json' }, }).then(result => { if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.'); return result; }).finally(() => { closeRequest = null; }); return closeRequest; }, release(item, currentLogin) { if (releaseRequest) return releaseRequest; releaseRequest = fetchJson(issuePath(item) + '/release', { method: 'PATCH', headers: { Accept: 'application/json' }, }).then(result => { if ( result?.number !== item.number || !Array.isArray(result.assignees) || result.assignees.includes(currentLogin) ) { throw new Error('Issue release was not confirmed.'); } return result; }).finally(() => { releaseRequest = null; }); return releaseRequest; }, handoff(item, recipient, currentLogin) { if (handoffRequest) return handoffRequest; handoffRequest = fetchJson(issuePath(item) + '/handoff', { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ recipient }), }).then(result => { if ( result?.number !== item.number || result?.recipient !== recipient || !Array.isArray(result.assignees) || !result.assignees.includes(recipient) || result.assignees.includes(currentLogin) ) { throw new Error('Issue handoff was not confirmed.'); } return result; }).finally(() => { handoffRequest = null; }); return handoffRequest; }, reassign(item, recipient) { if (reassignRequest) return reassignRequest; const expectedAssignees = Array.isArray(item?.assignees) ? item.assignees.slice() : []; reassignRequest = fetchJson(issuePath(item) + '/reassign', { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ recipient, expected_assignees: expectedAssignees }), }).then(result => { if ( result?.number !== item.number || result?.recipient !== recipient || !Array.isArray(result.assignees) || result.assignees.length !== 1 || result.assignees[0] !== recipient || JSON.stringify(result.previous_assignees) !== JSON.stringify(expectedAssignees) ) { throw new Error('Issue reassignment was not confirmed.'); } return result; }).finally(() => { reassignRequest = null; }); return reassignRequest; }, comment(item, body) { if (commentRequest) return commentRequest; this.saveDraft(item, body); let operationId; try { operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128); storage?.setItem(operationKey(item), operationId); } catch (_error) { operationId = String(createOperationId()).slice(0, 128); } const access = this.readOnly(item) ? '?access=filed' : ''; commentRequest = fetchJson(issuePath(item) + '/comments' + access, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId }, body: JSON.stringify({ body }), }).then(result => { try { storage?.removeItem(draftKey(item)); } catch (_error) { /* The upstream comment is authoritative. */ } try { storage?.removeItem(operationKey(item)); } catch (_error) { /* A confirmed result no longer needs replay identity. */ } return result; }).finally(() => { commentRequest = null; }); return commentRequest; }, }; } createIssueSheet.createPlanningLoader = createPlanningLoader; createIssueSheet.manageChecklistTask = manageChecklistTask; if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;