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 createIssueSheet({ fetchJson, storage, 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 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) { return fetchJson(issuePath(item) + '/handoff-candidates', { 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); editRequest = fetchJson(issuePath(item) + '/content', { 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; }, 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; }, 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; if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;