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;