548 lines
23 KiB
JavaScript
548 lines
23 KiB
JavaScript
function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) {
|
|
const storageKey = 'stackchain.issue-outbox.v1';
|
|
const operationId = createOperationId || (() =>
|
|
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
|
);
|
|
const pending = new Map();
|
|
|
|
function captureAttachment(value) {
|
|
const contentType = String(value?.contentType || '');
|
|
const filename = String(value?.filename || '').slice(0, 255);
|
|
const blob = value?.blob;
|
|
const data = String(value?.data || '');
|
|
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
|
|
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
|
const noteValue = note ? { note } : {};
|
|
if (blob) return { filename, contentType, blob, ...noteValue };
|
|
if (!data && value?.stored === true) return { filename, contentType, stored: true, ...noteValue };
|
|
if (!data) return undefined;
|
|
return { filename, contentType, data, ...noteValue };
|
|
}
|
|
|
|
function captureAttachments(values) {
|
|
if (!Array.isArray(values)) return undefined;
|
|
if (values.length > 5) throw new Error('You can attach up to 5 screenshots.');
|
|
const attachments = values.map(captureAttachment);
|
|
if (attachments.some(value => !value)) {
|
|
throw new Error('Some screenshots are unavailable. Choose them again before queueing.');
|
|
}
|
|
return attachments.length ? attachments : undefined;
|
|
}
|
|
|
|
function captureBlockers(values) {
|
|
if (!Array.isArray(values)) return undefined;
|
|
const seen = new Set();
|
|
const blockers = [];
|
|
for (const value of values) {
|
|
const repository = String(value?.repository || '').trim();
|
|
const number = Number(value?.number);
|
|
const key = repository + '#' + number;
|
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
|
!Number.isInteger(number) || number < 1 || seen.has(key)) continue;
|
|
seen.add(key);
|
|
blockers.push({
|
|
repository, number,
|
|
title: String(value?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255),
|
|
});
|
|
if (blockers.length === 5) break;
|
|
}
|
|
return blockers.length ? blockers : undefined;
|
|
}
|
|
|
|
function read() {
|
|
try {
|
|
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
|
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
|
|
return record.items.filter(item => item && typeof item === 'object');
|
|
} catch (_error) { return []; }
|
|
}
|
|
|
|
function write(items, mirror = true) {
|
|
storage?.setItem(storageKey, JSON.stringify({ version: 3, items }));
|
|
coordinator?.notify('issue');
|
|
if (mirror && backgroundSync?.reconcile) {
|
|
Promise.resolve(backgroundSync.reconcile(items))
|
|
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
|
|
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
|
|
}
|
|
}
|
|
|
|
function prepareItem(draft) {
|
|
const ownerLogin = String(getOwnerLogin() || '').trim();
|
|
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
|
|
const items = read();
|
|
if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.');
|
|
const item = {
|
|
id: String(operationId()).slice(0, 128),
|
|
operationId: '',
|
|
repository: String(draft?.repository || ''),
|
|
title: String(draft?.title || ''),
|
|
body: String(draft?.body || ''),
|
|
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
|
|
ownerLogin,
|
|
status: 'queued',
|
|
queuedAt: Number(now()),
|
|
};
|
|
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
|
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
|
|
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
|
const attachment = captureAttachment(draft?.attachment);
|
|
if (attachment) item.attachment = attachment;
|
|
const attachments = captureAttachments(draft?.attachments);
|
|
if (attachments) item.attachments = attachments;
|
|
const blockers = captureBlockers(draft?.blockers);
|
|
if (blockers) item.blockers = blockers;
|
|
item.operationId = item.id;
|
|
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
|
item.milestoneId = Number(draft.milestoneId);
|
|
}
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
|
|
return item;
|
|
}
|
|
|
|
function localIndexItem(item) {
|
|
const hasAttachmentBytes = item?.attachment?.data || item?.attachment?.blob;
|
|
const hasBundleBytes = Array.isArray(item?.attachments) &&
|
|
item.attachments.some(value => value?.data || value?.blob);
|
|
if (!hasAttachmentBytes && !hasBundleBytes) return item;
|
|
return {
|
|
...item,
|
|
...(hasAttachmentBytes ? {attachment: {
|
|
filename: item.attachment.filename,
|
|
contentType: item.attachment.contentType,
|
|
stored: true,
|
|
}} : {}),
|
|
...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({
|
|
filename:value.filename, contentType:value.contentType, stored:true,
|
|
...(value.note ? {note:value.note} : {}),
|
|
}))} : {}),
|
|
};
|
|
}
|
|
|
|
function enqueue(draft, mirror = true) {
|
|
const items = read();
|
|
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
|
const existing = sourceCaptureId && items.find(item => item.sourceCaptureId === sourceCaptureId);
|
|
if (existing) return { ...existing };
|
|
const item = prepareItem(draft);
|
|
items.push(item);
|
|
write(items, mirror);
|
|
return item;
|
|
}
|
|
|
|
async function enqueueDurably(draft) {
|
|
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
|
const existing = sourceCaptureId && read().find(item => item.sourceCaptureId === sourceCaptureId);
|
|
if (existing) {
|
|
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
|
return { item: { ...existing }, background: false, durability: 'foreground-only', reused: true };
|
|
}
|
|
await backgroundSync.reconcile(read());
|
|
await backgroundSync.requestSync();
|
|
return { item: { ...existing }, background: true, durability: 'background', reused: true };
|
|
}
|
|
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
|
const item = enqueue(draft, false);
|
|
return { item, background: false, durability: 'foreground-only' };
|
|
}
|
|
const item = prepareItem(draft);
|
|
const current = read();
|
|
try {
|
|
await backgroundSync.reconcile([...current, item]);
|
|
const localItem = localIndexItem(item);
|
|
write([...current, item].map(localIndexItem), false);
|
|
await backgroundSync.requestSync();
|
|
return { item: localItem, background: true, durability: 'background' };
|
|
} catch (error) {
|
|
const persisted = read().find(candidate => candidate.id === item.id);
|
|
if (!persisted) throw error;
|
|
return { item: persisted, background: false, durability: 'foreground-only', error };
|
|
}
|
|
}
|
|
|
|
async function hydrateForEdit(id) {
|
|
const item = read().find(candidate => candidate.id === id);
|
|
if (!item) return null;
|
|
const storedBundle = Array.isArray(item.attachments) && item.attachments.some(value => value?.stored);
|
|
const storedAttachment = item.attachment?.stored && !item.attachment.data && !item.attachment.blob;
|
|
if (!storedAttachment && !storedBundle) return { ...item };
|
|
if (!backgroundSync?.get) {
|
|
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
|
}
|
|
const durable = await backgroundSync.get(id);
|
|
const attachment = captureAttachment(durable?.attachment);
|
|
const attachments = captureAttachments(durable?.attachments);
|
|
if (durable?.operationId !== item.operationId ||
|
|
(storedAttachment && (!attachment?.data && !attachment?.blob)) ||
|
|
(storedBundle && (!attachments || attachments.length !== item.attachments.length))) {
|
|
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
|
}
|
|
return {
|
|
...item,
|
|
...(attachment ? {attachment} : {}),
|
|
...(attachments ? {attachments} : {}),
|
|
};
|
|
}
|
|
|
|
function prepareUpdate(id, draft) {
|
|
let updated = null;
|
|
const items = read().map(item => {
|
|
if (item.id !== id) return item;
|
|
const nextRepository = String(draft?.repository || '');
|
|
const nextTitle = String(draft?.title || '');
|
|
const nextBody = String(draft?.body || '');
|
|
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
|
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
|
|
? Number(draft.milestoneId) : undefined;
|
|
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
|
? String(draft.dueDate) : undefined;
|
|
const nextAttachment = captureAttachment(draft?.attachment);
|
|
const nextAttachments = captureAttachments(draft?.attachments);
|
|
const nextBlockers = captureBlockers(draft?.blockers);
|
|
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null) ||
|
|
JSON.stringify(item.attachments || null) !== JSON.stringify(nextAttachments || null);
|
|
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
|
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
|
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
|
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(nextBlockers || null);
|
|
updated = {
|
|
...item,
|
|
repository: nextRepository, title: nextTitle,
|
|
body: nextBody, labelIds: nextLabelIds,
|
|
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
|
attachment: nextAttachment, attachments:nextAttachments, blockers:nextBlockers,
|
|
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
|
status: 'queued',
|
|
};
|
|
if (draft?.completionIntent === 'create-and-start') updated.completionIntent = 'create-and-start';
|
|
else delete updated.completionIntent;
|
|
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
|
if (nextDueDate === undefined) delete updated.dueDate;
|
|
if (nextAttachment === undefined) delete updated.attachment;
|
|
if (nextAttachments === undefined) delete updated.attachments;
|
|
if (nextBlockers === undefined) delete updated.blockers;
|
|
if (attachmentChanged) {
|
|
delete updated.attachmentMarkdown;
|
|
delete updated.attachmentMarkdowns;
|
|
}
|
|
delete updated.error;
|
|
delete updated.deliveryState;
|
|
return updated;
|
|
});
|
|
return { items, updated };
|
|
}
|
|
|
|
function update(id, draft, mirror = true) {
|
|
const { items, updated } = prepareUpdate(id, draft);
|
|
write(items, mirror);
|
|
return updated;
|
|
}
|
|
|
|
async function updateDurably(id, draft) {
|
|
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
|
const item = update(id, draft, false);
|
|
return { item, background: false, durability: 'foreground-only' };
|
|
}
|
|
const { items, updated: item } = prepareUpdate(id, draft);
|
|
if (!item) return { item, background: false, durability: 'foreground-only' };
|
|
try {
|
|
await backgroundSync.reconcile(items);
|
|
const localItems = items.map(localIndexItem);
|
|
write(localItems, false);
|
|
await backgroundSync.requestSync();
|
|
return { item: localIndexItem(item), background: true, durability: 'background' };
|
|
} catch (error) {
|
|
const persisted = read().find(candidate => candidate.id === id);
|
|
if (!persisted || persisted.operationId !== item.operationId) throw error;
|
|
return { item: persisted, background: false, durability: 'foreground-only', error };
|
|
}
|
|
}
|
|
|
|
function discard(id) {
|
|
const items = read();
|
|
if (!items.some(item => item.id === id)) return false;
|
|
write(items.filter(item => item.id !== id));
|
|
return true;
|
|
}
|
|
|
|
function persistDeliveryStage(id, stage) {
|
|
write(read().map(item => item.id === id ? { ...item, ...stage } : item));
|
|
}
|
|
|
|
function stageOperationId(operationId, stage) {
|
|
const suffix = ':' + stage;
|
|
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
|
}
|
|
|
|
function evidenceMarkdown(attachment, markdown, index) {
|
|
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
|
if (!note) return markdown;
|
|
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
|
|
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
|
|
}
|
|
|
|
function attachmentMultipart(attachment) {
|
|
let blob = attachment?.blob;
|
|
if (!blob && attachment?.data) {
|
|
const binary = atob(String(attachment.data));
|
|
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
|
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
|
}
|
|
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
|
const form = new FormData();
|
|
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
|
return form;
|
|
}
|
|
|
|
async function sendDirect(item, repository) {
|
|
let issue = item.deliveredIssue;
|
|
if (!issue) {
|
|
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json', 'Content-Type': 'application/json',
|
|
'Idempotency-Key': item.operationId,
|
|
},
|
|
body: JSON.stringify({
|
|
title: item.title, body: item.body, label_ids: item.labelIds,
|
|
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
|
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
|
}),
|
|
});
|
|
if (item.attachment || item.attachments || item.blockers?.length) {
|
|
persistDeliveryStage(item.id, { deliveredIssue: issue });
|
|
}
|
|
}
|
|
const blockers = Array.isArray(item.blockers) ? item.blockers : [];
|
|
let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length);
|
|
for (let index = deliveredBlockers; index < blockers.length; index += 1) {
|
|
const blocker = blockers[index];
|
|
await fetchJson(
|
|
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/blockers',
|
|
{
|
|
method: 'PATCH',
|
|
headers: {
|
|
Accept: 'application/json', 'Content-Type': 'application/json',
|
|
'Idempotency-Key': stageOperationId(item.operationId, 'blocker-' + index),
|
|
},
|
|
body: JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}),
|
|
},
|
|
);
|
|
deliveredBlockers = index + 1;
|
|
persistDeliveryStage(item.id, { deliveredIssue:issue, deliveredBlockers });
|
|
}
|
|
const attachments = Array.isArray(item.attachments) ? item.attachments :
|
|
(item.attachment ? [item.attachment] : []);
|
|
if (!attachments.length) return issue;
|
|
const markdowns = Array.isArray(item.attachmentMarkdowns)
|
|
? item.attachmentMarkdowns.slice(0, attachments.length)
|
|
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
|
|
for (let index = markdowns.length; index < attachments.length; index += 1) {
|
|
const uploaded = await fetchJson(
|
|
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Idempotency-Key': stageOperationId(
|
|
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
|
|
),
|
|
},
|
|
body: attachmentMultipart(attachments[index]),
|
|
},
|
|
);
|
|
const markdown = String(uploaded?.markdown || '');
|
|
if (!markdown) {
|
|
const error = new Error('The server did not confirm the screenshot upload.');
|
|
error.status = 422;
|
|
throw error;
|
|
}
|
|
markdowns.push(markdown);
|
|
persistDeliveryStage(item.id, {
|
|
deliveredIssue: issue, attachmentMarkdowns:markdowns.slice(),
|
|
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
|
});
|
|
}
|
|
const markdown = markdowns.map((value, index) =>
|
|
evidenceMarkdown(attachments[index], value, index)).join('\n\n');
|
|
await fetchJson(
|
|
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json', 'Content-Type': 'application/json',
|
|
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
|
|
},
|
|
body: JSON.stringify({ body: markdown }),
|
|
},
|
|
);
|
|
return issue;
|
|
}
|
|
|
|
async function sendItem(item, currentLogin) {
|
|
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
|
if (pending.has(item.id)) return pending.get(item.id);
|
|
const attemptAt = Number(now());
|
|
write(read().map(candidate => candidate.id === item.id ? {
|
|
...candidate, status:'sending', lastAttemptAt:attemptAt,
|
|
} : candidate), false);
|
|
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
|
const request = (async () => {
|
|
try {
|
|
let issue;
|
|
if (backgroundSync?.send) {
|
|
const delivery = await backgroundSync.send(item, currentLogin);
|
|
if (delivery.attention) {
|
|
const error = delivery.error || new Error('Issue needs attention');
|
|
error.status = Number(error.status || 422);
|
|
throw error;
|
|
}
|
|
issue = delivery.issue;
|
|
} else {
|
|
issue = await sendDirect(item, repository);
|
|
}
|
|
if (!issue) return { blocked: true };
|
|
discard(item.id);
|
|
return { issue, item };
|
|
} catch (error) {
|
|
const status = Number(error?.status || 0);
|
|
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
|
|
if (status >= 400 && status < 500) {
|
|
write(read().map(candidate => candidate.id === item.id ? {
|
|
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
|
|
lastAttemptAt: attemptAt, lastAttemptError: attemptError,
|
|
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
|
} : candidate));
|
|
} else {
|
|
write(read().map(candidate => candidate.id === item.id ? {
|
|
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
|
|
} : candidate));
|
|
}
|
|
return { error, transient: !(status >= 400 && status < 500) };
|
|
}
|
|
})();
|
|
pending.set(item.id, request);
|
|
try { return await request; }
|
|
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
|
|
}
|
|
|
|
async function flushQueue(currentLogin) {
|
|
const confirmed = [];
|
|
const completions = [];
|
|
let blocked = 0;
|
|
currentLogin = String(currentLogin || '').trim();
|
|
for (const item of read()) {
|
|
if (item.status === 'attention' || item.status === 'completion') continue;
|
|
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
|
const result = await sendItem(item, currentLogin);
|
|
if (result.issue) {
|
|
confirmed.push(result.issue);
|
|
if (result.item?.completionIntent) completions.push({
|
|
id: result.item.id,
|
|
intent: result.item.completionIntent,
|
|
ownerLogin: result.item.ownerLogin,
|
|
operationId: result.item.operationId,
|
|
issue: result.issue,
|
|
});
|
|
}
|
|
if (result.transient) break;
|
|
}
|
|
return { confirmed, completions, remaining: read(), blocked };
|
|
}
|
|
|
|
async function flush(currentLogin) {
|
|
if (coordinator) return coordinator.runExclusive('issue', () => flushQueue(currentLogin));
|
|
return flushQueue(currentLogin);
|
|
}
|
|
|
|
async function retryItem(id, currentLogin) {
|
|
const item = read().find(candidate => candidate.id === id);
|
|
if (!item) return { confirmed: [], remaining: read() };
|
|
currentLogin = String(currentLogin || '').trim();
|
|
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
|
return { confirmed: [], remaining: read(), blocked: 1 };
|
|
}
|
|
let queued = item;
|
|
if (item.status === 'attention') {
|
|
queued = {
|
|
...item,
|
|
operationId: String(operationId()).slice(0, 128),
|
|
status: 'queued',
|
|
};
|
|
delete queued.error;
|
|
delete queued.deliveryState;
|
|
write(read().map(candidate => candidate.id === id ? queued : candidate));
|
|
}
|
|
const result = await sendItem(queued, currentLogin);
|
|
return {
|
|
confirmed: result.issue ? [result.issue] : [],
|
|
completions: result.issue && result.item?.completionIntent ? [{
|
|
id: result.item.id,
|
|
intent: result.item.completionIntent,
|
|
ownerLogin: result.item.ownerLogin,
|
|
operationId: result.item.operationId,
|
|
issue: result.issue,
|
|
}] : [],
|
|
remaining: read(), blocked: 0,
|
|
};
|
|
}
|
|
|
|
async function retry(id, currentLogin) {
|
|
if (coordinator) return coordinator.runExclusive('issue', () => retryItem(id, currentLogin));
|
|
return retryItem(id, currentLogin);
|
|
}
|
|
|
|
function reconcileBackground(records) {
|
|
const statuses = new Map((records || []).map(item => [item.id, item]));
|
|
const items = read().flatMap(item => {
|
|
const background = statuses.get(item.id);
|
|
const deliveryStage = {
|
|
...(background?.deliveredIssue ? { deliveredIssue: background.deliveredIssue } : {}),
|
|
...(background?.attachmentMarkdown ? { attachmentMarkdown: background.attachmentMarkdown } : {}),
|
|
};
|
|
if (background?.status === 'sent') {
|
|
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
|
|
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
|
|
}];
|
|
return [];
|
|
}
|
|
if (background?.status === 'attention') return [{
|
|
...localIndexItem(item), ...deliveryStage, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
|
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
|
}];
|
|
return [{ ...localIndexItem(item), ...deliveryStage }];
|
|
});
|
|
write(items);
|
|
return items;
|
|
}
|
|
|
|
function pendingCompletions(currentLogin) {
|
|
const login = String(currentLogin || '').trim();
|
|
if (!login) return [];
|
|
return read().filter(item => item.status === 'completion' && item.ownerLogin === login &&
|
|
item.completionIntent === 'create-and-start' && item.deliveredIssue).map(item => ({
|
|
id: item.id,
|
|
intent: item.completionIntent,
|
|
ownerLogin: item.ownerLogin,
|
|
operationId: item.operationId,
|
|
issue: item.deliveredIssue,
|
|
}));
|
|
}
|
|
|
|
function completeIntent(id, currentLogin) {
|
|
const login = String(currentLogin || '').trim();
|
|
const items = read();
|
|
const matched = items.some(item => item.id === id && item.status === 'completion' && item.ownerLogin === login);
|
|
if (!matched) return false;
|
|
write(items.filter(item => item.id !== id));
|
|
return true;
|
|
}
|
|
|
|
return {
|
|
enqueue, enqueueDurably, hydrateForEdit, update, updateDurably, discard, flush, retry, reconcileBackground,
|
|
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
|
|
};
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
|