function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, 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', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']); function reviewFingerprint(message) { return JSON.stringify({ body: String(message.body || ''), decision: String(message.decision || 'comment'), expectedHeadSha: String(message.expectedHeadSha || ''), comments: Array.isArray(message.comments) ? message.comments : [], }); } function clearConfirmedReviewState(item) { if (item.kind !== 'pull-review') return; try { if (item.draftKey && item.draftFingerprint && storage?.getItem(item.draftKey) === item.draftFingerprint) storage.removeItem(item.draftKey); if (item.progressKey && item.progressFingerprint && storage?.getItem(item.progressKey) === item.progressFingerprint) storage.removeItem(item.progressKey); } catch (_error) { /* Delivery is confirmed even when local cleanup is unavailable. */ } } 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, mirror = true) { storage?.setItem(storageKey, JSON.stringify({ version: 2, items })); coordinator?.notify('authored'); if (mirror && backgroundSync?.reconcile) { Promise.resolve(backgroundSync.reconcile(items, 'authored')) .then(() => items.length ? backgroundSync.requestSync?.() : undefined) .catch(() => { /* Foreground reconnect remains the compatibility fallback. */ }); } } function enqueue(message, mirror = true) { 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 (message.kind === 'pull-review') { const queuedReview = items.find(item => item.kind === 'pull-review' && item.repository === String(message.repository || '') && item.number === Number(message.number || 0) && item.expectedHeadSha === String(message.expectedHeadSha || '')); if (queuedReview) { if (reviewFingerprint(queuedReview) === reviewFingerprint(message)) return { ...queuedReview }; throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.'); } } 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()), ...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}), ...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? { attachment: { filename: String(message.attachment.filename || ''), contentType: String(message.attachment.contentType || ''), stored: true, }, } : {}), ...(message.kind === 'pull-review' ? { decision: String(message.decision || 'comment'), expectedHeadSha: String(message.expectedHeadSha || ''), comments: Array.isArray(message.comments) ? message.comments.map(comment => ({ ...comment })) : [], draftKey: String(message.draftKey || ''), progressKey: String(message.progressKey || ''), draftFingerprint: String(message.draftFingerprint || ''), progressFingerprint: String(message.progressFingerprint || ''), } : {}), ...(message.kind === 'issue-blocker' ? { blockerRepository: String(message.blockerRepository || ''), blockerNumber: Number(message.blockerNumber || 0), present: message.present === true, } : {}), ...(message.kind === 'issue-content' ? { title: String(message.title || ''), expectedUpdatedAt: String(message.expectedUpdatedAt || ''), } : {}), }; items.push(item); write(items, mirror); return item; } async function enqueueDurably(message) { const item = enqueue(message, false); if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) && (!backgroundSync?.reconcile || !backgroundSync?.requestSync)) { write(read().filter(candidate => candidate.id !== item.id), false); throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.'); } if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) { return { item, background: false, durability: 'foreground-only' }; } const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? { ...candidate, attachment: { filename: String(message.attachment.filename || ''), contentType: String(message.attachment.contentType || ''), ...(message.attachment.blob ? { blob: message.attachment.blob } : { data: String(message.attachment.data || '') }), }, } : candidate); try { await backgroundSync.reconcile(durableItems, 'authored'); } catch (error) { write(read().filter(candidate => candidate.id !== item.id), false); throw error; } try { await backgroundSync.requestSync(); return { item, background: true, durability: 'background' }; } catch (error) { return { item, background: false, durability: 'foreground-only', error }; } } 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; delete updated.deliveryState; 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('/'); if (item.kind === 'issue-close') { return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close'; } if (item.kind === 'issue-blocker') { return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers'; } if (item.kind === 'issue-content') { return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content'; } if (item.kind === 'pull-review') { return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review'; } 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 attemptAt = Number(now()); write(read().map(candidate => candidate.id === item.id ? { ...candidate, status:'sending', lastAttemptAt:attemptAt, } : candidate), false); const request = (async () => { try { let result; if (backgroundSync?.send) { const delivery = await backgroundSync.send(item, currentLogin); if (delivery.attention) { const error = delivery.error || new Error('Message needs attention'); error.status = Number(error.status || 422); throw error; } result = delivery.message; } else { if (item.kind === 'update-reply-read') { if (!item.replyConfirmed) { await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': item.operationId, }, body: JSON.stringify({ body: item.body }), }); write(read().map(candidate => candidate.id === item.id ? { ...candidate, replyConfirmed: true, status: 'sending', lastAttemptAt: attemptAt, } : candidate), false); } result = await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', { method: 'PATCH', headers: { Accept: 'application/json' }, }); } else if (item.kind === 'issue-close') { result = await fetchJson(endpoint(item), { method: 'PATCH', headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId }, }); if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.'); } else if (item.kind === 'issue-blocker') { result = await fetchJson(endpoint(item), { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': item.operationId, }, body: JSON.stringify({ repository: item.blockerRepository, number: item.blockerNumber, present: item.present, }), }); const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : []; const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository && Number(candidate?.number) === item.blockerNumber); if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) { throw new Error('Blocker change was not confirmed.'); } } else { const body = item.kind === 'pull-review' ? { body: item.body, decision: item.decision, expected_head_sha: item.expectedHeadSha, comments: item.comments, } : item.kind === 'issue-content' ? { title:item.title, body:item.body, expected_updated_at:item.expectedUpdatedAt, } : { body: item.body }; result = await fetchJson(endpoint(item), { method: item.kind === 'issue-content' ? 'PATCH' : 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': item.operationId, }, body: JSON.stringify(body), }); if (item.kind === 'issue-content' && (result?.number !== item.number || result?.title !== item.title || result?.body !== item.body)) { throw new Error('Checklist update was not confirmed.'); } } } if (!result) return { blocked: true }; clearConfirmedReviewState(item); discard(item.id); return { result }; } catch (error) { const status = Number(error?.status || 0); const permanent = status >= 400 && status < 500; const attemptError = String(error.message || 'Delivery failed').slice(0, 240); if (permanent) { write(read().map(candidate => candidate.id === item.id ? { ...candidate, status: 'attention', error: String(error.message || 'Message 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: !permanent }; } })(); 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 = []; let blocked = 0; currentLogin = String(currentLogin || '').trim(); for (const item of read()) { if (item.status === 'attention' || item.kind === 'issue-close') 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 flush(currentLogin) { if (coordinator) return coordinator.runExclusive('authored', () => 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(makeId()).slice(0, 128), status: 'queued', }; delete queued.error; delete queued.deliveryState; write(read().map(candidate => candidate.id === id ? queued : candidate)); } const outcome = await sendItem(queued, currentLogin); return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 }; } async function retry(id, currentLogin) { if (coordinator) return coordinator.runExclusive('authored', () => 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') { clearConfirmedReviewState(item); return []; } if (background?.status === 'attention') return [{ ...item, status: 'attention', error: String(background.error || 'Message needs attention').slice(0, 240), ...(background.deliveryState ? { deliveryState: background.deliveryState } : {}), }]; if (background?.status === 'authorization') return [{ ...item, status: 'authorization', error: String(background.error || 'Fresh authorization required').slice(0, 240), }]; return [item]; }); write(items); return items; } return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) }; } if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;