function createAuthoredOutbox({ storage, fetchJson, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) { const storageKey = 'stackchain.authored-outbox.v1'; const makeId = createOperationId || (() => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) ); const pending = new Map(); const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']); function read() { try { const record = JSON.parse(storage?.getItem(storageKey) || 'null'); if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return []; return record.items.filter(item => item && supportedKinds.has(item.kind)); } catch (_error) { return []; } } function write(items) { storage?.setItem(storageKey, JSON.stringify({ version: 2, items })); } function enqueue(message) { if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.'); const ownerLogin = String(getOwnerLogin() || '').trim(); if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.'); const items = read(); const requestedOperationId = String(message.operationId || '').slice(0, 128); const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId); if (existing) return { ...existing }; if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.'); const id = String(requestedOperationId || makeId()).slice(0, 128); const item = { id, operationId: requestedOperationId || id, kind: message.kind, repository: String(message.repository || ''), number: Number(message.number || 0), notificationId: Number(message.notificationId || 0), body: String(message.body || ''), ownerLogin, status: 'queued', queuedAt: Number(now()), }; items.push(item); write(items); return item; } function update(id, changes) { let updated = null; write(read().map(item => { if (item.id !== id) return item; const body = String(changes?.body ?? item.body); updated = { ...item, body, operationId: body === item.body ? item.operationId : String(makeId()).slice(0, 128), status: 'queued', }; delete updated.error; return updated; })); return updated; } 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 endpoint(item) { if (item.kind === 'update-reply') { return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply'; } const repository = item.repository.split('/').map(encodeURIComponent).join('/'); const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues'; return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments'; } async function sendItem(item, currentLogin) { if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true }; if (pending.has(item.id)) return pending.get(item.id); const request = (async () => { try { const result = await fetchJson(endpoint(item), { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': item.operationId, }, body: JSON.stringify({ body: item.body }), }); discard(item.id); return { result }; } catch (error) { const status = Number(error?.status || 0); const permanent = status >= 400 && status < 500; if (permanent) { write(read().map(candidate => candidate.id === item.id ? { ...candidate, status: 'attention', error: String(error.message || 'Message needs attention').slice(0, 240), } : candidate)); } return { error, transient: !permanent }; } })(); pending.set(item.id, request); try { return await request; } finally { if (pending.get(item.id) === request) pending.delete(item.id); } } async function flush(currentLogin) { const confirmed = []; let blocked = 0; currentLogin = String(currentLogin || '').trim(); for (const item of read()) { if (item.status === 'attention') continue; if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; } const outcome = await sendItem(item, currentLogin); if (outcome.result) confirmed.push(outcome.result); if (outcome.transient) break; } return { confirmed, remaining: read(), blocked }; } async function retry(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 }; } const queued = item.status === 'attention' ? update(id, item) : item; const outcome = await sendItem(queued, currentLogin); return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 }; } return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) }; } if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;