864 lines
35 KiB
JavaScript
864 lines
35 KiB
JavaScript
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-outbox-v1') {
|
|
let databasePromise;
|
|
function database() {
|
|
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(dbName, 1);
|
|
request.onupgradeneeded = () => {
|
|
if (!request.result.objectStoreNames.contains('issues')) {
|
|
request.result.createObjectStore('issues', { keyPath: 'id' });
|
|
}
|
|
};
|
|
request.onsuccess = () => {
|
|
const db = request.result;
|
|
db.onversionchange = () => {
|
|
db.close();
|
|
databasePromise = undefined;
|
|
};
|
|
resolve(db);
|
|
};
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
return databasePromise;
|
|
}
|
|
const requested = request => new Promise((resolve, reject) => {
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
const transact = async work => {
|
|
const db = await database();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction('issues', 'readwrite');
|
|
const objectStore = transaction.objectStore('issues');
|
|
let result;
|
|
let failed = false;
|
|
transaction.oncomplete = () => failed ? undefined : resolve(result);
|
|
transaction.onerror = () => reject(transaction.error);
|
|
transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted'));
|
|
Promise.resolve(work({
|
|
get: id => requested(objectStore.get(id)),
|
|
getAll: () => requested(objectStore.getAll()),
|
|
put: value => requested(objectStore.put(value)),
|
|
delete: id => requested(objectStore.delete(id)),
|
|
})).then(value => { result = value; }).catch(error => {
|
|
failed = true;
|
|
try { transaction.abort(); } catch (_abortError) { reject(error); }
|
|
reject(error);
|
|
});
|
|
});
|
|
};
|
|
transact.close = async () => {
|
|
if (!databasePromise) return;
|
|
const db = await databasePromise;
|
|
db.close();
|
|
databasePromise = undefined;
|
|
};
|
|
return transact;
|
|
}
|
|
|
|
function createUnfiledAttachmentStore(indexedDB = globalThis.indexedDB) {
|
|
const transact = createIndexedDbTransaction(indexedDB, 'stackchain-unfiled-captures-v1');
|
|
return {
|
|
put: (id, value) => transact(records => records.put({id, ...value})),
|
|
get: id => transact(async records => {
|
|
const value = await records.get(id);
|
|
if (!value) return null;
|
|
const {id: _id, ...attachment} = value;
|
|
return attachment;
|
|
}),
|
|
delete: id => transact(records => records.delete(id)),
|
|
};
|
|
}
|
|
|
|
function createIssueSyncStore({
|
|
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
|
|
createToken = () => globalThis.crypto?.randomUUID?.() ||
|
|
(Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)),
|
|
} = {}) {
|
|
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
|
|
|
async function reconcile(items, outboxLane = 'issue') {
|
|
return transact(async records => {
|
|
const existing = await records.getAll();
|
|
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
|
|
for (const current of existing) {
|
|
if (current.recordType === 'receipt-preference') continue;
|
|
const currentLane = current.outboxLane || 'issue';
|
|
if (currentLane !== outboxLane) continue;
|
|
let replacement = incoming.get(current.id);
|
|
if (!replacement) {
|
|
if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) {
|
|
await records.delete(current.id);
|
|
}
|
|
continue;
|
|
}
|
|
const preservedBundle = current.operationId === replacement.operationId &&
|
|
current.attachments?.every(value => value?.data || value?.blob) &&
|
|
replacement.attachments?.every(value => value?.stored && !value.data && !value.blob)
|
|
? current.attachments : null;
|
|
if (current.operationId === replacement.operationId &&
|
|
(current.attachment?.data || current.attachment?.blob) &&
|
|
replacement.attachment?.stored &&
|
|
!replacement.attachment.data && !replacement.attachment.blob || preservedBundle) {
|
|
replacement = {
|
|
...replacement,
|
|
...(preservedBundle ? {attachments:preservedBundle} : {attachment:current.attachment}),
|
|
};
|
|
incoming.set(current.id, replacement);
|
|
}
|
|
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
|
(['attention', 'authorization'].includes(current.status) &&
|
|
replacement.status === current.status) ||
|
|
current.status === 'sent') {
|
|
incoming.set(current.id, current);
|
|
}
|
|
}
|
|
for (const item of incoming.values()) await records.put(item);
|
|
});
|
|
}
|
|
|
|
async function claimNext(ownerLogin) {
|
|
return transact(async records => {
|
|
const timestamp = Number(now());
|
|
const items = await records.getAll();
|
|
const item = items.find(candidate => candidate.ownerLogin === ownerLogin &&
|
|
candidate.kind !== 'issue-close' &&
|
|
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
|
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
|
if (!item) return null;
|
|
const claimed = {
|
|
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
|
|
};
|
|
await records.put(claimed);
|
|
return claimed;
|
|
});
|
|
}
|
|
|
|
async function planBatch(ownerLogin, limit = 70) {
|
|
return transact(async records => {
|
|
const timestamp = Number(now());
|
|
const eligible = (await records.getAll()).filter(candidate =>
|
|
candidate.recordType !== 'receipt-preference' &&
|
|
candidate.ownerLogin === ownerLogin &&
|
|
candidate.kind !== 'issue-close' &&
|
|
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
|
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
|
const lanes = {
|
|
issue: eligible.filter(item => (item.outboxLane || 'issue') !== 'authored'),
|
|
authored: eligible.filter(item => item.outboxLane === 'authored'),
|
|
};
|
|
const selected = [];
|
|
const maximum = Math.max(0, Number(limit) || 0);
|
|
while (selected.length < maximum && (lanes.issue.length || lanes.authored.length)) {
|
|
if (lanes.issue.length) selected.push(lanes.issue.shift().id);
|
|
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift().id);
|
|
}
|
|
return selected;
|
|
});
|
|
}
|
|
|
|
async function claim(id, ownerLogin) {
|
|
return transact(async records => {
|
|
const timestamp = Number(now());
|
|
const item = (await records.getAll()).find(candidate => candidate.id === id);
|
|
if (!item || item.ownerLogin !== ownerLogin ||
|
|
!['queued', 'sending'].includes(item.status) ||
|
|
(item.status === 'sending' && Number(item.claimUntil) > timestamp)) return null;
|
|
const claimed = {
|
|
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
|
|
};
|
|
await records.put(claimed);
|
|
return claimed;
|
|
});
|
|
}
|
|
|
|
async function upsert(item) {
|
|
return transact(async records => {
|
|
const current = (await records.getAll()).find(candidate => candidate.id === item.id);
|
|
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
|
(['attention', 'authorization'].includes(current.status) &&
|
|
item.status === current.status) ||
|
|
current.status === 'sent')) return current;
|
|
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
|
|
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
|
|
? { attachment: current.attachment } : {};
|
|
const next = current && current.operationId === item.operationId ? {
|
|
...item, ...preservedAttachment,
|
|
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
|
|
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
|
|
} : { ...item };
|
|
await records.put(next);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
async function update(id, transform) {
|
|
return transact(async records => {
|
|
const item = (await records.getAll()).find(candidate => candidate.id === id);
|
|
if (item) await records.put(transform(item));
|
|
});
|
|
}
|
|
|
|
async function updateClaim(id, claimToken, transform) {
|
|
return transact(async records => {
|
|
const item = records.get ? await records.get(id) :
|
|
(await records.getAll()).find(candidate => candidate.id === id);
|
|
if (!item || item.status !== 'sending' || !claimToken || item.claimToken !== claimToken) {
|
|
return false;
|
|
}
|
|
await records.put(transform(item));
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function clearClaim(item, changes) {
|
|
const { claimToken: _claimToken, ...unclaimed } = item;
|
|
return { ...unclaimed, ...changes, claimUntil: 0 };
|
|
}
|
|
|
|
async function renew(id, claimToken) {
|
|
let renewed = null;
|
|
await updateClaim(id, claimToken, item => {
|
|
renewed = { ...item, claimUntil: Number(now()) + claimMs };
|
|
return renewed;
|
|
});
|
|
return renewed;
|
|
}
|
|
|
|
function preferenceId(ownerLogin) {
|
|
return 'receipt-preference:' + String(ownerLogin || '').trim();
|
|
}
|
|
|
|
async function setReceiptPreference(ownerLogin, enabled) {
|
|
const login = String(ownerLogin || '').trim();
|
|
if (!login) return;
|
|
return transact(async records => {
|
|
const id = preferenceId(login);
|
|
if (!enabled) return records.delete(id);
|
|
return records.put({ id, recordType: 'receipt-preference', ownerLogin: login, enabled: true });
|
|
});
|
|
}
|
|
|
|
async function getReceiptPreference(ownerLogin) {
|
|
const id = preferenceId(ownerLogin);
|
|
return transact(async records => Boolean(
|
|
(await records.getAll()).find(item => item.id === id)?.enabled
|
|
));
|
|
}
|
|
|
|
return {
|
|
get: id => transact(records => records.get(id)),
|
|
reconcile,
|
|
upsert,
|
|
update,
|
|
claim,
|
|
claimNext,
|
|
planBatch,
|
|
supportsClaimTokens: true,
|
|
renew,
|
|
checkpoint: updateClaim,
|
|
complete: (id, claimToken, deliveredIssue) => updateClaim(id, claimToken, item => clearClaim(item, {
|
|
status: 'sent',
|
|
...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}),
|
|
})),
|
|
release: (id, claimToken) => updateClaim(id, claimToken,
|
|
item => clearClaim(item, { status: 'queued' })),
|
|
fail: (id, claimToken, error, deliveryState) => updateClaim(id, claimToken, item => clearClaim(item, {
|
|
status: 'attention', error,
|
|
...(deliveryState ? { deliveryState } : {}),
|
|
})),
|
|
authorization: (id, claimToken, error) => updateClaim(
|
|
id, claimToken, item => clearClaim(item, { status: 'authorization', error })
|
|
),
|
|
snapshot: () => transact(async records =>
|
|
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
|
|
countBlocked: ownerLogin => transact(async records =>
|
|
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference' &&
|
|
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
|
setReceiptPreference,
|
|
getReceiptPreference,
|
|
close: () => transact.close?.(),
|
|
};
|
|
}
|
|
|
|
function createBackgroundIssueSync({
|
|
store, fetchJson, base = '', maxConcurrency = 3, batchSize = 70,
|
|
batch = work => work(), requestTimeoutMs = 15000,
|
|
}) {
|
|
let purgeRequested = false;
|
|
let activePurge = null;
|
|
let activeFlush = null;
|
|
const activeRequests = new Set();
|
|
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
|
|
|
|
const completeClaim = (item, delivered) => store.supportsClaimTokens
|
|
? store.complete(item.id, item.claimToken, delivered) : store.complete(item.id, delivered);
|
|
const releaseClaim = item => store.supportsClaimTokens
|
|
? store.release(item.id, item.claimToken) : store.release(item.id);
|
|
const failClaim = (item, error, deliveryState) => store.supportsClaimTokens
|
|
? store.fail(item.id, item.claimToken, error, deliveryState)
|
|
: store.fail(item.id, error, deliveryState);
|
|
const requireAuthorization = (item, error) => store.supportsClaimTokens
|
|
? store.authorization(item.id, item.claimToken, error)
|
|
: store.authorization(item.id, error);
|
|
const checkpointClaim = (item, transform) => store.supportsClaimTokens
|
|
? store.checkpoint(item.id, item.claimToken, transform) : store.update?.(item.id, transform);
|
|
|
|
async function requestStage(item, url, options) {
|
|
if (store.renew) {
|
|
const renewed = await store.renew(item.id, item.claimToken);
|
|
if (!renewed) throw new Error('Background delivery claim was lost.');
|
|
}
|
|
return requestJson(url, options);
|
|
}
|
|
|
|
async function requestJson(url, options = {}) {
|
|
const controller = new AbortController();
|
|
activeRequests.add(controller);
|
|
let timer;
|
|
const interrupted = new Promise((resolve, reject) => {
|
|
controller.signal.addEventListener('abort', () => {
|
|
reject(new Error(purgeRequested
|
|
? 'Background request canceled.'
|
|
: 'Background request timed out.'));
|
|
}, { once: true });
|
|
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
});
|
|
try {
|
|
return await Promise.race([
|
|
Promise.resolve().then(() => fetchJson(url, { ...options, signal: controller.signal })),
|
|
interrupted,
|
|
]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
activeRequests.delete(controller);
|
|
}
|
|
}
|
|
function receiptFor(item, status, delivered = {}) {
|
|
if (status === 'attention') {
|
|
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
|
|
}
|
|
if (item.kind === 'notification-read') {
|
|
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
|
|
}
|
|
if (item.kind === 'update-reply' || item.kind === 'update-reply-read') {
|
|
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
|
}
|
|
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
|
if (item.kind === 'pull-review') {
|
|
return {
|
|
id: item.id, status, kind: 'message',
|
|
route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number),
|
|
};
|
|
}
|
|
if (item.kind === 'issue-close') {
|
|
return {
|
|
id: item.id, status, kind: 'message',
|
|
route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(item.number),
|
|
};
|
|
}
|
|
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
|
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
|
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
|
|
}
|
|
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
|
|
}
|
|
|
|
function deliveryRequest(item) {
|
|
if (item.kind === 'notification-read') {
|
|
return {
|
|
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
|
|
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
|
|
};
|
|
}
|
|
if (item.kind === 'update-reply') {
|
|
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
|
}
|
|
if (item.kind === 'update-reply-read') {
|
|
return {
|
|
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
|
|
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
|
|
};
|
|
}
|
|
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
|
if (item.kind === 'issue-close') {
|
|
return {
|
|
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close',
|
|
options: {
|
|
method: 'PATCH',
|
|
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
|
},
|
|
};
|
|
}
|
|
if (item.kind === 'issue-blocker') {
|
|
return {
|
|
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers',
|
|
options: {
|
|
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 === true,
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
if (item.kind === 'pull-review') {
|
|
return {
|
|
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
|
options: {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'Idempotency-Key': item.operationId,
|
|
},
|
|
body: JSON.stringify({
|
|
body: item.body,
|
|
decision: item.decision,
|
|
expected_head_sha: item.expectedHeadSha,
|
|
comments: item.comments || [],
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
|
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
|
return authoredRequest(
|
|
'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
|
|
item,
|
|
);
|
|
}
|
|
return {
|
|
url: base + 'api/v1/repos/' + repository + '/issues',
|
|
options: {
|
|
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' } : {}),
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function authoredRequest(url, item) {
|
|
return {
|
|
url: base + url,
|
|
options: {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'Idempotency-Key': item.operationId,
|
|
},
|
|
body: JSON.stringify({ body: item.body }),
|
|
},
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async function deliverReplyRead(item) {
|
|
let current = item;
|
|
if (!current.replyConfirmed) {
|
|
let attachmentMarkdown = current.attachmentMarkdown;
|
|
if (current.attachment && !attachmentMarkdown) {
|
|
const uploaded = await requestStage(current,
|
|
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
|
method:'POST',
|
|
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
|
body:attachmentMultipart(current.attachment),
|
|
});
|
|
attachmentMarkdown = String(uploaded?.markdown || '');
|
|
if (!attachmentMarkdown) {
|
|
const error = new Error('The server did not confirm the screenshot upload.');
|
|
error.status = 422;
|
|
throw error;
|
|
}
|
|
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
|
current = { ...current, attachmentMarkdown };
|
|
}
|
|
const text = String(current.body || '').trim();
|
|
const replyBody = attachmentMarkdown ?
|
|
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
|
|
const options = authoredRequest('', { ...current, body:replyBody }).options;
|
|
if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
|
|
await requestStage(
|
|
current,
|
|
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
|
options,
|
|
);
|
|
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
|
|
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
|
|
current = { ...current, replyConfirmed: true };
|
|
}
|
|
const request = deliveryRequest(current);
|
|
return requestStage(current, request.url, request.options);
|
|
}
|
|
|
|
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 deliverIssueCapture(item) {
|
|
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
|
let deliveredIssue = item.deliveredIssue;
|
|
if (!deliveredIssue) {
|
|
const request = deliveryRequest(item);
|
|
deliveredIssue = await requestStage(item, request.url, request.options);
|
|
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
|
|
}
|
|
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 requestStage(
|
|
item,
|
|
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.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;
|
|
await checkpointClaim(item, current => ({ ...current, deliveredIssue, deliveredBlockers }));
|
|
}
|
|
const attachments = (Array.isArray(item.attachments) ? item.attachments : [item.attachment]).filter(Boolean);
|
|
if (!attachments.length) return deliveredIssue;
|
|
const attachmentMarkdowns = Array.isArray(item.attachmentMarkdowns)
|
|
? item.attachmentMarkdowns.slice(0, attachments.length)
|
|
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
|
|
for (let index = attachmentMarkdowns.length; index < attachments.length; index += 1) {
|
|
const uploaded = await requestStage(
|
|
item,
|
|
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.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;
|
|
}
|
|
attachmentMarkdowns.push(markdown);
|
|
await checkpointClaim(item, current => ({
|
|
...current, deliveredIssue, attachmentMarkdowns:attachmentMarkdowns.slice(),
|
|
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
|
}));
|
|
}
|
|
const attachmentMarkdown = attachmentMarkdowns.map((markdown, index) =>
|
|
evidenceMarkdown(attachments[index], markdown, index)).join('\n\n');
|
|
await requestStage(
|
|
item,
|
|
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json', 'Content-Type': 'application/json',
|
|
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
|
|
},
|
|
body: JSON.stringify({ body: attachmentMarkdown }),
|
|
},
|
|
);
|
|
return deliveredIssue;
|
|
}
|
|
|
|
async function deliverScreenshotComment(item) {
|
|
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
|
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
|
let attachmentMarkdown = item.attachmentMarkdown;
|
|
if (!attachmentMarkdown) {
|
|
const uploaded = await requestStage(
|
|
item,
|
|
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
|
},
|
|
body: attachmentMultipart(item.attachment),
|
|
},
|
|
);
|
|
attachmentMarkdown = String(uploaded?.markdown || '');
|
|
if (!attachmentMarkdown) {
|
|
const error = new Error('The server did not confirm the screenshot upload.');
|
|
error.status = 422;
|
|
throw error;
|
|
}
|
|
await checkpointClaim(item, current => ({ ...current, attachmentMarkdown }));
|
|
}
|
|
const text = String(item.body || '').trim();
|
|
return requestStage(
|
|
item,
|
|
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json', 'Content-Type': 'application/json',
|
|
'Idempotency-Key': stageOperationId(item.operationId, 'comment'),
|
|
},
|
|
body: JSON.stringify({ body: text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
|
|
},
|
|
);
|
|
}
|
|
|
|
async function deliverUpdateScreenshotReply(item) {
|
|
let current = item;
|
|
let attachmentMarkdown = current.attachmentMarkdown;
|
|
if (!attachmentMarkdown) {
|
|
const uploaded = await requestStage(current,
|
|
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
|
method:'POST',
|
|
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
|
body:attachmentMultipart(current.attachment),
|
|
});
|
|
attachmentMarkdown = String(uploaded?.markdown || '');
|
|
if (!attachmentMarkdown) {
|
|
const error = new Error('The server did not confirm the screenshot upload.');
|
|
error.status = 422;
|
|
throw error;
|
|
}
|
|
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
|
current = { ...current, attachmentMarkdown };
|
|
}
|
|
const text = String(current.body || '').trim();
|
|
return requestStage(current,
|
|
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
|
|
method:'POST',
|
|
headers:{ Accept:'application/json', 'Content-Type':'application/json',
|
|
'Idempotency-Key':stageOperationId(current.operationId, 'reply') },
|
|
body:JSON.stringify({ body:text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
|
|
});
|
|
}
|
|
|
|
async function deliver(item) {
|
|
const request = deliveryRequest(item);
|
|
try {
|
|
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
|
|
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
|
|
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
|
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
|
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
|
|
await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
|
|
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
|
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
|
const error = new Error('Issue closure was not confirmed.');
|
|
error.status = 422;
|
|
throw error;
|
|
}
|
|
if (item.kind === 'issue-blocker') {
|
|
const dependencies = Array.isArray(delivered?.dependencies) ? delivered.dependencies : [];
|
|
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
|
|
Number(candidate?.number) === item.blockerNumber);
|
|
if (delivered?.number !== item.number || delivered?.dependencies_available !== true || present !== item.present) {
|
|
const error = new Error('Blocker change was not confirmed.');
|
|
error.status = 422;
|
|
throw error;
|
|
}
|
|
}
|
|
await completeClaim(item, delivered);
|
|
const receipt = receiptFor(item, 'confirmed', delivered);
|
|
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
|
} catch (error) {
|
|
const status = Number(error?.status || 0);
|
|
if (status === 401) {
|
|
await releaseClaim(item);
|
|
throw error;
|
|
}
|
|
if (status === 428 && item.kind === 'pull-review') {
|
|
const message = String(error?.message || 'Fresh authorization required').slice(0, 240);
|
|
await requireAuthorization(item, message);
|
|
return {
|
|
authorization: true,
|
|
error,
|
|
receipt: receiptFor(item, 'authorization'),
|
|
};
|
|
}
|
|
if (status >= 400 && status < 500) {
|
|
await failClaim(
|
|
item,
|
|
String(error?.message || 'Issue needs attention').slice(0, 240),
|
|
error?.code === 'delivery_uncertain' ? 'uncertain' : undefined,
|
|
);
|
|
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
|
}
|
|
await releaseClaim(item);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function send(item, currentLogin) {
|
|
if (purgeRequested) return { blocked: true };
|
|
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
|
await store.upsert(item);
|
|
const claimed = await store.claim(item.id, currentLogin);
|
|
if (!claimed) return { busy: true };
|
|
return deliver(claimed);
|
|
}
|
|
|
|
async function runFlush() {
|
|
const identity = await requestJson(base + 'api/v1/background-identity', {
|
|
headers: { Accept: 'application/json' }, cache: 'no-store',
|
|
});
|
|
const login = String(identity?.login || '').trim();
|
|
const confirmed = [];
|
|
const receipts = [];
|
|
let attention = 0;
|
|
let authorization = 0;
|
|
if (!login) return { confirmed, blocked: 0, attention, authorization, login, receipts };
|
|
const collect = result => {
|
|
if (result.issue) confirmed.push(result.issue);
|
|
if (result.message) confirmed.push(result.message);
|
|
if (result.attention) attention += 1;
|
|
if (result.authorization) authorization += 1;
|
|
if (result.receipt) receipts.push(result.receipt);
|
|
};
|
|
if (store.planBatch) {
|
|
const planned = await store.planBatch(login, batchSize);
|
|
let next = 0;
|
|
let authenticationError = null;
|
|
let transientError = null;
|
|
const worker = async () => {
|
|
while (!purgeRequested && !authenticationError && next < planned.length) {
|
|
const id = planned[next++];
|
|
const item = await store.claim(id, login);
|
|
if (!item) continue;
|
|
try {
|
|
collect(await deliver(item));
|
|
} catch (error) {
|
|
if (Number(error?.status || 0) === 401) authenticationError = error;
|
|
else if (!transientError) transientError = error;
|
|
}
|
|
}
|
|
};
|
|
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, planned.length));
|
|
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
if (purgeRequested) throw new Error('Background delivery canceled.');
|
|
if (authenticationError) throw authenticationError;
|
|
if (transientError) throw transientError;
|
|
} else if (store.claimBatch) {
|
|
const claimed = await store.claimBatch(login, batchSize);
|
|
let next = 0;
|
|
let authenticationError = null;
|
|
let transientError = null;
|
|
const worker = async () => {
|
|
while (!purgeRequested && !authenticationError && next < claimed.length) {
|
|
const item = claimed[next++];
|
|
try {
|
|
collect(await deliver(item));
|
|
} catch (error) {
|
|
if (Number(error?.status || 0) === 401) authenticationError = error;
|
|
else if (!transientError) transientError = error;
|
|
}
|
|
}
|
|
};
|
|
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, claimed.length));
|
|
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
if (purgeRequested) {
|
|
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
|
|
throw new Error('Background delivery canceled.');
|
|
}
|
|
if (authenticationError) {
|
|
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
|
|
throw authenticationError;
|
|
}
|
|
if (transientError) throw transientError;
|
|
} else {
|
|
while (!purgeRequested) {
|
|
const item = await store.claimNext(login);
|
|
if (!item) break;
|
|
collect(await deliver(item));
|
|
}
|
|
}
|
|
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
|
return { confirmed, blocked, attention, authorization, login, receipts };
|
|
}
|
|
|
|
function flush() {
|
|
if (purgeRequested) return Promise.resolve({ confirmed: [], blocked: 0, attention: 0, login: '', receipts: [] });
|
|
if (activeFlush) return activeFlush;
|
|
activeFlush = Promise.resolve().then(() => batch(runFlush)).finally(() => { activeFlush = null; });
|
|
return activeFlush;
|
|
}
|
|
|
|
function purge() {
|
|
if (activePurge) return activePurge;
|
|
purgeRequested = true;
|
|
activeRequests.forEach(controller => controller.abort());
|
|
activePurge = (async () => {
|
|
if (activeFlush) await activeFlush.catch(() => {});
|
|
await store.close?.();
|
|
})();
|
|
return activePurge;
|
|
}
|
|
|
|
async function resume() {
|
|
const pendingPurge = activePurge;
|
|
if (pendingPurge) await pendingPurge;
|
|
if (activePurge === pendingPurge) activePurge = null;
|
|
purgeRequested = false;
|
|
}
|
|
|
|
return {
|
|
flush, send, purge, resume,
|
|
get: id => store.get(id),
|
|
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
|
snapshot: () => store.snapshot(),
|
|
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
|
getReceiptPreference: ownerLogin => store.getReceiptPreference(ownerLogin),
|
|
};
|
|
}
|
|
|
|
createBackgroundIssueSync.createIssueSyncStore = createIssueSyncStore;
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = createBackgroundIssueSync;
|
|
if (typeof globalThis !== 'undefined') {
|
|
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
|
|
globalThis.createIssueSyncStore = createIssueSyncStore;
|
|
globalThis.createUnfiledAttachmentStore = createUnfiledAttachmentStore;
|
|
}
|