589 lines
28 KiB
JavaScript
589 lines
28 KiB
JavaScript
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, relatedTaskDraft: relatedTaskDraftTransform = (...args) => globalThis.createIssueCapture?.relatedChecklistDraft(...args), linkRelatedTask: linkRelatedTaskTransform = (...args) => globalThis.createIssueCapture?.linkChecklistTask(...args), 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,
|
|
});
|
|
},
|
|
relatedTaskDraft(item, _detail, _taskIndex, label) {
|
|
if (typeof relatedTaskDraftTransform !== 'function') throw new Error('Related issue capture is unavailable.');
|
|
return relatedTaskDraftTransform(item, label);
|
|
},
|
|
linkRelatedTask(item, detail, taskIndex, child) {
|
|
if (typeof linkRelatedTaskTransform !== 'function') return Promise.reject(new Error('Related issue linking is unavailable.'));
|
|
return this.updateContent(item, {
|
|
title:detail.title,
|
|
body:linkRelatedTaskTransform(detail.body, taskIndex, child),
|
|
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, fileRelated, onFileRelated, 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';
|
|
fileRelated.disabled = /^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(label.value);
|
|
editor.hidden = false;
|
|
status.textContent = fileRelated.disabled ? 'This step already links to related work.' :
|
|
'Rename, reorder, remove, or file this step as related work.';
|
|
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' }));
|
|
fileRelated.addEventListener('click', async () => {
|
|
const state = current();
|
|
const selectedIndex = taskIndex;
|
|
if (!state?.item || !state.detail?.updated_at || selectedIndex === null || fileRelated.disabled) return;
|
|
try {
|
|
await onFileRelated({ item:state.item, detail:state.detail, taskIndex:selectedIndex,
|
|
label:label.value, trigger });
|
|
reset();
|
|
} catch (error) {
|
|
status.textContent = error.message + ' The checklist step was not changed.';
|
|
label.focus();
|
|
}
|
|
});
|
|
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 = '<option value="">No milestone</option>' + milestones.map(milestone =>
|
|
'<option value="' + Number(milestone.id) + '">' + escapeOptionHtml(milestone.title) + '</option>'
|
|
).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;
|