270 lines
12 KiB
JavaScript
270 lines
12 KiB
JavaScript
function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Date.now() }) {
|
|
const indexKey = 'stackchain.draft-index.v1';
|
|
|
|
function readIndex() {
|
|
try {
|
|
const value = JSON.parse(storage?.getItem(indexKey) || '{}');
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
} catch (_error) { return {}; }
|
|
}
|
|
|
|
function writeIndex(index) {
|
|
try { storage?.setItem(indexKey, JSON.stringify(index)); }
|
|
catch (_error) { /* Draft discovery remains available without metadata. */ }
|
|
}
|
|
|
|
function keys() {
|
|
try {
|
|
return Array.from({ length: Number(storage?.length || 0) }, (_, index) => storage.key(index))
|
|
.filter(Boolean);
|
|
} catch (_error) { return []; }
|
|
}
|
|
|
|
function textPreview(value) {
|
|
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 180);
|
|
}
|
|
|
|
function parseTarget(value) {
|
|
const match = String(value || '').match(/^(.+)#(\d+)$/);
|
|
return match ? { repository: match[1], number: Number(match[2]) } : null;
|
|
}
|
|
|
|
function parse(key, raw) {
|
|
let match;
|
|
if (key === 'stackchain.issue-capture.v1') {
|
|
try {
|
|
const value = JSON.parse(raw);
|
|
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
|
|
if (!preview && !value?.repository) return null;
|
|
return {
|
|
kind: 'new-issue', label: 'New issue', repository: String(value?.repository || ''),
|
|
title: textPreview(value?.title) || 'Untitled new issue', preview,
|
|
};
|
|
} catch (_error) { return null; }
|
|
}
|
|
match = key.match(/^stackchain\.(issue|pull)-comment\.v1:(.+#\d+)$/);
|
|
if (match) {
|
|
const target = parseTarget(match[2]);
|
|
const preview = textPreview(raw);
|
|
if (!target || !preview) return null;
|
|
const routeKind = match[1] === 'issue' ? 'issue' : 'pull';
|
|
return {
|
|
kind: routeKind + '-comment', label: routeKind === 'issue' ? 'Issue comment' : 'PR comment',
|
|
...target, title: target.repository + '#' + target.number, preview,
|
|
route: { kind: routeKind, ...target },
|
|
};
|
|
}
|
|
match = key.match(/^stackchain\.issue-content\.v1:(.+#\d+)$/);
|
|
if (match) {
|
|
const target = parseTarget(match[1]);
|
|
try {
|
|
const value = JSON.parse(raw);
|
|
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
|
|
if (!target || !preview || typeof value?.expectedUpdatedAt !== 'string') return null;
|
|
return {
|
|
kind: 'issue-edit', label: 'Issue edit', ...target,
|
|
title: target.repository + '#' + target.number, preview,
|
|
route: { kind: 'issue', ...target },
|
|
};
|
|
} catch (_error) { return null; }
|
|
}
|
|
match = key.match(/^stackchain\.update-reply\.v1\.(\d+)$/);
|
|
if (match) {
|
|
const preview = textPreview(raw);
|
|
if (!preview) return null;
|
|
const notificationId = Number(match[1]);
|
|
return {
|
|
kind: 'update-reply', label: 'Update reply', notification_id: notificationId,
|
|
title: 'Update #' + notificationId, preview,
|
|
route: { kind: 'update', notification_id: notificationId },
|
|
};
|
|
}
|
|
match = key.match(/^stackchain\.review-draft\.v1:(.+#\d+)@([^:]+)$/);
|
|
if (match) {
|
|
const target = parseTarget(match[1]);
|
|
try {
|
|
const value = JSON.parse(raw);
|
|
const notes = Object.values(value?.notes || {});
|
|
const comments = Array.isArray(value?.comments) ? value.comments.map(item => item?.body) : [];
|
|
const preview = textPreview([value?.summary, ...notes, ...comments].filter(Boolean).join(' — '));
|
|
if (!target || !preview) return null;
|
|
return {
|
|
kind: 'review', label: 'PR review', ...target, head_sha: match[2],
|
|
title: target.repository + '#' + target.number, preview,
|
|
route: { kind: 'review', ...target },
|
|
};
|
|
} catch (_error) { return null; }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseOutbox(raw) {
|
|
try {
|
|
const record = JSON.parse(raw);
|
|
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
|
|
const currentLogin = String(getCurrentLogin() || '').trim();
|
|
return record.items.filter(item =>
|
|
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
|
|
).map(item => {
|
|
const ownerLogin = String(item.ownerLogin || '').trim();
|
|
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
|
return {
|
|
id: 'stackchain.issue-outbox.v1:' + item.id,
|
|
outbox_id: item.id,
|
|
kind: 'issue-outbox',
|
|
status: item.status === 'completion' ? 'completion' :
|
|
(item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued')),
|
|
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
|
(item.status === 'completion' ? 'Created · ready to start' :
|
|
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
|
|
continuation: item.status === 'completion' && item.completionIntent === 'create-and-start',
|
|
delivery_state: item.deliveryState,
|
|
repository: item.repository,
|
|
title: textPreview(item.title) || 'Untitled queued issue',
|
|
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
|
|
copy_text: [item.title, item.body].filter(Boolean).join('\n\n'),
|
|
quarantined,
|
|
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
|
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
|
last_attempt_at: Number(item.lastAttemptAt || 0),
|
|
last_attempt_error: textPreview(item.lastAttemptError),
|
|
updated_at: Number(item.queuedAt || 0),
|
|
};
|
|
});
|
|
} catch (_error) { return []; }
|
|
}
|
|
|
|
function parseAuthoredOutbox(raw) {
|
|
try {
|
|
const record = JSON.parse(raw);
|
|
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
|
const currentLogin = String(getCurrentLogin() || '').trim();
|
|
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
|
|
.map(item => {
|
|
const isUpdate = item.kind === 'update-reply' || item.kind === 'update-reply-read';
|
|
const isReview = item.kind === 'pull-review';
|
|
const isClosure = item.kind === 'issue-close';
|
|
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
|
|
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
|
|
const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment =>
|
|
String(comment.path || 'File') + ' · ' +
|
|
(comment.new_position ? 'new line ' + comment.new_position : 'old line ' + comment.old_position) +
|
|
': ' + String(comment.body || '')
|
|
).filter(Boolean) : [];
|
|
const copyText = [item.body, inlineFeedback.length ? 'Inline feedback\n' + inlineFeedback.join('\n') : '']
|
|
.filter(Boolean).join('\n\n');
|
|
const ownerLogin = String(item.ownerLogin || '').trim();
|
|
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
|
const checklistConflict = !quarantined && item.kind === 'issue-content' &&
|
|
item.status === 'attention' && typeof item.baseBody === 'string';
|
|
return {
|
|
id: 'stackchain.authored-outbox.v1:' + item.id,
|
|
outbox_id: item.id,
|
|
outbox_kind: item.kind,
|
|
kind: 'authored-outbox',
|
|
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
|
|
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
|
|
label: checklistConflict ? 'Checklist conflict' : item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
|
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
|
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
|
|
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
|
authorization_required: isClosure || (isReview && item.status === 'authorization'),
|
|
delivery_state: item.deliveryState,
|
|
repository: isUpdate ? '' : item.repository,
|
|
title: target,
|
|
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
|
copy_text: copyText,
|
|
checklist_conflict: checklistConflict,
|
|
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
|
|
quarantined,
|
|
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
|
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
|
last_attempt_at: Number(item.lastAttemptAt || 0),
|
|
last_attempt_error: textPreview(item.lastAttemptError),
|
|
updated_at: Number(item.queuedAt || 0),
|
|
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
|
|
{ kind:routeKind, repository:item.repository, number:item.number },
|
|
};
|
|
});
|
|
} catch (_error) { return []; }
|
|
}
|
|
|
|
function list() {
|
|
const index = readIndex();
|
|
const seen = new Set();
|
|
const drafts = [];
|
|
keys().forEach(key => {
|
|
if (key === indexKey || key.endsWith(':operation')) return;
|
|
let raw;
|
|
try { raw = storage.getItem(key); }
|
|
catch (_error) { return; }
|
|
if (key === 'stackchain.issue-outbox.v1') {
|
|
parseOutbox(raw).forEach(item => drafts.push(item));
|
|
seen.add(key);
|
|
return;
|
|
}
|
|
if (key === 'stackchain.authored-outbox.v1') {
|
|
parseAuthoredOutbox(raw).forEach(item => drafts.push(item));
|
|
seen.add(key);
|
|
return;
|
|
}
|
|
const parsed = parse(key, raw);
|
|
if (!parsed) return;
|
|
seen.add(key);
|
|
const fingerprint = String(raw);
|
|
const previous = index[key];
|
|
if (!previous || previous.fingerprint !== fingerprint) {
|
|
index[key] = { fingerprint, updated_at: Number(now()) };
|
|
}
|
|
drafts.push({ id: key, ...parsed, updated_at: index[key].updated_at });
|
|
});
|
|
Object.keys(index).forEach(key => { if (!seen.has(key)) delete index[key]; });
|
|
writeIndex(index);
|
|
return drafts.sort((left, right) =>
|
|
Number(right.updated_at || 0) - Number(left.updated_at || 0) || left.id.localeCompare(right.id)
|
|
);
|
|
}
|
|
|
|
function discard(id) {
|
|
if (typeof id !== 'string' || !parse(id, (() => {
|
|
try { return storage?.getItem(id); } catch (_error) { return null; }
|
|
})())) return false;
|
|
try {
|
|
storage?.removeItem(id);
|
|
storage?.removeItem(id + ':operation');
|
|
const index = readIndex();
|
|
delete index[id];
|
|
writeIndex(index);
|
|
return true;
|
|
} catch (_error) { return false; }
|
|
}
|
|
|
|
function partition(items = list()) {
|
|
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
|
|
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
|
|
const counts = deliveries.reduce((summary, item) => {
|
|
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
|
|
(item.status === 'authorization' ? 'authorization' : 'waiting'));
|
|
summary[state] += 1;
|
|
return summary;
|
|
}, { waiting:0, sending:0, attention:0, authorization:0 });
|
|
const retryable = deliveries.filter(item =>
|
|
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
|
|
);
|
|
return { drafts, deliveries, counts, retryable };
|
|
}
|
|
|
|
function deliveryLabel(item) {
|
|
if (item.quarantined) return 'Identity protected';
|
|
if (item.status === 'completion') return 'Created · ready to start';
|
|
if (item.status === 'attention') return 'Needs attention';
|
|
if (item.status === 'sending') return 'Sending';
|
|
if (item.status === 'authorization') return 'Awaiting authorization';
|
|
return 'Queued for sync';
|
|
}
|
|
|
|
return { list, discard, partition, deliveryLabel };
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
|