285 lines
11 KiB
JavaScript
285 lines
11 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 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 enqueue(draft, mirror = true) {
|
|
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()),
|
|
};
|
|
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
|
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);
|
|
items.push(item);
|
|
write(items, mirror);
|
|
return item;
|
|
}
|
|
|
|
async function enqueueDurably(draft) {
|
|
const item = enqueue(draft, false);
|
|
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
|
return { item, background: false, durability: 'foreground-only' };
|
|
}
|
|
try {
|
|
await backgroundSync.reconcile(read());
|
|
await backgroundSync.requestSync();
|
|
return { item, background: true, durability: 'background' };
|
|
} catch (error) {
|
|
return { item, background: false, durability: 'foreground-only', error };
|
|
}
|
|
}
|
|
|
|
function update(id, draft, mirror = true) {
|
|
let updated = null;
|
|
write(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 changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
|
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
|
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
|
|
updated = {
|
|
...item,
|
|
repository: nextRepository, title: nextTitle,
|
|
body: nextBody, labelIds: nextLabelIds,
|
|
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
|
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;
|
|
delete updated.error;
|
|
delete updated.deliveryState;
|
|
return updated;
|
|
}), mirror);
|
|
return updated;
|
|
}
|
|
|
|
async function updateDurably(id, draft) {
|
|
const item = update(id, draft, false);
|
|
if (!item || !backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
|
return { item, background: false, durability: 'foreground-only' };
|
|
}
|
|
try {
|
|
await backgroundSync.reconcile(read());
|
|
await backgroundSync.requestSync();
|
|
return { item, background: true, durability: 'background' };
|
|
} catch (error) {
|
|
return { item, 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;
|
|
}
|
|
|
|
async function sendItem(item, currentLogin) {
|
|
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
|
if (pending.has(item.id)) return pending.get(item.id);
|
|
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 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 (!issue) return { blocked: true };
|
|
discard(item.id);
|
|
return { issue, item };
|
|
} catch (error) {
|
|
const status = Number(error?.status || 0);
|
|
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),
|
|
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
|
} : 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);
|
|
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 [{
|
|
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
|
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
|
}];
|
|
return [item];
|
|
});
|
|
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, update, updateDurably, discard, flush, retry, reconcileBackground,
|
|
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
|
|
};
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
|