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;