stackchain-dashboard/frontend/issue-outbox.js
timmy 16604d697b
All checks were successful
CI / lint (pull_request) Successful in 1m6s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
feat: use binary screenshot transport (Closes #491)
2026-08-10 15:03:45 +00:00

433 lines
18 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 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;
if (blob) return { filename, contentType, blob };
if (!data && value?.stored === true) return { filename, contentType, stored: true };
if (!data) return undefined;
return { filename, contentType, data };
}
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()),
};
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
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) {
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
return {
...item,
attachment: {
filename: item.attachment.filename,
contentType: item.attachment.contentType,
stored: true,
},
};
}
function enqueue(draft, mirror = true) {
const items = read();
const item = prepareItem(draft);
items.push(item);
write(items, mirror);
return item;
}
async function enqueueDurably(draft) {
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;
if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) 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);
if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
return { ...item, attachment };
}
function prepareUpdate(id, draft) {
let updated = null;
const items = 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 nextAttachment = captureAttachment(draft?.attachment);
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|| attachmentChanged;
updated = {
...item,
repository: nextRepository, title: nextTitle,
body: nextBody, labelIds: nextLabelIds,
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
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;
if (nextAttachment === undefined) delete updated.attachment;
if (attachmentChanged) delete updated.attachmentMarkdown;
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 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.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
}
if (!item.attachment) return issue;
let markdown = item.attachmentMarkdown;
if (!markdown) {
const uploaded = await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: attachmentMultipart(item.attachment),
},
);
markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
}
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 = [];
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);
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,
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;