Compare commits
No commits in common. "8df41e2fe72b67dbfa1453c678aaf5fae3a34fee" and "7110b11c15f6b1a6d0c693824a6f16f9bdc71670" have entirely different histories.
8df41e2fe7
...
7110b11c15
|
|
@ -166,7 +166,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
|
saveDraft, loadDraft, loadLabels, loadMilestones, submit,
|
||||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,26 +98,6 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOutbox(raw) {
|
|
||||||
try {
|
|
||||||
const record = JSON.parse(raw);
|
|
||||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
|
||||||
return record.items.filter(item =>
|
|
||||||
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
|
|
||||||
).map(item => ({
|
|
||||||
id: 'stackchain.issue-outbox.v1:' + item.id,
|
|
||||||
outbox_id: item.id,
|
|
||||||
kind: 'issue-outbox',
|
|
||||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
|
||||||
label: item.status === 'attention' ? 'Needs attention' : 'Queued issue',
|
|
||||||
repository: item.repository,
|
|
||||||
title: textPreview(item.title) || 'Untitled queued issue',
|
|
||||||
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
|
|
||||||
updated_at: Number(item.queuedAt || 0),
|
|
||||||
}));
|
|
||||||
} catch (_error) { return []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function list() {
|
function list() {
|
||||||
const index = readIndex();
|
const index = readIndex();
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
|
@ -127,11 +107,6 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||||
let raw;
|
let raw;
|
||||||
try { raw = storage.getItem(key); }
|
try { raw = storage.getItem(key); }
|
||||||
catch (_error) { return; }
|
catch (_error) { return; }
|
||||||
if (key === 'stackchain.issue-outbox.v1') {
|
|
||||||
parseOutbox(raw).forEach(item => drafts.push(item));
|
|
||||||
seen.add(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parsed = parse(key, raw);
|
const parsed = parse(key, raw);
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
|
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
|
||||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
.draft-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
.draft-actions button { min-height:44px; width:100%; }
|
.draft-actions button { min-height:44px; width:100%; }
|
||||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||||
|
|
@ -718,7 +718,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<script src="static/search-preview.js"></script>
|
<script src="static/search-preview.js"></script>
|
||||||
<script src="static/widgets.js"></script>
|
<script src="static/widgets.js"></script>
|
||||||
<script src="static/drafts.js"></script>
|
<script src="static/drafts.js"></script>
|
||||||
<script src="static/issue-outbox.js"></script>
|
|
||||||
<script src="static/offline-work.js"></script>
|
<script src="static/offline-work.js"></script>
|
||||||
<script src="static/my-work.js"></script>
|
<script src="static/my-work.js"></script>
|
||||||
<script src="static/pick-work.js"></script>
|
<script src="static/pick-work.js"></script>
|
||||||
|
|
@ -799,16 +798,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
let bulkConfirmationPending = false;
|
let bulkConfirmationPending = false;
|
||||||
let bulkMarkPending = false;
|
let bulkMarkPending = false;
|
||||||
let reviewHandoffPending = false;
|
let reviewHandoffPending = false;
|
||||||
let editingOutboxId = null;
|
|
||||||
|
|
||||||
async function fetchReviewJson(url, options) {
|
async function fetchReviewJson(url, options) {
|
||||||
const response = await fetch(url, options);
|
const response = await fetch(url, options);
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error(payload.error || 'Review request failed.');
|
||||||
const error = new Error(payload.error || payload.detail || 'Review request failed.');
|
|
||||||
error.status = response.status;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -829,7 +823,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
loadMilestones: item => issueController.loadMilestones(item),
|
loadMilestones: item => issueController.loadMilestones(item),
|
||||||
});
|
});
|
||||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
|
|
||||||
const shareParams = new URLSearchParams(location.search);
|
const shareParams = new URLSearchParams(location.search);
|
||||||
const sharedLaunch = {
|
const sharedLaunch = {
|
||||||
title: shareParams.get('title') || '',
|
title: shareParams.get('title') || '',
|
||||||
|
|
@ -1250,22 +1243,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
|
|
||||||
function renderDrafts() {
|
function renderDrafts() {
|
||||||
const list = qs('#my-work-list');
|
const list = qs('#my-work-list');
|
||||||
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
|
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) =>
|
||||||
const outboxActions = item.kind === 'issue-outbox' ?
|
'<article class="my-work-card draft-card">' +
|
||||||
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
|
|
||||||
'<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' +
|
|
||||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
|
||||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' +
|
|
||||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
|
||||||
const state = item.kind === 'issue-outbox' ?
|
|
||||||
'<span class="pill">' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '</span>' : '';
|
|
||||||
return '<article class="my-work-card draft-card">' +
|
|
||||||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||||
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state +
|
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' +
|
||||||
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
|
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
|
||||||
'<div class="draft-actions">' + outboxActions + '</div></article>';
|
'<div class="draft-actions"><button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' +
|
||||||
}).join('') : '<div class="muted">No unfinished drafts.</div>';
|
'<button class="draft-discard" data-draft-id="' + escAttr(item.id) + '" type="button">Discard draft</button></div></article>'
|
||||||
|
).join('') : '<div class="muted">No unfinished drafts.</div>';
|
||||||
list.querySelectorAll('.draft-resume').forEach(button => {
|
list.querySelectorAll('.draft-resume').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
|
|
@ -1274,31 +1260,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
else if (item.route) workRoute.open(item.route);
|
else if (item.route) workRoute.open(item.route);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
list.querySelectorAll('.draft-edit').forEach(button => {
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
|
||||||
const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id);
|
|
||||||
if (!queued) return;
|
|
||||||
editingOutboxId = queued.id;
|
|
||||||
issueCapture.saveDraft(queued);
|
|
||||||
openCreateIssueSheet();
|
|
||||||
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
list.querySelectorAll('.draft-send').forEach(button => {
|
|
||||||
button.addEventListener('click', async () => {
|
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
|
||||||
if (!item?.outbox_id) return;
|
|
||||||
button.disabled = true;
|
|
||||||
applyOutboxResult(await issueOutbox.retry(item.outbox_id));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
list.querySelectorAll('.draft-discard').forEach(button => {
|
list.querySelectorAll('.draft-discard').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
if (!window.confirm('Discard this unfinished draft?')) return;
|
if (!window.confirm('Discard this unfinished draft?')) return;
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
draftInbox.discard(button.dataset.draftId);
|
||||||
if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
|
||||||
else if (item) draftInbox.discard(item.id);
|
|
||||||
lastDrafts = draftInbox.list();
|
lastDrafts = draftInbox.list();
|
||||||
const count = qs('[data-work-count="draft"]');
|
const count = qs('[data-work-count="draft"]');
|
||||||
if (count) count.textContent = lastDrafts.length;
|
if (count) count.textContent = lastDrafts.length;
|
||||||
|
|
@ -1944,32 +1909,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#new-issue').focus();
|
qs('#new-issue').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyOutboxResult(result, openCreated = false) {
|
|
||||||
(result.confirmed || []).forEach(confirmed => {
|
|
||||||
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
|
||||||
});
|
|
||||||
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
|
|
||||||
refreshMyWorkView();
|
|
||||||
if (result.confirmed?.length) {
|
|
||||||
const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', ');
|
|
||||||
qs('#my-work-action-status').textContent = keys + ' created and assigned to you.';
|
|
||||||
const confirmed = result.confirmed[result.confirmed.length - 1];
|
|
||||||
const created = lastMyWork.find(item =>
|
|
||||||
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
|
|
||||||
);
|
|
||||||
if (openCreated && created) openRoutedWork(created, qs('#new-issue'));
|
|
||||||
} else if ((result.remaining || []).some(item => item.status === 'attention')) {
|
|
||||||
qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.';
|
|
||||||
} else {
|
|
||||||
qs('#my-work-action-status').textContent = 'Queued for sync when the connection returns.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flushIssueOutbox() {
|
|
||||||
if (!navigator.onLine || !issueOutbox.list().length) return;
|
|
||||||
applyOutboxResult(await issueOutbox.flush());
|
|
||||||
}
|
|
||||||
|
|
||||||
function inlineAnchor(target) {
|
function inlineAnchor(target) {
|
||||||
return {
|
return {
|
||||||
path: target.dataset.reviewFilename,
|
path: target.dataset.reviewFilename,
|
||||||
|
|
@ -2552,10 +2491,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#create-issue-title').focus();
|
qs('#create-issue-title').focus();
|
||||||
});
|
});
|
||||||
qs('#cancel-new-issue').addEventListener('click', () => {
|
qs('#cancel-new-issue').addEventListener('click', () => {
|
||||||
if (editingOutboxId) {
|
saveIssueCaptureDraft();
|
||||||
issueCapture.clearDraft();
|
|
||||||
editingOutboxId = null;
|
|
||||||
} else saveIssueCaptureDraft();
|
|
||||||
closeCreateIssueSheet();
|
closeCreateIssueSheet();
|
||||||
});
|
});
|
||||||
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
|
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
|
||||||
|
|
@ -2585,16 +2521,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
const button = qs('#submit-new-issue');
|
const button = qs('#submit-new-issue');
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
qs('#create-issue-status').textContent = navigator.onLine ? 'Sending issue…' : 'Saving issue to outbox…';
|
qs('#create-issue-status').textContent = 'Creating issue…';
|
||||||
try {
|
try {
|
||||||
const queued = editingOutboxId ? issueOutbox.update(editingOutboxId, captureDraft) :
|
const confirmed = await issueCapture.submit(captureDraft);
|
||||||
issueOutbox.enqueue(captureDraft);
|
lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
||||||
editingOutboxId = null;
|
lastMyWork = buildMyWork(lastContextSnapshot);
|
||||||
issueCapture.clearDraft();
|
const created = lastMyWork.find(item =>
|
||||||
|
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
|
||||||
|
);
|
||||||
closeCreateIssueSheet();
|
closeCreateIssueSheet();
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
qs('#my-work-action-status').textContent = 'Queued for sync.';
|
qs('#my-work-action-status').textContent = created.key + ' created and assigned to you.';
|
||||||
if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id), true);
|
openRoutedWork(created, qs('#new-issue'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
|
|
@ -3151,7 +3089,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
if (!navigator.onLine) showOfflineStatus();
|
if (!navigator.onLine) showOfflineStatus();
|
||||||
window.addEventListener('offline', showOfflineStatus);
|
window.addEventListener('offline', showOfflineStatus);
|
||||||
window.addEventListener('online', reconnectLiveData);
|
window.addEventListener('online', reconnectLiveData);
|
||||||
window.addEventListener('online', flushIssueOutbox);
|
|
||||||
|
|
||||||
qs('#refresh').addEventListener('click', load);
|
qs('#refresh').addEventListener('click', load);
|
||||||
qs('#start-work-session').addEventListener('click', () => {
|
qs('#start-work-session').addEventListener('click', () => {
|
||||||
|
|
@ -3236,7 +3173,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
contextPoller.start();
|
contextPoller.start();
|
||||||
flushIssueOutbox();
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
contextPoller.setVisible(!document.hidden);
|
contextPoller.setVisible(!document.hidden);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
function createIssueOutbox({ storage, fetchJson, 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 read() {
|
|
||||||
try {
|
|
||||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
|
||||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
|
||||||
return record.items.filter(item => item && typeof item === 'object');
|
|
||||||
} catch (_error) { return []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function write(items) {
|
|
||||||
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function enqueue(draft) {
|
|
||||||
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) : [],
|
|
||||||
status: 'queued',
|
|
||||||
queuedAt: Number(now()),
|
|
||||||
};
|
|
||||||
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);
|
|
||||||
items.push(item);
|
|
||||||
write(items);
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
function update(id, draft) {
|
|
||||||
let updated = null;
|
|
||||||
write(read().map(item => {
|
|
||||||
if (item.id !== id) return item;
|
|
||||||
updated = {
|
|
||||||
...item,
|
|
||||||
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) : [],
|
|
||||||
status: 'queued',
|
|
||||||
};
|
|
||||||
delete updated.error;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendItem(item) {
|
|
||||||
if (pending.has(item.id)) return pending.get(item.id);
|
|
||||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
|
||||||
const request = (async () => {
|
|
||||||
try {
|
|
||||||
const 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' } : {}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
discard(item.id);
|
|
||||||
return { issue };
|
|
||||||
} catch (error) {
|
|
||||||
const status = Number(error?.status || 0);
|
|
||||||
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),
|
|
||||||
} : 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 flush() {
|
|
||||||
const confirmed = [];
|
|
||||||
for (const item of read()) {
|
|
||||||
if (item.status === 'attention') continue;
|
|
||||||
const result = await sendItem(item);
|
|
||||||
if (result.issue) confirmed.push(result.issue);
|
|
||||||
if (result.transient) break;
|
|
||||||
}
|
|
||||||
return { confirmed, remaining: read() };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function retry(id) {
|
|
||||||
const item = read().find(candidate => candidate.id === id);
|
|
||||||
if (!item) return { confirmed: [], remaining: read() };
|
|
||||||
update(id, item);
|
|
||||||
const result = await sendItem({ ...item, status: 'queued' });
|
|
||||||
return { confirmed: result.issue ? [result.issue] : [], remaining: read() };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const CACHE = 'stackchain-dashboard-shell-v5';
|
const CACHE = 'stackchain-dashboard-shell-v4';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
|
|
@ -11,7 +11,6 @@ const SHELL = [
|
||||||
BASE + 'static/search-preview.js',
|
BASE + 'static/search-preview.js',
|
||||||
BASE + 'static/widgets.js',
|
BASE + 'static/widgets.js',
|
||||||
BASE + 'static/drafts.js',
|
BASE + 'static/drafts.js',
|
||||||
BASE + 'static/issue-outbox.js',
|
|
||||||
BASE + 'static/offline-work.js',
|
BASE + 'static/offline-work.js',
|
||||||
BASE + 'static/my-work.js',
|
BASE + 'static/my-work.js',
|
||||||
BASE + 'static/pick-work.js',
|
BASE + 'static/pick-work.js',
|
||||||
|
|
|
||||||
|
|
@ -102,25 +102,6 @@ process.stdout.write(JSON.stringify(drafts));
|
||||||
assert output[0]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 18}
|
assert output[0]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 18}
|
||||||
|
|
||||||
|
|
||||||
def test_draft_inbox_expands_issue_outbox_items_with_actionable_states():
|
|
||||||
script = f"""
|
|
||||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
|
||||||
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:1,items:[
|
|
||||||
{{id:'queued-1',repository:'stackchain/api',title:'Offline capture',body:'Context',status:'queued',queuedAt:100}},
|
|
||||||
{{id:'attention-2',repository:'stackchain/web',title:'Fix labels',body:'Details',status:'attention',error:'Unknown label',queuedAt:200}},
|
|
||||||
]}})]]);
|
|
||||||
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
||||||
const drafts = createDraftInbox({{storage,now:()=>300}}).list();
|
|
||||||
process.stdout.write(JSON.stringify(drafts));
|
|
||||||
"""
|
|
||||||
output = run_node(script)
|
|
||||||
|
|
||||||
assert [item["outbox_id"] for item in output] == ["attention-2", "queued-1"]
|
|
||||||
assert [item["status"] for item in output] == ["attention", "queued"]
|
|
||||||
assert output[0]["label"] == "Needs attention"
|
|
||||||
assert output[1]["label"] == "Queued issue"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.views import dashboard
|
|
||||||
|
|
||||||
|
|
||||||
OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.js"
|
|
||||||
|
|
||||||
|
|
||||||
def run_node(script: str):
|
|
||||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
||||||
return json.loads(result.stdout)
|
|
||||||
|
|
||||||
|
|
||||||
def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids():
|
|
||||||
script = f"""
|
|
||||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
|
||||||
const values = new Map();
|
|
||||||
const storage = {{
|
|
||||||
getItem:key => values.has(key) ? values.get(key) : null,
|
|
||||||
setItem:(key,value) => values.set(key,value),
|
|
||||||
removeItem:key => values.delete(key),
|
|
||||||
}};
|
|
||||||
let sequence = 0;
|
|
||||||
const outbox = createIssueOutbox({{storage, createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}});
|
|
||||||
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One',labelIds:[3]}});
|
|
||||||
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two',milestoneId:4,dueDate:'2026-08-09'}});
|
|
||||||
const reloaded = createIssueOutbox({{storage}}).list();
|
|
||||||
process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.get('stackchain.issue-outbox.v1'))}}));
|
|
||||||
"""
|
|
||||||
output = run_node(script)
|
|
||||||
|
|
||||||
assert [(item["repository"], item["title"]) for item in output["items"]] == [
|
|
||||||
("stackchain/api", "First"),
|
|
||||||
("stackchain/web", "Second"),
|
|
||||||
]
|
|
||||||
assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
|
|
||||||
assert all(item["status"] == "queued" for item in output["items"])
|
|
||||||
assert output["stored"]["version"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
|
|
||||||
script = f"""
|
|
||||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
|
||||||
const values = new Map();
|
|
||||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
||||||
let sequence = 0;
|
|
||||||
const calls = [];
|
|
||||||
const outbox = createIssueOutbox({{
|
|
||||||
storage,
|
|
||||||
createOperationId:() => 'stable-' + (++sequence),
|
|
||||||
fetchJson:async (url, options) => {{
|
|
||||||
calls.push({{url, key:options.headers['Idempotency-Key']}});
|
|
||||||
if (calls.length === 2) {{ const error = new Error('upstream unavailable'); error.status = 503; throw error; }}
|
|
||||||
return {{repository:'stackchain/api',number:41,title:'First'}};
|
|
||||||
}},
|
|
||||||
}});
|
|
||||||
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One'}});
|
|
||||||
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two'}});
|
|
||||||
outbox.flush().then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}})));
|
|
||||||
"""
|
|
||||||
output = run_node(script)
|
|
||||||
|
|
||||||
assert output["result"]["confirmed"][0]["number"] == 41
|
|
||||||
assert output["calls"] == [
|
|
||||||
{"url": "api/v1/repos/stackchain/api/issues", "key": "stable-1"},
|
|
||||||
{"url": "api/v1/repos/stackchain/web/issues", "key": "stable-2"},
|
|
||||||
]
|
|
||||||
assert len(output["remaining"]) == 1
|
|
||||||
assert output["remaining"][0]["title"] == "Second"
|
|
||||||
assert output["remaining"][0]["status"] == "queued"
|
|
||||||
|
|
||||||
|
|
||||||
def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry():
|
|
||||||
script = f"""
|
|
||||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
|
||||||
const values = new Map();
|
|
||||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
||||||
let invalid = true;
|
|
||||||
const calls = [];
|
|
||||||
const outbox = createIssueOutbox({{
|
|
||||||
storage, createOperationId:() => 'stable-edit',
|
|
||||||
fetchJson:async (_url, options) => {{
|
|
||||||
calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
|
||||||
if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }}
|
|
||||||
return {{repository:'stackchain/api',number:42,title:'Fixed title'}};
|
|
||||||
}},
|
|
||||||
}});
|
|
||||||
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bad',body:'Context'}});
|
|
||||||
outbox.flush().then(async () => {{
|
|
||||||
const attention = outbox.list()[0];
|
|
||||||
outbox.update(queued.id, {{...attention,title:'Fixed title'}});
|
|
||||||
invalid = false;
|
|
||||||
const result = await outbox.retry(queued.id);
|
|
||||||
process.stdout.write(JSON.stringify({{attention,result,calls,remaining:outbox.list()}}));
|
|
||||||
}});
|
|
||||||
"""
|
|
||||||
output = run_node(script)
|
|
||||||
|
|
||||||
assert output["attention"]["status"] == "attention"
|
|
||||||
assert output["attention"]["error"] == "Title is invalid"
|
|
||||||
assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"]
|
|
||||||
assert output["calls"][1]["body"]["title"] == "Fixed title"
|
|
||||||
assert output["result"]["confirmed"][0]["number"] == 42
|
|
||||||
assert output["remaining"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap():
|
|
||||||
script = f"""
|
|
||||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
|
||||||
const values = new Map();
|
|
||||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
||||||
let calls = 0;
|
|
||||||
let release;
|
|
||||||
const gate = new Promise(resolve => {{ release = resolve; }});
|
|
||||||
const outbox = createIssueOutbox({{
|
|
||||||
storage, createOperationId:() => 'single-flight',
|
|
||||||
fetchJson:async () => {{ calls += 1; await gate; return {{repository:'stackchain/api',number:43,title:'Once'}}; }},
|
|
||||||
}});
|
|
||||||
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Once',body:'Context'}});
|
|
||||||
const reconnect = outbox.flush();
|
|
||||||
const sendNow = outbox.retry(queued.id);
|
|
||||||
release();
|
|
||||||
Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.stringify({{calls,results,remaining:outbox.list()}})));
|
|
||||||
"""
|
|
||||||
output = run_node(script)
|
|
||||||
|
|
||||||
assert output["calls"] == 1
|
|
||||||
assert output["remaining"] == []
|
|
||||||
assert sum(len(result["confirmed"]) for result in output["results"]) >= 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
|
|
||||||
html = await dashboard()
|
|
||||||
|
|
||||||
assert '<script src="static/issue-outbox.js"></script>' in html
|
|
||||||
assert "createIssueOutbox({ storage: localStorage" in html
|
|
||||||
assert "issueOutbox.enqueue(captureDraft)" in html
|
|
||||||
assert "navigator.onLine" in html
|
|
||||||
assert "window.addEventListener('online', flushIssueOutbox)" in html
|
|
||||||
assert 'class="draft-send"' in html
|
|
||||||
assert 'class="draft-edit"' in html
|
|
||||||
assert 'Queued for sync' in html
|
|
||||||
assert 'Needs attention' in html
|
|
||||||
assert "issueOutbox.discard(item.outbox_id)" in html
|
|
||||||
assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html
|
|
||||||
|
|
@ -83,7 +83,6 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/search-preview.js",
|
"/dashboard/static/search-preview.js",
|
||||||
"/dashboard/static/widgets.js",
|
"/dashboard/static/widgets.js",
|
||||||
"/dashboard/static/drafts.js",
|
"/dashboard/static/drafts.js",
|
||||||
"/dashboard/static/issue-outbox.js",
|
|
||||||
"/dashboard/static/offline-work.js",
|
"/dashboard/static/offline-work.js",
|
||||||
"/dashboard/static/my-work.js",
|
"/dashboard/static/my-work.js",
|
||||||
"/dashboard/static/pick-work.js",
|
"/dashboard/static/pick-work.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user