Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51ac802d77 | ||
|
|
e0bc7271fa | ||
|
|
ddd87e9051 | ||
|
|
8b71de70bd | ||
|
|
2bd8426db8 | ||
|
|
9289434ad2 | ||
|
|
a73b3bc773 | ||
|
|
d155f0081f |
No files matched your search
@@ -97,9 +97,13 @@ API responses and mutations are never cached by the service worker. New issue ca
|
||||
issue comments, pull-request comments, and unread-update replies use bounded local
|
||||
outboxes when connectivity or a retryable server failure prevents delivery. Drafts
|
||||
shows queued and needs-attention messages with explicit send/discard controls; reconnect
|
||||
flushes messages sequentially with their original idempotency keys. State-sensitive
|
||||
actions such as reviews, merges, closures, labels, milestones, and assignments are never
|
||||
queued. Service-worker upgrades are atomic and remove only older
|
||||
flushes messages sequentially with their original idempotency keys. Tabs coordinate each
|
||||
queue through the browser lock manager (with an expiring local lease fallback), so an
|
||||
installed PWA and a browser tab cannot submit the same operation concurrently. Queue
|
||||
changes are broadcast to other tabs and refresh Drafts without polling; an abandoned
|
||||
lease can be reclaimed after expiry. State-sensitive actions such as reviews, merges,
|
||||
closures, labels, milestones, and assignments are never queued. Service-worker upgrades
|
||||
are atomic and remove only older
|
||||
`stackchain-dashboard-*` caches, preserving unrelated caches on the same origin.
|
||||
|
||||
Run the test suite with:
|
||||
|
||||
+32
-10
@@ -1,4 +1,4 @@
|
||||
function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
||||
function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
||||
const storageKey = 'stackchain.authored-outbox.v1';
|
||||
const makeId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
@@ -9,17 +9,20 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && supportedKinds.has(item.kind));
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
coordinator?.notify('authored');
|
||||
}
|
||||
|
||||
function enqueue(message) {
|
||||
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
|
||||
const items = read();
|
||||
const requestedOperationId = String(message.operationId || '').slice(0, 128);
|
||||
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
|
||||
@@ -34,6 +37,7 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
|
||||
number: Number(message.number || 0),
|
||||
notificationId: Number(message.notificationId || 0),
|
||||
body: String(message.body || ''),
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
@@ -75,7 +79,8 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
|
||||
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
|
||||
}
|
||||
|
||||
async function sendItem(item) {
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const request = (async () => {
|
||||
try {
|
||||
@@ -108,23 +113,40 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
|
||||
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
const outcome = await sendItem(item);
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const outcome = await sendItem(item, currentLogin);
|
||||
if (outcome.result) confirmed.push(outcome.result);
|
||||
if (outcome.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read() };
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function retry(id) {
|
||||
async function flush(currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('authored', () => 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 };
|
||||
}
|
||||
const queued = item.status === 'attention' ? update(id, item) : item;
|
||||
const outcome = await sendItem(queued);
|
||||
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read() };
|
||||
const outcome = await sendItem(queued, currentLogin);
|
||||
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 };
|
||||
}
|
||||
|
||||
async function retry(id, currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('authored', () => retryItem(id, currentLogin));
|
||||
return retryItem(id, currentLogin);
|
||||
}
|
||||
|
||||
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
|
||||
|
||||
+21
-5
@@ -1,4 +1,4 @@
|
||||
function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||
function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Date.now() }) {
|
||||
const indexKey = 'stackchain.draft-index.v1';
|
||||
|
||||
function readIndex() {
|
||||
@@ -101,10 +101,14 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||
function parseOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
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.repository === 'string' && typeof item.title === 'string'
|
||||
).map(item => ({
|
||||
).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',
|
||||
@@ -113,20 +117,28 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||
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') : '',
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
}));
|
||||
};
|
||||
});
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function parseAuthoredOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
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';
|
||||
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
return {
|
||||
id: 'stackchain.authored-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
@@ -136,6 +148,10 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: item.body,
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
|
||||
{ kind:routeKind, repository:item.repository, number:item.number },
|
||||
|
||||
+90
-23
@@ -113,6 +113,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.review-handoff button { min-height:44px; }
|
||||
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
|
||||
.review-handoff-link[hidden] { display:none; }
|
||||
.review-merge-continuation { position:sticky; bottom:0; z-index:5; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); }
|
||||
#continue-review-to-merge { min-height:44px; width:100%; }
|
||||
#continue-review-to-merge[hidden] { display:none; }
|
||||
.review-copy-fallback { min-height:140px; }
|
||||
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
|
||||
.review-progress-actions button { min-height:44px; max-width:100%; }
|
||||
@@ -304,6 +307,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
</div>
|
||||
<div class="work-filters" aria-label="Filter My Work">
|
||||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
||||
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||
@@ -707,6 +711,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<textarea class="review-copy-fallback" id="review-copy-fallback" readonly hidden aria-label="Feedback to copy manually"></textarea>
|
||||
<a class="review-handoff-link" id="review-handoff-link" target="_blank" rel="noopener noreferrer" hidden>Continue to Gitea</a>
|
||||
</div>
|
||||
<div class="review-merge-continuation">
|
||||
<button id="continue-review-to-merge" type="button" hidden>Continue to merge</button>
|
||||
</div>
|
||||
</section>
|
||||
<h2>Review history</h2>
|
||||
<div id="review-history" class="muted"></div>
|
||||
@@ -719,7 +726,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
</div>
|
||||
|
||||
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page">Work</button>
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page">Work <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
|
||||
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
||||
@@ -733,6 +740,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/outbox-coordinator.js"></script>
|
||||
<script src="static/issue-outbox.js"></script>
|
||||
<script src="static/authored-outbox.js"></script>
|
||||
<script src="static/offline-work.js"></script>
|
||||
@@ -775,12 +783,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
Array.from(document.querySelectorAll('[data-mobile-task]')).map(button => [button.dataset.mobileTask, button])
|
||||
);
|
||||
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
|
||||
function openMobileWork() {
|
||||
const counts = countMyWork(lastMyWork);
|
||||
const filter = counts.attention ? 'attention' : 'all';
|
||||
qs('[data-work-filter="' + filter + '"]').click();
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
const mobileTaskDock = createMobileTaskDock({
|
||||
nav: qs('#mobile-task-dock'),
|
||||
buttons: mobileTaskButtons,
|
||||
attentionBadge: qs('#mobile-attention-count'),
|
||||
overlays: mobileTaskOverlays,
|
||||
actions: {
|
||||
work: () => qs('#my-work').scrollIntoView({block:'start'}) || qs('#my-work').focus(),
|
||||
work: openMobileWork,
|
||||
find: () => qs('#find-work').click(),
|
||||
new: () => qs('#new-issue').click(),
|
||||
search: () => qs('#open-palette').click(),
|
||||
@@ -809,7 +825,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
let selectedWorkMilestone = 'all';
|
||||
try {
|
||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||
if (['all', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||
if (['all', 'attention', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
||||
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
||||
} catch (e) {
|
||||
@@ -847,6 +863,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
let bulkMarkPending = false;
|
||||
let reviewHandoffPending = false;
|
||||
let editingOutboxId = null;
|
||||
let confirmedOwnerLogin = '';
|
||||
let activeFlushLogin = '';
|
||||
|
||||
async function fetchReviewJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
@@ -876,8 +894,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
});
|
||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
|
||||
const authoredOutbox = createAuthoredOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
|
||||
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
|
||||
const issueOutbox = createIssueOutbox({
|
||||
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
||||
getOwnerLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
const authoredOutbox = createAuthoredOutbox({
|
||||
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
||||
getOwnerLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
const shareParams = new URLSearchParams(location.search);
|
||||
const sharedLaunch = {
|
||||
title: shareParams.get('title') || '',
|
||||
@@ -887,7 +912,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ?
|
||||
issueCapture.stageSharedContent(sharedLaunch) : null;
|
||||
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const draftInbox = createDraftInbox({ storage: localStorage });
|
||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
|
||||
const findWorkController = createFindWork({
|
||||
fetchJson: fetchReviewJson,
|
||||
@@ -1242,6 +1268,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
function handleContextError(e) {
|
||||
console.error('context failed', e);
|
||||
liveMode = false;
|
||||
activeFlushLogin = '';
|
||||
if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;
|
||||
setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable');
|
||||
if (!hasContextSnapshot) {
|
||||
@@ -1267,6 +1294,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
const element = qs('[data-work-count="' + filter + '"]');
|
||||
if (element) element.textContent = count;
|
||||
});
|
||||
mobileTaskDock.updateAttention(counts.attention);
|
||||
const milestoneSelect = qs('#work-milestone-filter');
|
||||
const lanes = milestoneLanes(lastMyWork);
|
||||
milestoneSelect.innerHTML = '<option value="all">All milestones</option>' +
|
||||
@@ -1290,6 +1318,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
|
||||
function activeWorkStreams() {
|
||||
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
|
||||
if (selectedWorkFilter === 'issue') return ['issue'];
|
||||
if (selectedWorkFilter === 'pull') return ['pull'];
|
||||
if (selectedWorkFilter === 'review') return ['review'];
|
||||
@@ -1301,7 +1330,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
const list = qs('#my-work-list');
|
||||
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
|
||||
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||
const outboxActions = item.kind === 'issue-outbox' ?
|
||||
const outboxActions = item.quarantined ?
|
||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
item.kind === 'issue-outbox' ?
|
||||
'<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>' :
|
||||
@@ -1312,7 +1344,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
'<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 = isOutbox ?
|
||||
'<span class="pill">' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '</span>' : '';
|
||||
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
||||
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
||||
return '<article class="my-work-card draft-card">' +
|
||||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||
@@ -1344,8 +1378,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item?.outbox_id) return;
|
||||
button.disabled = true;
|
||||
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id));
|
||||
else applyOutboxResult(await issueOutbox.retry(item.outbox_id));
|
||||
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id, activeFlushLogin));
|
||||
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-copy').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item?.copy_text) return;
|
||||
await navigator.clipboard.writeText(item.copy_text);
|
||||
qs('#my-work-action-status').textContent = 'Queued content copied without sending it.';
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-discard').forEach(button => {
|
||||
@@ -1419,7 +1461,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + '</article>';
|
||||
}).join('') : '<div class="muted">' + (incomplete ?
|
||||
'More work is available. Load the next page.' :
|
||||
'No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.') + '</div>';
|
||||
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')))) + '.') + '</div>';
|
||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
||||
});
|
||||
@@ -2001,6 +2043,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
|
||||
function applyOutboxResult(result, openCreated = false) {
|
||||
if (result.lease_skipped) { refreshMyWorkView(); return; }
|
||||
(result.confirmed || []).forEach(confirmed => {
|
||||
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
||||
});
|
||||
@@ -2022,11 +2065,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
|
||||
async function flushIssueOutbox() {
|
||||
if (!navigator.onLine || !issueOutbox.list().length) return;
|
||||
applyOutboxResult(await issueOutbox.flush());
|
||||
if (!navigator.onLine || !activeFlushLogin || !issueOutbox.list().length) return;
|
||||
applyOutboxResult(await issueOutbox.flush(activeFlushLogin));
|
||||
}
|
||||
|
||||
function applyAuthoredOutboxResult(result) {
|
||||
if (result.lease_skipped) { refreshMyWorkView(); return; }
|
||||
refreshMyWorkView();
|
||||
const attention = (result.remaining || []).some(item => item.status === 'attention');
|
||||
qs('#my-work-action-status').textContent = result.confirmed?.length ?
|
||||
@@ -2036,8 +2080,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
|
||||
async function flushAuthoredOutbox() {
|
||||
if (!navigator.onLine || !authoredOutbox.list().length) return;
|
||||
applyAuthoredOutboxResult(await authoredOutbox.flush());
|
||||
if (!navigator.onLine || !activeFlushLogin || !authoredOutbox.list().length) return;
|
||||
applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin));
|
||||
}
|
||||
|
||||
function canQueueMessage(error) {
|
||||
@@ -2099,6 +2143,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#review-handoff-link').href = item.url;
|
||||
qs('#review-handoff-status').textContent = '';
|
||||
qs('#review-submit-status').textContent = '';
|
||||
qs('#continue-review-to-merge').hidden = true;
|
||||
qs('#submit-review').disabled = true;
|
||||
closeInlineComposer();
|
||||
draft = null;
|
||||
@@ -2259,6 +2304,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
if (snapshot.context) {
|
||||
setOfflineWorkMode(false);
|
||||
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
||||
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) confirmedOwnerLogin = activeFlushLogin;
|
||||
snapshot.context.notifications = lastNotifications;
|
||||
renderContextSnapshot(snapshot.context);
|
||||
if (contextFreshness?.stale) markMyWorkStale();
|
||||
@@ -2271,6 +2320,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
});
|
||||
updateOfflineWorkControls();
|
||||
}
|
||||
flushIssueOutbox();
|
||||
flushAuthoredOutbox();
|
||||
} else handleContextError(new Error('Context section unavailable'));
|
||||
if (Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
|
||||
if (eventsFreshness?.revalidating) {
|
||||
@@ -2669,7 +2720,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
closeCreateIssueSheet();
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = 'Queued for sync.';
|
||||
if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id), true);
|
||||
if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id, activeFlushLogin), true);
|
||||
} catch (error) {
|
||||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
button.disabled = false;
|
||||
@@ -3107,12 +3158,21 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
button.disabled = true;
|
||||
qs('#review-submit-status').textContent = 'Submitting review…';
|
||||
try {
|
||||
const item = selectedReview;
|
||||
const progressSnapshot = progress?.snapshot() || { reviewed: [] };
|
||||
const result = await reviewController.submit(selectedReview, {
|
||||
decision: snapshot.decision,
|
||||
body: createReviewController.formatFeedback(snapshot, reviewFiles),
|
||||
expected_head_sha: selectedReviewHead,
|
||||
comments: snapshot.comments,
|
||||
});
|
||||
const canContinueToMerge = createReviewController.prepareMergeContinuation({
|
||||
storage: localStorage,
|
||||
item,
|
||||
headSha: selectedReviewHead,
|
||||
reviewed: progressSnapshot.reviewed,
|
||||
decision: snapshot.decision,
|
||||
});
|
||||
draft.clear();
|
||||
progress?.clear();
|
||||
qs('#review-decision').value = 'comment';
|
||||
@@ -3120,15 +3180,25 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
document.querySelectorAll('.review-note').forEach(note => { note.value = ''; });
|
||||
if (progress) showReviewProgress(progress.snapshot());
|
||||
qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.';
|
||||
await load();
|
||||
if (workSession.active()) workSession.complete();
|
||||
else button.focus();
|
||||
if (canContinueToMerge) {
|
||||
qs('#continue-review-to-merge').hidden = false;
|
||||
qs('#continue-review-to-merge').focus();
|
||||
} else {
|
||||
await load();
|
||||
if (workSession.active()) workSession.complete();
|
||||
else button.focus();
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#continue-review-to-merge').addEventListener('click', () => {
|
||||
const item = selectedReview;
|
||||
if (!item) return;
|
||||
workRoute.open({ ...item, kind:'pull', is_review:false }, { replace:true });
|
||||
});
|
||||
qs('#copy-review-feedback').addEventListener('click', async () => {
|
||||
if (!draft || !selectedReview || reviewHandoffPending) return;
|
||||
reviewHandoffPending = true;
|
||||
@@ -3222,6 +3292,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
return true;
|
||||
}
|
||||
function showOfflineStatus() {
|
||||
activeFlushLogin = '';
|
||||
offlineStatus.hidden = false;
|
||||
setStatus('Offline');
|
||||
if (!hasContextSnapshot) hydrateOfflineWork();
|
||||
@@ -3248,8 +3319,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
if (!navigator.onLine) showOfflineStatus();
|
||||
window.addEventListener('offline', showOfflineStatus);
|
||||
window.addEventListener('online', reconnectLiveData);
|
||||
window.addEventListener('online', flushIssueOutbox);
|
||||
window.addEventListener('online', flushAuthoredOutbox);
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
qs('#start-work-session').addEventListener('click', () => {
|
||||
@@ -3334,8 +3403,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
);
|
||||
}
|
||||
contextPoller.start();
|
||||
flushIssueOutbox();
|
||||
flushAuthoredOutbox();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
contextPoller.setVisible(!document.hidden);
|
||||
});
|
||||
|
||||
+32
-10
@@ -1,4 +1,4 @@
|
||||
function createIssueOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 20 }) {
|
||||
function createIssueOutbox({ storage, fetchJson, coordinator, 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)
|
||||
@@ -8,16 +8,19 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2].includes(record?.version) || !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 }));
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
coordinator?.notify('issue');
|
||||
}
|
||||
|
||||
function enqueue(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 = {
|
||||
@@ -27,6 +30,7 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
|
||||
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()),
|
||||
};
|
||||
@@ -64,7 +68,8 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendItem(item) {
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
const request = (async () => {
|
||||
@@ -98,23 +103,40 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
|
||||
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
const result = await sendItem(item);
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const result = await sendItem(item, currentLogin);
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read() };
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function retry(id) {
|
||||
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 };
|
||||
}
|
||||
update(id, item);
|
||||
const result = await sendItem({ ...item, status: 'queued' });
|
||||
return { confirmed: result.issue ? [result.issue] : [], remaining: read() };
|
||||
const result = await sendItem({ ...item, status: 'queued' }, currentLogin);
|
||||
return { confirmed: result.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);
|
||||
}
|
||||
|
||||
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
|
||||
|
||||
@@ -34,5 +34,20 @@
|
||||
refreshVisibility();
|
||||
}
|
||||
|
||||
return {start, refreshVisibility};
|
||||
function updateAttention(count) {
|
||||
const total = Math.max(0, Number(count) || 0);
|
||||
const badge = options.attentionBadge;
|
||||
if (badge) {
|
||||
badge.textContent = String(total);
|
||||
badge.hidden = total === 0;
|
||||
}
|
||||
if (buttons.work) {
|
||||
buttons.work.setAttribute(
|
||||
'aria-label',
|
||||
total ? 'Work, ' + total + ' items need attention' : 'Work'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention};
|
||||
});
|
||||
+3
-1
@@ -400,7 +400,8 @@ function createNotificationReplier({
|
||||
|
||||
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
let filtered = items;
|
||||
if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
if (selectedFilter === 'attention') filtered = items.filter((item) => item.has_update || item.is_review);
|
||||
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||
if (selectedMilestone === 'all') return filtered;
|
||||
@@ -559,6 +560,7 @@ function summarizeMyWork(items) {
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
attention: items.filter((item) => item.has_update || item.is_review).length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
function createOutboxCoordinator({
|
||||
storage,
|
||||
locks = globalThis.navigator?.locks,
|
||||
channelFactory = typeof globalThis.BroadcastChannel === 'function' ?
|
||||
() => new globalThis.BroadcastChannel('stackchain-outbox-v1') : null,
|
||||
addStorageListener = globalThis.addEventListener?.bind(globalThis),
|
||||
removeStorageListener = globalThis.removeEventListener?.bind(globalThis),
|
||||
tabId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
now = () => Date.now(),
|
||||
leaseMs = 15000,
|
||||
} = {}) {
|
||||
const changeKey = 'stackchain.outbox-change.v1';
|
||||
const subscribers = new Set();
|
||||
const channel = channelFactory ? channelFactory() : null;
|
||||
let sequence = 0;
|
||||
|
||||
function validChange(value) {
|
||||
return value && (value.queue === 'issue' || value.queue === 'authored') ? value : null;
|
||||
}
|
||||
|
||||
function publish(change) {
|
||||
subscribers.forEach(listener => listener(change));
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
channel.onmessage = event => {
|
||||
const change = validChange(event?.data);
|
||||
if (change && change.tabId !== tabId) publish(change);
|
||||
};
|
||||
}
|
||||
|
||||
const onStorage = event => {
|
||||
if (event?.key !== changeKey || !event.newValue) return;
|
||||
try {
|
||||
const change = validChange(JSON.parse(event.newValue));
|
||||
if (change && change.tabId !== tabId) publish(change);
|
||||
} catch (_error) { /* Ignore malformed cross-tab signals. */ }
|
||||
};
|
||||
if (addStorageListener) addStorageListener('storage', onStorage);
|
||||
|
||||
function notify(queue) {
|
||||
const change = { queue, tabId, changedAt: Number(now()), sequence: ++sequence };
|
||||
if (!validChange(change)) return;
|
||||
try { storage?.setItem(changeKey, JSON.stringify(change)); } catch (_error) { /* Broadcast may still work. */ }
|
||||
try { channel?.postMessage(change); } catch (_error) { /* Storage events remain available. */ }
|
||||
}
|
||||
|
||||
function skipped() {
|
||||
return { confirmed: [], remaining: [], blocked: 0, lease_skipped: true };
|
||||
}
|
||||
|
||||
async function withFallbackLease(queue, work) {
|
||||
const key = 'stackchain.outbox-lease.' + queue + '.v1';
|
||||
const token = tabId + ':' + (++sequence);
|
||||
const timestamp = Number(now());
|
||||
try {
|
||||
const current = JSON.parse(storage?.getItem(key) || 'null');
|
||||
if (current?.token && Number(current.expiresAt) > timestamp) return skipped();
|
||||
storage?.setItem(key, JSON.stringify({ token, expiresAt: timestamp + leaseMs }));
|
||||
const claimed = JSON.parse(storage?.getItem(key) || 'null');
|
||||
if (claimed?.token !== token) return skipped();
|
||||
} catch (_error) {
|
||||
return work();
|
||||
}
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
try {
|
||||
const current = JSON.parse(storage?.getItem(key) || 'null');
|
||||
if (current?.token === token) storage?.removeItem(key);
|
||||
} catch (_error) { /* An expired lease will be reclaimed. */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function runExclusive(queue, work) {
|
||||
if (locks?.request) {
|
||||
return locks.request('stackchain-outbox-' + queue + '-v1', { mode: 'exclusive', ifAvailable: true },
|
||||
lock => lock ? work() : skipped());
|
||||
}
|
||||
return withFallbackLease(queue, work);
|
||||
}
|
||||
|
||||
function subscribe(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
subscribers.add(listener);
|
||||
return () => subscribers.delete(listener);
|
||||
}
|
||||
|
||||
function close() {
|
||||
subscribers.clear();
|
||||
try { channel?.close(); } catch (_error) { /* No-op. */ }
|
||||
if (removeStorageListener) removeStorageListener('storage', onStorage);
|
||||
}
|
||||
|
||||
return { runExclusive, notify, subscribe, close };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createOutboxCoordinator;
|
||||
@@ -309,6 +309,18 @@ async function copyAndContinue({ text, url, copy, open, fallback }) {
|
||||
return { copied: true, opened: true };
|
||||
}
|
||||
|
||||
function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }) {
|
||||
const reasons = Array.isArray(item?.work_reasons) ? item.work_reasons : [];
|
||||
if (decision !== 'approve' || !reasons.includes('assigned_to_me') || !headSha) return false;
|
||||
const filenames = Array.isArray(reviewed)
|
||||
? reviewed.filter(filename => typeof filename === 'string' && filename)
|
||||
: [];
|
||||
const key = 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + headSha;
|
||||
try { storage?.setItem(key, JSON.stringify(filenames)); }
|
||||
catch (_error) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
createReviewController.renderDiffFile = renderDiffFile;
|
||||
createReviewController.parseDiffLines = parseDiffLines;
|
||||
createReviewController.toggleDiff = toggleDiff;
|
||||
@@ -316,6 +328,7 @@ createReviewController.createProgress = createProgress;
|
||||
createReviewController.createDraft = createDraft;
|
||||
createReviewController.formatFeedback = formatFeedback;
|
||||
createReviewController.copyAndContinue = copyAndContinue;
|
||||
createReviewController.prepareMergeContinuation = prepareMergeContinuation;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createReviewController;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'stackchain-dashboard-shell-v7';
|
||||
const CACHE = 'stackchain-dashboard-shell-v11';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
const SHELL = [
|
||||
@@ -11,6 +11,7 @@ const SHELL = [
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
|
||||
@@ -15,6 +15,34 @@ def run_node(script: str):
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_authored_outbox_quarantines_legacy_and_cross_account_messages():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify({{version:1,items:[
|
||||
{{id:'legacy',operationId:'legacy',kind:'issue-comment',repository:'o/r',number:1,body:'Old',status:'queued'}}
|
||||
]}})]]);
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const calls = [];
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy',
|
||||
fetchJson:async url => {{calls.push(url); return {{id:1}};}},
|
||||
}});
|
||||
const bound = outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:2,body:'Mine',operationId:'mine'}});
|
||||
(async()=>{{
|
||||
const wrong = await outbox.flush('alexander');
|
||||
const right = await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{bound,wrong,right,calls,remaining:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["bound"]["ownerLogin"] == "timmy"
|
||||
assert output["wrong"]["blocked"] == 2
|
||||
assert len(output["right"]["confirmed"]) == 1
|
||||
assert [item["id"] for item in output["remaining"]] == ["legacy"]
|
||||
assert len(output["calls"]) == 1
|
||||
|
||||
|
||||
def test_authored_outbox_persists_each_message_kind_and_flushes_sequentially():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -22,7 +50,7 @@ const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const calls = [];
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, now:() => 1234,
|
||||
storage, getOwnerLogin:()=>'timmy', now:() => 1234,
|
||||
fetchJson:async (url, options) => {{
|
||||
calls.push({{url,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
return {{id:calls.length}};
|
||||
@@ -32,7 +60,7 @@ outbox.enqueue({{kind:'issue-comment',repository:'stackchain/api',number:7,body:
|
||||
outbox.enqueue({{kind:'pull-comment',repository:'stackchain/web',number:8,body:'PR note',operationId:'pull-op'}});
|
||||
outbox.enqueue({{kind:'update-reply',notificationId:9,body:'Update note',operationId:'update-op'}});
|
||||
const persisted = createAuthoredOutbox({{storage,fetchJson:outbox.fetchJson}}).list();
|
||||
outbox.flush().then(result => process.stdout.write(JSON.stringify({{persisted,calls,result,remaining:outbox.list()}})));
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persisted,calls,result,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
@@ -58,6 +86,7 @@ let phase = 'permanent';
|
||||
const calls = [];
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage,
|
||||
getOwnerLogin:()=>'timmy',
|
||||
fetchJson:async (_url, options) => {{
|
||||
const body = JSON.parse(options.body).body; calls.push(body);
|
||||
if (body === 'Bad' && phase === 'permanent') {{ const e = new Error('Reply rejected'); e.status=422; throw e; }}
|
||||
@@ -67,10 +96,10 @@ const outbox = createAuthoredOutbox({{
|
||||
}});
|
||||
const bad=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Bad',operationId:'a'}});
|
||||
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Good',operationId:'b'}});
|
||||
outbox.flush().then(async first => {{
|
||||
outbox.flush('timmy').then(async first => {{
|
||||
phase='transient';
|
||||
outbox.enqueue({{kind:'update-reply',notificationId:3,body:'Later',operationId:'c'}});
|
||||
const second=await outbox.flush();
|
||||
const second=await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{first,second,calls,remaining:outbox.list(),badId:bad.id}}));
|
||||
}});
|
||||
"""
|
||||
@@ -91,12 +120,12 @@ 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 release; const gate=new Promise(resolve=>release=resolve); let calls=0; let sequence=0;
|
||||
const outbox=createAuthoredOutbox({{
|
||||
storage, createOperationId:()=> 'new-' + (++sequence),
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'new-' + (++sequence),
|
||||
fetchJson:async()=>{{calls++; await gate; return {{id:1}};}},
|
||||
}});
|
||||
const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Old',operationId:'old'}});
|
||||
const edited=outbox.update(item.id,{{body:'New'}});
|
||||
const flush=outbox.flush(); const retry=outbox.retry(item.id); release();
|
||||
const flush=outbox.flush('timmy'); const retry=outbox.retry(item.id, 'timmy'); release();
|
||||
Promise.all([flush,retry]).then(results=>process.stdout.write(JSON.stringify({{edited,calls,results,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
@@ -112,7 +141,7 @@ def test_authored_outbox_deduplicates_repeated_queue_attempts_by_operation_id():
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createAuthoredOutbox({{storage}});
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy'}});
|
||||
const first=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
|
||||
const second=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
|
||||
process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
|
||||
@@ -128,9 +157,10 @@ async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/authored-outbox.js"></script>' in html
|
||||
assert "createAuthoredOutbox({ storage: localStorage" in html
|
||||
assert "window.addEventListener('online', flushAuthoredOutbox)" in html
|
||||
assert "const authoredOutbox = createAuthoredOutbox({" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
||||
assert "flushAuthoredOutbox();" in html
|
||||
assert "authoredOutbox.enqueue" in html
|
||||
assert "authoredOutbox.retry(item.outbox_id)" in html
|
||||
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
|
||||
assert "authoredOutbox.discard(item.outbox_id)" in html
|
||||
assert "if (result?.queued)" in html
|
||||
@@ -121,6 +121,32 @@ process.stdout.write(JSON.stringify(drafts));
|
||||
assert output[1]["label"] == "Queued issue"
|
||||
|
||||
|
||||
def test_draft_inbox_marks_mismatched_and_legacy_outbox_content_copy_only():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'other',repository:'o/r',title:'Other issue',body:'Private context',ownerLogin:'alexander',status:'queued'}},
|
||||
{{id:'legacy',repository:'o/r',title:'Legacy issue',body:'Old context',status:'queued'}}
|
||||
]}})],
|
||||
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'mine',kind:'issue-comment',repository:'o/r',number:2,body:'My comment',ownerLogin:'timmy',status:'queued'}}
|
||||
]}})],
|
||||
]);
|
||||
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)}};
|
||||
const drafts = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list();
|
||||
process.stdout.write(JSON.stringify(drafts));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
by_id = {item["outbox_id"]: item for item in output}
|
||||
assert by_id["other"]["quarantined"] is True
|
||||
assert by_id["other"]["ownership"] == "Queued by alexander — current account is timmy"
|
||||
assert by_id["other"]["copy_text"] == "Other issue\n\nPrivate context"
|
||||
assert by_id["legacy"]["ownership"] == "Queued by an unknown account — current account is timmy"
|
||||
assert by_id["mine"]["quarantined"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
html = await dashboard()
|
||||
@@ -134,3 +160,22 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
assert '.draft-actions button { min-height:44px;' in html
|
||||
assert 'createDraftInbox({ storage: localStorage' in html
|
||||
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot():
|
||||
html = await dashboard()
|
||||
|
||||
assert "let confirmedOwnerLogin = '';" in html
|
||||
assert "let activeFlushLogin = '';" in html
|
||||
assert "getOwnerLogin: () => confirmedOwnerLogin" in html
|
||||
assert "getCurrentLogin: () => activeFlushLogin" in html
|
||||
assert "const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&" in html
|
||||
assert "!contextFreshness?.degraded && !contextFreshness?.revalidating;" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';" in html
|
||||
assert "issueOutbox.flush(activeFlushLogin)" in html
|
||||
assert "authoredOutbox.flush(activeFlushLogin)" in html
|
||||
assert "activeFlushLogin = '';" in html
|
||||
assert 'class="draft-copy"' in html
|
||||
assert "navigator.clipboard.writeText(item.copy_text)" in html
|
||||
assert "item.quarantined" in html
|
||||
+43
-11
@@ -15,6 +15,35 @@ def run_node(script: str):
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_issue_outbox_binds_items_and_only_flushes_for_the_matching_account():
|
||||
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)}};
|
||||
const calls = [];
|
||||
let confirmedLogin = 'timmy';
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:() => confirmedLogin, createOperationId:() => 'bound-op',
|
||||
fetchJson:async url => {{ calls.push(url); return {{number:1}}; }},
|
||||
}});
|
||||
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bound',body:'Context'}});
|
||||
confirmedLogin = 'alexander';
|
||||
(async () => {{
|
||||
const mismatch = await outbox.flush('alexander');
|
||||
const unknown = await outbox.retry(queued.id, '');
|
||||
const matched = await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{queued,mismatch,unknown,matched,calls}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["ownerLogin"] == "timmy"
|
||||
assert output["mismatch"]["blocked"] == 1
|
||||
assert output["unknown"]["blocked"] == 1
|
||||
assert len(output["matched"]["confirmed"]) == 1
|
||||
assert len(output["calls"]) == 1
|
||||
|
||||
|
||||
def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -25,7 +54,7 @@ const storage = {{
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let sequence = 0;
|
||||
const outbox = createIssueOutbox({{storage, createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}});
|
||||
const outbox = createIssueOutbox({{storage, getOwnerLogin:()=>'timmy', 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();
|
||||
@@ -39,7 +68,7 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
|
||||
]
|
||||
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
|
||||
assert output["stored"]["version"] == 2
|
||||
|
||||
|
||||
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
|
||||
@@ -51,6 +80,7 @@ let sequence = 0;
|
||||
const calls = [];
|
||||
const outbox = createIssueOutbox({{
|
||||
storage,
|
||||
getOwnerLogin:()=>'timmy',
|
||||
createOperationId:() => 'stable-' + (++sequence),
|
||||
fetchJson:async (url, options) => {{
|
||||
calls.push({{url, key:options.headers['Idempotency-Key']}});
|
||||
@@ -60,7 +90,7 @@ const outbox = createIssueOutbox({{
|
||||
}});
|
||||
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()}})));
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
@@ -82,7 +112,7 @@ const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),
|
||||
let invalid = true;
|
||||
const calls = [];
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, createOperationId:() => 'stable-edit',
|
||||
storage, getOwnerLogin:()=>'timmy', 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; }}
|
||||
@@ -90,11 +120,11 @@ const outbox = createIssueOutbox({{
|
||||
}},
|
||||
}});
|
||||
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bad',body:'Context'}});
|
||||
outbox.flush().then(async () => {{
|
||||
outbox.flush('timmy').then(async () => {{
|
||||
const attention = outbox.list()[0];
|
||||
outbox.update(queued.id, {{...attention,title:'Fixed title'}});
|
||||
invalid = false;
|
||||
const result = await outbox.retry(queued.id);
|
||||
const result = await outbox.retry(queued.id, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{attention,result,calls,remaining:outbox.list()}}));
|
||||
}});
|
||||
"""
|
||||
@@ -117,12 +147,12 @@ let calls = 0;
|
||||
let release;
|
||||
const gate = new Promise(resolve => {{ release = resolve; }});
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, createOperationId:() => 'single-flight',
|
||||
storage, getOwnerLogin:()=>'timmy', 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);
|
||||
const reconnect = outbox.flush('timmy');
|
||||
const sendNow = outbox.retry(queued.id, 'timmy');
|
||||
release();
|
||||
Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.stringify({{calls,results,remaining:outbox.list()}})));
|
||||
"""
|
||||
@@ -138,10 +168,12 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/issue-outbox.js"></script>' in html
|
||||
assert "createIssueOutbox({ storage: localStorage" in html
|
||||
assert "const issueOutbox = createIssueOutbox({" in html
|
||||
assert "issueOutbox.enqueue(captureDraft)" in html
|
||||
assert "issueOutbox.retry(queued.id, activeFlushLogin)" in html
|
||||
assert "navigator.onLine" in html
|
||||
assert "window.addEventListener('online', flushIssueOutbox)" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
||||
assert "flushIssueOutbox();" in html
|
||||
assert 'class="draft-send"' in html
|
||||
assert 'class="draft-edit"' in html
|
||||
assert 'Queued for sync' in html
|
||||
|
||||
@@ -70,6 +70,31 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
|
||||
const badge = {{ textContent:'', hidden:true }};
|
||||
const dock = createDock({{ nav:{{}}, buttons:{{work}}, attentionBadge:badge }});
|
||||
dock.updateAttention(3);
|
||||
const pending = {{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
dock.updateAttention(0);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
pending,
|
||||
empty:{{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }},
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"pending": {"count": "3", "hidden": False, "label": "Work, 3 items need attention"},
|
||||
"empty": {"count": "0", "hidden": True, "label": "Work"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
html = await dashboard()
|
||||
@@ -79,15 +104,20 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
for task in ("work", "find", "new", "search", "drafts"):
|
||||
assert f'data-mobile-task="{task}"' in html
|
||||
assert 'id="mobile-draft-count"' in html
|
||||
assert 'data-work-filter="attention"' in html
|
||||
assert 'id="mobile-attention-count"' in html
|
||||
assert '.mobile-task-dock { display:none;' in html
|
||||
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
|
||||
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
|
||||
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: () => qs('#my-work').scrollIntoView" in html
|
||||
assert "work: openMobileWork" in html
|
||||
assert "counts.attention ? 'attention' : 'all'" in html
|
||||
assert "qs('[data-work-filter=\"' + filter + '\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
assert "search: () => qs('#open-palette').click()" in html
|
||||
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
|
||||
assert "draftCount.textContent = sourceDraftCount.textContent" in html
|
||||
assert "mobileTaskDock.updateAttention(counts.attention)" in html
|
||||
+91
-3
@@ -651,6 +651,32 @@ process.stdout.write(JSON.stringify({{
|
||||
assert output["summary"] == "1 review · 2 assigned"
|
||||
|
||||
|
||||
def test_attention_filter_unions_updates_and_reviews_without_double_counting():
|
||||
items = [
|
||||
{"title": "Unread issue", "kind": "issue", "has_update": True, "is_review": False},
|
||||
{"title": "Requested review", "kind": "pull", "has_update": False, "is_review": True},
|
||||
{"title": "Updated review", "kind": "pull", "has_update": True, "is_review": True},
|
||||
{"title": "Ordinary assignment", "kind": "issue", "has_update": False, "is_review": False},
|
||||
]
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = {json.dumps(items)};
|
||||
process.stdout.write(JSON.stringify({{
|
||||
attention: buildMyWork.filterMyWork(items, 'attention').map(item => item.title),
|
||||
count: buildMyWork.countMyWork(items).attention,
|
||||
}}));
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"attention": ["Unread issue", "Requested review", "Updated review"],
|
||||
"count": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
||||
payload = {
|
||||
"issues": [
|
||||
@@ -842,7 +868,9 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1, "update": 0}
|
||||
assert json.loads(result.stdout) == {
|
||||
"all": 3, "attention": 1, "issue": 1, "pull": 1, "review": 1, "update": 0
|
||||
}
|
||||
|
||||
|
||||
def test_work_pager_is_single_flight_and_unions_pull_responsibilities():
|
||||
@@ -1458,7 +1486,9 @@ process.stdout.write(JSON.stringify({{
|
||||
assert output["updates"][0]["kind"] == "update"
|
||||
assert output["updates"][1]["kind"] == "issue"
|
||||
assert output["updates"][1]["url"].endswith("#issuecomment-9")
|
||||
assert output["counts"] == {"all": 2, "issue": 1, "pull": 0, "review": 0, "update": 2}
|
||||
assert output["counts"] == {
|
||||
"all": 2, "attention": 2, "issue": 1, "pull": 0, "review": 0, "update": 2
|
||||
}
|
||||
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
|
||||
|
||||
|
||||
@@ -2641,11 +2671,12 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
|
||||
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
|
||||
assert 'data-work-count="all"' in html
|
||||
assert 'data-work-count="attention"' in html
|
||||
assert 'data-work-count="issue"' in html
|
||||
assert 'data-work-count="pull"' in html
|
||||
assert 'data-work-count="review"' in html
|
||||
assert 'data-work-count="update"' in html
|
||||
assert "['all', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)" in html
|
||||
assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)" in html
|
||||
assert 'data-work-count="draft"' in html
|
||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||
@@ -3106,6 +3137,63 @@ process.stdout.write(JSON.stringify({{removed, draft:draft.snapshot(), progress:
|
||||
assert payload["progress"]["reviewedCount"] == 0
|
||||
|
||||
|
||||
def test_approved_assigned_review_carries_exact_head_progress_into_merge():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.get(key) || null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}};
|
||||
const assigned = {{
|
||||
repository:'stackchain/api', number:7,
|
||||
work_reasons:['assigned_to_me', 'review_requested']
|
||||
}};
|
||||
const carried = reviewSheet.prepareMergeContinuation({{
|
||||
storage, item:assigned, headSha:'abc123',
|
||||
reviewed:['a.py', 'b.py'], decision:'approve'
|
||||
}});
|
||||
const unassigned = reviewSheet.prepareMergeContinuation({{
|
||||
storage, item:{{...assigned, number:8, work_reasons:['review_requested']}},
|
||||
headSha:'def456', reviewed:['c.py'], decision:'approve'
|
||||
}});
|
||||
const changesRequested = reviewSheet.prepareMergeContinuation({{
|
||||
storage, item:{{...assigned, number:9}}, headSha:'ghi789',
|
||||
reviewed:['d.py'], decision:'request_changes'
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{carried, unassigned, changesRequested, entries:[...values.entries()]}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"carried": True,
|
||||
"unassigned": False,
|
||||
"changesRequested": False,
|
||||
"entries": [[
|
||||
"stackchain.pull-review.v1:stackchain/api#7:abc123",
|
||||
'["a.py","b.py"]',
|
||||
]],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_assigned_approval_continues_to_merge_in_place():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="continue-review-to-merge"' in html
|
||||
assert '#continue-review-to-merge[hidden]' in html and 'display:none' in html
|
||||
assert '.review-merge-continuation' in html and 'position:sticky' in html
|
||||
assert '#continue-review-to-merge { min-height:44px;' in html
|
||||
assert "createReviewController.prepareMergeContinuation" in html
|
||||
assert "reviewed: progressSnapshot.reviewed" in html
|
||||
assert "decision: snapshot.decision" in html
|
||||
assert "workRoute.open({ ...item, kind:'pull', is_review:false }, { replace:true })" in html
|
||||
assert "qs('#continue-review-to-merge').hidden = false" in html
|
||||
assert "qs('#continue-review-to-merge').focus()" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
COORDINATOR = ROOT / "frontend" / "outbox-coordinator.js"
|
||||
ISSUE_OUTBOX = ROOT / "frontend" / "issue-outbox.js"
|
||||
AUTHORED_OUTBOX = ROOT / "frontend" / "authored-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_two_tabs_share_one_flush_lease_for_each_outbox():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
const createIssueOutbox = require({json.dumps(str(ISSUE_OUTBOX))});
|
||||
const createAuthoredOutbox = require({json.dumps(str(AUTHORED_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)}};
|
||||
const held = new Set();
|
||||
const locks = {{request: async (name, options, work) => {{
|
||||
if (held.has(name)) return work(null);
|
||||
held.add(name);
|
||||
try {{ return await work({{name}}); }} finally {{ held.delete(name); }}
|
||||
}}}};
|
||||
let releaseIssue, releaseMessage;
|
||||
const issueGate = new Promise(resolve => releaseIssue = resolve);
|
||||
const messageGate = new Promise(resolve => releaseMessage = resolve);
|
||||
let issueCalls = 0, messageCalls = 0;
|
||||
const issueA = createIssueOutbox({{storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'issue-1',
|
||||
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'a'}}),fetchJson:async()=>{{issueCalls++;await issueGate;return {{number:1}};}}}});
|
||||
const issueB = createIssueOutbox({{storage,getOwnerLogin:()=> 'timmy',
|
||||
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'b'}}),fetchJson:async()=>{{issueCalls++;return {{number:1}};}}}});
|
||||
issueA.enqueue({{repository:'o/r',title:'One'}});
|
||||
const messageA = createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy',
|
||||
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'a'}}),fetchJson:async()=>{{messageCalls++;await messageGate;return {{id:1}};}}}});
|
||||
const messageB = createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy',
|
||||
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'b'}}),fetchJson:async()=>{{messageCalls++;return {{id:1}};}}}});
|
||||
messageA.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Reply',operationId:'message-1'}});
|
||||
(async()=>{{
|
||||
const issueFirst=issueA.flush('timmy'); const issueSecond=issueB.retry('issue-1', 'timmy');
|
||||
const messageFirst=messageA.flush('timmy'); const messageSecond=messageB.retry('message-1', 'timmy');
|
||||
await Promise.resolve();
|
||||
releaseIssue(); releaseMessage();
|
||||
const results=await Promise.all([issueFirst,issueSecond,messageFirst,messageSecond]);
|
||||
process.stdout.write(JSON.stringify({{issueCalls,messageCalls,results,issueRemaining:issueA.list(),messageRemaining:messageA.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["issueCalls"] == 1
|
||||
assert output["messageCalls"] == 1
|
||||
assert output["issueRemaining"] == []
|
||||
assert output["messageRemaining"] == []
|
||||
assert sum(result.get("lease_skipped", False) for result in output["results"]) == 2
|
||||
|
||||
|
||||
def test_fallback_lease_expires_and_stale_owner_cannot_release_successor():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
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 clock=100;
|
||||
let releaseFirst;
|
||||
const firstGate=new Promise(resolve=>releaseFirst=resolve);
|
||||
const first=createCoordinator({{storage,locks:null,channelFactory:null,tabId:'first',now:()=>clock,leaseMs:50}});
|
||||
const second=createCoordinator({{storage,locks:null,channelFactory:null,tabId:'second',now:()=>clock,leaseMs:50}});
|
||||
(async()=>{{
|
||||
const running=first.runExclusive('issue', async()=>{{await firstGate;return 'first-done';}});
|
||||
await Promise.resolve();
|
||||
const blocked=await second.runExclusive('issue', async()=> 'too-early');
|
||||
clock=151;
|
||||
const takeover=await second.runExclusive('issue', async()=> 'taken-over');
|
||||
releaseFirst();
|
||||
const original=await running;
|
||||
const after=await second.runExclusive('issue', async()=> 'after-release');
|
||||
process.stdout.write(JSON.stringify({{blocked,takeover,original,after,lease:storage.getItem('stackchain.outbox-lease.issue.v1')}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["blocked"]["lease_skipped"] is True
|
||||
assert output["takeover"] == "taken-over"
|
||||
assert output["original"] == "first-done"
|
||||
assert output["after"] == "after-release"
|
||||
assert output["lease"] is None
|
||||
|
||||
|
||||
def test_queue_change_notifications_cross_tabs_and_can_unsubscribe():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const channels=[];
|
||||
function channelFactory() {{
|
||||
const channel={{onmessage:null,postMessage(message){{channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data:message}}));}},close(){{}}}};
|
||||
channels.push(channel); return channel;
|
||||
}}
|
||||
const first=createCoordinator({{storage,channelFactory,tabId:'first'}});
|
||||
const second=createCoordinator({{storage,channelFactory,tabId:'second'}});
|
||||
const seen=[];
|
||||
const unsubscribe=second.subscribe(change=>seen.push(change));
|
||||
first.notify('issue');
|
||||
first.notify('authored');
|
||||
unsubscribe();
|
||||
first.notify('issue');
|
||||
process.stdout.write(JSON.stringify({{seen,signal:JSON.parse(storage.getItem('stackchain.outbox-change.v1'))}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [change["queue"] for change in output["seen"]] == ["issue", "authored"]
|
||||
assert output["signal"]["queue"] == "issue"
|
||||
assert output["signal"]["tabId"] == "first"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_cross_tab_draft_refresh_and_precaches_coordinator():
|
||||
html = await dashboard()
|
||||
worker = (ROOT / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/outbox-coordinator.js"></script>' in html
|
||||
assert "const outboxCoordinator = createOutboxCoordinator" in html
|
||||
assert "coordinator: outboxCoordinator" in html
|
||||
assert "outboxCoordinator.subscribe" in html
|
||||
assert "refreshMyWorkView()" in html
|
||||
assert "BASE + 'static/outbox-coordinator.js'" in worker
|
||||
@@ -63,6 +63,12 @@ async function dispatch(name, request) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_mobile_attention_queue_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v11" in source
|
||||
|
||||
|
||||
def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -83,6 +89,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/search-preview.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/outbox-coordinator.js",
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/offline-work.js",
|
||||
|
||||
Reference in new issue
Block a user