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 captureEstimate(value) { const estimate = Number(value); return Number.isInteger(estimate) && estimate >= 5 && estimate <= 1440 ? estimate : undefined; } function captureRelatedDraft(value) { const repository = String(value?.repository || ''); if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return undefined; const plan = { repository, title:'', body:String(value?.body || '').slice(0, 9000), labelIds:Array.from(new Set((Array.isArray(value?.labelIds) ? value.labelIds : []) .filter(id => Number.isInteger(id) && id > 0))).slice(0, 20), }; const milestoneId = Number(value?.milestoneId); if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId; if (/^\d{4}-\d{2}-\d{2}$/.test(String(value?.dueDate || ''))) plan.dueDate = String(value.dueDate); if (value?.unassigned === true) plan.unassigned = true; else if (/^[A-Za-z0-9_.-]+$/.test(String(value?.assignee || ''))) { plan.assignee = String(value.assignee); plan.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255); } const templateId = String(value?.templateId || '').slice(0, 80); if (templateId) { plan.templateId = templateId; plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80); plan.capturedBody = ''; } return plan; } 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 assignee = /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || '')) ? String(draft.assignee) : ''; if (assignee) { item.assignee = assignee; item.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255); } if (draft?.unassigned === true) item.unassigned = true; const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128); if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId; if (draft?.completionIntent === 'create-and-start' && !assignee && !item.unassigned) { item.completionIntent = 'create-and-start'; item.estimateMinutes = captureEstimate(draft?.estimateMinutes); } 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; const relatedDraft = captureRelatedDraft(draft?.relatedDraft); if (relatedDraft) item.relatedDraft = relatedDraft; 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 repository = String(draft?.repository || ''); const title = String(draft?.title || ''); const body = String(draft?.body || ''); const labelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : []; const unassigned = draft?.unassigned === true; const assignee = !unassigned && /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || '')) ? String(draft.assignee) : undefined; const assigneeName = assignee ? String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255) : undefined; const milestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0 ? Number(draft.milestoneId) : undefined; const dueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || '')) ? String(draft.dueDate) : undefined; const attachment = captureAttachment(draft?.attachment); const attachments = captureAttachments(draft?.attachments); const blockers = captureBlockers(draft?.blockers); const relatedDraft = captureRelatedDraft(draft?.relatedDraft); const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(attachment || null) || JSON.stringify(item.attachments || null) !== JSON.stringify(attachments || null); const changed = item.repository !== repository || item.title !== title || item.body !== body || JSON.stringify(item.labelIds || []) !== JSON.stringify(labelIds) || Boolean(item.unassigned) !== unassigned || item.assignee !== assignee || item.assigneeName !== assigneeName || item.milestoneId !== milestoneId || item.dueDate !== dueDate || item.estimateMinutes !== captureEstimate(draft?.estimateMinutes) || attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(blockers || null) || JSON.stringify(item.relatedDraft || null) !== JSON.stringify(relatedDraft || null); updated = { ...item, repository, title, body, labelIds, unassigned: unassigned || undefined, assignee, assigneeName, milestoneId, dueDate, attachment, attachments, blockers, relatedDraft, operationId: changed ? String(operationId()).slice(0, 128) : item.operationId, status: 'queued', }; if (draft?.completionIntent === 'create-and-start' && !assignee && !unassigned) { updated.completionIntent = 'create-and-start'; updated.estimateMinutes = captureEstimate(draft?.estimateMinutes); } else { delete updated.completionIntent; delete updated.estimateMinutes; } if (assignee === undefined) { delete updated.assignee; delete updated.assigneeName; } if (!unassigned) delete updated.unassigned; if (milestoneId === undefined) delete updated.milestoneId; if (dueDate === undefined) delete updated.dueDate; if (attachment === undefined) delete updated.attachment; if (attachments === undefined) delete updated.attachments; if (blockers === undefined) delete updated.blockers; if (relatedDraft === undefined) delete updated.relatedDraft; 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.unassigned ? { unassigned: true } : {}), ...(item.assignee ? { assignee: item.assignee } : {}), ...(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 = []; const filings = []; 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?.relatedDraft) filings.push({issue:result.issue, relatedDraft:result.item.relatedDraft}); if (result.item?.completionIntent) completions.push({ id: result.item.id, intent: result.item.completionIntent, ownerLogin: result.item.ownerLogin, operationId: result.item.operationId, estimateMinutes: result.item.estimateMinutes, issue: result.issue, }); } if (result.transient) break; } return { confirmed, completions, filings, 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] : [], filings: result.issue && result.item?.relatedDraft ? [{issue:result.issue, relatedDraft:result.item.relatedDraft}] : [], completions: result.issue && result.item?.completionIntent ? [{ id: result.item.id, intent: result.item.completionIntent, ownerLogin: result.item.ownerLogin, operationId: result.item.operationId, estimateMinutes: result.item.estimateMinutes, 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, estimateMinutes: item.estimateMinutes, 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;