291 lines
12 KiB
JavaScript
291 lines
12 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 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_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;
|
|
closeRequest = fetchJson(issuePath(item) + '/close', {
|
|
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;
|