Compare commits
148
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a24aff537 | ||
|
|
43769460fc | ||
|
|
2e62eb09f5 | ||
|
|
1ef3e291c2 | ||
|
|
3694f2501b | ||
|
|
c3d58d7111 | ||
|
|
da45cc36a3 | ||
|
|
c04cba29d9 | ||
|
|
cacecac865 | ||
|
|
10f7cc832a | ||
|
|
e5f37a866c | ||
|
|
5a4fbd1564 | ||
|
|
7bcab03b8a | ||
|
|
51fe9e5af2 | ||
|
|
2406d24cb8 | ||
|
|
745d2529e5 | ||
|
|
af9699e362 | ||
|
|
71b38687c4 | ||
|
|
0a8f33dc83 | ||
|
|
0f45b7aead | ||
|
|
a44bf46bef | ||
|
|
ec86bf55a8 | ||
|
|
7636e30482 | ||
|
|
bd054998b2 | ||
|
|
3c65bc1b31 | ||
|
|
bc082f2bd9 | ||
|
|
9ae0a89970 | ||
|
|
43163ed926 | ||
|
|
66a2b78860 | ||
|
|
20d4f1ef7e | ||
|
|
70281bb56a | ||
|
|
7b736bc4c6 | ||
|
|
fa3e5e3bf0 | ||
|
|
bc0d47e8e7 | ||
|
|
741c27eacf | ||
|
|
3896f17a58 | ||
|
|
9d14fa5ffb | ||
|
|
83be8fcdbe | ||
|
|
35f3999787 | ||
|
|
378291d3c8 | ||
|
|
584752ed24 | ||
|
|
d54f58bf36 | ||
|
|
84302d6f1e | ||
|
|
2e0d64907b | ||
|
|
80042ce561 | ||
|
|
931f656f78 | ||
|
|
18f56583ba | ||
|
|
6e2f5d1120 | ||
|
|
9bd755f521 | ||
|
|
fd2f801f3e | ||
|
|
e21cc58cca | ||
|
|
d6212b2812 | ||
|
|
6528b2e73f | ||
|
|
a9d586e689 | ||
|
|
7c86f28346 | ||
|
|
707ee4c1bc | ||
|
|
82982e5741 | ||
|
|
db08c7e75f | ||
|
|
1a3df5d7a7 | ||
|
|
fdba0554df | ||
|
|
d2a85cd5ea | ||
|
|
1f034a6f64 | ||
|
|
de6d6d0e61 | ||
|
|
8dc3880167 | ||
|
|
b93e63ab70 | ||
|
|
8c82644515 | ||
|
|
6c6cd3c71b | ||
|
|
4f6cebae7c | ||
|
|
341a3283ea | ||
|
|
9c276fa251 | ||
|
|
940f7c05d2 | ||
|
|
d34c082da2 | ||
|
|
1c086be09b | ||
|
|
4cfcb92eec | ||
|
|
b484bbef03 | ||
|
|
623817eb7d | ||
|
|
11347e5fe0 | ||
|
|
bb1712279c | ||
|
|
e7d5196987 | ||
|
|
64ff5127d8 | ||
|
|
4ac5a0f499 | ||
|
|
715df380e0 | ||
|
|
1fc70f6890 | ||
|
|
523968f5a3 | ||
|
|
af27bb06e0 | ||
|
|
4410af09a6 | ||
|
|
880f60831a | ||
|
|
e860d16d29 | ||
|
|
abf8e23685 | ||
|
|
02a472f841 | ||
|
|
c5e6e17fc1 | ||
|
|
8a12524595 | ||
|
|
cbe4017bdc | ||
|
|
fcc9ead33e | ||
|
|
0d29b29bef | ||
|
|
dce9ae04c9 | ||
|
|
9ec88179c3 | ||
|
|
93890e72de | ||
|
|
8f0df3d393 | ||
|
|
fc79e4523d | ||
|
|
741d545199 | ||
|
|
9320016b50 | ||
|
|
b069379df0 | ||
|
|
8387c57ceb | ||
|
|
72842ecbcd | ||
|
|
c503994be1 | ||
|
|
5e6328a8d0 | ||
|
|
4209834d6d | ||
|
|
46888d2876 | ||
|
|
7d7abefb0b | ||
|
|
2796808759 | ||
|
|
a2f83b0955 | ||
|
|
6b56188d30 | ||
|
|
467c8c665d | ||
|
|
1b311c9cd4 | ||
|
|
71d06347fd | ||
|
|
1de6d73e75 | ||
|
|
46c75f0b22 | ||
|
|
5cdb3f7b3e | ||
|
|
15d197327a | ||
|
|
df8385b325 | ||
|
|
47c6412a12 | ||
|
|
6cac778acd | ||
|
|
a8a93b2f3b | ||
|
|
ef37a66fd7 | ||
|
|
52e2c26304 | ||
|
|
3817d2c67b | ||
|
|
ad6c1edf80 | ||
|
|
d2b2c7bada | ||
|
|
9ba03613d4 | ||
|
|
1132075318 | ||
|
|
16b1d8670f | ||
|
|
e5c5c8ec31 | ||
|
|
87e0d74414 | ||
|
|
f031a37e8d | ||
|
|
057bec7dc2 | ||
|
|
654ae8714e | ||
|
|
a0dd044995 | ||
|
|
c93a682dfb | ||
|
|
5675ca5d41 | ||
|
|
0767ccaa9d | ||
|
|
c6eabb5fc7 | ||
|
|
77fab85a66 | ||
|
|
dcf26c896c | ||
|
|
fba1f4238d | ||
|
|
9e301924b4 | ||
|
|
8edd542379 | ||
|
|
0fbe047e03 |
No files matched your search
@@ -62,7 +62,17 @@ and durable delivery flow. A different or unconfirmed account can only copy or d
|
||||
the private content. Issue capture and authored mobile actions (issue
|
||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||
another worker replays a confirmed result instead of posting duplicate content. The ordered,
|
||||
another worker replays a confirmed result instead of posting duplicate content. Mobile Search previews
|
||||
let operators assign an eligible issue directly into Later at an exact local return time without filling
|
||||
Today or interrupting active work. Cancel and browser Back preserve the Search preview without assigning;
|
||||
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
||||
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
||||
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
||||
Search previews also
|
||||
let operators assign an eligible issue and add it to Today without starting or replacing active work.
|
||||
The queue action keeps the Search query, filters, results, and scroll position available for continued
|
||||
planning, reports an existing Today item without duplicating it, and uses the same capacity, sync, and
|
||||
offline-warm path as other Today admission flows. The ordered,
|
||||
five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
|
||||
and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
|
||||
time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,
|
||||
@@ -81,7 +91,11 @@ an open, unassigned issue update also offers **Take ownership & start**, which c
|
||||
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
|
||||
the session, and opens the issue. The adjacent **Take ownership** action remains available for
|
||||
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
|
||||
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
|
||||
**Mark read & next** remains the explicit acknowledgement path. During an online Updates pass,
|
||||
Stackchain preloads at most the next surviving conversation from the fixed snapshot while the
|
||||
current one is being read. Advancing consumes that account-bound result without another detail
|
||||
request; failures fall back to the normal foreground retry path, and offline triage never speculates.
|
||||
Delivery or local-admission
|
||||
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||
@@ -210,6 +224,10 @@ export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
||||
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
|
||||
# within each device; one slow device does not delay healthy devices behind it.
|
||||
export STACKCHAIN_PUSH_POLL_SECONDS=30
|
||||
# Unread updates and deadline reminders run on independent, fixed-cadence
|
||||
# workers, so a slow channel cannot delay the other or add drift to its ticks.
|
||||
# On each phone, Device Setup can enable deadline reminders and choose any local
|
||||
# reminder hour from 00:00 through 23:00; the server-confirmed choice is restored.
|
||||
export STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS=10
|
||||
export STACKCHAIN_PUSH_MAX_CONCURRENCY=8
|
||||
# Maximum individual alerts per device and poll before one digest covers the rest.
|
||||
@@ -281,8 +299,9 @@ remain compatible. The bounded **Passkey counter anomaly** Security activity ent
|
||||
only the device label and attempted action; repeated alerts should prompt removal and
|
||||
re-enrollment of that passkey.
|
||||
|
||||
High-impact actions—merging a pull request, closing an assigned issue, revoking a
|
||||
remote device, or signing out every device—require a passkey assertion or the
|
||||
High-impact actions—merging a pull request, closing an assigned issue, permanently
|
||||
deleting an authored comment, revoking a remote device, or signing out every device—
|
||||
require a passkey assertion or the
|
||||
operator access token again.
|
||||
The server issues a random 90-second grant bound to the active session, exact action,
|
||||
and exact target. Only its digest is stored, and the grant is consumed atomically on
|
||||
@@ -373,7 +392,10 @@ account and never contain the Gitea token. Find Work uses the same worker-shared
|
||||
`STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB` overrides
|
||||
`STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3`. One expiring lease bounds each
|
||||
catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry
|
||||
bursts, and confirmed claims are removed from every worker's retained catalog. Releasing
|
||||
bursts, and confirmed claims are removed from every worker's retained catalog. On a cold
|
||||
catalog, non-owner workers wait within the bounded foreground deadline for the lease owner
|
||||
to publish; if that owner releases or abandons the lease, a waiter can take over the scan
|
||||
within its remaining request budget. Releasing
|
||||
an assignment invalidates the shared catalog so the newly available issue can be discovered
|
||||
by the next authoritative scan. Each bounded opaque revision token includes the
|
||||
store generation, so a token from a different deployment or before replacement of the store
|
||||
@@ -396,6 +418,14 @@ filter, preserves the selected release lane, shows the current draft count, resp
|
||||
the device safe area, and moves out of the way while a full-screen task is open.
|
||||
Desktop layout is unchanged.
|
||||
|
||||
Mobile **Search** provides touch-sized **Repository**, **Type**, and **Status** controls.
|
||||
Operators can find and select a repository visible to their Gitea account, then scope results
|
||||
to that exact repository, issues, pull requests, open work, closed work, or all accessible work;
|
||||
the server applies every scope before pagination. Clearing Repository returns to organization-wide
|
||||
Search. The selected scope is bounded and addressable, survives preview/back, reload, sharing,
|
||||
and sign-in continuation, and a scope change cancels obsolete requests before restarting at the
|
||||
first page. Repository lookup failure leaves organization-wide Search usable.
|
||||
|
||||
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
|
||||
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
|
||||
accepts a valid future local date and time and returns the item at that exact instant; the picker
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
function createAgendaReplan({ now = () => new Date(), update }) {
|
||||
let items = [];
|
||||
let index = 0;
|
||||
let pending = false;
|
||||
let active = false;
|
||||
|
||||
const dayKey = date => [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
const dueDay = value => {
|
||||
const match = String(value || '').match(/^(\d{4}-\d{2}-\d{2})(?:$|T)/);
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
const current = () => active ? items[index] || null : null;
|
||||
const snapshot = () => ({
|
||||
active,
|
||||
index,
|
||||
total: items.length,
|
||||
current: current()?.key || null,
|
||||
pending,
|
||||
});
|
||||
const advance = () => {
|
||||
index += 1;
|
||||
if (index >= items.length) active = false;
|
||||
return { ok:true, done:!active };
|
||||
};
|
||||
const change = async dueDate => {
|
||||
const item = current();
|
||||
if (!item) return { ok:false, error:'No overdue deadline selected.' };
|
||||
if (pending) return { ok:false, error:'Deadline update already in progress.' };
|
||||
pending = true;
|
||||
try {
|
||||
await update(item, dueDate);
|
||||
return advance();
|
||||
} catch (error) {
|
||||
return { ok:false, error:error?.message || 'Deadline could not be updated.' };
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start(overdue) {
|
||||
items = (overdue || []).slice().sort((left, right) =>
|
||||
dueDay(left.due_date).localeCompare(dueDay(right.due_date)) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
);
|
||||
index = 0;
|
||||
pending = false;
|
||||
active = items.length > 0;
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
current,
|
||||
keep() {
|
||||
if (pending) return Promise.resolve({ ok:false, error:'Deadline update already in progress.' });
|
||||
if (!current()) return Promise.resolve({ ok:false, error:'No overdue deadline selected.' });
|
||||
return Promise.resolve(advance());
|
||||
},
|
||||
tomorrow() {
|
||||
const date = new Date(now());
|
||||
date.setDate(date.getDate() + 1);
|
||||
return change(dayKey(date) + 'T23:59:59Z');
|
||||
},
|
||||
choose(value) {
|
||||
const chosen = String(value || '');
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(chosen) || chosen <= dayKey(now())) {
|
||||
return Promise.resolve({ ok:false, error:'Choose a future date.' });
|
||||
}
|
||||
return change(chosen + 'T23:59:59Z');
|
||||
},
|
||||
cancel() {
|
||||
active = false;
|
||||
pending = false;
|
||||
return snapshot();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAgendaReplan;
|
||||
@@ -0,0 +1,33 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createAgendaSessionLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createAgendaSessionLauncher(options) {
|
||||
let pending = null;
|
||||
|
||||
function open() {
|
||||
if (pending) return pending;
|
||||
options.selectAgenda();
|
||||
pending = Promise.resolve()
|
||||
.then(() => options.discover())
|
||||
.then(complete => {
|
||||
if (!options.isAgendaSelected()) return 'cancelled';
|
||||
if (complete === false || options.hasMore()) {
|
||||
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
|
||||
return 'incomplete';
|
||||
}
|
||||
const opened = options.hasCheckpoint() ? options.resume() : options.start();
|
||||
if (!opened) options.announce('No deadlines are ready in Agenda.');
|
||||
return opened ? 'opened' : 'empty';
|
||||
})
|
||||
.catch(() => {
|
||||
if (options.isAgendaSelected()) {
|
||||
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
|
||||
}
|
||||
return 'incomplete';
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {open};
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
function createBatchFindWork({
|
||||
capacity,
|
||||
claim,
|
||||
queue,
|
||||
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
|
||||
persistEstimate = () => {},
|
||||
onProgress = () => {},
|
||||
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
||||
owner = () => '',
|
||||
journalName = 'find-work-batch',
|
||||
queueFailureReason = 'assigned but Today sync is unavailable',
|
||||
autoMount = true,
|
||||
}) {
|
||||
let request = null;
|
||||
const journalKey = () => 'stackchain.' + journalName + '.v1.' +
|
||||
encodeURIComponent(String(owner() || ''));
|
||||
|
||||
function readJournal() {
|
||||
if (!storage || !String(owner() || '')) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(journalKey()) || 'null');
|
||||
return value?.owner === String(owner()) && Array.isArray(value.items) ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function writeJournal(value) {
|
||||
if (!storage || !String(owner() || '')) return;
|
||||
storage.setItem(journalKey(), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clearJournal() {
|
||||
if (storage && String(owner() || '')) storage.removeItem(journalKey());
|
||||
}
|
||||
|
||||
function result(status, selected, available, queued = [], failed = []) {
|
||||
return { status, selected, available, queued, failed };
|
||||
}
|
||||
|
||||
function key(item) {
|
||||
return String(item.repository || '') + '#' + String(item.number || '');
|
||||
}
|
||||
|
||||
async function processJournal(journal) {
|
||||
const selected = journal.items;
|
||||
const queued = [];
|
||||
const failed = [];
|
||||
for (let index = 0; index < selected.length; index += 1) {
|
||||
const entry = selected[index];
|
||||
const item = entry.item;
|
||||
if (entry.state === 'queued') {
|
||||
queued.push(key(item));
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const confirmed = entry.confirmed || await claim(item);
|
||||
if (!entry.confirmed) {
|
||||
entry.confirmed = confirmed;
|
||||
entry.state = 'assigned';
|
||||
writeJournal(journal);
|
||||
}
|
||||
const queueResult = await queue(confirmed, journal.context);
|
||||
if (queueResult === 'queued' || queueResult === 'exists') {
|
||||
queued.push(key(item));
|
||||
entry.state = 'queued';
|
||||
if (Number.isInteger(entry.estimate) && entry.estimate > 0) {
|
||||
persistEstimate(confirmed, entry.estimate);
|
||||
}
|
||||
writeJournal(journal);
|
||||
} else {
|
||||
failed.push({ key: key(item), reason: queueFailureReason, assigned: true });
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
||||
}
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
}
|
||||
const outcome = result('complete', selected.length, journal.available, queued, failed);
|
||||
if (!failed.length) clearJournal();
|
||||
else writeJournal(journal);
|
||||
onProgress({ ...outcome, processed: selected.length });
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function run(items, estimates = {}, context = null) {
|
||||
if (request) return request;
|
||||
const selected = Array.isArray(items) ? items.slice() : [];
|
||||
const available = Math.max(0, Number(capacity()) || 0);
|
||||
if (selected.length > available) {
|
||||
const outcome = result('full', selected.length, available);
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
const budget = timeBudget() || {};
|
||||
if (Number.isInteger(budget.capacity_minutes) && budget.capacity_minutes > 0) {
|
||||
const remaining = Math.max(0, budget.capacity_minutes - (Number(budget.planned_minutes) || 0));
|
||||
const invalid = selected.map(key).filter(id =>
|
||||
!Number.isInteger(estimates[id]) || estimates[id] <= 0
|
||||
);
|
||||
const requested = selected.reduce((sum, item) => sum +
|
||||
(Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0 ? estimates[key(item)] : 0), 0);
|
||||
if (invalid.length) {
|
||||
const outcome = { ...result('estimates-required', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, invalid };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
if (requested > remaining) {
|
||||
const outcome = { ...result('over-budget', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, over_minutes: requested - remaining };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
}
|
||||
const journal = {
|
||||
owner: String(owner() || ''), available, context,
|
||||
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
|
||||
};
|
||||
writeJournal(journal);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (request) return request;
|
||||
const journal = readJournal();
|
||||
if (!journal) return Promise.resolve(null);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const journal = readJournal();
|
||||
return journal ? journal.items.filter(item => item.state !== 'queued').length : 0;
|
||||
}
|
||||
|
||||
function mountRecovery(button, opener) {
|
||||
const show = () => {
|
||||
const journal = readJournal();
|
||||
button.hidden = !journal;
|
||||
if (journal) button.textContent = 'Resume ' + journal.items.filter(item => item.state !== 'queued').length + ' interrupted';
|
||||
};
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
const outcome = await resume();
|
||||
button.disabled = false;
|
||||
show();
|
||||
if (outcome) {
|
||||
button.previousElementSibling.textContent = outcome.failed.length ?
|
||||
outcome.failed.length + ' still need recovery.' : outcome.queued.length + ' queued · batch recovered.';
|
||||
}
|
||||
});
|
||||
opener.addEventListener('click', show);
|
||||
}
|
||||
|
||||
if (autoMount && typeof document !== 'undefined') mountRecovery(
|
||||
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
|
||||
);
|
||||
return { run, resume, pending, mountRecovery };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|
||||
+73
-17
@@ -15,6 +15,15 @@
|
||||
return current;
|
||||
};
|
||||
|
||||
filterCommands.searchUrl = function searchUrl(query, page, scope, continuation) {
|
||||
const streamPages = continuation ?
|
||||
(continuation.issues ? '&issues_page=' + encodeURIComponent(continuation.issues) : '') +
|
||||
(continuation.pulls ? '&pulls_page=' + encodeURIComponent(continuation.pulls) : '') : '';
|
||||
return 'api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page +
|
||||
'&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state) +
|
||||
(scope.repository ? '&repository=' + encodeURIComponent(scope.repository) : '') + streamPages;
|
||||
};
|
||||
|
||||
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
|
||||
const search = options.search;
|
||||
const onState = options.onState;
|
||||
@@ -22,6 +31,46 @@
|
||||
let timer = null;
|
||||
let generation = 0;
|
||||
let activeController = null;
|
||||
let scope = { kind:'all', state:'all' };
|
||||
let state = { status: 'idle', query: '', items: [], more: false, next: 1, scope };
|
||||
|
||||
function publish(next) {
|
||||
state = next;
|
||||
onState(next);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function requestPage(query, page, current, append, continuation) {
|
||||
const requestController = new AbortController();
|
||||
activeController = requestController;
|
||||
try {
|
||||
const requestScope = { ...scope };
|
||||
const result = await search(
|
||||
query, requestController.signal, page, requestScope, continuation
|
||||
);
|
||||
const partial = result.partial === true;
|
||||
const incoming = result.items || result;
|
||||
const combined = append ? state.items.concat(incoming) : incoming;
|
||||
const items = [...new Map((combined || []).map(item =>
|
||||
[`${item.kind}:${item.repository}:${item.number}`, item]
|
||||
)).values()];
|
||||
if (current === generation) return publish({
|
||||
status: 'ready', query, items, partial,
|
||||
more: !!result.has_more,
|
||||
next: result.next_page,
|
||||
continuation:result.continuation,
|
||||
failedStreams:result.failed_streams || [],
|
||||
scope:requestScope,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (current === generation) return publish(append
|
||||
? { ...state, status: 'ready' }
|
||||
: { status: 'error', query, items: [], error, scope:{ ...scope } });
|
||||
} finally {
|
||||
if (activeController === requestController) activeController = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setQuery(value) {
|
||||
@@ -32,25 +81,32 @@
|
||||
if (activeController !== null) activeController.abort();
|
||||
activeController = null;
|
||||
if (query.length < 2) {
|
||||
onState({ status: 'idle', query, items: [] });
|
||||
publish({ status: 'idle', query, items: [], more: false, next: 1, scope:{ ...scope } });
|
||||
return;
|
||||
}
|
||||
onState({ status: 'loading', query, items: [] });
|
||||
timer = setTimeout(async () => {
|
||||
const requestController = new AbortController();
|
||||
activeController = requestController;
|
||||
try {
|
||||
const result = await search(query, requestController.signal);
|
||||
const items = Array.isArray(result) ? result : result.items;
|
||||
const partial = !Array.isArray(result) && result.partial === true;
|
||||
if (current === generation) onState({ status: 'ready', query, items, partial });
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (current === generation) onState({ status: 'error', query, items: [], error });
|
||||
} finally {
|
||||
if (activeController === requestController) activeController = null;
|
||||
}
|
||||
}, delay);
|
||||
publish({ status: 'loading', query, items: [], more: false, next: 1, scope:{ ...scope } });
|
||||
timer = setTimeout(() => requestPage(query, 1, current, false), delay);
|
||||
},
|
||||
setScope(value) {
|
||||
const repository = typeof value?.repository === 'string' &&
|
||||
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
|
||||
? value.repository.trim() : '';
|
||||
const next = {
|
||||
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
|
||||
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
|
||||
...(repository ? { repository } : {}),
|
||||
};
|
||||
if (next.kind === scope.kind && next.state === scope.state &&
|
||||
(next.repository || '') === (scope.repository || '')) return;
|
||||
const query = state.query;
|
||||
scope = next;
|
||||
this.setQuery(query);
|
||||
},
|
||||
loadMore() {
|
||||
if (state.status !== 'ready' || !state.more || activeController) return Promise.resolve(state);
|
||||
const current = generation;
|
||||
const page = state.next;
|
||||
return requestPage(state.query, page, current, true, state.continuation);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,6 +37,8 @@ function createContextPoller({
|
||||
let retainedSnapshot = null;
|
||||
let nextDelayMs = intervalMs;
|
||||
let failureStreak = 0;
|
||||
let nextRetryAt = null;
|
||||
let lastSuccessAt = null;
|
||||
|
||||
function snapshotDelay(snapshot) {
|
||||
const freshness = snapshot && snapshot.freshness;
|
||||
@@ -69,8 +71,10 @@ function createContextPoller({
|
||||
function schedule(delayMs = intervalMs) {
|
||||
cancelTimer();
|
||||
if (stopped || isHidden()) return;
|
||||
nextRetryAt = Date.now() + delayMs;
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
nextRetryAt = null;
|
||||
refresh();
|
||||
}, delayMs);
|
||||
}
|
||||
@@ -125,6 +129,7 @@ function createContextPoller({
|
||||
.then((snapshot) => {
|
||||
if (activeRequest !== requestState) return retainedSnapshot;
|
||||
failureStreak = 0;
|
||||
lastSuccessAt = Date.now();
|
||||
nextDelayMs = snapshotDelay(snapshot);
|
||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||
@@ -165,6 +170,14 @@ function createContextPoller({
|
||||
start: refresh,
|
||||
refresh,
|
||||
setVisible,
|
||||
getState() {
|
||||
return {
|
||||
refreshing: Boolean(activeRequest),
|
||||
nextRetryAt,
|
||||
lastSuccessAt,
|
||||
failureStreak,
|
||||
};
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelTimer();
|
||||
|
||||
+144
-3
@@ -5,7 +5,17 @@ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text);
|
||||
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
|
||||
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.live-data-status-sheet[hidden] { display:none; }
|
||||
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
|
||||
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
|
||||
.live-data-status-feeds { display:grid; gap:8px; margin:14px 0; }
|
||||
.live-data-status-feed { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.live-data-status-feed strong, .live-data-status-feed span { overflow-wrap:anywhere; }
|
||||
.live-data-status-actions { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||
.app-menu { margin-left:auto; }
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
@@ -38,6 +48,9 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; }
|
||||
.device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.device-setup-step p { margin:5px 0 0; }
|
||||
.device-setup-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; }
|
||||
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
|
||||
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
|
||||
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
|
||||
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
|
||||
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
|
||||
@@ -77,6 +90,14 @@ main { position: relative; z-index: 1; display: grid; grid-template-columns: 300
|
||||
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
||||
a { color: #60a5fa; text-decoration: none; }
|
||||
.muted { color: #94a3b8; }
|
||||
.empty-work-start { display:grid; gap:12px; margin:12px 0; padding:16px; border:1px solid #31577f; border-radius:14px; background:#0b1b2f; }
|
||||
.empty-work-start[hidden] { display:none; }
|
||||
.empty-work-start p { margin:6px 0 0; }
|
||||
.empty-work-start-actions { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); gap:8px; }
|
||||
.empty-work-start-actions button { min-width:0; min-height:44px; white-space:normal; }
|
||||
@media (max-width:420px) {
|
||||
.empty-work-start-actions { grid-template-columns:1fr; }
|
||||
}
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
||||
.kv .label { color: #94a3b8; }
|
||||
.kv .value { color: #e5e7eb; }
|
||||
@@ -93,11 +114,43 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||||
#cmd-palette.open { display: block; }
|
||||
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.cmd-palette-header-actions { display:flex; gap:8px; }
|
||||
.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
|
||||
.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; }
|
||||
.cmd-search-scope label { font-size:12px; color:#cbd5e1; }
|
||||
.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
|
||||
.cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
|
||||
.cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; }
|
||||
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
|
||||
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
|
||||
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
|
||||
.cmd-select-result { min-height:44px; width:100%; display:grid; grid-template-columns:28px 1fr auto; gap:10px; align-items:center; text-align:left; }
|
||||
.cmd-select-result input { width:22px; height:22px; }
|
||||
.cmd-select-result[disabled] { cursor:not-allowed; opacity:.62; }
|
||||
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||
.search-batch-actions[hidden] { display:none; }
|
||||
.search-batch-actions span { grid-column:1 / -1; }
|
||||
.search-batch-actions button, .search-batch-recovery { min-height:44px; }
|
||||
.search-batch-recovery { width:100%; font-weight:700; }
|
||||
.search-batch-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.search-batch-estimate-review[hidden] { display:none; }
|
||||
.search-batch-estimate-review > div:first-child, #search-batch-estimate-list { display:grid; gap:8px; }
|
||||
.search-batch-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
|
||||
.search-batch-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.search-batch-estimate-review input { min-height:44px; width:76px; }
|
||||
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-batch-estimate-actions button { min-height:44px; }
|
||||
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.search-release-review[hidden] { display:none; }
|
||||
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
|
||||
.search-release-review label { display:grid; gap:6px; }
|
||||
.search-release-review select, .search-release-review input { min-height:44px; width:100%; }
|
||||
.search-release-row { padding:8px 0; border-bottom:1px solid #2a496e; }
|
||||
.search-release-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-release-actions button { min-height:44px; }
|
||||
|
||||
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
|
||||
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
||||
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
|
||||
@@ -235,12 +288,26 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.draft-filing-session-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); }
|
||||
.agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; }
|
||||
.agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.agenda-replan-launch p { margin:4px 0 0; }
|
||||
.agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; }
|
||||
.agenda-replan-actions button { min-height:44px; width:100%; }
|
||||
.agenda-replan-actions label { grid-column:1/-1; }
|
||||
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; }
|
||||
.protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.protect-today p { margin:4px 0; }
|
||||
.protect-today button { min-height:44px; flex:0 0 auto; }
|
||||
@media(max-width:360px) { .agenda-replan-launch { align-items:stretch; flex-direction:column; } .agenda-replan-actions { grid-template-columns:1fr; } .agenda-replan-actions label { grid-column:auto; } }
|
||||
@media(max-width:430px) { .protect-today { align-items:stretch; flex-direction:column; } .protect-today button { width:100%; } }
|
||||
.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.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.update-selection-controls button { min-height:44px; }
|
||||
.selection-scope-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; max-width:100%; }
|
||||
.selection-scope-actions button { min-height:44px; max-width:100%; }
|
||||
.update-selector { min-height:44px; }
|
||||
.work-selector { min-height:44px; }
|
||||
.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
@@ -348,7 +415,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.update-sheet-header button { min-height:44px; }
|
||||
.update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.update-new-activity { scroll-margin-block:18px 96px; border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||
.update-new-activity::before { content:'New activity'; display:inline-block; margin-bottom:6px; padding:2px 7px; border-radius:999px; background:#1d4ed8; color:#fff; font-size:12px; font-weight:700; }
|
||||
.update-new-activity-jump { position:sticky; bottom:92px; z-index:4; min-height:44px; width:100%; margin:10px 0; border-color:#60a5fa; background:#17345a; }
|
||||
.update-reply { display:grid; gap:8px; margin-top:16px; }
|
||||
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
|
||||
.update-reply button { min-height:44px; width:100%; }
|
||||
@@ -365,7 +436,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-ownership-actions button { min-width:0; width:100%; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions #mute-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-more-actions { margin-top:14px; }
|
||||
.update-more-actions > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-decision-bar { display:grid; grid-template-columns:repeat(auto-fit,minmax(76px,1fr)); gap:6px; margin-top:14px; padding:8px; background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.update-decision-bar button, .update-decision-bar summary { min-height:44px; min-width:0; padding-inline:6px; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.issue-sheet.open { display:flex; }
|
||||
@@ -451,9 +527,38 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.find-work-header-actions { display:flex; gap:8px; }
|
||||
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
|
||||
.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
|
||||
.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.find-work-search input, .find-work-search button { min-height:44px; }
|
||||
.find-work-filters { border:1px solid #2a496e; border-radius:10px; padding:10px; }
|
||||
.find-work-filters summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
|
||||
.find-work-filter-panel, .find-work-filter-options { display:grid; gap:8px; }
|
||||
.find-work-filter-panel fieldset { min-width:0; border:0; padding:6px 0; margin:0; }
|
||||
.find-work-filter-options { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
|
||||
.find-work-filter-option { min-height:44px; display:flex; align-items:center; gap:8px; padding:6px; overflow-wrap:anywhere; }
|
||||
.find-work-filter-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||
#clear-find-work-filters { min-height:44px; width:100%; }
|
||||
.find-work-list { display:grid; gap:10px; }
|
||||
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||
.find-work-select { display:flex; align-items:center; gap:10px; min-height:44px; }
|
||||
.find-work-select input { width:22px; height:22px; }
|
||||
.fill-find-work-today { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-batch-actions { position:sticky; bottom:0; z-index:2; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:12px 0 calc(12px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||
.find-work-batch-actions[hidden] { display:none; }
|
||||
.find-work-batch-actions span { grid-column:1 / -1; }
|
||||
.find-work-batch-actions button { min-height:44px; }
|
||||
.find-work-batch-recovery { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.find-work-estimate-review[hidden] { display:none; }
|
||||
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
|
||||
.find-work-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
|
||||
.find-work-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.find-work-estimate-review input { min-height:44px; width:76px; }
|
||||
.find-work-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.find-work-estimate-actions button { min-height:44px; }
|
||||
.find-work-card button { width:100%; font-weight:700; }
|
||||
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
|
||||
@@ -466,8 +571,23 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
|
||||
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
|
||||
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.search-preview-conversation { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-conversation h2 { margin:0; font-size:1rem; }
|
||||
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||
.search-preview-conversation button { min-height:44px; width:100%; }
|
||||
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-reply[hidden] { display:none; }
|
||||
.search-preview-reply h2 { margin:0; font-size:1rem; }
|
||||
.search-preview-reply textarea { box-sizing:border-box; width:100%; min-height:110px; resize:vertical; scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom)); }
|
||||
.search-preview-reply-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.search-preview-reply-actions button { min-height:44px; }
|
||||
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
|
||||
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
@media (max-width:420px) {
|
||||
.search-preview-primary-actions { grid-template-columns:1fr; }
|
||||
}
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||
.markdown-content > :first-child { margin-top:0; }
|
||||
@@ -545,12 +665,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.mobile-task-dock { display:none; }
|
||||
.mobile-task-dock[hidden] { display:none; }
|
||||
|
||||
.mobile-today-hud { display:none; }
|
||||
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
.mobile-task-action[hidden] { display:none; }
|
||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; max-width:100%; padding:1px 5px; border-radius:999px; background:#31577f; font-size:10px; line-height:16px; white-space:nowrap; }
|
||||
.mobile-task-deadline { padding:1px 5px; border-radius:999px; color:#fef3c7; background:#92400e; font-size:10px; line-height:14px; }
|
||||
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
|
||||
.attention-interruption button { min-height:44px; flex:0 0 auto; }
|
||||
@media (max-width: 600px) {
|
||||
@@ -593,6 +715,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
|
||||
.update-sheet-panel { padding-bottom:calc(82px + env(safe-area-inset-bottom)); }
|
||||
.update-gesture-status { display:block; padding:6px 10px; border-radius:999px; text-align:center; background:#102641; }
|
||||
.update-sheet-panel[data-triage-gesture="keep"] { box-shadow:inset 8px 0 #60a5fa; }
|
||||
.update-sheet-panel[data-triage-gesture="read"] { box-shadow:inset -8px 0 #34d399; }
|
||||
.update-decision-bar { position:fixed; inset:auto 0 0; z-index:57; margin:0; padding-bottom:calc(8px + env(safe-area-inset-bottom)); }
|
||||
.update-more-actions > summary { display:none; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-blocker-row { grid-template-columns:1fr; }
|
||||
.issue-blocker-remove { width:100%; }
|
||||
@@ -600,8 +728,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
|
||||
#cmd-palette.open { display:flex; flex-direction:column; }
|
||||
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
|
||||
#close-command-palette { min-height:44px; }
|
||||
#close-command-palette, #cmd-load-more { min-height:44px; }
|
||||
#cmd-input { flex:0 0 auto; min-height:44px; }
|
||||
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
|
||||
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; }
|
||||
#cmd-search-repository { min-height:44px; width:100%; }
|
||||
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
@@ -618,6 +749,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
|
||||
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
@@ -627,6 +759,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||
.mobile-queue-list small { color:var(--muted); }
|
||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
|
||||
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
||||
.mobile-update-outcome { margin-top:12px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
|
||||
.mobile-update-outcome p { margin:0 0 10px; }
|
||||
.mobile-update-outcome button { width:100%; min-height:44px; }
|
||||
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
|
||||
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
|
||||
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
@@ -635,6 +772,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-today-hud button { min-height:44px; max-width:100%; }
|
||||
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
|
||||
}
|
||||
@media (min-width:701px) { .update-gesture-status { display:none; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.update-sheet-panel[data-triage-gesture] { transition:none; }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
+989
-107
File diff suppressed because it is too large.
Load diff
+231
-17
@@ -12,7 +12,7 @@
|
||||
<canvas id="bg"></canvas>
|
||||
<header>
|
||||
<div class="app-brand"><span class="obi"></span><strong>Stackchain</strong><span class="muted">Dashboard</span></div>
|
||||
<div class="app-live-status"><span class="dot"></span><span class="small" id="status">Live</span></div>
|
||||
<button class="app-live-status" id="open-live-data-status" type="button" aria-controls="live-data-status-sheet" aria-expanded="false"><span class="dot"></span><span class="small" id="status">Live</span></button>
|
||||
<details class="app-menu">
|
||||
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
|
||||
<div class="app-menu-panel">
|
||||
@@ -26,6 +26,20 @@
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
|
||||
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
|
||||
<div class="live-data-status-header">
|
||||
<div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div>
|
||||
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
|
||||
</div>
|
||||
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
|
||||
<p id="live-data-status-retry" class="small muted"></p>
|
||||
<div class="live-data-status-actions">
|
||||
<button id="refresh-live-data" type="button">Refresh now</button>
|
||||
<span id="live-data-status-result" class="small" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="offline-status" id="offline-status" role="status" aria-live="polite" hidden>
|
||||
Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
|
||||
</div>
|
||||
@@ -82,6 +96,16 @@
|
||||
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
|
||||
<div class="device-setup-deadline-controls">
|
||||
<label for="device-setup-deadline-hour">Local hour</label>
|
||||
<select id="device-setup-deadline-hour" aria-label="Deadline reminder local hour"></select>
|
||||
<label for="device-setup-deadline-days">Warn me</label>
|
||||
<select id="device-setup-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select>
|
||||
<button class="device-setup-action" id="device-setup-deadline" type="button">Enable</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
@@ -109,6 +133,7 @@
|
||||
<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="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
|
||||
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">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>
|
||||
@@ -128,6 +153,10 @@
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
|
||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
|
||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||
@@ -169,6 +198,10 @@
|
||||
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="selection-scope-actions" id="selection-scope-actions" hidden>
|
||||
<button id="select-matching-work" type="button">Select all matches</button>
|
||||
<button id="clear-work-selection" type="button">Clear selection</button>
|
||||
</div>
|
||||
<form class="queue-finder" id="queue-finder" role="search">
|
||||
<label for="queue-find-input">Find in <span id="queue-find-label">All</span></label>
|
||||
<div class="queue-finder-row">
|
||||
@@ -178,6 +211,41 @@
|
||||
<div class="small" id="queue-find-status" role="status" aria-live="polite"></div>
|
||||
<button id="search-older-work" type="button" hidden>Search older work</button>
|
||||
</form>
|
||||
<section class="empty-work-start" id="empty-work-start" aria-labelledby="empty-work-start-heading" hidden>
|
||||
<div>
|
||||
<strong id="empty-work-start-heading">Ready for something new?</strong>
|
||||
<p class="small muted">Find an available issue to own, or create the work that needs doing.</p>
|
||||
</div>
|
||||
<div class="empty-work-start-actions">
|
||||
<button id="empty-work-find" type="button">Find and start work</button>
|
||||
<button id="empty-work-create" type="button">Create an issue</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="agenda-replan" id="agenda-replan" aria-labelledby="agenda-replan-heading" hidden>
|
||||
<div class="agenda-replan-launch">
|
||||
<div><strong id="agenda-replan-heading">Overdue deadlines</strong><p class="small muted">Make the Agenda credible without leaving the queue.</p></div>
|
||||
<button id="start-agenda-replan" type="button">Replan overdue</button>
|
||||
</div>
|
||||
<div id="agenda-replan-controls" hidden>
|
||||
<div class="small" id="agenda-replan-progress" role="status" aria-live="assertive"></div>
|
||||
<div class="agenda-replan-actions">
|
||||
<button id="agenda-replan-keep" type="button">Keep date & next</button>
|
||||
<button id="agenda-replan-tomorrow" type="button">Tomorrow & next</button>
|
||||
<label for="agenda-replan-date">Choose a future date</label>
|
||||
<input id="agenda-replan-date" type="date" />
|
||||
<button id="agenda-replan-choose" type="button">Choose date & next</button>
|
||||
<button id="agenda-replan-cancel" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="protect-today" id="protect-today-panel" aria-labelledby="protect-today-heading" hidden>
|
||||
<div>
|
||||
<strong id="protect-today-heading">Deadlines need a plan</strong>
|
||||
<p class="small muted">Put overdue and due-today work first, then review what fits.</p>
|
||||
<div class="small" id="protect-today-status" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<button id="protect-today" type="button">Protect Today</button>
|
||||
</section>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
||||
@@ -358,10 +426,65 @@
|
||||
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
||||
<div class="cmd-palette-header">
|
||||
<strong>Search work</strong>
|
||||
<button id="close-command-palette" type="button">Close</button>
|
||||
<div class="cmd-palette-header-actions">
|
||||
<button id="select-search-results" type="button">Select</button>
|
||||
<button id="close-command-palette" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
|
||||
<fieldset class="cmd-search-scope">
|
||||
<legend>Filter search results</legend>
|
||||
<label for="cmd-search-kind">Type</label>
|
||||
<select id="cmd-search-kind">
|
||||
<option value="all">All work</option>
|
||||
<option value="issue">Issues</option>
|
||||
<option value="pull">Pull requests</option>
|
||||
</select>
|
||||
<label for="cmd-search-state">Status</label>
|
||||
<select id="cmd-search-state">
|
||||
<option value="all">Any status</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
<label for="cmd-search-repository">Repository</label>
|
||||
<input id="cmd-search-repository" type="search" list="cmd-search-repositories" autocomplete="off" placeholder="All repositories" aria-describedby="cmd-repository-status" />
|
||||
<datalist id="cmd-search-repositories"></datalist>
|
||||
<span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span>
|
||||
</fieldset>
|
||||
<div id="cmd-search-action-status" class="small" aria-live="polite"></div>
|
||||
<button id="resume-search-batch" class="search-batch-recovery" type="button" hidden>Resume batch</button>
|
||||
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
|
||||
<button id="cmd-load-more" hidden>More results</button>
|
||||
<div class="search-batch-actions" id="search-batch-actions" hidden>
|
||||
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
|
||||
<button id="cancel-search-selection" type="button">Cancel</button>
|
||||
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
|
||||
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
|
||||
<button id="queue-selected-search-results" type="button" disabled>Assign & add to Today</button>
|
||||
</div>
|
||||
<section class="search-batch-estimate-review" id="search-batch-estimate-review"
|
||||
aria-labelledby="search-batch-estimate-heading" hidden>
|
||||
<div><strong id="search-batch-estimate-heading">Fit this work into Today</strong>
|
||||
<span class="small" id="search-batch-estimate-summary" aria-live="polite"></span></div>
|
||||
<div id="search-batch-estimate-list"></div>
|
||||
<div class="search-batch-estimate-actions">
|
||||
<button id="cancel-search-batch-estimates" type="button">Back</button>
|
||||
<button id="confirm-search-batch-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="search-release-review" id="search-release-review"
|
||||
aria-labelledby="search-release-heading" hidden>
|
||||
<div><strong id="search-release-heading">Plan selected issues into a release</strong>
|
||||
<span class="small" id="search-release-summary" aria-live="polite"></span></div>
|
||||
<label>Open milestone<select id="search-release-milestone" required></select></label>
|
||||
<label>Shared due date <span class="small">(optional)</span>
|
||||
<input id="search-release-due-date" type="date" /></label>
|
||||
<div id="search-release-list"></div>
|
||||
<div class="search-release-actions">
|
||||
<button id="cancel-search-release" type="button">Back</button>
|
||||
<button id="confirm-search-release" type="button">Assign & plan</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
|
||||
@@ -376,12 +499,36 @@
|
||||
<div id="search-preview-status" class="small" aria-live="polite">Choose a search result.</div>
|
||||
<div id="search-preview-meta" class="row"></div>
|
||||
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
|
||||
<section id="search-preview-conversation" class="search-preview-conversation" aria-labelledby="search-preview-conversation-title">
|
||||
<h2 id="search-preview-conversation-title">Current conversation</h2>
|
||||
<div id="search-preview-comments"></div>
|
||||
<div id="search-preview-conversation-status" class="small" aria-live="polite"></div>
|
||||
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
|
||||
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
|
||||
</section>
|
||||
<section class="search-preview-reply" aria-labelledby="search-preview-reply-title" hidden>
|
||||
<h2 id="search-preview-reply-title">Reply</h2>
|
||||
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
<div id="search-preview-reply-status" class="small" aria-live="polite"></div>
|
||||
<div class="search-preview-reply-actions">
|
||||
<button id="send-search-preview-reply" type="button">Send</button>
|
||||
<button id="send-search-preview-reply-next" type="button">Send & next</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="search-preview-actions">
|
||||
<nav class="search-preview-navigation" aria-label="Search result navigation" hidden>
|
||||
<button id="previous-search-result" type="button">Previous</button>
|
||||
<span id="search-preview-position" aria-live="polite"></span>
|
||||
<button id="next-search-result" type="button">Next</button>
|
||||
</nav>
|
||||
<div class="search-preview-primary-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<button id="defer-search-result" type="button" hidden>Assign & defer</button>
|
||||
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
|
||||
</div>
|
||||
<button id="share-search-result" type="button" disabled>Share result</button>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -528,12 +675,44 @@
|
||||
<section class="find-work-panel">
|
||||
<div class="find-work-header">
|
||||
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
|
||||
<button id="close-find-work" type="button">Close</button>
|
||||
<div class="find-work-header-actions"><button id="select-find-work" type="button">Select work</button><button id="close-find-work" type="button">Close</button></div>
|
||||
</div>
|
||||
<p class="small">Claim an available issue and continue it in My Work.</p>
|
||||
<form class="find-work-search" id="find-work-search-form" role="search">
|
||||
<label for="find-work-search">Search repository, #number, or title</label>
|
||||
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
|
||||
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
|
||||
<span id="find-work-match-status" class="small" aria-live="polite"></span>
|
||||
</form>
|
||||
<details class="find-work-filters" id="find-work-filters">
|
||||
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
|
||||
<div class="find-work-filter-panel" aria-label="Filter available work">
|
||||
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<button id="clear-find-work-filters" type="button">Clear filters</button>
|
||||
</div>
|
||||
</details>
|
||||
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
||||
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
||||
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
|
||||
aria-describedby="find-work-status" hidden>Resume assigning & queueing</button>
|
||||
<div class="find-work-list" id="find-work-list"></div>
|
||||
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
||||
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
|
||||
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
|
||||
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
|
||||
<div id="find-work-estimate-list"></div>
|
||||
<div class="find-work-estimate-actions">
|
||||
<button id="cancel-find-work-estimates" type="button">Back</button>
|
||||
<button id="confirm-find-work-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
|
||||
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
|
||||
<button id="cancel-find-work-selection" type="button">Cancel</button>
|
||||
<button id="claim-selected-work" type="button" disabled>Assign & queue Today</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -643,14 +822,15 @@
|
||||
</div>
|
||||
|
||||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||||
<section class="update-sheet-panel">
|
||||
<section class="update-sheet-panel" data-triage-gesture="">
|
||||
<div class="update-sheet-header">
|
||||
<div>
|
||||
<div class="small" id="update-sheet-key"></div>
|
||||
<h3 id="update-sheet-title">Unread update</h3>
|
||||
</div>
|
||||
<button id="keep-update-unread" type="button">Keep unread</button>
|
||||
</div>
|
||||
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
||||
<div id="update-gesture-status" class="update-gesture-status small" aria-live="polite">Swipe right to keep unread or left to mark read. Buttons remain available.</div>
|
||||
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
|
||||
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
|
||||
<div class="row">
|
||||
@@ -659,8 +839,10 @@
|
||||
</div>
|
||||
<h2>Full conversation</h2>
|
||||
<div id="update-comments"></div>
|
||||
<button class="update-new-activity-jump" id="jump-update-new-activity" type="button" hidden>Jump to new activity</button>
|
||||
<button class="conversation-more" id="load-older-update-comments" type="button" hidden>Load older messages</button>
|
||||
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<button class="update-retry" id="retry-update-conversation" type="button" hidden>Retry conversation</button>
|
||||
<details>
|
||||
<summary><h2>Subject context</h2></summary>
|
||||
<div class="update-sheet-content muted markdown-content" id="update-subject-body"></div>
|
||||
@@ -686,18 +868,21 @@
|
||||
</div>
|
||||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="update-sheet-actions">
|
||||
<div class="update-ownership-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership & start</button>
|
||||
<details class="update-more-actions">
|
||||
<summary>More actions</summary>
|
||||
<div class="update-sheet-actions">
|
||||
<div class="update-ownership-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership & start</button>
|
||||
</div>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||
<button id="mute-update-next" type="button" hidden>Mute future updates & next</button>
|
||||
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
</details>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
@@ -705,6 +890,13 @@
|
||||
<button type="button" data-work-session-complete="update" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
<div class="update-decision-bar" aria-label="Update decisions">
|
||||
<button id="keep-update-unread" type="button" aria-label="Keep unread and open next update">Keep unread</button>
|
||||
<button id="mark-update-read-next" type="button" aria-label="Mark read and open next update">Mark read</button>
|
||||
<button id="review-update-now" type="button" hidden>Review now</button>
|
||||
<button id="focus-update-reply" type="button">Reply</button>
|
||||
<button id="toggle-update-more" type="button" aria-expanded="false">More</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -882,13 +1074,19 @@
|
||||
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
|
||||
<section class="mobile-queue-panel">
|
||||
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
|
||||
<div id="mobile-update-outcome" class="mobile-update-outcome" role="status" aria-live="polite" hidden>
|
||||
<p id="mobile-update-outcome-summary"></p>
|
||||
<button id="review-kept-updates" type="button" hidden>Review kept unread</button>
|
||||
</div>
|
||||
<p class="small muted">Choose what to work through next.</p>
|
||||
<div class="mobile-queue-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
|
||||
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -899,7 +1097,7 @@
|
||||
<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>
|
||||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues, 0 items">Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues: no active queues; no upcoming deadlines"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0 active</span></span><span class="mobile-task-deadline" id="mobile-deadline-count" hidden>0 due</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
@@ -910,9 +1108,11 @@
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/search-defer.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/unfiled-captures.js"></script>
|
||||
<script src="static/shared-image-capture.js"></script>
|
||||
<script src="static/draft-filing-session.js"></script>
|
||||
<script src="static/draft-capacity-dialog.js"></script>
|
||||
<script src="static/outbox-coordinator.js"></script>
|
||||
@@ -925,6 +1125,8 @@
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/agenda-replan.js"></script>
|
||||
<script src="static/protect-today.js"></script>
|
||||
<script src="static/notification-undo.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/work-selection.js"></script>
|
||||
@@ -948,6 +1150,8 @@
|
||||
<script src="static/detail-defer.js"></script>
|
||||
<script src="static/later-picker.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
<script src="static/batch-find-work.js"></script>
|
||||
<script src="static/search-batch-plan.js"></script>
|
||||
<script src="static/conversation.js"></script>
|
||||
<script src="static/comment-actions.js"></script>
|
||||
<script src="static/issue-attachment.js"></script>
|
||||
@@ -961,10 +1165,20 @@
|
||||
<script src="static/work-route.js"></script>
|
||||
<script src="static/task-overlay-history.js"></script>
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script src="static/live-data-status.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/update-triage-session.js"></script>
|
||||
<script src="static/update-review-handoff.js"></script>
|
||||
<script src="static/update-read-position.js"></script>
|
||||
<script src="static/work-detail-position.js"></script>
|
||||
<script src="static/update-triage-launcher.js"></script>
|
||||
<script src="static/update-triage-gesture.js"></script>
|
||||
<script src="static/update-decision-transaction.js"></script>
|
||||
<script src="static/agenda-session-launcher.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
|
||||
@@ -90,7 +90,8 @@ function createLaterPicker({
|
||||
onState({ open:true, message:result.message, value:String(input || '') });
|
||||
return false;
|
||||
}
|
||||
const confirmed = onConfirm(item, result.value, context);
|
||||
const confirmed = typeof item?.confirm === 'function' ? item.confirm(result.value) :
|
||||
onConfirm(item, result.value, context);
|
||||
if (confirmed !== true && typeof confirmed !== 'function') return false;
|
||||
committed = true;
|
||||
afterClose = typeof confirmed === 'function' ? confirmed : null;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.liveDataStatus = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const feeds = [
|
||||
['context', 'Work'],
|
||||
['notifications', 'Updates'],
|
||||
['events', 'Activity'],
|
||||
];
|
||||
|
||||
function boundedSeconds(value) {
|
||||
const seconds = Number(value);
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(Math.round(seconds), 86400) : null;
|
||||
}
|
||||
|
||||
function describe(freshness = {}) {
|
||||
const sections = freshness.sections || {};
|
||||
const hasSectionData = feeds.some(([key]) => Object.prototype.hasOwnProperty.call(sections, key));
|
||||
const described = feeds.map(([key, label]) => {
|
||||
const section = sections[key] || {};
|
||||
const state = section.revalidating ? 'refreshing' :
|
||||
(section.stale || section.degraded ? 'delayed' : 'live');
|
||||
return { key, label, state, ageSeconds: boundedSeconds(section.age_seconds) };
|
||||
});
|
||||
const delayed = described.filter(feed => feed.state === 'delayed');
|
||||
const refreshing = described.filter(feed => feed.state === 'refreshing');
|
||||
let summary = hasSectionData ? 'Live' : 'Live data unavailable';
|
||||
if (delayed.length === 1) summary = delayed[0].label + ' delayed';
|
||||
else if (delayed.length > 1) summary = delayed.length + ' data feeds delayed';
|
||||
else if (refreshing.length === 1) summary = refreshing[0].label + ' refreshing';
|
||||
else if (refreshing.length > 1) summary = 'Refreshing live data';
|
||||
const retryValues = described.map(feed => {
|
||||
const section = sections[feed.key] || {};
|
||||
return boundedSeconds(section.retry_in_seconds);
|
||||
}).filter(value => value !== null && value > 0);
|
||||
const aggregateRetry = boundedSeconds(freshness.retry_in_seconds);
|
||||
if (aggregateRetry !== null && aggregateRetry > 0) retryValues.push(aggregateRetry);
|
||||
return {
|
||||
summary,
|
||||
feeds: described,
|
||||
nextRetrySeconds: retryValues.length ? Math.min(...retryValues) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function createRefreshController({ button, output, refresh, onState = () => {} }) {
|
||||
let pending = null;
|
||||
function run() {
|
||||
if (pending) return pending;
|
||||
button.disabled = true;
|
||||
output.textContent = 'Refreshing live data…';
|
||||
onState('refreshing');
|
||||
let request;
|
||||
try {
|
||||
request = refresh();
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
pending = Promise.resolve(request).then(result => {
|
||||
if (!result) throw new Error('Live data remains unavailable.');
|
||||
output.textContent = 'Live data refreshed.';
|
||||
onState('success');
|
||||
return result;
|
||||
}).catch(error => {
|
||||
output.textContent = error && error.message ? error.message : 'Live data refresh failed.';
|
||||
onState('error');
|
||||
return null;
|
||||
}).finally(() => {
|
||||
button.disabled = false;
|
||||
pending = null;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
return { run };
|
||||
}
|
||||
|
||||
return { describe, createRefreshController };
|
||||
});
|
||||
+73
-10
@@ -4,7 +4,8 @@
|
||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||
function validShareContinuation(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
||||
const limits = { title: 200, text: 8000, url: 2048 };
|
||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200,
|
||||
preview: 200, search_kind: 5, search_state: 6, search_repository: 161 };
|
||||
const params = new URLSearchParams(value.slice(3));
|
||||
const entries = Array.from(params.entries());
|
||||
if (!entries.length) return './';
|
||||
@@ -14,6 +15,27 @@
|
||||
|| content.length > limits[name]
|
||||
));
|
||||
if (invalid) return './';
|
||||
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
|
||||
const search = params.get('search');
|
||||
const preview = params.get('preview');
|
||||
if (search !== null || preview !== null) {
|
||||
if (entries.some(([name]) => ![
|
||||
'search', 'preview', 'search_kind', 'search_state', 'search_repository'
|
||||
].includes(name))) return './';
|
||||
if (search === null || preview === null) return './';
|
||||
if (!/^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9]\d*$/.test(preview)) return './';
|
||||
const kind = params.get('search_kind');
|
||||
const state = params.get('search_state');
|
||||
const repository = params.get('search_repository');
|
||||
if (kind !== null && !['all', 'issue', 'pull'].includes(kind)) return './';
|
||||
if (state !== null && !['all', 'open', 'closed'].includes(state)) return './';
|
||||
if (repository !== null && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return './';
|
||||
return value;
|
||||
}
|
||||
const launch = params.get('launch');
|
||||
const shared = params.get('shared');
|
||||
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
|
||||
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -28,7 +50,31 @@
|
||||
const continuation = validShareContinuation(options.continuation);
|
||||
const setIntervalImpl = options.setIntervalImpl || setInterval;
|
||||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||||
const setTimeoutImpl = options.setTimeoutImpl || setTimeout;
|
||||
const clearTimeoutImpl = options.clearTimeoutImpl || clearTimeout;
|
||||
const requestTimeoutMs = options.requestTimeoutMs || 15000;
|
||||
let timer = null;
|
||||
let activeAttempt = false;
|
||||
|
||||
function setAttemptActive(active) {
|
||||
activeAttempt = active;
|
||||
button.disabled = active;
|
||||
if (passkeyButton) passkeyButton.disabled = active || !credentials?.get;
|
||||
}
|
||||
|
||||
async function requestWithDeadline(url, init) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeoutImpl(() => {
|
||||
const error = new Error('Sign-in request timed out');
|
||||
error.name = 'TimeoutError';
|
||||
controller.abort(error);
|
||||
}, requestTimeoutMs);
|
||||
try {
|
||||
return await fetchImpl(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeoutImpl(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
|
||||
@@ -58,7 +104,12 @@
|
||||
};
|
||||
}
|
||||
|
||||
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
|
||||
if (continuation !== './') {
|
||||
const continuationParams = new URLSearchParams(continuation.slice(3));
|
||||
status.textContent = continuationParams.has('preview')
|
||||
? 'Sign in to open the shared Search result.'
|
||||
: 'Sign in to continue your shared capture.';
|
||||
}
|
||||
|
||||
async function showReason(reason) {
|
||||
if (reason === 'session-expired') {
|
||||
@@ -100,18 +151,24 @@
|
||||
}
|
||||
|
||||
async function submit(accessToken, deviceLabel = 'This device') {
|
||||
if (activeAttempt) return false;
|
||||
setAttemptActive(true);
|
||||
status.textContent = 'Signing in…';
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl('api/v1/session', {
|
||||
response = await requestWithDeadline('api/v1/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }),
|
||||
});
|
||||
} catch (_error) {
|
||||
} catch (error) {
|
||||
form.reset();
|
||||
status.textContent = 'Sign-in failed. Check your connection and try again.';
|
||||
return;
|
||||
status.textContent = error?.name === 'TimeoutError'
|
||||
? 'Sign-in timed out. Check your connection and try again.'
|
||||
: 'Sign-in failed. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
setAttemptActive(false);
|
||||
}
|
||||
form.reset();
|
||||
if (response.ok) {
|
||||
@@ -130,9 +187,11 @@
|
||||
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
|
||||
return false;
|
||||
}
|
||||
if (activeAttempt) return false;
|
||||
setAttemptActive(true);
|
||||
status.textContent = 'Waiting for your passkey…';
|
||||
try {
|
||||
const optionsResponse = await fetchImpl('api/v1/passkeys/authentication/options', {
|
||||
const optionsResponse = await requestWithDeadline('api/v1/passkeys/authentication/options', {
|
||||
method: 'POST', headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
|
||||
@@ -143,7 +202,7 @@
|
||||
...item, id: decodeBase64Url(item.id),
|
||||
}));
|
||||
const credential = await credentials.get({ publicKey });
|
||||
const response = await fetchImpl('api/v1/passkeys/authentication/verify', {
|
||||
const response = await requestWithDeadline('api/v1/passkeys/authentication/verify', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -157,9 +216,13 @@
|
||||
if (!response.ok) throw new Error('Passkey verification failed');
|
||||
location.replace(continuation);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
status.textContent = 'Passkey sign-in was not completed. Try again or use the access token.';
|
||||
} catch (error) {
|
||||
status.textContent = error?.name === 'TimeoutError'
|
||||
? 'Passkey sign-in timed out. Check your connection and try again.'
|
||||
: 'Passkey sign-in was not completed. Try again or use the access token.';
|
||||
return false;
|
||||
} finally {
|
||||
setAttemptActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,38 @@
|
||||
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Continue work",
|
||||
"short_name": "Continue",
|
||||
"description": "Resume the highest-priority mobile work flow.",
|
||||
"url": "./?launch=continue",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
},
|
||||
{
|
||||
"name": "New issue",
|
||||
"short_name": "New",
|
||||
"description": "Capture work now and file it online or offline.",
|
||||
"url": "./?launch=new",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
},
|
||||
{
|
||||
"name": "Open Agenda",
|
||||
"short_name": "Agenda",
|
||||
"description": "Check every assigned deadline and continue the Agenda session.",
|
||||
"url": "./?launch=agenda",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "./",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {"title": "title", "text": "text", "url": "url"}
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.mobileAppShortcuts = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
const ACTIONS = new Set(['continue', 'new', 'agenda']);
|
||||
|
||||
function parse(search) {
|
||||
const action = new URLSearchParams(search || '').get('launch');
|
||||
return ACTIONS.has(action) ? action : null;
|
||||
}
|
||||
|
||||
function createController(options) {
|
||||
const launchAction = parse(options.search);
|
||||
let handled = false;
|
||||
|
||||
function action() { return launchAction; }
|
||||
|
||||
function run() {
|
||||
if (!launchAction || handled) return null;
|
||||
handled = true;
|
||||
if (launchAction === 'continue') return options.continueWork();
|
||||
if (launchAction === 'new') return options.newIssue();
|
||||
return options.agenda();
|
||||
}
|
||||
|
||||
return {action, run};
|
||||
}
|
||||
|
||||
return {parse, createController};
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
['install', options.installButton, options.installStatus, options.install],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
||||
];
|
||||
let trigger = options.launcher;
|
||||
|
||||
@@ -100,8 +101,10 @@ function mountMobileDeviceSetup(options) {
|
||||
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
|
||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
deadlineButton:qs('#device-setup-deadline'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), readyStatus:qs('#device-setup-ready-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
||||
readyStatus:qs('#device-setup-ready-status'),
|
||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
||||
promptStorage:options.promptStorage,
|
||||
@@ -118,9 +121,11 @@ function mountMobileDeviceSetup(options) {
|
||||
: qs('#push-updates').checked
|
||||
? {state:'complete', detail:'New update notifications are enabled.'}
|
||||
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
enablePush:options.enablePush,
|
||||
enableDeadline:options.enableDeadline,
|
||||
});
|
||||
}
|
||||
@@ -4,13 +4,14 @@
|
||||
else root.mobileLaunch = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const allowedFilters = new Set([
|
||||
'all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft',
|
||||
'all', 'today', 'attention', 'agenda', 'issue', 'pull', 'review', 'update', 'later', 'draft',
|
||||
]);
|
||||
|
||||
function chooseFilter({ saved, today = 0, attention = 0 } = {}) {
|
||||
function chooseFilter({ saved, today = 0, attention = 0, agenda = 0 } = {}) {
|
||||
if (allowedFilters.has(saved)) return saved;
|
||||
if (today > 0) return 'today';
|
||||
if (attention > 0) return 'attention';
|
||||
if (agenda > 0) return 'agenda';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,28 @@
|
||||
later: 'No deferred work is ready to open.',
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const continuation = [
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['update', 'Resume Updates'],
|
||||
['agenda', 'Open Agenda'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
];
|
||||
|
||||
function recommend() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const match = continuation.find(([name]) => Number(counts[name]) > 0);
|
||||
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
|
||||
const [name, label] = match;
|
||||
const count = Math.max(0, Number(counts[name]) || 0);
|
||||
return {name, count, label: label + ' (' + count + ')'};
|
||||
}
|
||||
|
||||
function open(name) {
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
if (!action) {
|
||||
@@ -21,5 +40,14 @@
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
return { open };
|
||||
function continueWork() {
|
||||
const next = recommend();
|
||||
if (next.name === 'find') {
|
||||
options.openFindWork();
|
||||
return 'find';
|
||||
}
|
||||
return open(next.name);
|
||||
}
|
||||
|
||||
return { open, recommend, continueWork };
|
||||
});
|
||||
@@ -64,18 +64,15 @@
|
||||
}
|
||||
|
||||
function updateWork(mode) {
|
||||
const labels = {
|
||||
continue: ['Continue', 'Continue Today'],
|
||||
resume: ['Resume', 'Resume Today'],
|
||||
start: ['Start', 'Start Today'],
|
||||
plan: ['Plan', 'Plan Today'],
|
||||
work: ['Work', 'Work'],
|
||||
};
|
||||
const workMode = labels[mode] ? mode : 'work';
|
||||
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
|
||||
if (buttons.work) {
|
||||
buttons.work.setAttribute('aria-label', labels[workMode][1]);
|
||||
}
|
||||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', queue
|
||||
? (mode === 'update' ? 'Resume ' : 'Open ') + text
|
||||
: mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today'));
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
@@ -94,9 +91,10 @@
|
||||
}
|
||||
|
||||
function updateQueues(counts) {
|
||||
const names = 'today attention update later draft'.split(' ');
|
||||
const names = 'today agenda attention update later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const total = ['today', 'attention', 'later', 'draft'].reduce((sum, name) => sum + normalized[name], 0);
|
||||
const actionableNames = ['today', 'attention', 'update', 'later', 'draft'];
|
||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
});
|
||||
@@ -104,10 +102,27 @@
|
||||
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
|
||||
);
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = String(total);
|
||||
options.queueBadge.hidden = total === 0;
|
||||
options.queueBadge.textContent = active + ' active';
|
||||
options.queueBadge.hidden = active === 0;
|
||||
}
|
||||
buttons.queues?.setAttribute('aria-label', 'Queues, ' + total + ' items');
|
||||
if (options.deadlineBadge) {
|
||||
options.deadlineBadge.textContent = normalized.agenda + ' due';
|
||||
options.deadlineBadge.hidden = normalized.agenda === 0;
|
||||
}
|
||||
if (options.queueRows?.agenda) {
|
||||
if (normalized.agenda > 0) options.queueRows.agenda.setAttribute('data-deadlines', 'true');
|
||||
else options.queueRows.agenda.removeAttribute('data-deadlines');
|
||||
}
|
||||
const label = active === 0 && normalized.agenda === 0
|
||||
? 'Queues: no active queues; no upcoming deadlines'
|
||||
: 'Queues: Today ' + normalized.today
|
||||
+ ', Agenda ' + normalized.agenda + ' due'
|
||||
+ ', Attention ' + normalized.attention
|
||||
+ ', Updates ' + normalized.update
|
||||
+ ', Later ' + normalized.later
|
||||
+ ', Drafts ' + normalized.draft
|
||||
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
|
||||
buttons.queues?.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
const recommended = options.queueLauncher && options.queueLauncher.recommend();
|
||||
if (recommended && recommended.name !== 'today') return recommended.name;
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
return 'work';
|
||||
return 'find';
|
||||
}
|
||||
|
||||
function open() {
|
||||
@@ -16,7 +18,8 @@
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else if (current === 'plan') options.planToday();
|
||||
else options.openFallback();
|
||||
else if (current === 'find') options.findWork();
|
||||
else options.queueLauncher.open(current);
|
||||
return current;
|
||||
}
|
||||
|
||||
|
||||
+162
-27
@@ -1,15 +1,26 @@
|
||||
function issueDueState(dueDate, now) {
|
||||
if (!dueDate) return null;
|
||||
const due = new Date(dueDate);
|
||||
if (Number.isNaN(due.getTime())) return null;
|
||||
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
function calendarDay(value) {
|
||||
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
|
||||
if (!match) return '';
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
return date.getFullYear() === Number(match[1]) && date.getMonth() === Number(match[2]) - 1 &&
|
||||
date.getDate() === Number(match[3]) ? match.slice(1, 4).join('-') : '';
|
||||
}
|
||||
|
||||
function localDay(value) {
|
||||
return value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(value.getDate()).padStart(2, '0');
|
||||
const dueDay = day(due);
|
||||
const today = day(now);
|
||||
}
|
||||
|
||||
function issueDueState(dueDate, now) {
|
||||
const dueDay = calendarDay(dueDate);
|
||||
if (!dueDay) return null;
|
||||
const today = localDay(now);
|
||||
if (dueDay < today) return { label: 'Overdue', priority: 2 };
|
||||
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
|
||||
return {
|
||||
label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
label: 'Due ' + new Date(
|
||||
Number(dueDay.slice(0, 4)), Number(dueDay.slice(5, 7)) - 1, Number(dueDay.slice(8, 10))
|
||||
).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
priority: 4,
|
||||
};
|
||||
}
|
||||
@@ -33,6 +44,19 @@ function needsAttention(item) {
|
||||
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
|
||||
}
|
||||
|
||||
function updateActionability(item) {
|
||||
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||
if ((item?.labels || []).some(label => priorityLabels.includes(String(label).toLowerCase()))) {
|
||||
return { priority: 0, reason: 'Critical' };
|
||||
}
|
||||
if (item?.is_review) return { priority: 1, reason: 'Review requested' };
|
||||
if (item?.is_assigned && ['Overdue', 'Due today'].includes(item?.due_label)) {
|
||||
return { priority: 2, reason: item.due_label };
|
||||
}
|
||||
if (item?.is_assigned) return { priority: 3, reason: 'Assigned to you' };
|
||||
return { priority: 4, reason: '' };
|
||||
}
|
||||
|
||||
function buildMyWork(data, now = new Date()) {
|
||||
const login = data.user?.login || '';
|
||||
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
||||
@@ -71,13 +95,15 @@ function buildMyWork(data, now = new Date()) {
|
||||
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
|
||||
const existing = byKey.get(subjectKind + ':' + key);
|
||||
if (existing) {
|
||||
const actionability = updateActionability(existing);
|
||||
existing.has_update = true;
|
||||
existing.needs_attention = true;
|
||||
existing.attention_reason = 'Unread update';
|
||||
existing.notification_id = update.id;
|
||||
existing.url = update.url || existing.url;
|
||||
existing.updated_at = update.updated_at || existing.updated_at;
|
||||
existing._priority = Math.min(existing._priority, 1);
|
||||
existing.update_reason = actionability.reason;
|
||||
existing._priority = actionability.priority;
|
||||
return;
|
||||
}
|
||||
if (!update.url) return;
|
||||
@@ -92,7 +118,8 @@ function buildMyWork(data, now = new Date()) {
|
||||
attention_reason: 'Unread update',
|
||||
notification_id: update.id,
|
||||
reason: 'Unread update',
|
||||
_priority: 1,
|
||||
update_reason: '',
|
||||
_priority: 4,
|
||||
};
|
||||
work.push(item);
|
||||
byKey.set(subjectKind + ':' + key, item);
|
||||
@@ -237,7 +264,8 @@ function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
return {
|
||||
let completing = null;
|
||||
const pager = {
|
||||
reset(next) {
|
||||
pagination = { ...pagination, ...(next || {}) };
|
||||
onPagination(pagination);
|
||||
@@ -271,18 +299,31 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
loadAll(getExisting) {
|
||||
if (completing) return completing;
|
||||
completing = (async () => {
|
||||
while (pagination.has_more) {
|
||||
const loaded = await pager.loadMore(getExisting());
|
||||
if (!loaded) return false;
|
||||
}
|
||||
return true;
|
||||
})().finally(() => { completing = null; });
|
||||
return completing;
|
||||
},
|
||||
};
|
||||
return pager;
|
||||
}
|
||||
|
||||
function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
let pagination = {};
|
||||
const pending = new Set();
|
||||
const completing = new Map();
|
||||
const labels = {
|
||||
issue: 'issues',
|
||||
pull: 'pull requests',
|
||||
review: 'review requests',
|
||||
};
|
||||
return {
|
||||
const pager = {
|
||||
reset(next) {
|
||||
Object.entries(next || {}).forEach(([stream, value]) => {
|
||||
const current = pagination[stream];
|
||||
@@ -331,7 +372,20 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
pending.delete(stream);
|
||||
}
|
||||
},
|
||||
loadAll(stream, getExisting) {
|
||||
if (completing.has(stream)) return completing.get(stream);
|
||||
const completion = (async () => {
|
||||
while (pagination[stream]?.has_more) {
|
||||
const loaded = await pager.loadMore(stream, getExisting());
|
||||
if (!loaded) return false;
|
||||
}
|
||||
return true;
|
||||
})().finally(() => completing.delete(stream));
|
||||
completing.set(stream, completion);
|
||||
return completion;
|
||||
},
|
||||
};
|
||||
return pager;
|
||||
}
|
||||
|
||||
function createNotificationReader({
|
||||
@@ -341,13 +395,52 @@ function createNotificationReader({
|
||||
loadSaved = () => null,
|
||||
loadConversation = null,
|
||||
onConversation = () => {},
|
||||
onConversationStatus = () => {},
|
||||
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
|
||||
getScope = () => '',
|
||||
}) {
|
||||
let selected = null;
|
||||
let loadVersion = 0;
|
||||
let marking = false;
|
||||
let conversationPager = null;
|
||||
let offlineHydrated = false;
|
||||
let prefetched = null;
|
||||
let prefetchKey = '';
|
||||
|
||||
async function hydrateConversation(item, version) {
|
||||
if (!loadConversation || !createPager || !item || offlineHydrated) return false;
|
||||
onConversationStatus('Loading conversation…');
|
||||
try {
|
||||
const initial = await loadConversation(item.notification_id, null);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
conversationPager = createPager({
|
||||
loadPage: page => loadConversation(item.notification_id, page),
|
||||
});
|
||||
const state = conversationPager.reset(initial);
|
||||
onConversation(state);
|
||||
onConversationStatus(state.comments.length + ' of ' +
|
||||
Math.max(state.total || 0, state.comments.length) + ' messages loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (selected === item && version === loadVersion) {
|
||||
conversationPager = null;
|
||||
onConversationStatus('Conversation temporarily unavailable. Retry.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function prefetch(item) {
|
||||
if (!item) { prefetched = null; prefetchKey = ''; return false; }
|
||||
const notificationId = item?.notification_id;
|
||||
if (!Number.isInteger(notificationId) || offlineHydrated) return false;
|
||||
const key = getScope() + notificationId;
|
||||
if (prefetchKey === key) return prefetched;
|
||||
if (prefetched) return false;
|
||||
prefetchKey = key;
|
||||
prefetched = load(notificationId);
|
||||
return prefetched;
|
||||
}
|
||||
|
||||
async function advanceAfterRead(items, current, queueing = false) {
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
@@ -369,10 +462,19 @@ function createNotificationReader({
|
||||
selected = item;
|
||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||
const version = ++loadVersion;
|
||||
const preload = !offlineHydrated && prefetchKey === getScope() + item.notification_id ? prefetched : null;
|
||||
prefetched = null;
|
||||
prefetchKey = '';
|
||||
onOpen(item);
|
||||
onStatus('Loading update…');
|
||||
if (!preload) onStatus('Loading update…');
|
||||
try {
|
||||
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
|
||||
let detail;
|
||||
if (offlineHydrated) detail = savedDetail;
|
||||
else if (preload) detail = await preload.catch(() => {
|
||||
onStatus('Loading update…');
|
||||
return load(item.notification_id);
|
||||
});
|
||||
else detail = await load(item.notification_id);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
onDetail(detail);
|
||||
if (createPager && loadConversation && detail.conversation) {
|
||||
@@ -384,6 +486,9 @@ function createNotificationReader({
|
||||
conversationPager = null;
|
||||
}
|
||||
onStatus('Update ready.');
|
||||
if (!offlineHydrated && detail.conversation_available) {
|
||||
void hydrateConversation(item, version);
|
||||
}
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (selected === item && version === loadVersion) {
|
||||
@@ -395,6 +500,11 @@ function createNotificationReader({
|
||||
|
||||
return {
|
||||
open,
|
||||
prefetch,
|
||||
retryConversation() {
|
||||
if (!selected || offlineHydrated) return Promise.resolve(false);
|
||||
return hydrateConversation(selected, loadVersion);
|
||||
},
|
||||
commentPager() {
|
||||
return conversationPager;
|
||||
},
|
||||
@@ -478,7 +588,7 @@ function createNotificationReplier({
|
||||
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
|
||||
storage.setItem(keyFor(item), body);
|
||||
}
|
||||
catch (_error) { /* Keep the editable textarea as the fallback. */ }
|
||||
catch (_error) {}
|
||||
},
|
||||
async submit(item, body, attachment = null) {
|
||||
if (pending) return false;
|
||||
@@ -504,15 +614,15 @@ function createNotificationReplier({
|
||||
return { queued:true };
|
||||
}
|
||||
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
|
||||
catch (_error) { /* Confirmed delivery is authoritative. */ }
|
||||
catch (_error) {}
|
||||
onStatus('Reply posted. You can mark this update read when ready.');
|
||||
return delivery.confirmed[0];
|
||||
}
|
||||
const result = await post(item.notification_id, body, operationId);
|
||||
try { storage.removeItem(keyFor(item)); }
|
||||
catch (_error) { /* The posted reply is still authoritative. */ }
|
||||
catch (_error) {}
|
||||
try { storage.removeItem(operationKeyFor(item)); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
catch (_error) {}
|
||||
onStatus('Reply posted. You can mark this update read when ready.');
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -553,6 +663,29 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
);
|
||||
}
|
||||
|
||||
function agendaMyWork(items, now = new Date()) {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const today = localDay(start);
|
||||
const tomorrowDate = new Date(start); tomorrowDate.setDate(tomorrowDate.getDate() + 1);
|
||||
const tomorrow = localDay(tomorrowDate);
|
||||
const afterTomorrowDate = new Date(start); afterTomorrowDate.setDate(afterTomorrowDate.getDate() + 2);
|
||||
const afterTomorrow = localDay(afterTomorrowDate);
|
||||
const horizonDate = new Date(start); horizonDate.setDate(horizonDate.getDate() + 7);
|
||||
const horizon = localDay(horizonDate);
|
||||
return (items || []).flatMap(item => {
|
||||
if (item?.kind !== 'issue' || !item.is_assigned || !item.due_date) return [];
|
||||
const due = calendarDay(item.due_date);
|
||||
if (!due || due >= horizon) return [];
|
||||
const group = due < today ? 'Overdue' : due < tomorrow ? 'Today' :
|
||||
due < afterTomorrow ? 'Tomorrow' : 'Next 7 days';
|
||||
return [{ ...item, agenda_group: group, _agenda_due: due }];
|
||||
}).sort((left, right) =>
|
||||
left._agenda_due.localeCompare(right._agenda_due) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
).map(({ _agenda_due, ...item }) => item);
|
||||
}
|
||||
|
||||
function milestoneLanes(items) {
|
||||
const lanes = new Map();
|
||||
(items || []).forEach(item => {
|
||||
@@ -627,13 +760,14 @@ function createWorkSessionCheckpoint({
|
||||
|
||||
function createWorkSession({
|
||||
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
|
||||
checkpointEnabled = () => true,
|
||||
checkpointEnabled = () => true, checkpointedEnabled = checkpointEnabled,
|
||||
onOpen, onProgress, onFinish,
|
||||
}) {
|
||||
let currentIdentity = '';
|
||||
let currentIndex = -1;
|
||||
let running = false;
|
||||
let durable = false;
|
||||
const activeCheckpoint = () => typeof checkpoint === 'function' ? checkpoint() : checkpoint;
|
||||
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||
const report = (items, index) => onProgress({
|
||||
@@ -648,7 +782,7 @@ function createWorkSession({
|
||||
durable = false;
|
||||
currentIdentity = '';
|
||||
currentIndex = -1;
|
||||
if (clearCheckpoint) checkpoint?.clear();
|
||||
if (clearCheckpoint) activeCheckpoint()?.clear();
|
||||
onFinish();
|
||||
return false;
|
||||
};
|
||||
@@ -656,7 +790,7 @@ function createWorkSession({
|
||||
if (!items.length || index < 0 || index >= items.length) return finish();
|
||||
currentIndex = index;
|
||||
currentIdentity = workIdentity(items[index]);
|
||||
if (durable) checkpoint?.save(currentIdentity, currentIndex);
|
||||
if (durable) activeCheckpoint()?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
@@ -664,9 +798,9 @@ function createWorkSession({
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
|
||||
checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity),
|
||||
end: () => finish(),
|
||||
resumable: () => Boolean(checkpoint?.read()),
|
||||
resumable: () => Boolean(activeCheckpoint()?.read()),
|
||||
reopen(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
@@ -675,13 +809,13 @@ function createWorkSession({
|
||||
if (index < 0) return false;
|
||||
currentIdentity = requestedIdentity;
|
||||
currentIndex = index;
|
||||
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
|
||||
if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
},
|
||||
resume(requested = null) {
|
||||
const saved = checkpoint?.read();
|
||||
const saved = activeCheckpoint()?.read();
|
||||
if (!saved) return false;
|
||||
durable = true;
|
||||
const items = queue();
|
||||
@@ -695,7 +829,7 @@ function createWorkSession({
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
durable = Boolean(checkpoint && checkpointEnabled());
|
||||
durable = Boolean(activeCheckpoint() && checkpointEnabled());
|
||||
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
|
||||
return openAt(items, requested >= 0 ? requested : 0);
|
||||
},
|
||||
@@ -743,7 +877,7 @@ function createWorkSession({
|
||||
if (!items.length) return null;
|
||||
if (action === 'start') return items[0];
|
||||
if (action === 'resume') {
|
||||
const saved = checkpoint?.read();
|
||||
const saved = activeCheckpoint()?.read();
|
||||
if (!saved) return null;
|
||||
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
|
||||
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
|
||||
@@ -837,6 +971,7 @@ function countMyWork(items) {
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.agendaMyWork = agendaMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
|
||||
|
||||
+115
-6
@@ -1,14 +1,26 @@
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
|
||||
let available = [];
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let loadRequest = null;
|
||||
let loadGeneration = 0;
|
||||
let query = '';
|
||||
let filters = { repositories: [], labels: [] };
|
||||
let facetView;
|
||||
let claimRequest = null;
|
||||
const previewed = new Set();
|
||||
let selecting = false;
|
||||
const selected = new Map();
|
||||
|
||||
function itemKey(item) {
|
||||
return String(item?.repository || '') + '#' + String(item?.number || '');
|
||||
}
|
||||
|
||||
function emitSelection() {
|
||||
const state = { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
onSelection(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function apply(result, append) {
|
||||
const incoming = Array.isArray(result?.items) ? result.items : [];
|
||||
if (append) {
|
||||
@@ -31,13 +43,31 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
};
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
if (result?.facets) onFacets({
|
||||
repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [],
|
||||
labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [],
|
||||
});
|
||||
if (result?.facets && typeof document !== 'undefined') {
|
||||
facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value =>
|
||||
String(value).replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character])),
|
||||
next => { filters = next; loadPage(1, false); });
|
||||
facetView(result.facets);
|
||||
}
|
||||
}
|
||||
|
||||
function loadPage(page, append) {
|
||||
if (loadRequest) return loadRequest;
|
||||
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
|
||||
function loadPage(page, append, requestedQuery = query) {
|
||||
if (loadRequest && requestedQuery === query && append) return loadRequest;
|
||||
const generation = ++loadGeneration;
|
||||
const params = new URLSearchParams();
|
||||
params.set('facets', 'true');
|
||||
if (requestedQuery) params.set('q', requestedQuery);
|
||||
filters.repositories.forEach(value => params.append('repository', value));
|
||||
filters.labels.forEach(value => params.append('label', value));
|
||||
const queryPart = params.toString() ? '&' + params.toString() : '';
|
||||
const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (generation !== loadGeneration) return result;
|
||||
apply(result, append);
|
||||
if (result?.refresh_failed === true) {
|
||||
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
|
||||
@@ -45,8 +75,9 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
onStatus('Showing saved available work while the catalog refreshes…');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { loadRequest = null; });
|
||||
return loadRequest;
|
||||
}).finally(() => { if (loadRequest === request) loadRequest = null; });
|
||||
loadRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -60,9 +91,66 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
if (!pagination.has_more) return Promise.resolve(false);
|
||||
return loadPage(pagination.page + 1, true);
|
||||
},
|
||||
search(value) {
|
||||
query = String(value || '').trim();
|
||||
return loadPage(1, false, query);
|
||||
},
|
||||
query() {
|
||||
return query;
|
||||
},
|
||||
setFilters(next) {
|
||||
filters = {
|
||||
repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10),
|
||||
labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10),
|
||||
};
|
||||
return loadPage(1, false);
|
||||
},
|
||||
filters() {
|
||||
return { repositories: filters.repositories.slice(), labels: filters.labels.slice() };
|
||||
},
|
||||
|
||||
items() {
|
||||
return available.slice();
|
||||
},
|
||||
startSelection() {
|
||||
selecting = true;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
cancelSelection() {
|
||||
selecting = false;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
toggleSelection(item) {
|
||||
if (!selecting || !item) return emitSelection();
|
||||
const key = itemKey(item);
|
||||
if (selected.has(key)) selected.delete(key);
|
||||
else selected.set(key, item);
|
||||
return emitSelection();
|
||||
},
|
||||
fillSelection(limit) {
|
||||
limit = Math.max(0, Math.floor(Number(limit) || 0));
|
||||
if (!limit) return { selected: selected.size, added: 0, limit };
|
||||
selecting = true;
|
||||
const before = selected.size;
|
||||
available.some(item => {
|
||||
if (selected.size >= limit) return true;
|
||||
selected.set(itemKey(item), item);
|
||||
return false;
|
||||
});
|
||||
emitSelection();
|
||||
return { selected: selected.size, added: selected.size - before, limit };
|
||||
},
|
||||
isSelected(item) {
|
||||
return selected.has(itemKey(item));
|
||||
},
|
||||
selectedItems() {
|
||||
return Array.from(selected.values());
|
||||
},
|
||||
selection() {
|
||||
return { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
},
|
||||
togglePreview(item) {
|
||||
const key = itemKey(item);
|
||||
if (previewed.has(key)) previewed.delete(key);
|
||||
@@ -91,6 +179,8 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
pagination.total = Math.max(available.length, pagination.total - 1);
|
||||
pagination.has_more = available.length < pagination.total;
|
||||
previewed.delete(itemKey(item));
|
||||
selected.delete(itemKey(item));
|
||||
emitSelection();
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
onStatus('Assigned ' + key + ' to you.');
|
||||
@@ -101,4 +191,23 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
};
|
||||
}
|
||||
|
||||
function attachFindWorkFacets(root, escapeHtml, onChange) {
|
||||
const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value);
|
||||
root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') });
|
||||
root.querySelector('#clear-find-work-filters').onclick = () => {
|
||||
root.querySelectorAll('input').forEach(input => { input.checked = false; });
|
||||
onChange({ repositories:[], labels:[] });
|
||||
};
|
||||
return facets => {
|
||||
const active = { repositories:values('find-work-repository'), labels:values('find-work-label') };
|
||||
const render = (id, choices, selected, name) => {
|
||||
root.querySelector(id).innerHTML = choices.map(value => '<label class="find-work-filter-option"><input type="checkbox" name="' + name + '" value="' + escapeHtml(value) + '"' + (selected.includes(value) ? ' checked' : '') + ' />' + escapeHtml(value) + '</label>').join('');
|
||||
};
|
||||
render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository');
|
||||
render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label');
|
||||
const count = active.repositories.length + active.labels.length;
|
||||
root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : '';
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
|
||||
@@ -0,0 +1,46 @@
|
||||
function createProtectToday() {
|
||||
function propose({ agenda = [], today = [], identity, limit = 5 }) {
|
||||
const key = item => String(identity?.(item) || '');
|
||||
const urgent = [];
|
||||
const seen = new Set();
|
||||
for (const item of [...agenda, ...today]) {
|
||||
const id = key(item);
|
||||
const eligible = item?.kind === 'issue' && item.is_assigned === true && item.state !== 'closed' &&
|
||||
['Overdue', 'Today'].includes(item.agenda_group);
|
||||
if (!eligible || !id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
urgent.push(item);
|
||||
}
|
||||
urgent.sort((left, right) =>
|
||||
String(left.due_date || '').localeCompare(String(right.due_date || '')) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
);
|
||||
if (!urgent.length) return {
|
||||
selected:[], protected:[], displaced:[],
|
||||
summary:'No overdue or due-today work needs protection.',
|
||||
};
|
||||
const existing = [];
|
||||
for (const item of today) {
|
||||
const id = key(item);
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
existing.push(item);
|
||||
}
|
||||
const combined = [...urgent, ...existing];
|
||||
const selected = combined.slice(0, limit);
|
||||
const selectedIds = new Set(selected.map(key));
|
||||
const displaced = today.filter(item => !selectedIds.has(key(item)) && !urgent.some(candidate => key(candidate) === key(item)));
|
||||
return {
|
||||
selected,
|
||||
protected:urgent,
|
||||
displaced,
|
||||
summary:urgent.length + ' urgent ' + (urgent.length === 1 ? 'item' : 'items') + ' protected · ' +
|
||||
displaced.length + (displaced.length ? ' displaced for review' : ' displaced'),
|
||||
};
|
||||
}
|
||||
return { propose };
|
||||
}
|
||||
|
||||
const protectToday = createProtectToday();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = protectToday;
|
||||
@@ -2,10 +2,29 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createPushNotifications = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, notification, serviceWorker, fetchJson,
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
let configuration = null;
|
||||
|
||||
function formattedHour(value) {
|
||||
return `${String(Number(value)).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function enabledDeadlineText(hour, days) {
|
||||
const value = Number(days ?? 2);
|
||||
const horizon = value === 0 ? 'due today' : `next ${value} days`;
|
||||
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
|
||||
}
|
||||
|
||||
function deadlineReadiness() {
|
||||
if (!configuration?.available || deadlineControl?.disabled) {
|
||||
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
|
||||
}
|
||||
return configuration.deadline_enabled
|
||||
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
|
||||
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
|
||||
}
|
||||
|
||||
function applicationServerKey(value) {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
||||
if (typeof atob === 'function') {
|
||||
@@ -20,15 +39,17 @@
|
||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||
await subscription?.unsubscribe?.();
|
||||
control.checked = false;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
async function ensureSubscription() {
|
||||
const permission = await notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
control.checked = false;
|
||||
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
@@ -45,6 +66,12 @@
|
||||
});
|
||||
control.checked = true;
|
||||
status.textContent = 'New update notifications enabled for this device.';
|
||||
configuration.subscribed = true;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
await ensureSubscription();
|
||||
}
|
||||
|
||||
async function change() {
|
||||
@@ -60,20 +87,73 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function changeDeadline() {
|
||||
deadlineControl.disabled = true;
|
||||
if (deadlineHour) deadlineHour.disabled = true;
|
||||
if (deadlineDays) deadlineDays.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (deadlineControl.checked && !subscription) {
|
||||
deadlineControl.checked = false;
|
||||
deadlineStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9);
|
||||
const reminderDays = Number(deadlineDays?.value ?? configuration?.reminder_days ?? 2);
|
||||
await fetchJson('api/v1/push-subscription/deadlines', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour, reminder_days:reminderDays}),
|
||||
});
|
||||
configuration.deadline_enabled = deadlineControl.checked;
|
||||
configuration.reminder_hour = reminderHour;
|
||||
configuration.reminder_days = reminderDays;
|
||||
configuration.timezone = timezone;
|
||||
deadlineStatus.textContent = deadlineControl.checked
|
||||
? enabledDeadlineText(reminderHour, reminderDays)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
return true;
|
||||
} catch (error) {
|
||||
deadlineControl.checked = !deadlineControl.checked;
|
||||
deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
deadlineControl.disabled = false;
|
||||
if (deadlineHour) deadlineHour.disabled = false;
|
||||
if (deadlineDays) deadlineDays.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDeadline() {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!control || !notification || !serviceWorker) return;
|
||||
control.addEventListener('change', change);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
if (deadlineControl) deadlineControl.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
status.textContent = configuration.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
return {init, change};
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
(function (root, factory) {
|
||||
const createSearchBatchPlan = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchBatchPlan;
|
||||
if (root) {
|
||||
root.createSearchBatchPlan = createSearchBatchPlan;
|
||||
root.mountSearchBatchPlanning = createSearchBatchPlan.mount;
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function createSearchBatchPlan({
|
||||
limit = 50,
|
||||
resolve = item => Promise.resolve(item),
|
||||
claim = item => Promise.resolve(item),
|
||||
onChange = () => {},
|
||||
} = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
const selected = new Map();
|
||||
let active = false;
|
||||
|
||||
const identity = item => [item?.kind || '', item?.repository || '', item?.number || ''].join(':');
|
||||
const eligible = item => item?.kind === 'issue' && item?.state === 'open' &&
|
||||
Boolean(item.repository) && Number.isInteger(item.number);
|
||||
function snapshot() {
|
||||
return { active, count:selected.size, items:Array.from(selected.values()) };
|
||||
}
|
||||
function changed() {
|
||||
const state = snapshot();
|
||||
onChange(state);
|
||||
return state;
|
||||
}
|
||||
function start() {
|
||||
active = true;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
function cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
function toggle(item) {
|
||||
if (!active) return 'inactive';
|
||||
if (!eligible(item)) return 'ineligible';
|
||||
const key = identity(item);
|
||||
if (selected.has(key)) {
|
||||
selected.delete(key);
|
||||
changed();
|
||||
return 'removed';
|
||||
}
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.set(key, { ...item });
|
||||
changed();
|
||||
return 'selected';
|
||||
}
|
||||
async function prepare(item) {
|
||||
const detail = await resolve(item);
|
||||
if (detail?.assigned_to_me) return detail;
|
||||
if (detail?.claimable) return claim(detail);
|
||||
throw new Error('This issue is no longer available to assign.');
|
||||
}
|
||||
function releaseRepository() {
|
||||
const repositories = new Set(Array.from(selected.values()).map(item => item.repository));
|
||||
return repositories.size === 1 ? repositories.values().next().value : null;
|
||||
}
|
||||
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
|
||||
}
|
||||
|
||||
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
|
||||
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
|
||||
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute,
|
||||
laterWork = null, laterPicker = null
|
||||
) {
|
||||
const get = selector => document.querySelector(selector);
|
||||
let processor;
|
||||
let laterProcessor;
|
||||
let releaseProcessor;
|
||||
let releaseContext = null;
|
||||
const plan = createSearchBatchPlan({
|
||||
resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}),
|
||||
claim:detail => fetchJson(
|
||||
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(detail.number) + '/claim',
|
||||
{method:'PATCH', headers:{Accept:'application/json'}}
|
||||
),
|
||||
onChange:state => {
|
||||
get('#search-batch-actions').hidden = !state.active;
|
||||
get('#select-search-results').hidden = state.active;
|
||||
get('#queue-selected-search-results').disabled = state.count === 0;
|
||||
get('#defer-selected-search-results').disabled = state.count === 0;
|
||||
get('#plan-selected-search-results').disabled = state.count === 0;
|
||||
get('#search-selection-status').textContent = state.count ?
|
||||
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
|
||||
render();
|
||||
},
|
||||
});
|
||||
processor = batchFactory({
|
||||
capacity:() => Math.max(0, todayWork.limit - todayWork.read().length),
|
||||
owner:getOwner,
|
||||
timeBudget:() => {
|
||||
const planning = todayWork.planning();
|
||||
return {
|
||||
capacity_minutes:planning.capacity_minutes,
|
||||
planned_minutes:Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0),
|
||||
};
|
||||
},
|
||||
journalName:'search-today-batch',
|
||||
autoMount:false,
|
||||
claim:item => plan.prepare(item),
|
||||
queue:confirmed => queueToday(acceptIssue(confirmed)),
|
||||
persistEstimate:(confirmed, minutes) => {
|
||||
const planning = todayWork.planning();
|
||||
planning.estimates[todayWork.identity(confirmed)] = minutes;
|
||||
todayWork.replacePlanning(planning);
|
||||
todaySync.enqueueConfiguration(planning.capacity_minutes, planning.estimates);
|
||||
todaySync.flush();
|
||||
},
|
||||
onProgress:progress => {
|
||||
get('#queue-selected-search-results').disabled = progress.status === 'running';
|
||||
if (progress.status === 'running') {
|
||||
get('#search-selection-status').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
} else if (progress.status === 'full') {
|
||||
get('#search-selection-status').textContent = 'Today has ' + progress.available + ' remaining slot' +
|
||||
(progress.available === 1 ? '.' : 's.');
|
||||
} else if (progress.status === 'estimates-required') {
|
||||
get('#search-batch-estimate-summary').textContent = 'Estimate every selected issue.';
|
||||
} else if (progress.status === 'over-budget') {
|
||||
get('#search-batch-estimate-summary').textContent = progress.requested_minutes +
|
||||
' min selected · ' + progress.remaining_minutes + ' min remain.';
|
||||
} else if (progress.status === 'complete') {
|
||||
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||
progress.queued.length + ' queued · ' + progress.failed.length + ' need retry.' :
|
||||
progress.queued.length + ' added to Today.';
|
||||
get('#resume-search-batch').hidden = processor.pending() === 0;
|
||||
closeEstimateReview();
|
||||
if (!progress.failed.length) plan.cancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
laterProcessor = batchFactory({
|
||||
capacity:() => plan.limit,
|
||||
owner:getOwner,
|
||||
journalName:'search-later-batch',
|
||||
queueFailureReason:'assigned but Later could not be saved',
|
||||
autoMount:false,
|
||||
claim:item => plan.prepare(item),
|
||||
queue:(confirmed, context) => {
|
||||
const outcome = laterWork?.defer(acceptIssue(confirmed), context?.until);
|
||||
return outcome === 'deferred' ? 'queued' : outcome;
|
||||
},
|
||||
onProgress:progress => {
|
||||
get('#defer-selected-search-results').disabled = progress.status === 'running';
|
||||
if (progress.status === 'running') {
|
||||
get('#search-selection-status').textContent = 'Deferring ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
} else if (progress.status === 'complete') {
|
||||
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||
progress.queued.length + ' deferred · ' + progress.failed.length + ' need retry.' :
|
||||
progress.queued.length + ' added to Later.';
|
||||
restore();
|
||||
if (!progress.failed.length) plan.cancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
releaseProcessor = batchFactory({
|
||||
capacity:() => plan.limit,
|
||||
owner:getOwner,
|
||||
journalName:'search-release-batch',
|
||||
queueFailureReason:'assigned but release planning could not be confirmed',
|
||||
autoMount:false,
|
||||
claim:item => plan.prepare(item),
|
||||
queue:async (confirmed, context) => {
|
||||
const issuePath = 'api/v1/repos/' + confirmed.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(confirmed.number);
|
||||
const milestone = await fetchJson(issuePath + '/milestone', {
|
||||
method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'},
|
||||
body:JSON.stringify({milestone_id:context.milestone_id}),
|
||||
});
|
||||
if (milestone?.number !== confirmed.number || milestone?.milestone?.id !== context.milestone_id) {
|
||||
throw new Error('Issue milestone was not confirmed.');
|
||||
}
|
||||
if (context.due_date) {
|
||||
const deadline = await fetchJson(issuePath + '/due-date', {
|
||||
method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'},
|
||||
body:JSON.stringify({due_date:context.due_date}),
|
||||
});
|
||||
if (deadline?.number !== confirmed.number || deadline?.due_date !== context.due_date) {
|
||||
throw new Error('Issue due date was not confirmed.');
|
||||
}
|
||||
}
|
||||
return 'queued';
|
||||
},
|
||||
onProgress:progress => {
|
||||
get('#confirm-search-release').disabled = progress.status === 'running';
|
||||
if (progress.status === 'running') {
|
||||
get('#search-release-summary').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
} else if (progress.status === 'complete') {
|
||||
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||
progress.queued.length + ' planned · ' + progress.failed.length + ' need retry.' :
|
||||
progress.queued.length + ' planned for ' + (releaseContext?.milestone_title || 'release') + '.';
|
||||
get('#resume-search-batch').hidden = releaseProcessor.pending() === 0;
|
||||
closeReleaseReview();
|
||||
if (!progress.failed.length) plan.cancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
function estimateValues() {
|
||||
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
|
||||
[input.dataset.searchBatchEstimate, Number(input.value)]
|
||||
));
|
||||
}
|
||||
function closeEstimateReview() {
|
||||
get('#search-batch-estimate-review').hidden = true;
|
||||
get('#search-batch-actions').hidden = !plan.snapshot().active;
|
||||
}
|
||||
function openEstimateReview(items) {
|
||||
const planning = todayWork.planning();
|
||||
const planned = Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
|
||||
const remaining = Math.max(0, planning.capacity_minutes - planned);
|
||||
get('#search-batch-estimate-summary').textContent = remaining +
|
||||
' min remain. Estimate before assigning.';
|
||||
get('#search-batch-estimate-list').innerHTML = items.map(item => {
|
||||
const id = String(item.repository || '') + '#' + String(item.number || '');
|
||||
const estimate = planning.estimates?.[id] || '';
|
||||
return '<div class="search-batch-estimate-row"><div><span class="small">' + escapeHtml(id) +
|
||||
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div><label class="small">' +
|
||||
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
|
||||
escapeAttribute(estimate) + '" data-search-batch-estimate="' + escapeAttribute(id) +
|
||||
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes" /> min</label></div>';
|
||||
}).join('');
|
||||
get('#search-batch-actions').hidden = true;
|
||||
get('#search-batch-estimate-review').hidden = false;
|
||||
document.querySelector('[data-search-batch-estimate]')?.focus();
|
||||
}
|
||||
function closeReleaseReview() {
|
||||
get('#search-release-review').hidden = true;
|
||||
get('#search-batch-actions').hidden = !plan.snapshot().active;
|
||||
}
|
||||
async function openReleaseReview() {
|
||||
const items = plan.snapshot().items;
|
||||
const repository = plan.releaseRepository();
|
||||
if (!items.length) return;
|
||||
if (!repository) {
|
||||
get('#search-selection-status').textContent = 'Select issues from one repository to plan a release.';
|
||||
return;
|
||||
}
|
||||
get('#plan-selected-search-results').disabled = true;
|
||||
get('#search-selection-status').textContent = 'Loading release milestones…';
|
||||
try {
|
||||
const milestones = await fetchJson(
|
||||
'api/v1/repos/' + repository.split('/').map(encodeURIComponent).join('/') + '/milestones',
|
||||
{headers:{Accept:'application/json'}}
|
||||
);
|
||||
const open = (Array.isArray(milestones) ? milestones : []).filter(item => item?.state !== 'closed');
|
||||
get('#search-release-milestone').innerHTML = '<option value="">Choose milestone</option>' + open.map(item =>
|
||||
'<option value="' + Number(item.id) + '">' + escapeHtml(item.title) + '</option>'
|
||||
).join('');
|
||||
get('#search-release-list').innerHTML = items.map(item => '<div class="search-release-row"><span>' +
|
||||
escapeHtml(item.repository + ' #' + item.number) + '</span><strong>' +
|
||||
escapeHtml(item.title || 'Untitled work') + '</strong></div>').join('');
|
||||
get('#search-release-summary').textContent = items.length + ' issues in ' + repository + '. Assignment starts only after confirmation.';
|
||||
get('#search-batch-actions').hidden = true;
|
||||
get('#search-release-review').hidden = false;
|
||||
get('#search-release-milestone').focus();
|
||||
} catch (error) {
|
||||
get('#search-selection-status').textContent = error?.message || 'Milestones could not be loaded. Try again.';
|
||||
} finally {
|
||||
get('#plan-selected-search-results').disabled = false;
|
||||
}
|
||||
}
|
||||
get('#select-search-results').addEventListener('click', () => plan.start());
|
||||
get('#cancel-search-selection').addEventListener('click', () => plan.cancel());
|
||||
get('#queue-selected-search-results').addEventListener('click', () => {
|
||||
const items = plan.snapshot().items;
|
||||
const capacity = todayWork.planning().capacity_minutes;
|
||||
if (Number.isInteger(capacity) && capacity > 0) openEstimateReview(items);
|
||||
else processor.run(items);
|
||||
});
|
||||
get('#defer-selected-search-results').addEventListener('click', event => {
|
||||
const items = plan.snapshot().items;
|
||||
if (!items.length) return;
|
||||
laterPicker?.open({ items, confirm:until => () => laterProcessor.run(
|
||||
items, {}, { until:new Date(until).toISOString() }
|
||||
) }, event.currentTarget, 'search-batch');
|
||||
});
|
||||
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
|
||||
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
|
||||
get('#confirm-search-release').addEventListener('click', () => {
|
||||
const milestoneSelect = get('#search-release-milestone');
|
||||
const milestoneId = Number(milestoneSelect.value);
|
||||
if (!Number.isInteger(milestoneId) || milestoneId < 1) {
|
||||
get('#search-release-summary').textContent = 'Choose an open milestone.';
|
||||
return;
|
||||
}
|
||||
const selectedOption = milestoneSelect.options?.[milestoneSelect.selectedIndex];
|
||||
const milestoneTitle = selectedOption?.textContent || selectedOption?.text || 'release';
|
||||
const dueDay = get('#search-release-due-date').value;
|
||||
releaseContext = {
|
||||
milestone_id:milestoneId, milestone_title:milestoneTitle,
|
||||
due_date:dueDay ? dueDay + 'T23:59:59Z' : null,
|
||||
};
|
||||
releaseProcessor.run(plan.snapshot().items, {}, releaseContext);
|
||||
});
|
||||
get('#cancel-search-batch-estimates').addEventListener('click', closeEstimateReview);
|
||||
get('#confirm-search-batch-estimates').addEventListener('click', () =>
|
||||
processor.run(plan.snapshot().items, estimateValues())
|
||||
);
|
||||
get('#resume-search-batch').addEventListener('click', () => {
|
||||
if (processor.pending()) return processor.resume();
|
||||
if (laterProcessor.pending()) return laterProcessor.resume();
|
||||
return releaseProcessor.resume();
|
||||
});
|
||||
get('#open-palette').addEventListener('click', () => restore());
|
||||
get('#cmd-results').addEventListener('change', event => {
|
||||
const index = event.target?.dataset?.searchSelect;
|
||||
if (index === undefined) return;
|
||||
const result = lookup(Number(index));
|
||||
if (result) plan.toggle(result);
|
||||
});
|
||||
function restore() {
|
||||
const pending = processor.pending() + laterProcessor.pending() + releaseProcessor.pending();
|
||||
get('#resume-search-batch').hidden = pending === 0;
|
||||
get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
|
||||
}
|
||||
function resultHtml(result, index) {
|
||||
const allowed = result.kind === 'issue' && result.state === 'open';
|
||||
const selected = plan.snapshot().items.some(item => plan.identity(item) === plan.identity(result));
|
||||
return '<label class="cmd-select-result"' + (allowed ? '' : ' aria-disabled="true"') + '>' +
|
||||
'<input type="checkbox" data-search-select="' + index + '" ' + (selected ? 'checked ' : '') +
|
||||
(allowed ? '' : 'disabled ') + 'aria-label="Select ' + escapeAttribute(result.title) + '" />' +
|
||||
'<span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' +
|
||||
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
|
||||
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
|
||||
}
|
||||
return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
|
||||
};
|
||||
|
||||
return createSearchBatchPlan;
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
function createSearchDefer({ claim, accept, defer, refresh, announce, formatTime }) {
|
||||
let request = null;
|
||||
|
||||
function run(detail, until) {
|
||||
if (request) return request;
|
||||
const wasClaimable = Boolean(detail?.claimable);
|
||||
request = Promise.resolve(wasClaimable ? claim(detail) : detail)
|
||||
.then(confirmed => {
|
||||
const item = accept(confirmed);
|
||||
const outcome = defer(item, until);
|
||||
refresh();
|
||||
if (outcome === 'deferred') {
|
||||
announce((wasClaimable ? 'Assigned and deferred until ' : 'Deferred until ') + formatTime(until) + '.');
|
||||
} else {
|
||||
announce(wasClaimable
|
||||
? 'Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry.'
|
||||
: 'Could not save Later on this device. Retry scheduling.');
|
||||
}
|
||||
return outcome;
|
||||
})
|
||||
.finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { run, pending:() => Boolean(request) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createSearchDefer;
|
||||
+304
-9
@@ -1,40 +1,225 @@
|
||||
(function (root, factory) {
|
||||
const createSearchPreview = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
||||
if (root) root.createSearchPreview = createSearchPreview;
|
||||
if (root) {
|
||||
root.createSearchPreview = createSearchPreview;
|
||||
root.safeSearchUrl = value => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||||
} catch (_) { return ''; }
|
||||
};
|
||||
root.searchPreviewUrl = (state, defaultScope, location) => {
|
||||
const { query, preview, scope = defaultScope } = state;
|
||||
const params = new URLSearchParams({
|
||||
search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number,
|
||||
search_kind:scope.kind, search_state:scope.state,
|
||||
});
|
||||
if (scope.repository) params.set('search_repository', scope.repository);
|
||||
return new URL('?' + params, location.origin + location.pathname).href;
|
||||
};
|
||||
root.searchPreviewPath = item => {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview?kind=' + encodeURIComponent(item.kind);
|
||||
};
|
||||
root.searchPreviewConversationPath = (item, page) => {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const query = new URLSearchParams({ kind:item.kind, limit:'20' });
|
||||
if (page) query.set('page', String(page));
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview/conversation?' + query.toString();
|
||||
};
|
||||
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||
'/comments?kind=' + encodeURIComponent(item.kind);
|
||||
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
|
||||
storage,
|
||||
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
postReply:(item,body,operationId) => fetchJson(root.searchPreviewReplyPath(item), {
|
||||
method:'POST', headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':operationId},
|
||||
body:JSON.stringify({body}),
|
||||
}),
|
||||
});
|
||||
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
|
||||
const comments = document.querySelector('#search-preview-comments');
|
||||
const status = document.querySelector('#search-preview-conversation-status');
|
||||
const retry = document.querySelector('#retry-search-preview-conversation');
|
||||
const older = document.querySelector('#load-older-search-preview-comments');
|
||||
if (!conversation) {
|
||||
comments.textContent = status.textContent = '';
|
||||
retry.hidden = older.hidden = true;
|
||||
return;
|
||||
}
|
||||
comments.innerHTML = (conversation.comments || []).map(comment =>
|
||||
'<article class="search-preview-comment" data-comment-id="' + Number(comment.id || 0) + '">' +
|
||||
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '</div>' +
|
||||
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
|
||||
).join('');
|
||||
retry.hidden = conversation.status !== 'error';
|
||||
older.hidden = conversation.status !== 'ready' || !conversation.olderPage;
|
||||
older.disabled = conversation.status === 'loading';
|
||||
if (conversation.status === 'loading') status.textContent = conversation.comments?.length
|
||||
? 'Loading older messages…' : 'Loading current conversation…';
|
||||
else if (conversation.status === 'error') status.textContent =
|
||||
'Conversation unavailable. Preview and planning actions still work.';
|
||||
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
|
||||
else status.textContent = conversation.comments.length +
|
||||
(conversation.comments.length === 1 ? ' message.' : ' messages.');
|
||||
};
|
||||
root.renderSearchPreviewReply = (state, detail, preview, document) => {
|
||||
const section = document.querySelector('.search-preview-reply');
|
||||
const input = document.querySelector('#search-preview-reply');
|
||||
const status = document.querySelector('#search-preview-reply-status');
|
||||
const buttons = ['#send-search-preview-reply', '#send-search-preview-reply-next']
|
||||
.map(selector => document.querySelector(selector));
|
||||
section.hidden = detail?.commentable !== true;
|
||||
status.textContent = '';
|
||||
if (section.hidden) {
|
||||
buttons.forEach(button => { button.disabled = true; });
|
||||
return;
|
||||
}
|
||||
input.value = preview.replyDraft();
|
||||
const replying = state.status === 'replying';
|
||||
buttons.forEach(button => { button.disabled = replying || !input.value.trim(); });
|
||||
if (replying) status.textContent = 'Sending reply…';
|
||||
else if (state.status === 'replied') status.textContent = 'Reply posted.';
|
||||
else if (state.status === 'reply-error') status.textContent =
|
||||
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
|
||||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, mutate, onState }) {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||
if (Array.isArray(session)) {
|
||||
getSession = session[0];
|
||||
loadMore = () => session[1].loadMore();
|
||||
onNavigate = session[2];
|
||||
navigationRoot = document;
|
||||
}
|
||||
let generation = 0;
|
||||
let current = null;
|
||||
let mutationRequest = null;
|
||||
let shareRequest = null;
|
||||
let moveRequest = null;
|
||||
let replyRequest = null;
|
||||
let conversation = null;
|
||||
|
||||
function sameItem(left, right) {
|
||||
return left && right && left.kind === right.kind && left.repository === right.repository &&
|
||||
Number(left.number) === Number(right.number);
|
||||
}
|
||||
|
||||
function replyKey(item, suffix) {
|
||||
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
|
||||
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
|
||||
}
|
||||
|
||||
function stored(key) {
|
||||
try { return storage?.getItem(key) || ''; }
|
||||
catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function save(key, value) {
|
||||
try {
|
||||
if (value) storage?.setItem(key, value);
|
||||
else storage?.removeItem(key);
|
||||
} catch (_) { /* Volatile drafts still remain in the textarea. */ }
|
||||
}
|
||||
|
||||
function navigation(item) {
|
||||
const session = typeof getSession === 'function' ? getSession() : null;
|
||||
const items = Array.isArray(session?.items) ? session.items : [];
|
||||
const index = items.findIndex(candidate => sameItem(candidate, item));
|
||||
if (index < 0) return null;
|
||||
return {
|
||||
position:index + 1,
|
||||
total:items.length,
|
||||
hasPrevious:index > 0,
|
||||
hasNext:index < items.length - 1 || session.more === true,
|
||||
};
|
||||
}
|
||||
|
||||
function publish(state) {
|
||||
const position = navigation(state.item || current);
|
||||
if (navigationRoot) {
|
||||
const bar = navigationRoot.querySelector('.search-preview-navigation');
|
||||
bar.hidden = !position;
|
||||
if (position) {
|
||||
navigationRoot.querySelector('#search-preview-position').textContent = position.position + ' of ' + position.total;
|
||||
navigationRoot.querySelector('#previous-search-result').disabled = !position.hasPrevious;
|
||||
navigationRoot.querySelector('#next-search-result').disabled = !position.hasNext;
|
||||
}
|
||||
}
|
||||
onState(position ? { ...state, navigation:position } : state);
|
||||
if (state.complete && navigationRoot) {
|
||||
navigationRoot.querySelector('#search-preview-status').textContent = 'Search pass complete.';
|
||||
}
|
||||
}
|
||||
|
||||
function run(action, pending, success, detail) {
|
||||
if (mutationRequest) return mutationRequest;
|
||||
onState({ status: pending, item: current, detail });
|
||||
publish({ status: pending, item: current, detail });
|
||||
mutationRequest = mutate(detail, action).then(result => {
|
||||
onState({ status: success, item: current, detail, result });
|
||||
publish({ status: success, item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
publish({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { mutationRequest = null; });
|
||||
return mutationRequest;
|
||||
}
|
||||
|
||||
return {
|
||||
function loadConversation(detail, requestGeneration, page) {
|
||||
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
|
||||
const previousComments = page && Array.isArray(conversation?.comments)
|
||||
? conversation.comments : [];
|
||||
conversation = { status:'loading', comments:previousComments, olderPage:page ?? null };
|
||||
publish({ status:'ready', item:current, detail, conversation });
|
||||
return fetchConversation(detail, page).then(result => {
|
||||
if (requestGeneration !== generation) return result;
|
||||
const incoming = Array.isArray(result?.comments) ? result.comments : [];
|
||||
const liveComments = Array.isArray(conversation?.comments) ? conversation.comments : previousComments;
|
||||
const comments = [...incoming, ...liveComments].filter((comment, index, all) =>
|
||||
all.findIndex(candidate => candidate?.id === comment?.id) === index);
|
||||
conversation = {
|
||||
status:'ready',
|
||||
comments,
|
||||
olderPage:result?.older_page ?? null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
conversation = {
|
||||
status:'error', comments:previousComments,
|
||||
olderPage:page ?? conversation?.olderPage ?? null, error,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
const api = {
|
||||
open(item) {
|
||||
generation += 1;
|
||||
const requestGeneration = generation;
|
||||
current = { ...item };
|
||||
onState({ status: 'loading', item: current });
|
||||
conversation = null;
|
||||
publish({ status: 'loading', item: current });
|
||||
return fetchJson(current).then(detail => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'ready', item: current, detail });
|
||||
current = { ...current, ...detail };
|
||||
if (typeof fetchConversation === 'function') {
|
||||
loadConversation(current, requestGeneration);
|
||||
} else {
|
||||
publish({ status: 'ready', item: current, detail });
|
||||
}
|
||||
}
|
||||
return detail;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'error', item: current, error });
|
||||
publish({ status: 'error', item: current, error });
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
@@ -44,6 +229,98 @@
|
||||
current = null;
|
||||
onState({ status: 'closed' });
|
||||
},
|
||||
previous() {
|
||||
const session = typeof getSession === 'function' ? getSession() : null;
|
||||
const items = Array.isArray(session?.items) ? session.items : [];
|
||||
const index = items.findIndex(item => sameItem(item, current));
|
||||
if (index <= 0) return Promise.resolve(current);
|
||||
const item = items[index - 1];
|
||||
if (typeof onNavigate === 'function') onNavigate(item);
|
||||
return api.open(item);
|
||||
},
|
||||
next() {
|
||||
if (moveRequest) return moveRequest;
|
||||
moveRequest = (async () => {
|
||||
let session = typeof getSession === 'function' ? getSession() : null;
|
||||
let items = Array.isArray(session?.items) ? session.items : [];
|
||||
let index = items.findIndex(item => sameItem(item, current));
|
||||
if (index < 0) return current;
|
||||
if (index === items.length - 1 && session?.more === true && typeof loadMore === 'function') {
|
||||
await loadMore();
|
||||
session = getSession();
|
||||
items = Array.isArray(session?.items) ? session.items : [];
|
||||
index = items.findIndex(item => sameItem(item, current));
|
||||
}
|
||||
if (index >= 0 && index < items.length - 1) {
|
||||
const item = items[index + 1];
|
||||
if (typeof onNavigate === 'function') onNavigate(item);
|
||||
return api.open(item);
|
||||
}
|
||||
publish({ status:'ready', item:current, detail:current, complete:true });
|
||||
return current;
|
||||
})().finally(() => { moveRequest = null; });
|
||||
return moveRequest;
|
||||
},
|
||||
retryConversation() {
|
||||
if (!current) return Promise.resolve(null);
|
||||
return loadConversation(current, generation);
|
||||
},
|
||||
loadOlderConversation() {
|
||||
if (!current || !conversation?.olderPage) return Promise.resolve(null);
|
||||
return loadConversation(current, generation, conversation.olderPage);
|
||||
},
|
||||
saveReplyDraft(body) {
|
||||
if (!current) return '';
|
||||
const key = replyKey(current, 'draft');
|
||||
const next = String(body || '');
|
||||
if (stored(key) !== next) save(replyKey(current, 'operation'), '');
|
||||
save(key, next);
|
||||
return next;
|
||||
},
|
||||
replyDraft() {
|
||||
return current ? stored(replyKey(current, 'draft')) : '';
|
||||
},
|
||||
reply({ advance = false } = {}) {
|
||||
if (replyRequest) return replyRequest;
|
||||
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
|
||||
const body = api.replyDraft().trim();
|
||||
if (!body) return Promise.reject(new Error('Write a reply first.'));
|
||||
const item = { ...current };
|
||||
const operationKey = replyKey(item, 'operation');
|
||||
let operationId = stored(operationKey);
|
||||
if (!operationId) {
|
||||
operationId = String(createOperationId?.() || Date.now()).slice(0, 128);
|
||||
save(operationKey, operationId);
|
||||
}
|
||||
publish({ status:'replying', item:current, detail:current, conversation });
|
||||
replyRequest = postReply(item, body, operationId).then(comment => {
|
||||
const comments = [...(conversation?.comments || [])];
|
||||
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
|
||||
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
|
||||
save(replyKey(item, 'draft'), '');
|
||||
save(operationKey, '');
|
||||
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
|
||||
return advance ? api.next().then(() => comment) : comment;
|
||||
}).catch(error => {
|
||||
publish({ status:'reply-error', item:current, detail:current, conversation, error });
|
||||
throw error;
|
||||
}).finally(() => { replyRequest = null; });
|
||||
return replyRequest;
|
||||
},
|
||||
share(url) {
|
||||
if (shareRequest) return shareRequest;
|
||||
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
|
||||
const detail = current;
|
||||
publish({ status:'sharing', item:current, detail });
|
||||
shareRequest = share(url).then(result => {
|
||||
publish({ status:result, item:current, detail });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
publish({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { shareRequest = null; });
|
||||
return shareRequest;
|
||||
},
|
||||
claim(detail) {
|
||||
return run('claim', 'claiming', 'claimed', detail);
|
||||
},
|
||||
@@ -51,5 +328,23 @@
|
||||
return run('reopen', 'reopening', 'reopened', detail);
|
||||
},
|
||||
};
|
||||
if (navigationRoot) {
|
||||
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
|
||||
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
|
||||
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
|
||||
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
|
||||
const reply = navigationRoot.querySelector('#search-preview-reply');
|
||||
reply?.addEventListener('input', event => {
|
||||
api.saveReplyDraft(event.target.value);
|
||||
const disabled = !event.target.value.trim();
|
||||
navigationRoot.querySelector('#send-search-preview-reply').disabled = disabled;
|
||||
navigationRoot.querySelector('#send-search-preview-reply-next').disabled = disabled;
|
||||
});
|
||||
reply?.addEventListener('focus', event => event.target.scrollIntoView({ block:'center', behavior:'smooth' }));
|
||||
navigationRoot.querySelector('#send-search-preview-reply')?.addEventListener('click', () => api.reply().catch(() => {}));
|
||||
navigationRoot.querySelector('#send-search-preview-reply-next')?.addEventListener('click', () =>
|
||||
api.reply({ advance:true }).catch(() => {}));
|
||||
}
|
||||
return api;
|
||||
};
|
||||
});
|
||||
@@ -131,6 +131,7 @@
|
||||
passkey_counter_anomaly: 'Passkey counter anomaly',
|
||||
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
comment_deleted: 'Comment deleted',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||
};
|
||||
|
||||
+103
-1
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v97';
|
||||
const CACHE = 'stackchain-dashboard-shell-v101';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -18,9 +18,11 @@ const SHELL = [
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/search-defer.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/shared-image-capture.js',
|
||||
BASE + 'static/draft-filing-session.js',
|
||||
BASE + 'static/draft-capacity-dialog.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
@@ -32,6 +34,8 @@ const SHELL = [
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/agenda-replan.js',
|
||||
BASE + 'static/protect-today.js',
|
||||
BASE + 'static/notification-undo.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/work-selection.js',
|
||||
@@ -55,6 +59,8 @@ const SHELL = [
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/batch-find-work.js',
|
||||
BASE + 'static/search-batch-plan.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/comment-actions.js',
|
||||
BASE + 'static/issue-attachment.js',
|
||||
@@ -68,10 +74,20 @@ const SHELL = [
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/task-overlay-history.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/live-data-status.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/update-triage-session.js',
|
||||
BASE + 'static/update-review-handoff.js',
|
||||
BASE + 'static/update-read-position.js',
|
||||
BASE + 'static/work-detail-position.js',
|
||||
BASE + 'static/update-triage-launcher.js',
|
||||
BASE + 'static/update-triage-gesture.js',
|
||||
BASE + 'static/update-decision-transaction.js',
|
||||
BASE + 'static/agenda-session-launcher.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
@@ -83,6 +99,44 @@ const SHELL = [
|
||||
const OPTIONAL_FEATURES = [
|
||||
];
|
||||
|
||||
const SHARED_IMAGE_ID = 'shared-image';
|
||||
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
|
||||
|
||||
function boundedShareField(form, name, limit) {
|
||||
const value = form.get(name);
|
||||
return typeof value === 'string' ? value.trim().slice(0, limit) : '';
|
||||
}
|
||||
|
||||
async function acceptSharedContent(request) {
|
||||
const form = await request.formData();
|
||||
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
|
||||
let marker = '';
|
||||
await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {});
|
||||
if (images.length > 1) marker = 'multiple';
|
||||
else if (images.length === 1) {
|
||||
const image = images[0];
|
||||
if (!SHARED_IMAGE_TYPES.has(String(image.type || '')) || image.size > MAX_SHARED_IMAGE_BYTES) {
|
||||
marker = 'unsupported';
|
||||
} else {
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
});
|
||||
marker = 'image';
|
||||
}
|
||||
}
|
||||
const target = new URL(BASE, self.location.origin);
|
||||
target.searchParams.set('launch', 'new');
|
||||
if (marker) target.searchParams.set('shared', marker);
|
||||
for (const [name, limit] of [['title', 255], ['text', 10000], ['url', 2048]]) {
|
||||
const value = boundedShareField(form, name, limit);
|
||||
if (value) target.searchParams.set(name, value);
|
||||
}
|
||||
return new Response(null, {status:303, headers:{Location:target.pathname + target.search}});
|
||||
}
|
||||
|
||||
async function fetchNavigation(request) {
|
||||
const controller = new AbortController();
|
||||
let timeout;
|
||||
@@ -311,9 +365,33 @@ self.addEventListener('push', event => {
|
||||
try { payload = event.data?.json?.() || {}; }
|
||||
catch (_error) { return; }
|
||||
const route = String(payload.route || '');
|
||||
const protectRoute = String(payload.protect_route || '');
|
||||
const tag = String(payload.tag || '');
|
||||
const notificationId = Number(payload.notification_id);
|
||||
const updateCount = Number(payload.update_count);
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
if (
|
||||
route === '#/my-work/agenda'
|
||||
&& protectRoute === '#/my-work/agenda/protect-today'
|
||||
&& /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag)
|
||||
&& Number.isSafeInteger(deadlineCount)
|
||||
&& deadlineCount > 0
|
||||
&& deadlineCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(
|
||||
deadlineCount + ' deadline' + (deadlineCount === 1 ? '' : 's') + ' need' + (deadlineCount === 1 ? 's' : '') + ' attention',
|
||||
{
|
||||
body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'),
|
||||
tag,
|
||||
actions: [
|
||||
{ action: 'protect-today', title: 'Protect Today' },
|
||||
{ action: 'open-agenda', title: 'Open Agenda' },
|
||||
],
|
||||
data: {route, protectRoute},
|
||||
}
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/updates'
|
||||
&& tag === 'stackchain-update-digest'
|
||||
@@ -359,6 +437,22 @@ async function openWorkRoute(route) {
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
const route = String(event.notification.data?.route || '');
|
||||
if (event.action === 'protect-today') {
|
||||
const protectRoute = String(event.notification.data?.protectRoute || route);
|
||||
if (
|
||||
protectRoute !== '#/my-work/agenda/protect-today'
|
||||
|| !['#/my-work/agenda', protectRoute].includes(route)
|
||||
) return;
|
||||
event.notification.close();
|
||||
event.waitUntil(openWorkRoute(protectRoute));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'open-agenda') {
|
||||
if (route !== '#/my-work/agenda') return;
|
||||
event.notification.close();
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'tomorrow') {
|
||||
const notificationId = Number(event.notification.data?.notificationId);
|
||||
if (
|
||||
@@ -419,6 +513,14 @@ self.addEventListener('notificationclick', event => {
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request;
|
||||
const requestUrl = new URL(request.url);
|
||||
if (
|
||||
request.method === 'POST' && request.mode === 'navigate'
|
||||
&& requestUrl.origin === self.location.origin && requestUrl.pathname === BASE
|
||||
) {
|
||||
event.respondWith(acceptSharedContent(request));
|
||||
return;
|
||||
}
|
||||
if (request.method !== 'GET' || request.url.includes('/api/')) return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.sharedImageCapture = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const RECORD_ID = 'shared-image';
|
||||
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
async function consume({marker, store, restore, status = () => {}}) {
|
||||
if (!marker) return false;
|
||||
if (marker !== 'image') {
|
||||
status('Share one PNG, JPEG, or WebP screenshot.');
|
||||
return false;
|
||||
}
|
||||
const value = await store?.get(RECORD_ID);
|
||||
if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
|
||||
status('The shared screenshot is unavailable. Share it again.');
|
||||
return false;
|
||||
}
|
||||
restore(value);
|
||||
await store.delete(RECORD_ID);
|
||||
status('Shared screenshot ready to file with this issue.');
|
||||
return true;
|
||||
}
|
||||
|
||||
return {consume, RECORD_ID};
|
||||
});
|
||||
@@ -6,21 +6,132 @@
|
||||
'use strict';
|
||||
|
||||
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
|
||||
const searchKinds = new Set(['search', 'search-preview']);
|
||||
const maxQueryLength = 200;
|
||||
|
||||
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
||||
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
||||
function cleanQuery(value) {
|
||||
const query = typeof value === 'string' ? value.trim() : '';
|
||||
return query.length <= maxQueryLength ? query : '';
|
||||
}
|
||||
|
||||
function cleanScope(value) {
|
||||
const repository = typeof value?.repository === 'string' &&
|
||||
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
|
||||
? value.repository.trim() : '';
|
||||
return {
|
||||
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
|
||||
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
|
||||
repository,
|
||||
};
|
||||
}
|
||||
|
||||
function cleanPreview(value) {
|
||||
if (!value || !['issue', 'pull'].includes(value.kind)) return null;
|
||||
const repository = typeof value.repository === 'string' ? value.repository : '';
|
||||
const parts = repository.split('/');
|
||||
const validPart = part => /^[A-Za-z0-9_.-]+$/.test(part);
|
||||
const number = Number(value.number);
|
||||
if (parts.length !== 2 || !parts.every(validPart) || !Number.isInteger(number) || number < 1) return null;
|
||||
return { kind:value.kind, repository, number };
|
||||
}
|
||||
|
||||
function previewToken(preview) {
|
||||
return preview ? `${preview.kind}:${preview.repository}:${preview.number}` : '';
|
||||
}
|
||||
|
||||
function parsePreview(value) {
|
||||
const match = /^(issue|pull):([^/:]+\/[^/:]+):(\d+)$/.exec(value || '');
|
||||
return match ? cleanPreview({ kind:match[1], repository:match[2], number:Number(match[3]) }) : null;
|
||||
}
|
||||
|
||||
return function createTaskOverlayHistory({ history, location, eventTarget, onChange }) {
|
||||
let started = false;
|
||||
let restoredFromUrl = false;
|
||||
|
||||
function stateKind(state = history.state) {
|
||||
return allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||
function stateDetail(state = history.state) {
|
||||
const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||
if (!searchKinds.has(kind)) return { kind };
|
||||
const query = cleanQuery(state.searchQuery);
|
||||
const scope = state.searchScope === undefined ? null : cleanScope(state.searchScope);
|
||||
const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null;
|
||||
if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) {
|
||||
return { kind:'search', ...(query ? { query } : {}) };
|
||||
}
|
||||
return { kind, ...(query ? { query } : {}), ...(scope ? { scope } : {}), ...(preview ? { preview } : {}) };
|
||||
}
|
||||
|
||||
function urlDetail() {
|
||||
if (!location || typeof URLSearchParams === 'undefined') return { kind:null };
|
||||
const params = new URLSearchParams(location.search || '');
|
||||
const rawQuery = params.get('search');
|
||||
if (rawQuery === null) return { kind:null };
|
||||
const query = cleanQuery(rawQuery);
|
||||
if (rawQuery.trim() && !query) return { kind:null };
|
||||
const preview = parsePreview(params.get('preview'));
|
||||
const hasScope = params.has('search_kind') || params.has('search_state') || params.has('search_repository');
|
||||
const scope = hasScope ? cleanScope({
|
||||
kind:params.get('search_kind'), state:params.get('search_state'),
|
||||
repository:params.get('search_repository'),
|
||||
}) : null;
|
||||
return {
|
||||
kind:preview ? 'search-preview' : 'search',
|
||||
...(query ? { query } : {}),
|
||||
...(scope ? { scope } : {}),
|
||||
...(preview ? { preview } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toState(detail, base = history.state) {
|
||||
const state = { ...(base || {}), taskOverlay:detail.kind };
|
||||
delete state.searchQuery;
|
||||
delete state.searchPreview;
|
||||
delete state.searchScope;
|
||||
if (searchKinds.has(detail.kind)) {
|
||||
if (detail.query) state.searchQuery = detail.query;
|
||||
if (detail.scope) state.searchScope = cleanScope(detail.scope);
|
||||
if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function urlFor(detail) {
|
||||
if (!location || typeof URLSearchParams === 'undefined') return undefined;
|
||||
const params = new URLSearchParams(location.search || '');
|
||||
params.delete('search');
|
||||
params.delete('preview');
|
||||
params.delete('search_kind');
|
||||
params.delete('search_state');
|
||||
params.delete('search_repository');
|
||||
if (searchKinds.has(detail.kind)) {
|
||||
params.set('search', detail.query || '');
|
||||
if (detail.scope) {
|
||||
params.set('search_kind', detail.scope.kind);
|
||||
params.set('search_state', detail.scope.state);
|
||||
if (detail.scope.repository) params.set('search_repository', detail.scope.repository);
|
||||
}
|
||||
if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
|
||||
}
|
||||
const query = params.toString();
|
||||
return `${location.pathname || ''}${query ? `?${query}` : ''}${location.hash || ''}`;
|
||||
}
|
||||
|
||||
let initial = stateDetail();
|
||||
if (!initial.kind) {
|
||||
const fromUrl = urlDetail();
|
||||
if (fromUrl.kind) {
|
||||
initial = fromUrl;
|
||||
restoredFromUrl = true;
|
||||
history.replaceState(toState(fromUrl), '', urlFor(fromUrl));
|
||||
}
|
||||
}
|
||||
let active = initial;
|
||||
|
||||
function apply(state) {
|
||||
const next = stateKind(state);
|
||||
if (next === active) return;
|
||||
const previous = active;
|
||||
const next = stateDetail(state);
|
||||
if (JSON.stringify(next) === JSON.stringify(active)) return;
|
||||
const previous = active.kind;
|
||||
active = next;
|
||||
onChange(next, previous);
|
||||
onChange(next.kind, previous, next);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -28,33 +139,54 @@
|
||||
if (started) return;
|
||||
started = true;
|
||||
eventTarget.addEventListener('popstate', event => apply(event.state));
|
||||
if (restoredFromUrl) onChange(active.kind, null, active);
|
||||
},
|
||||
open(kind) {
|
||||
open(kind, detail = {}) {
|
||||
if (!allowed.has(kind)) return false;
|
||||
if (active === kind) return true;
|
||||
const previous = active;
|
||||
const state = { ...(history.state || {}), taskOverlay: kind };
|
||||
history.pushState(state, '');
|
||||
active = kind;
|
||||
onChange(kind, previous);
|
||||
const next = stateDetail(toState({
|
||||
kind, query:cleanQuery(detail.query),
|
||||
scope:detail.scope === undefined ? undefined : cleanScope(detail.scope),
|
||||
preview:cleanPreview(detail.preview),
|
||||
}));
|
||||
if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true;
|
||||
const previous = active.kind;
|
||||
history.pushState(toState(next), '', urlFor(next));
|
||||
active = next;
|
||||
onChange(next.kind, previous, next);
|
||||
return true;
|
||||
},
|
||||
update(detail = {}) {
|
||||
if (!searchKinds.has(active.kind)) return false;
|
||||
const next = stateDetail(toState({
|
||||
kind:active.kind,
|
||||
query:detail.query === undefined ? active.query : cleanQuery(detail.query),
|
||||
scope:detail.scope === undefined ? active.scope : cleanScope(detail.scope),
|
||||
preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview),
|
||||
}));
|
||||
history.replaceState(toState(next), '', urlFor(next));
|
||||
active = next;
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (!active) return false;
|
||||
if (!active.kind) return false;
|
||||
history.back();
|
||||
return true;
|
||||
},
|
||||
leave() {
|
||||
if (!active) return false;
|
||||
const previous = active;
|
||||
if (!active.kind) return false;
|
||||
const previous = active.kind;
|
||||
const state = { ...(history.state || {}) };
|
||||
delete state.taskOverlay;
|
||||
history.replaceState(state, '');
|
||||
active = null;
|
||||
onChange(null, previous);
|
||||
delete state.searchQuery;
|
||||
delete state.searchPreview;
|
||||
delete state.searchScope;
|
||||
active = { kind:null };
|
||||
history.replaceState(state, '', urlFor(active));
|
||||
onChange(null, previous, active);
|
||||
return true;
|
||||
},
|
||||
current() { return active; },
|
||||
current() { return active.kind; },
|
||||
currentState() { return { ...active, ...(active.preview ? { preview:{ ...active.preview } } : {}) }; },
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
(function(r){
|
||||
function transaction({controls=[],status}) {
|
||||
let busy=false, disabled=[];
|
||||
async function run(message, action, {origin,failure}={}) {
|
||||
if (busy) return false;
|
||||
busy=true;
|
||||
disabled=controls.map(control=>control.disabled);
|
||||
controls.forEach(control=>{ control.disabled=true; });
|
||||
status.textContent=message;
|
||||
let completed=false;
|
||||
try { completed=Boolean(await action()); }
|
||||
catch (error) { failure=failure || error.message || 'Could not complete decision. Retry.'; }
|
||||
if (!completed && failure) status.textContent=failure;
|
||||
busy=false;
|
||||
controls.forEach((control,index)=>{ control.disabled=disabled[index]; });
|
||||
if (!completed) origin?.focus?.();
|
||||
return completed;
|
||||
}
|
||||
return {run,busy:()=>busy};
|
||||
}
|
||||
function actions(o) {
|
||||
return {
|
||||
keepUnread:()=>o.transaction.run('Keeping unread…',()=>{
|
||||
if(o.triage.active()) o.triage.keepUnreadAndNext(); else o.close();
|
||||
return true;
|
||||
},{origin:o.keepControl,failure:'Could not keep this update unread. Retry.'}),
|
||||
markRead:()=>o.transaction.run('Marking read…',async()=>{
|
||||
const result=await o.reader.markReadAndNext(o.items());
|
||||
if(!result) return false;
|
||||
o.undo.offer(result.item,result.items);
|
||||
if(o.triage.active()) o.triage.acceptCompleted();
|
||||
return true;
|
||||
},{origin:o.readControl,failure:'Could not mark update read. Retry.'}),
|
||||
};
|
||||
}
|
||||
if(typeof module==='object'&&module.exports){module.exports=transaction;module.exports.createActions=actions;}
|
||||
else {r.createUpdateDecisionTransaction=transaction;r.createUpdateDecisionActions=actions;}
|
||||
})(typeof self!=='undefined'?self:this);
|
||||
@@ -0,0 +1,49 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateReadPosition = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateReadPosition(options) {
|
||||
const panel = options.panel;
|
||||
const jump = options.jump;
|
||||
let identity = '';
|
||||
let openingTop = 0;
|
||||
let movedByUser = false;
|
||||
let positioned = false;
|
||||
let target = null;
|
||||
|
||||
function moveToTarget() {
|
||||
if (!target) return false;
|
||||
target.classList?.add('update-new-activity');
|
||||
target.scrollIntoView({ block:'end' });
|
||||
positioned = true;
|
||||
jump.hidden = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
panel.addEventListener('scroll', () => {
|
||||
if (identity && !positioned && panel.scrollTop !== openingTop) movedByUser = true;
|
||||
}, { passive:true });
|
||||
|
||||
return {
|
||||
open(nextIdentity) {
|
||||
identity = String(nextIdentity || '');
|
||||
openingTop = panel.scrollTop;
|
||||
movedByUser = false;
|
||||
positioned = false;
|
||||
target = null;
|
||||
jump.hidden = true;
|
||||
},
|
||||
ready(readyIdentity, newest) {
|
||||
if (!identity || String(readyIdentity || '') !== identity || positioned || !newest) return false;
|
||||
target = newest;
|
||||
target.classList?.add('update-new-activity');
|
||||
if (movedByUser) {
|
||||
jump.hidden = false;
|
||||
return false;
|
||||
}
|
||||
return moveToTarget();
|
||||
},
|
||||
jump() {
|
||||
return moveToTarget();
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateReviewHandoff = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateReviewHandoff(options) {
|
||||
let source = null;
|
||||
let completing = null;
|
||||
|
||||
function eligible(detail, item = detail) {
|
||||
const merged = { ...(item || {}), ...(detail || {}) };
|
||||
return Boolean(
|
||||
merged.is_review && merged.update_reason === 'Review requested' &&
|
||||
merged.subject_type === 'Pull' && /^[^/]+\/[^/]+$/.test(String(merged.repository || '')) &&
|
||||
Number.isInteger(Number(merged.number)) && Number(merged.number) > 0 &&
|
||||
Number.isInteger(Number(merged.notification_id))
|
||||
);
|
||||
}
|
||||
|
||||
function reviewItem(item) {
|
||||
return {
|
||||
...item,
|
||||
kind: 'review',
|
||||
is_review: true,
|
||||
key: item.key || item.repository + '#' + item.number,
|
||||
};
|
||||
}
|
||||
|
||||
function begin(item) {
|
||||
if (!eligible(item) || source) return false;
|
||||
source = item;
|
||||
options.openReview(reviewItem(item));
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (!source || completing) return false;
|
||||
const item = source;
|
||||
source = null;
|
||||
options.restoreUpdate?.(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
function complete() {
|
||||
if (!source) return Promise.resolve(false);
|
||||
if (completing) return completing;
|
||||
const item = source;
|
||||
completing = Promise.resolve(options.admitRead(item.notification_id)).then(() => {
|
||||
options.advance(item);
|
||||
source = null;
|
||||
return true;
|
||||
}).finally(() => { completing = null; });
|
||||
return completing;
|
||||
}
|
||||
|
||||
return {
|
||||
eligible,
|
||||
begin,
|
||||
cancel,
|
||||
complete,
|
||||
active: () => Boolean(source),
|
||||
source: () => source,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageGesture = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageGesture(options) {
|
||||
const viewportWidth = options.viewportWidth || (() => root.innerWidth);
|
||||
const surface = options.surface;
|
||||
const threshold = options.threshold || 72;
|
||||
const edgeInset = options.edgeInset || 24;
|
||||
const interactive = 'a,button,input,textarea,select,label,summary,[contenteditable="true"],[role="button"]';
|
||||
let active = null;
|
||||
let busy = false;
|
||||
|
||||
function horizontalScroller(target) {
|
||||
for (let node = target; node && node !== surface; node = node.parentElement) {
|
||||
if (Number(node.scrollWidth) > Number(node.clientWidth) + 1) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function ignored(event) {
|
||||
return event.pointerType === 'mouse' || event.clientX <= edgeInset ||
|
||||
event.clientX >= viewportWidth() - edgeInset ||
|
||||
Boolean(event.target?.closest?.(interactive)) || horizontalScroller(event.target);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
active = null;
|
||||
delete surface.dataset.triageGesture;
|
||||
}
|
||||
|
||||
function start(event) {
|
||||
if (busy || !options.enabled() || viewportWidth() > 700 || ignored(event) || active) return;
|
||||
active = {id:event.pointerId, x:event.clientX, y:event.clientY, cancelled:false};
|
||||
surface.setPointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
function move(event) {
|
||||
if (!active || active.id !== event.pointerId) return;
|
||||
const dx = event.clientX - active.x;
|
||||
const dy = event.clientY - active.y;
|
||||
if (Math.abs(dy) > Math.abs(dx) || Math.abs(dy) > 36) {
|
||||
active.cancelled = true;
|
||||
delete surface.dataset.triageGesture;
|
||||
return;
|
||||
}
|
||||
if (Math.abs(dx) < 12) return;
|
||||
event.preventDefault?.();
|
||||
surface.dataset.triageGesture = dx > 0 ? 'keep' : 'read';
|
||||
}
|
||||
|
||||
function decide(callback) {
|
||||
busy = true;
|
||||
let outcome;
|
||||
try {
|
||||
outcome = callback();
|
||||
} catch (error) {
|
||||
busy = false;
|
||||
throw error;
|
||||
}
|
||||
if (outcome && typeof outcome.then === 'function') {
|
||||
Promise.resolve(outcome).finally(() => { busy = false; });
|
||||
} else {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function finish(event) {
|
||||
if (!active || active.id !== event.pointerId) return;
|
||||
const gesture = active;
|
||||
const dx = event.clientX - gesture.x;
|
||||
const dy = event.clientY - gesture.y;
|
||||
surface.releasePointerCapture?.(event.pointerId);
|
||||
reset();
|
||||
if (gesture.cancelled || Math.abs(dx) < threshold || Math.abs(dx) <= Math.abs(dy)) return;
|
||||
decide(dx > 0 ? options.keepUnread : options.markRead);
|
||||
}
|
||||
|
||||
surface.addEventListener('pointerdown', start);
|
||||
surface.addEventListener('pointermove', move);
|
||||
surface.addEventListener('pointerup', finish);
|
||||
surface.addEventListener('pointercancel', reset);
|
||||
|
||||
function destroy() {
|
||||
surface.removeEventListener('pointerdown', start);
|
||||
surface.removeEventListener('pointermove', move);
|
||||
surface.removeEventListener('pointerup', finish);
|
||||
surface.removeEventListener('pointercancel', reset);
|
||||
reset();
|
||||
}
|
||||
|
||||
return {destroy};
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageLauncher(options) {
|
||||
let pending = null;
|
||||
|
||||
function open() {
|
||||
if (pending) return pending;
|
||||
options.selectUpdates();
|
||||
options.announce('Checking all unread updates…');
|
||||
pending = Promise.resolve()
|
||||
.then(() => options.discover())
|
||||
.then(discovery => {
|
||||
if (!options.isUpdatesSelected()) return 'cancelled';
|
||||
if (discovery === false || discovery?.complete === false) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
return 'incomplete';
|
||||
}
|
||||
if (discovery?.complete) options.applySnapshot?.(discovery);
|
||||
if (options.hasMore()) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
return 'incomplete';
|
||||
}
|
||||
const opened = options.hasCheckpoint() ? options.resume() : options.start();
|
||||
if (!opened) options.announce('No unread updates are ready.');
|
||||
return opened ? 'opened' : 'empty';
|
||||
})
|
||||
.catch(() => {
|
||||
if (options.isUpdatesSelected()) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
}
|
||||
return 'incomplete';
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {open};
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageSession = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageSession(options) {
|
||||
const key = options.key || 'stackchain.update-triage.v1';
|
||||
const identity = item => Number.isInteger(item?.notification_id) ? String(item.notification_id) : '';
|
||||
const login = () => String(options.getLogin() || '').trim();
|
||||
let state = null;
|
||||
let running = false;
|
||||
let lastOutcome = null;
|
||||
|
||||
function read() {
|
||||
const owner = login();
|
||||
if (!owner) return null;
|
||||
try {
|
||||
const value = JSON.parse(options.storage.getItem(key) || 'null');
|
||||
if (![1, 2].includes(value?.version) || value.login !== owner || !Array.isArray(value.identities) ||
|
||||
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
|
||||
const identities = value.identities.filter(value => typeof value === 'string' && value);
|
||||
const completed = value.completed.filter(value => identities.includes(value));
|
||||
const kept = Array.isArray(value.kept) ? value.kept.filter(value => identities.includes(value)) : [];
|
||||
if (!identities.length || !identities.includes(value.current)) return null;
|
||||
return { version:2, login:owner, identities, current:value.current, completed, kept };
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
if (!state) return;
|
||||
options.storage.setItem(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function available() {
|
||||
const byIdentity = new Map((options.getItems() || []).map(item => [identity(item), item]));
|
||||
return state.identities.filter(id => !state.completed.includes(id) && byIdentity.has(id))
|
||||
.map(id => byIdentity.get(id));
|
||||
}
|
||||
|
||||
function nextAvailable() {
|
||||
if (!running || !state) return null;
|
||||
const candidates = available().filter(item => identity(item) !== state.current);
|
||||
return candidates.find(item => state.identities.indexOf(identity(item)) > state.identities.indexOf(state.current)) || candidates[0] || null;
|
||||
}
|
||||
|
||||
function finish() {
|
||||
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
|
||||
const keptIdentities = state ? state.kept.filter(id => unread.has(id)) : [];
|
||||
const reviewed = state ? state.completed.length : 0;
|
||||
const outcome = { reviewed, resolved:reviewed - keptIdentities.length, kept:keptIdentities.length, keptIdentities };
|
||||
lastOutcome = outcome;
|
||||
running = false;
|
||||
state = null;
|
||||
options.storage.removeItem(key);
|
||||
options.onFinish(outcome);
|
||||
return false;
|
||||
}
|
||||
|
||||
function openCurrent(preferredIndex = null) {
|
||||
const items = available();
|
||||
if (!items.length) return finish();
|
||||
let index = items.findIndex(item => identity(item) === state.current);
|
||||
if (index < 0) index = Math.min(preferredIndex ?? 0, items.length - 1);
|
||||
state.current = identity(items[index]);
|
||||
persist();
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
const progress = {
|
||||
index:originalIndex + 1,
|
||||
total:state.identities.length,
|
||||
};
|
||||
if (items[index]?.update_reason) progress.reason = String(items[index].update_reason);
|
||||
options.onProgress(progress);
|
||||
options.onOpen(items[index]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function advance(openNext = true) {
|
||||
if (!running || !state) return false;
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
if (!state.completed.includes(state.current)) state.completed.push(state.current);
|
||||
const candidates = available();
|
||||
const next = candidates.find(item => state.identities.indexOf(identity(item)) > originalIndex) || candidates[0];
|
||||
if (!next) return finish();
|
||||
state.current = identity(next);
|
||||
if (openNext) return openCurrent();
|
||||
persist();
|
||||
const progress = {
|
||||
index:state.identities.indexOf(state.current) + 1,
|
||||
total:state.identities.length,
|
||||
};
|
||||
if (next?.update_reason) progress.reason = String(next.update_reason);
|
||||
options.onProgress(progress);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
resumable: () => Boolean(read()),
|
||||
start() {
|
||||
const identities = (options.getItems() || []).map(identity).filter(Boolean);
|
||||
if (!identities.length) return finish();
|
||||
state = { version:2, login:login(), identities, current:identities[0], completed:[], kept:[] };
|
||||
if (!state.login) return false;
|
||||
running = true;
|
||||
return openCurrent();
|
||||
},
|
||||
resume() {
|
||||
state = read();
|
||||
if (!state) return false;
|
||||
running = true;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
reconcile() {
|
||||
if (!running || !state) return false;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
completeAndNext: advance,
|
||||
acceptCompleted: () => advance(false),
|
||||
keepUnreadAndNext() {
|
||||
if (!running || !state) return false;
|
||||
if (!state.kept.includes(state.current)) state.kept.push(state.current);
|
||||
return advance();
|
||||
},
|
||||
reviewKept(identities = lastOutcome?.keptIdentities || []) {
|
||||
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
|
||||
const kept = (identities || []).filter(id => unread.has(id));
|
||||
if (!kept.length) return false;
|
||||
state = { version:2, login:login(), identities:kept, current:kept[0], completed:[], kept:[] };
|
||||
if (!state.login) return false;
|
||||
running = true;
|
||||
return openCurrent();
|
||||
},
|
||||
next: nextAvailable,
|
||||
items: () => state ? available().slice() : [],
|
||||
end: finish,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
(function(r,f){if(typeof module==='object'&&module.exports)module.exports=f;else r.createWorkDetailPosition=f})(typeof self!=='undefined'?self:this,function(o){let i='';return{open(n){n=String(n||'');if(n&&n===i)return false;i=n;o.panel.scrollTop=0;return true}}});
|
||||
@@ -6,7 +6,7 @@
|
||||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'attention', 'update', 'later', 'draft'];
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'update', 'later', 'draft'];
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
@@ -20,6 +20,9 @@
|
||||
if (queueFilters.includes(queue) && parts.length === 3) {
|
||||
return { kind: 'queue', filter: queue };
|
||||
}
|
||||
if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) {
|
||||
return { kind: 'queue', filter: 'agenda', action: 'protect-today' };
|
||||
}
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
@@ -114,7 +117,7 @@
|
||||
if (active && active !== fragment) onClose();
|
||||
if (active === fragment) return;
|
||||
active = fragment;
|
||||
onQueue(route.filter);
|
||||
onQueue(route.filter, route.action || null);
|
||||
return;
|
||||
}
|
||||
const item = items.find(candidate => sameRoute(candidate, route));
|
||||
|
||||
@@ -52,6 +52,30 @@ function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
return select(item);
|
||||
}
|
||||
|
||||
function selectMany(items) {
|
||||
if (!active) return { status: 'inactive', added: 0, count: selected.size, limit: maximum };
|
||||
let added = 0;
|
||||
let capped = false;
|
||||
for (const item of items || []) {
|
||||
const id = identity(item);
|
||||
if (!id || selected.has(id)) continue;
|
||||
if (selected.size >= maximum) {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
selected.add(id);
|
||||
added += 1;
|
||||
}
|
||||
changed();
|
||||
return { status: capped ? 'limit' : 'selected', added, count: selected.size, limit: maximum };
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!active) return snapshot();
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function retain(items) {
|
||||
const retained = new Set((items || []).map(identity).filter(Boolean));
|
||||
Array.from(selected).forEach(id => { if (!retained.has(id)) selected.delete(id); });
|
||||
@@ -62,7 +86,7 @@ function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
return { active, count: selected.size, ids: Array.from(selected) };
|
||||
}
|
||||
|
||||
return { identity, start, cancel, select, toggle, retain, snapshot, limit: maximum };
|
||||
return { identity, start, cancel, select, selectMany, clear, toggle, retain, snapshot, limit: maximum };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createWorkSelection;
|
||||
@@ -244,6 +244,14 @@ async def managed_session_active(management_id: str) -> bool:
|
||||
return status == "active"
|
||||
|
||||
|
||||
async def managed_session_statuses(management_ids) -> dict[str, str]:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().managed_statuses,
|
||||
management_ids,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
|
||||
|
||||
async def touch_session(session: Session) -> bool:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().touch,
|
||||
|
||||
@@ -28,10 +28,12 @@ FEATURE_SOURCES = {
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/commands.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||
"static/assign-and-start.js", "static/queue-today.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js",
|
||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
"static/search-batch-plan.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
|
||||
+158
-20
@@ -4,7 +4,7 @@ import re
|
||||
import shlex
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
from urllib.parse import quote, urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -403,6 +403,41 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def assigned_issue_snapshot(
|
||||
*,
|
||||
limit: int = 50,
|
||||
max_pages: int = 20,
|
||||
max_concurrency: int = 4,
|
||||
deadline_seconds: float = 5.0,
|
||||
) -> dict:
|
||||
"""Load a complete, bounded assigned-issue snapshot for deadline dispatch."""
|
||||
async with asyncio.timeout(deadline_seconds):
|
||||
first = await work_page("issue", 1, limit)
|
||||
page_count = max(1, (first["total"] + limit - 1) // limit)
|
||||
if page_count > max_pages:
|
||||
raise ValueError("Assigned issue snapshot exceeds the scan limit")
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def load(page: int) -> dict:
|
||||
async with semaphore:
|
||||
return await work_page("issue", page, limit)
|
||||
|
||||
remaining = await asyncio.gather(
|
||||
*(load(page) for page in range(2, page_count + 1))
|
||||
)
|
||||
pages = [first, *remaining]
|
||||
if any(page.get("total") != first["total"] for page in pages[1:]):
|
||||
raise ValueError("Assigned issue pagination changed during the scan")
|
||||
items = [item for page in pages for item in page["items"]]
|
||||
issue_ids = [
|
||||
item.get("id") for item in items
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), int) and item["id"] > 0
|
||||
]
|
||||
if len(items) != first["total"] or len(set(issue_ids)) != first["total"]:
|
||||
raise ValueError("Assigned issue snapshot has an incomplete issue set")
|
||||
return {"items": items, "complete": True}
|
||||
|
||||
|
||||
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
@@ -428,13 +463,46 @@ def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
async def global_search(query: str, limit: int = 10) -> dict:
|
||||
"""Search accessible issues and pulls concurrently with a bounded result set."""
|
||||
async def global_search(
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
page: int = 1,
|
||||
kind: str = "all",
|
||||
state: str = "all",
|
||||
repository: str | None = None,
|
||||
continuation: dict[str, int | None] | None = None,
|
||||
) -> dict:
|
||||
"""Search accessible work with lossless, independently retryable streams."""
|
||||
item_types = ("issues", "pulls") if kind == "all" else (
|
||||
"issues" if kind == "issue" else "pulls",
|
||||
)
|
||||
stream_limits = {
|
||||
item_type: (
|
||||
(limit + 1) // 2 if item_type == "issues" else limit // 2
|
||||
) if kind == "all" else limit
|
||||
for item_type in item_types
|
||||
}
|
||||
stream_pages = {
|
||||
item_type: continuation.get(item_type) if continuation is not None else page
|
||||
for item_type in item_types
|
||||
}
|
||||
active_types = tuple(
|
||||
item_type for item_type in item_types
|
||||
if stream_pages[item_type] is not None and stream_limits[item_type] > 0
|
||||
)
|
||||
|
||||
async def load(item_type: str) -> Any:
|
||||
params = {
|
||||
"q": query, "type": item_type, "state": state,
|
||||
"limit": stream_limits[item_type], "page": stream_pages[item_type],
|
||||
}
|
||||
if repository:
|
||||
owner, repo = repository.split("/", 1)
|
||||
params.update({"owner": owner, "repo": repo})
|
||||
response = await _get_client().get(
|
||||
"/api/v1/repos/issues/search",
|
||||
headers=_auth(),
|
||||
params={"q": query, "type": item_type, "limit": limit, "page": 1},
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
@@ -443,30 +511,58 @@ async def global_search(query: str, limit: int = 10) -> dict:
|
||||
return payload
|
||||
|
||||
outcomes = await asyncio.gather(
|
||||
load("issues"), load("pulls"), return_exceptions=True
|
||||
*(load(item_type) for item_type in active_types), return_exceptions=True
|
||||
)
|
||||
for outcome in outcomes:
|
||||
if isinstance(outcome, asyncio.CancelledError):
|
||||
raise outcome
|
||||
if all(isinstance(outcome, BaseException) for outcome in outcomes):
|
||||
if outcomes and all(isinstance(outcome, BaseException) for outcome in outcomes):
|
||||
raise outcomes[0]
|
||||
|
||||
results = []
|
||||
streams: list[list[dict]] = []
|
||||
seen: set[tuple[str, str, int]] = set()
|
||||
for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True):
|
||||
result_kinds = tuple(
|
||||
"issue" if item_type == "issues" else "pull" for item_type in active_types
|
||||
)
|
||||
for outcome, result_kind in zip(outcomes, result_kinds, strict=True):
|
||||
normalized_stream = []
|
||||
if isinstance(outcome, BaseException):
|
||||
streams.append(normalized_stream)
|
||||
continue
|
||||
for item in outcome:
|
||||
normalized = _normalize_global_search_item(item, kind)
|
||||
normalized = _normalize_global_search_item(item, result_kind)
|
||||
if normalized is not None:
|
||||
identity = (kind, normalized["repository"], normalized["number"])
|
||||
identity = (result_kind, normalized["repository"], normalized["number"])
|
||||
if identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
results.append(normalized)
|
||||
normalized_stream.append(normalized)
|
||||
streams.append(normalized_stream)
|
||||
results = []
|
||||
for index in range(max((len(stream) for stream in streams), default=0)):
|
||||
for stream in streams:
|
||||
if index < len(stream):
|
||||
results.append(stream[index])
|
||||
|
||||
failed_streams = [
|
||||
"issue" if item_type == "issues" else "pull"
|
||||
for item_type, outcome in zip(active_types, outcomes, strict=True)
|
||||
if isinstance(outcome, BaseException)
|
||||
]
|
||||
next_continuation = {item_type: None for item_type in item_types}
|
||||
for item_type, outcome in zip(active_types, outcomes, strict=True):
|
||||
if isinstance(outcome, BaseException):
|
||||
next_continuation[item_type] = stream_pages[item_type]
|
||||
elif len(outcome) >= stream_limits[item_type]:
|
||||
next_continuation[item_type] = int(stream_pages[item_type]) + 1
|
||||
has_more = any(value is not None for value in next_continuation.values())
|
||||
return {
|
||||
"items": results[:limit],
|
||||
"partial": any(isinstance(outcome, BaseException) for outcome in outcomes),
|
||||
"partial": bool(failed_streams),
|
||||
"has_more": has_more,
|
||||
"next_page": page + 1,
|
||||
"continuation": next_continuation,
|
||||
"failed_streams": failed_streams,
|
||||
}
|
||||
|
||||
|
||||
@@ -490,6 +586,19 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
login = user.get("login") if isinstance(user, dict) else ""
|
||||
state = issue.get("state") if isinstance(issue.get("state"), str) else ""
|
||||
actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue"
|
||||
reviewable = False
|
||||
if actual_kind == "pull" and state == "open" and login:
|
||||
try:
|
||||
pull = await fetch(f"repos/{repository}/pulls/{number}")
|
||||
except Exception:
|
||||
pull = None
|
||||
requested_reviewers = pull.get("requested_reviewers") if isinstance(pull, dict) else []
|
||||
if not isinstance(requested_reviewers, list):
|
||||
requested_reviewers = []
|
||||
reviewable = any(
|
||||
isinstance(reviewer, dict) and reviewer.get("login") == login
|
||||
for reviewer in requested_reviewers
|
||||
)
|
||||
return {
|
||||
"kind": actual_kind,
|
||||
"repository": repository,
|
||||
@@ -507,6 +616,8 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
"claimable": actual_kind == "issue" and state == "open" and not assignee_names,
|
||||
"reopenable": actual_kind == "issue" and state == "closed",
|
||||
"assigned_to_me": bool(login and login in assignee_names),
|
||||
"reviewable": reviewable,
|
||||
"commentable": bool(login),
|
||||
}
|
||||
|
||||
|
||||
@@ -881,6 +992,14 @@ def _gitea_api_path(value: Any) -> str:
|
||||
return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "")
|
||||
|
||||
|
||||
async def _notification_subscription(repository: str, number: str) -> dict:
|
||||
try:
|
||||
value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
|
||||
except Exception:
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
async def notification_detail(thread_id: int) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -905,18 +1024,16 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
)
|
||||
if supported_conversation:
|
||||
assert conversation_match is not None
|
||||
subject_detail, comment, conversation = await asyncio.gather(
|
||||
subject_detail, comment, subscription = await asyncio.gather(
|
||||
fetch(subject_path),
|
||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||
issue_conversation_page(
|
||||
conversation_match.group(1), int(conversation_match.group(3))
|
||||
),
|
||||
_notification_subscription(conversation_match.group(1), conversation_match.group(3)),
|
||||
)
|
||||
else:
|
||||
subject_detail, comment, conversation = await asyncio.gather(
|
||||
subject_detail, comment, subscription = await asyncio.gather(
|
||||
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
|
||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
|
||||
asyncio.sleep(0, result={}),
|
||||
)
|
||||
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
|
||||
comment = comment if isinstance(comment, dict) else {}
|
||||
@@ -977,12 +1094,18 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
and comment_match
|
||||
and comment_match.group(1) == repository_name
|
||||
),
|
||||
"conversation": conversation,
|
||||
"mute_supported": bool(
|
||||
supported_conversation
|
||||
and isinstance(subscription, dict)
|
||||
and subscription.get("subscribed") is True
|
||||
and subscription.get("ignored") is not True
|
||||
),
|
||||
"conversation_available": bool(supported_conversation),
|
||||
}
|
||||
|
||||
|
||||
async def notification_conversation_page(
|
||||
thread_id: int, page: int, limit: int = 20
|
||||
thread_id: int, page: int | None, limit: int = 20
|
||||
) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -1023,6 +1146,21 @@ async def notification_conversation_target(thread_id: int) -> tuple[str, int]:
|
||||
return match.group(1), int(match.group(3))
|
||||
|
||||
|
||||
async def mute_notification(thread_id: int) -> dict:
|
||||
"""Unsubscribe the current operator from the trusted notification conversation."""
|
||||
repository, number = await notification_conversation_target(thread_id)
|
||||
user = await fetch("user")
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("Gitea did not identify the current operator")
|
||||
response = await _get_client().delete(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/subscriptions/{quote(login, safe='')}",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {"id": thread_id, "repository": repository, "number": number, "muted": True}
|
||||
|
||||
|
||||
async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
|
||||
+10
-2
@@ -22,10 +22,14 @@ class LaterStore:
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
|
||||
connection.close()
|
||||
return
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
@@ -63,8 +67,12 @@ class LaterStore:
|
||||
"UPDATE later_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(self.clock(),),
|
||||
)
|
||||
connection.execute("PRAGMA user_version = 1")
|
||||
connection.commit()
|
||||
return connection
|
||||
connection.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
|
||||
+496
-50
@@ -5,11 +5,13 @@ import hashlib
|
||||
import hmac
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import Awaitable, Coroutine
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
@@ -47,7 +49,11 @@ from src.live_snapshot_store import (
|
||||
)
|
||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||
from src.passkey_store import PasskeyStore
|
||||
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||
from src.push_notifications import (
|
||||
PushConfiguration,
|
||||
dispatch_deadline_reminders,
|
||||
dispatch_unread_updates,
|
||||
)
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
@@ -59,6 +65,9 @@ from src.views import FRONTEND_BUILD, router as frontend_router
|
||||
|
||||
|
||||
async def _drain_authored_action_operations() -> None:
|
||||
for key, operation in list(_authored_action_operations.items()):
|
||||
if operation[1].done():
|
||||
_authored_action_operations.pop(key, None)
|
||||
tasks = {
|
||||
operation[1]
|
||||
for operation in _authored_action_operations.values()
|
||||
@@ -79,8 +88,28 @@ async def _drain_authored_action_operations() -> None:
|
||||
_authored_action_operations.pop(key, None)
|
||||
|
||||
|
||||
async def _push_channel_loop(dispatch, *, interval: float) -> None:
|
||||
"""Run one push channel on fixed ticks without overlapping or catch-up bursts."""
|
||||
loop = asyncio.get_running_loop()
|
||||
next_tick = loop.time() + interval
|
||||
while True:
|
||||
await asyncio.sleep(max(0.0, next_tick - loop.time()))
|
||||
try:
|
||||
await dispatch()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
now = loop.time()
|
||||
elapsed_intervals = max(1, int((now - next_tick) // interval) + 1)
|
||||
next_tick += elapsed_intervals * interval
|
||||
|
||||
|
||||
async def _push_poll_loop() -> None:
|
||||
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
|
||||
deadline_interval = max(
|
||||
60.0, float(os.getenv("STACKCHAIN_DEADLINE_POLL_SECONDS", "600"))
|
||||
)
|
||||
send_timeout = max(
|
||||
1.0, float(os.getenv("STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS", "10"))
|
||||
)
|
||||
@@ -95,31 +124,59 @@ async def _push_poll_loop() -> None:
|
||||
0,
|
||||
int(os.getenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "3")),
|
||||
)
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
await dispatch_unread_updates(
|
||||
async def dispatch_unread() -> None:
|
||||
await dispatch_unread_updates(
|
||||
_push_subscription_store,
|
||||
_push_configuration(),
|
||||
gitea_proxy.unread_notification_snapshot,
|
||||
session_active=dashboard_auth.managed_session_active,
|
||||
session_statuses=dashboard_auth.managed_session_statuses,
|
||||
lease_seconds=lease_seconds,
|
||||
send_timeout_seconds=send_timeout,
|
||||
max_concurrency=max_concurrency,
|
||||
max_individual_notifications=max_individual_notifications,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
# Gitea and push endpoints are external; one failed poll must not
|
||||
# stop later delivery attempts.
|
||||
continue
|
||||
|
||||
async def dispatch_deadlines() -> None:
|
||||
await dispatch_deadline_reminders(
|
||||
_push_subscription_store,
|
||||
_push_configuration(),
|
||||
gitea_proxy.assigned_issue_snapshot,
|
||||
session_statuses=dashboard_auth.managed_session_statuses,
|
||||
send_timeout_seconds=send_timeout,
|
||||
lease_seconds=lease_seconds,
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
|
||||
channel_tasks = (
|
||||
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
|
||||
asyncio.create_task(
|
||||
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
|
||||
),
|
||||
)
|
||||
try:
|
||||
done, _pending = await asyncio.wait(
|
||||
channel_tasks, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
await next(iter(done))
|
||||
finally:
|
||||
for task in channel_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(*channel_tasks, return_exceptions=True)
|
||||
|
||||
|
||||
async def _readiness_monitor() -> None:
|
||||
while True:
|
||||
await _check_readiness()
|
||||
await asyncio.sleep(READINESS_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
global _live_snapshot_task, _available_issue_snapshot_task, _push_poll_task
|
||||
global _readiness_task
|
||||
gitea_proxy.start_client()
|
||||
_readiness_task = asyncio.create_task(_readiness_monitor())
|
||||
if _push_configuration().enabled:
|
||||
_push_poll_task = asyncio.create_task(_push_poll_loop())
|
||||
try:
|
||||
@@ -128,7 +185,8 @@ async def lifespan(_app: FastAPI):
|
||||
live_task = _live_snapshot_task
|
||||
available_task = _available_issue_snapshot_task
|
||||
push_task = _push_poll_task
|
||||
for task in (live_task, available_task, push_task):
|
||||
readiness_task = _readiness_task
|
||||
for task in (live_task, available_task, push_task, readiness_task):
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
@@ -145,6 +203,8 @@ async def lifespan(_app: FastAPI):
|
||||
_available_issue_snapshot_task = None
|
||||
if _push_poll_task is push_task:
|
||||
_push_poll_task = None
|
||||
if _readiness_task is readiness_task:
|
||||
_readiness_task = None
|
||||
|
||||
|
||||
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
|
||||
@@ -153,6 +213,60 @@ app.add_middleware(NegotiatedGZipMiddleware, minimum_size=1_024)
|
||||
CONTEXT_TIMEOUT_SECONDS = 5.0
|
||||
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
|
||||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
READINESS_INTERVAL_SECONDS = max(
|
||||
READINESS_TIMEOUT_SECONDS,
|
||||
float(os.getenv("STACKCHAIN_READINESS_INTERVAL_SECONDS", "30")),
|
||||
)
|
||||
READINESS_MAX_AGE_SECONDS = max(
|
||||
READINESS_INTERVAL_SECONDS + READINESS_TIMEOUT_SECONDS + 5.0,
|
||||
float(os.getenv("STACKCHAIN_READINESS_MAX_AGE_SECONDS", "65")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReadinessState:
|
||||
checked_at: float
|
||||
ready: bool
|
||||
login: str | None = None
|
||||
error: str | None = None
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
_readiness_state: ReadinessState | None = None
|
||||
|
||||
|
||||
async def _check_readiness() -> None:
|
||||
global _readiness_state
|
||||
checked_at = time.monotonic()
|
||||
try:
|
||||
user = await asyncio.wait_for(current_user(), timeout=READINESS_TIMEOUT_SECONDS)
|
||||
if not isinstance(user, dict) or not user.get("login"):
|
||||
raise ReadinessPayloadError(
|
||||
"Gitea current-user response did not include a login"
|
||||
)
|
||||
except Exception as exc:
|
||||
timed_out = isinstance(exc, TimeoutError)
|
||||
error = (
|
||||
f"Gitea readiness check timed out after {READINESS_TIMEOUT_SECONDS:g}s"
|
||||
if timed_out
|
||||
else (
|
||||
str(exc)
|
||||
if isinstance(exc, ReadinessPayloadError)
|
||||
else "Gitea readiness check is temporarily unavailable"
|
||||
)
|
||||
)
|
||||
_readiness_state = ReadinessState(
|
||||
checked_at=checked_at,
|
||||
ready=False,
|
||||
error=error,
|
||||
timed_out=timed_out,
|
||||
)
|
||||
return
|
||||
_readiness_state = ReadinessState(
|
||||
checked_at=checked_at,
|
||||
ready=True,
|
||||
login=user["login"],
|
||||
)
|
||||
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
|
||||
ISSUE_ACTION_TIMEOUT_SECONDS = 5.0
|
||||
ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS = 600.0
|
||||
@@ -181,6 +295,7 @@ AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS = WORK_PAGE_TIMEOUT_SECONDS + 1.0
|
||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||
_live_snapshot_task: asyncio.Task | None = None
|
||||
_push_poll_task: asyncio.Task | None = None
|
||||
_readiness_task: asyncio.Task | None = None
|
||||
_live_snapshot_value: dict | None = None
|
||||
_live_snapshot_created_at: float | None = None
|
||||
LIVE_SNAPSHOT_SECTIONS = ("context", "events", "notifications")
|
||||
@@ -276,10 +391,29 @@ class PushSubscriptionPayload(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class DeadlineReminderPayload(BaseModel):
|
||||
enabled: bool
|
||||
timezone: str = Field(min_length=1, max_length=64)
|
||||
reminder_hour: int = Field(default=9, ge=0, le=23)
|
||||
reminder_days: Literal[0, 2, 7] = 2
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str) -> str:
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except ZoneInfoNotFoundError as error:
|
||||
raise ValueError("Valid IANA timezone required") from error
|
||||
return value
|
||||
|
||||
|
||||
StepUpAction = Literal[
|
||||
"merge_pull",
|
||||
"submit_pull_review",
|
||||
"close_issue",
|
||||
"delete_comment",
|
||||
"log_recap_time",
|
||||
"revoke_device",
|
||||
"revoke_all_sessions",
|
||||
@@ -869,6 +1003,41 @@ app.include_router(frontend_router)
|
||||
|
||||
|
||||
def _share_target_login_redirect(request: Request) -> str:
|
||||
search_values = request.query_params.getlist("search")
|
||||
preview_values = request.query_params.getlist("preview")
|
||||
if search_values or preview_values:
|
||||
allowed = {"search", "preview", "search_kind", "search_state", "search_repository"}
|
||||
kind_values = request.query_params.getlist("search_kind")
|
||||
state_values = request.query_params.getlist("search_state")
|
||||
repository_values = request.query_params.getlist("search_repository")
|
||||
if (
|
||||
set(request.query_params.keys()) - allowed
|
||||
or len(search_values) != 1
|
||||
or len(preview_values) != 1
|
||||
or len(kind_values) > 1
|
||||
or len(state_values) > 1
|
||||
or len(repository_values) > 1
|
||||
or len(search_values[0]) > 200
|
||||
or len(preview_values[0]) > 200
|
||||
or not re.fullmatch(
|
||||
r"(?:issue|pull):[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+:[1-9]\d*",
|
||||
preview_values[0],
|
||||
)
|
||||
):
|
||||
return "login"
|
||||
continuation_values = [
|
||||
("search", search_values[0].strip()), ("preview", preview_values[0]),
|
||||
]
|
||||
if kind_values and kind_values[0] in {"all", "issue", "pull"}:
|
||||
continuation_values.append(("search_kind", kind_values[0]))
|
||||
if state_values and state_values[0] in {"all", "open", "closed"}:
|
||||
continuation_values.append(("search_state", state_values[0]))
|
||||
if repository_values and re.fullmatch(
|
||||
r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository_values[0]
|
||||
):
|
||||
continuation_values.append(("search_repository", repository_values[0]))
|
||||
continuation = urlencode(continuation_values)
|
||||
return f"login?{urlencode({'continue': f'./?{continuation}'})}"
|
||||
limits = {"title": 200, "text": 8000, "url": 2048}
|
||||
if any(
|
||||
len(request.query_params.get(name, "")) > limit
|
||||
@@ -880,6 +1049,17 @@ def _share_target_login_redirect(request: Request) -> str:
|
||||
for name in limits
|
||||
if request.query_params.get(name)
|
||||
]
|
||||
launch = request.query_params.get("launch", "")
|
||||
if launch in {"continue", "new", "agenda"}:
|
||||
shared.append(("launch", launch))
|
||||
image_markers = request.query_params.getlist("shared")
|
||||
if image_markers:
|
||||
if (
|
||||
image_markers != ["image"]
|
||||
or request.query_params.getlist("launch") != ["new"]
|
||||
):
|
||||
return "login"
|
||||
shared.append(("shared", "image"))
|
||||
if dashboard_auth.application_path(request) != "/" or not shared:
|
||||
return "login"
|
||||
continuation = f"./?{urlencode(shared)}"
|
||||
@@ -1798,10 +1978,17 @@ async def push_status(request: Request):
|
||||
_push_subscription_store.is_subscribed,
|
||||
device_id,
|
||||
)
|
||||
preferences = await asyncio.to_thread(
|
||||
_push_subscription_store.deadline_preferences, device_id
|
||||
)
|
||||
return {
|
||||
"available": configuration.enabled,
|
||||
"subscribed": subscribed,
|
||||
"public_key": configuration.public_key if configuration.enabled else "",
|
||||
"deadline_enabled": preferences["enabled"],
|
||||
"timezone": preferences["timezone"],
|
||||
"reminder_hour": preferences["reminder_hour"],
|
||||
"reminder_days": preferences["reminder_days"],
|
||||
}
|
||||
|
||||
|
||||
@@ -1855,6 +2042,31 @@ async def unsubscribe_push(request: Request):
|
||||
return {"subscribed": False}
|
||||
|
||||
|
||||
@app.put("/api/v1/push-subscription/deadlines")
|
||||
async def update_deadline_reminders(payload: DeadlineReminderPayload, request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
if payload.enabled and not await asyncio.to_thread(
|
||||
_push_subscription_store.is_subscribed, device_id
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="Enable device notifications first")
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.set_deadline_preferences,
|
||||
device_id,
|
||||
enabled=payload.enabled,
|
||||
timezone=payload.timezone,
|
||||
reminder_hour=payload.reminder_hour,
|
||||
reminder_days=payload.reminder_days,
|
||||
)
|
||||
return {
|
||||
"deadline_enabled": payload.enabled,
|
||||
"timezone": payload.timezone,
|
||||
"reminder_hour": payload.reminder_hour,
|
||||
"reminder_days": payload.reminder_days,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/session/activity")
|
||||
async def record_session_activity(request: Request):
|
||||
session = request.state.dashboard_session
|
||||
@@ -2358,43 +2570,35 @@ async def sign_out_all_devices(
|
||||
|
||||
@app.get("/readyz")
|
||||
async def readiness():
|
||||
"""Return readiness after verifying the configured Gitea connection."""
|
||||
try:
|
||||
user = await asyncio.wait_for(
|
||||
current_user(), timeout=READINESS_TIMEOUT_SECONDS
|
||||
)
|
||||
if not isinstance(user, dict) or not user.get("login"):
|
||||
raise ReadinessPayloadError(
|
||||
"Gitea current-user response did not include a login"
|
||||
)
|
||||
except Exception as exc:
|
||||
timed_out = isinstance(exc, TimeoutError)
|
||||
error_message = (
|
||||
f"Gitea readiness check timed out after {READINESS_TIMEOUT_SECONDS:g}s"
|
||||
if timed_out
|
||||
else (
|
||||
str(exc)
|
||||
if isinstance(exc, ReadinessPayloadError)
|
||||
else "Gitea readiness check is temporarily unavailable"
|
||||
)
|
||||
)
|
||||
"""Return the latest readiness snapshot without contacting Gitea."""
|
||||
state = _readiness_state
|
||||
if state is None or time.monotonic() - state.checked_at > READINESS_MAX_AGE_SECONDS:
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "not_ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"error": error_message,
|
||||
"error": "Gitea readiness status is not available",
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
if not state.ready:
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "not_ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"error": state.error,
|
||||
},
|
||||
status_code=503,
|
||||
headers={
|
||||
"Retry-After": str(max(1, math.ceil(READINESS_TIMEOUT_SECONDS)))
|
||||
} if timed_out else None,
|
||||
} if state.timed_out else None,
|
||||
)
|
||||
payload = {
|
||||
"status": "ready",
|
||||
"service": "stackchain-dashboard",
|
||||
}
|
||||
if not dashboard_auth.enabled():
|
||||
payload["gitea_user"] = user["login"]
|
||||
payload["gitea_user"] = state.login
|
||||
return payload
|
||||
|
||||
|
||||
@@ -2522,13 +2726,33 @@ async def background_identity() -> JSONResponse:
|
||||
async def global_search(
|
||||
q: str = Query(min_length=2, max_length=100),
|
||||
limit: int = Query(default=10, ge=1, le=25),
|
||||
page: int = Query(default=1, ge=1, le=100),
|
||||
kind: Literal["all", "issue", "pull"] = Query(default="all"),
|
||||
state: Literal["all", "open", "closed"] = Query(default="all"),
|
||||
repository: str | None = Query(
|
||||
default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", max_length=161
|
||||
),
|
||||
issues_page: int | None = Query(default=None, ge=1, le=100),
|
||||
pulls_page: int | None = Query(default=None, ge=1, le=100),
|
||||
) -> JSONResponse:
|
||||
query = q.strip()
|
||||
if len(query) < 2:
|
||||
raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters")
|
||||
try:
|
||||
arguments = (query, limit, page, kind, state)
|
||||
continuation = None
|
||||
if issues_page is not None or pulls_page is not None:
|
||||
continuation = {"issues": issues_page, "pulls": pulls_page}
|
||||
if continuation is not None:
|
||||
operation = gitea_proxy.global_search(
|
||||
*arguments, repository, continuation
|
||||
)
|
||||
elif repository:
|
||||
operation = gitea_proxy.global_search(*arguments, repository)
|
||||
else:
|
||||
operation = gitea_proxy.global_search(*arguments)
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.global_search(query, limit),
|
||||
operation,
|
||||
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
@@ -2537,7 +2761,10 @@ async def global_search(
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse({"query": query, **result})
|
||||
scope = {"kind": kind, "state": state}
|
||||
if repository:
|
||||
scope["repository"] = repository
|
||||
return JSONResponse({"query": query, "scope": scope, **result})
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview")
|
||||
@@ -2561,6 +2788,74 @@ async def global_search_preview(
|
||||
return JSONResponse(preview)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation")
|
||||
async def global_search_preview_conversation(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
kind: Literal["issue", "pull"] = Query(),
|
||||
page: int | None = Query(default=None, ge=1, le=100),
|
||||
limit: int = Query(default=20, ge=1, le=50),
|
||||
) -> JSONResponse:
|
||||
del kind # Issue and pull-request conversations share Gitea's issue-comments API.
|
||||
try:
|
||||
conversation = await asyncio.wait_for(
|
||||
gitea_proxy.issue_conversation_page(
|
||||
f"{owner}/{repo}", number, page, limit
|
||||
),
|
||||
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "This conversation is temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(conversation)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/api/v1/repos/{owner}/{repo}/issues/{number}/preview/comments",
|
||||
status_code=201,
|
||||
)
|
||||
async def comment_on_global_search_preview(
|
||||
comment: IssueComment,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
kind: Literal["issue", "pull"] = Query(),
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def post_comment():
|
||||
preview = await gitea_proxy.work_preview(repository, kind, number)
|
||||
if (
|
||||
preview.get("repository") != repository
|
||||
or preview.get("kind") != kind
|
||||
or preview.get("number") != number
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Search result not found")
|
||||
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
|
||||
|
||||
try:
|
||||
result = await _run_idempotent_authored_action(
|
||||
post_comment(),
|
||||
idempotency_key=idempotency_key,
|
||||
fingerprint=("search-preview-comment", repository, kind, number, comment.body),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The reply could not be posted. Your draft is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.get("/api/v1/work-route")
|
||||
async def resolve_work_route(
|
||||
kind: Literal["issue", "pull", "review", "update"] = Query(),
|
||||
@@ -2705,14 +3000,18 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
|
||||
if _available_issue_snapshot_value is not None:
|
||||
return _available_issue_snapshot_value, True, True, False
|
||||
if not local_refresh:
|
||||
for _ in range(50):
|
||||
wait_deadline = time.monotonic() + WORK_PAGE_TIMEOUT_SECONDS
|
||||
while time.monotonic() < wait_deadline:
|
||||
await asyncio.sleep(0.02)
|
||||
shared = await asyncio.to_thread(_available_issue_snapshot_store.load)
|
||||
if shared.items is not None:
|
||||
return shared.items, False, False, False
|
||||
if not shared.refreshing:
|
||||
break
|
||||
raise RuntimeError("available issue catalog refresh is owned by another worker")
|
||||
local_refresh = await _start_available_issue_refresh()
|
||||
if local_refresh:
|
||||
break
|
||||
if not local_refresh:
|
||||
raise RuntimeError("available issue catalog refresh is owned by another worker")
|
||||
try:
|
||||
return await asyncio.shield(_available_issue_snapshot_task), False, False, False
|
||||
except Exception:
|
||||
@@ -2721,19 +3020,53 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
|
||||
raise
|
||||
|
||||
|
||||
def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict:
|
||||
def _available_issue_page(
|
||||
items: list[dict], page: int, limit: int = 50, query: str = "",
|
||||
repositories: list[str] | None = None, labels: list[str] | None = None,
|
||||
include_facets: bool = False,
|
||||
) -> dict:
|
||||
facets = {
|
||||
"repositories": sorted({str(item.get("repository")) for item in items if item.get("repository")}, key=str.casefold),
|
||||
"labels": sorted({str(label) for item in items for label in (item.get("labels") or [])}, key=str.casefold),
|
||||
}
|
||||
repository_filter = {value.casefold() for value in (repositories or [])}
|
||||
label_filter = {value.casefold() for value in (labels or [])}
|
||||
if repository_filter:
|
||||
items = [item for item in items if str(item.get("repository") or "").casefold() in repository_filter]
|
||||
if label_filter:
|
||||
items = [item for item in items if label_filter & {str(label).casefold() for label in (item.get("labels") or [])}]
|
||||
normalized_query = query.strip().casefold()
|
||||
if normalized_query:
|
||||
number_query = normalized_query.removeprefix("#")
|
||||
items = [
|
||||
item for item in items
|
||||
if normalized_query in str(item.get("repository") or "").casefold()
|
||||
or normalized_query in str(item.get("title") or "").casefold()
|
||||
or (number_query.isdigit() and number_query == str(item.get("number") or ""))
|
||||
]
|
||||
start = (page - 1) * limit
|
||||
page_items = items[start:start + limit]
|
||||
return {
|
||||
result = {
|
||||
"items": page_items,
|
||||
"page": page,
|
||||
"total": len(items),
|
||||
"has_more": start + len(page_items) < len(items),
|
||||
}
|
||||
if include_facets or repository_filter or label_filter:
|
||||
result["facets"] = facets
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/v1/available-issues")
|
||||
async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse:
|
||||
async def available_issues(
|
||||
page: int = Query(default=1, ge=1, le=100),
|
||||
q: str = Query(default="", max_length=100),
|
||||
repository: list[str] = Query(default=[], max_length=100),
|
||||
label: list[str] = Query(default=[], max_length=100),
|
||||
facets: bool = Query(default=False),
|
||||
) -> JSONResponse:
|
||||
if len(repository) > 10 or len(label) > 10:
|
||||
return JSONResponse({"error": "Select at most 10 repositories or labels."}, status_code=422)
|
||||
try:
|
||||
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
|
||||
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
|
||||
@@ -2744,7 +3077,9 @@ async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONRe
|
||||
status_code=503,
|
||||
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
|
||||
)
|
||||
result = _available_issue_page(items, page)
|
||||
result = _available_issue_page(
|
||||
items, page, query=q, repositories=repository, labels=label, include_facets=facets
|
||||
)
|
||||
if stale:
|
||||
result["stale"] = True
|
||||
if revalidating:
|
||||
@@ -3385,6 +3720,21 @@ async def notification_page(page: int = Query(default=1, ge=1)) -> JSONResponse:
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/notifications/snapshot")
|
||||
async def notification_snapshot() -> JSONResponse:
|
||||
try:
|
||||
result = await gitea_proxy.unread_notification_snapshot(
|
||||
deadline_seconds=NOTIFICATION_PAGE_TIMEOUT_SECONDS
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Complete unread updates are temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/notifications/{thread_id}")
|
||||
async def notification_thread_detail(
|
||||
thread_id: int = PathParam(gt=0),
|
||||
@@ -3412,7 +3762,7 @@ async def notification_thread_detail(
|
||||
@app.get("/api/v1/notifications/{thread_id}/conversation")
|
||||
async def notification_thread_conversation(
|
||||
thread_id: int = PathParam(gt=0),
|
||||
page: int = Query(ge=1),
|
||||
page: int | None = Query(default=None, ge=1),
|
||||
limit: int = Query(default=20, ge=1, le=50),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
@@ -3478,6 +3828,43 @@ async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
return JSONResponse({"id": thread_id, "status": "unread"})
|
||||
|
||||
|
||||
@app.post("/api/v1/notifications/{thread_id}/mute")
|
||||
async def mute_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
gitea_proxy.mute_notification(thread_id),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Muting future updates timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Future updates could not be muted. Please retry."},
|
||||
status_code=503,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
mark_notification_read(thread_id),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": thread_id,
|
||||
"muted": True,
|
||||
"status": "unread",
|
||||
"error": "Future updates are muted; current item is still unread. Retry mark read & next.",
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
await _remove_notifications_from_live_snapshot([thread_id])
|
||||
return JSONResponse({"id": thread_id, "muted": True, "status": "read"})
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/{thread_id}/later")
|
||||
async def defer_notification(
|
||||
payload: NotificationLaterRequest,
|
||||
@@ -4193,25 +4580,66 @@ async def _edit_conversation_comment(
|
||||
|
||||
|
||||
async def _delete_conversation_comment(
|
||||
repository: str, number: int, comment_id: int
|
||||
request: Request,
|
||||
step_up_grant: str | None,
|
||||
repository: str,
|
||||
number: int,
|
||||
comment_id: int,
|
||||
) -> JSONResponse:
|
||||
target = f"{repository}#{number}:{comment_id}"
|
||||
await _require_step_up(
|
||||
request,
|
||||
step_up_grant,
|
||||
action="delete_comment",
|
||||
target=target,
|
||||
)
|
||||
try:
|
||||
journal = _security_event_store()
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"comment_deleted",
|
||||
target=target,
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
return JSONResponse(
|
||||
{"error": "Security activity is temporarily unavailable. The comment was not deleted."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.delete_owned_comment(repository, number, comment_id),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.CommentMutationForbiddenError as exc:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=403, detail="You can only change your own comments"
|
||||
) from exc
|
||||
except HTTPException:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
raise
|
||||
except Exception:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"error": "The comment deletion could not be confirmed. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
# Upstream deletion is authoritative; pending evidence remains truthful.
|
||||
pass
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@@ -4231,15 +4659,21 @@ async def edit_assigned_issue_comment(
|
||||
|
||||
@app.delete("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}")
|
||||
async def delete_assigned_issue_comment(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await _delete_conversation_comment(repository, number, comment_id)
|
||||
return await _delete_conversation_comment(
|
||||
request, step_up_grant, repository, number, comment_id
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}")
|
||||
@@ -4258,15 +4692,21 @@ async def edit_assigned_pull_comment(
|
||||
|
||||
@app.delete("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}")
|
||||
async def delete_assigned_pull_comment(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
return await _delete_conversation_comment(repository, number, comment_id)
|
||||
return await _delete_conversation_comment(
|
||||
request, step_up_grant, repository, number, comment_id
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/{thread_id}/comments/{comment_id}")
|
||||
@@ -4284,14 +4724,20 @@ async def edit_notification_comment(
|
||||
|
||||
@app.delete("/api/v1/notifications/{thread_id}/comments/{comment_id}")
|
||||
async def delete_notification_comment(
|
||||
request: Request,
|
||||
thread_id: int = PathParam(gt=0),
|
||||
comment_id: int = PathParam(gt=0),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
):
|
||||
try:
|
||||
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
|
||||
return await _delete_conversation_comment(repository, number, comment_id)
|
||||
return await _delete_conversation_comment(
|
||||
request, step_up_grant, repository, number, comment_id
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
|
||||
|
||||
+173
-8
@@ -3,7 +3,9 @@ import json
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Awaitable, Callable
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
@@ -47,7 +49,7 @@ async def dispatch_unread_updates(
|
||||
unread: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
session_active: Callable[[str], Awaitable[bool]] | None = None,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
lease_seconds: float = 60.0,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
@@ -60,6 +62,7 @@ async def dispatch_unread_updates(
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
@@ -76,20 +79,22 @@ async def dispatch_unread_updates(
|
||||
}
|
||||
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
|
||||
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
|
||||
if session_active is not None:
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
authorized = [await session_active(item.session_id) for item in deliveries]
|
||||
statuses = await session_statuses(
|
||||
[item.session_id for item in deliveries]
|
||||
)
|
||||
except Exception:
|
||||
# Authorization state is mandatory for delivery. Preserve subscriptions
|
||||
# so a temporary registry failure can be retried safely.
|
||||
return 0
|
||||
for delivery, active in zip(deliveries, authorized):
|
||||
if not active:
|
||||
for delivery in deliveries:
|
||||
if statuses.get(delivery.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, delivery.session_id)
|
||||
deliveries = [
|
||||
delivery
|
||||
for delivery, active in zip(deliveries, authorized)
|
||||
if active
|
||||
for delivery in deliveries
|
||||
if statuses.get(delivery.session_id) == "active"
|
||||
]
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
ownership_lost = asyncio.Event()
|
||||
@@ -126,6 +131,7 @@ async def dispatch_unread_updates(
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
@@ -172,6 +178,7 @@ async def dispatch_unread_updates(
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
@@ -233,4 +240,162 @@ async def dispatch_unread_updates(
|
||||
)
|
||||
return sum(counts)
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner)
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="unread")
|
||||
|
||||
|
||||
async def dispatch_deadline_reminders(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
assigned: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
**kwargs,
|
||||
) -> int:
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="deadline",
|
||||
now=time.time(),
|
||||
lease_seconds=max(
|
||||
15.0,
|
||||
float(kwargs.get("lease_seconds", 60.0)),
|
||||
float(kwargs.get("send_timeout_seconds", 10.0)) + 5.0,
|
||||
),
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
return await _dispatch_deadline_reminders_unlocked(
|
||||
store, configuration, assigned, send, owner=owner, **kwargs
|
||||
)
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="deadline")
|
||||
|
||||
|
||||
async def _dispatch_deadline_reminders_unlocked(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
assigned: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
lease_seconds: float = 60.0,
|
||||
max_concurrency: int = 8,
|
||||
owner: str,
|
||||
) -> int:
|
||||
"""Send one privacy-safe Agenda digest per eligible device and local day."""
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
devices = await asyncio.to_thread(store.deadline_reminder_devices)
|
||||
if not devices:
|
||||
return 0
|
||||
current = now or datetime.now(timezone.utc)
|
||||
eligible_devices = []
|
||||
for device in devices:
|
||||
try:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
except ZoneInfoNotFoundError:
|
||||
continue
|
||||
if (
|
||||
local_now.hour >= device.reminder_hour
|
||||
and device.delivered_local_day != local_now.date().isoformat()
|
||||
):
|
||||
eligible_devices.append(device)
|
||||
if not eligible_devices:
|
||||
return 0
|
||||
snapshot = await assigned()
|
||||
if snapshot.get("complete") is False:
|
||||
return 0
|
||||
due_days = []
|
||||
for item in snapshot.get("items", []):
|
||||
if not isinstance(item, dict) or not item.get("due_date"):
|
||||
continue
|
||||
raw_due = str(item["due_date"])
|
||||
try:
|
||||
due_day = datetime.strptime(raw_due[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
continue
|
||||
due_days.append(due_day)
|
||||
if not due_days:
|
||||
return 0
|
||||
due_counts = {}
|
||||
for device in eligible_devices:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
local_cutoff = local_now.date() + timedelta(days=device.reminder_days)
|
||||
due_count = sum(due_day <= local_cutoff for due_day in due_days)
|
||||
if due_count:
|
||||
due_counts[device.session_id] = due_count
|
||||
eligible_devices = [
|
||||
device for device in eligible_devices if device.session_id in due_counts
|
||||
]
|
||||
if not eligible_devices:
|
||||
return 0
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses(
|
||||
[device.session_id for device in eligible_devices]
|
||||
)
|
||||
except Exception:
|
||||
return 0
|
||||
for device in eligible_devices:
|
||||
if statuses.get(device.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
eligible_devices = [
|
||||
device
|
||||
for device in eligible_devices
|
||||
if statuses.get(device.session_id) == "active"
|
||||
]
|
||||
if not eligible_devices:
|
||||
return 0
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def dispatch_device(device) -> int:
|
||||
async with semaphore:
|
||||
try:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
except ZoneInfoNotFoundError:
|
||||
return 0
|
||||
local_day = local_now.date().isoformat()
|
||||
if (
|
||||
local_now.hour < device.reminder_hour
|
||||
or device.delivered_local_day == local_day
|
||||
):
|
||||
return 0
|
||||
due_count = due_counts[device.session_id]
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="deadline",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not still_owner:
|
||||
return 0
|
||||
payload = json.dumps({
|
||||
"title": f"{due_count} deadline{'s' if due_count != 1 else ''} need{'s' if due_count == 1 else ''} attention",
|
||||
"body": f"Open Agenda to review or replan {'it' if due_count == 1 else 'them'}.",
|
||||
"route": "#/my-work/agenda",
|
||||
"protect_route": "#/my-work/agenda/protect-today",
|
||||
"tag": f"stackchain-deadline-digest-{local_day}",
|
||||
"deadline_count": due_count,
|
||||
}, separators=(",", ":"))
|
||||
try:
|
||||
operation = (
|
||||
send(device.subscription, payload)
|
||||
if send is not None
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception:
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_deadline_reminder_delivered, device.session_id, local_day
|
||||
)
|
||||
return 1
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(dispatch_device(device) for device in eligible_devices), return_exceptions=True
|
||||
)
|
||||
return sum(result for result in results if isinstance(result, int))
|
||||
+110
-10
@@ -22,6 +22,16 @@ class PushDelivery:
|
||||
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeadlineReminderDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
timezone: str
|
||||
reminder_hour: int
|
||||
reminder_days: int
|
||||
delivered_local_day: str | None
|
||||
|
||||
|
||||
def _revisions(
|
||||
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
) -> tuple[tuple[int, str], ...]:
|
||||
@@ -66,7 +76,7 @@ class PushSubscriptionStore:
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_dispatch_lease (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
channel TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
@@ -78,6 +88,16 @@ class PushSubscriptionStore:
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_deadline_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
reminder_days INTEGER NOT NULL DEFAULT 2,
|
||||
delivered_local_day TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
delivery_columns = {
|
||||
@@ -95,6 +115,32 @@ class PushSubscriptionStore:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_digest_pending ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
|
||||
)
|
||||
lease_columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(push_dispatch_lease)")
|
||||
}
|
||||
if "singleton" in lease_columns:
|
||||
connection.executescript(
|
||||
"""
|
||||
ALTER TABLE push_dispatch_lease RENAME TO push_dispatch_lease_legacy;
|
||||
CREATE TABLE push_dispatch_lease (
|
||||
channel TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
INSERT INTO push_dispatch_lease(channel, owner, expires_at)
|
||||
SELECT 'unread', owner, expires_at
|
||||
FROM push_dispatch_lease_legacy WHERE singleton = 1;
|
||||
DROP TABLE push_dispatch_lease_legacy;
|
||||
"""
|
||||
)
|
||||
preference_columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(push_deadline_preferences)")
|
||||
}
|
||||
if "reminder_days" not in preference_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
|
||||
)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def _connect(self):
|
||||
@@ -103,29 +149,30 @@ class PushSubscriptionStore:
|
||||
return connection
|
||||
|
||||
def acquire_dispatch_lease(
|
||||
self, owner: str, *, now: float, lease_seconds: float
|
||||
self, owner: str, *, channel: str = "unread", now: float, lease_seconds: float
|
||||
) -> bool:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current = connection.execute(
|
||||
"SELECT owner, expires_at FROM push_dispatch_lease WHERE singleton = 1"
|
||||
"SELECT owner, expires_at FROM push_dispatch_lease WHERE channel = ?",
|
||||
(channel,),
|
||||
).fetchone()
|
||||
if current is not None and current[0] != owner and current[1] > now:
|
||||
return False
|
||||
connection.execute(
|
||||
"""INSERT INTO push_dispatch_lease(singleton, owner, expires_at)
|
||||
VALUES (1, ?, ?)
|
||||
ON CONFLICT(singleton) DO UPDATE SET
|
||||
"""INSERT INTO push_dispatch_lease(channel, owner, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(channel) DO UPDATE SET
|
||||
owner = excluded.owner, expires_at = excluded.expires_at""",
|
||||
(owner, now + lease_seconds),
|
||||
(channel, owner, now + lease_seconds),
|
||||
)
|
||||
return True
|
||||
|
||||
def release_dispatch_lease(self, owner: str) -> bool:
|
||||
def release_dispatch_lease(self, owner: str, *, channel: str = "unread") -> bool:
|
||||
with self._connect() as connection:
|
||||
result = connection.execute(
|
||||
"DELETE FROM push_dispatch_lease WHERE singleton = 1 AND owner = ?",
|
||||
(owner,),
|
||||
"DELETE FROM push_dispatch_lease WHERE channel = ? AND owner = ?",
|
||||
(channel, owner),
|
||||
)
|
||||
return result.rowcount == 1
|
||||
|
||||
@@ -154,6 +201,59 @@ class PushSubscriptionStore:
|
||||
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
||||
).fetchone() is not None
|
||||
|
||||
def set_deadline_preferences(
|
||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int,
|
||||
reminder_days: int = 2,
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_deadline_preferences(
|
||||
session_id, enabled, timezone, reminder_hour, reminder_days
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
timezone = excluded.timezone,
|
||||
reminder_hour = excluded.reminder_hour,
|
||||
reminder_days = excluded.reminder_days""",
|
||||
(session_id, int(enabled), timezone, reminder_hour, reminder_days),
|
||||
)
|
||||
|
||||
def deadline_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT enabled, timezone, reminder_hour, reminder_days
|
||||
FROM push_deadline_preferences WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"enabled": bool(row[0]) if row else False,
|
||||
"timezone": row[1] if row else "UTC",
|
||||
"reminder_hour": row[2] if row else 9,
|
||||
"reminder_days": row[3] if row else 2,
|
||||
}
|
||||
|
||||
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
||||
p.reminder_hour, p.reminder_days, p.delivered_local_day
|
||||
FROM push_subscriptions s
|
||||
JOIN push_deadline_preferences p ON p.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
return [
|
||||
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4], row[5])
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def mark_deadline_reminder_delivered(self, session_id: str, local_day: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_deadline_preferences SET delivered_local_day = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(local_day, session_id),
|
||||
)
|
||||
|
||||
def claim_unseen(
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||
) -> list[PushDelivery]:
|
||||
|
||||
+29
-10
@@ -273,21 +273,40 @@ class SessionStore:
|
||||
|
||||
def managed_status(self, management_id: str, *, idle_timeout_seconds: int) -> str:
|
||||
"""Return the authorization state for a durable device identifier."""
|
||||
return self.managed_statuses(
|
||||
[management_id], idle_timeout_seconds=idle_timeout_seconds
|
||||
)[management_id]
|
||||
|
||||
def managed_statuses(
|
||||
self, management_ids, *, idle_timeout_seconds: int
|
||||
) -> dict[str, str]:
|
||||
"""Return authorization states for durable device identifiers in one read."""
|
||||
requested = set(management_ids)
|
||||
if not requested:
|
||||
return {}
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT expires_at, last_active_at FROM active_sessions "
|
||||
"WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
placeholders = ",".join("?" for _ in requested)
|
||||
rows = connection.execute(
|
||||
"SELECT management_id, expires_at, last_active_at "
|
||||
f"FROM active_sessions WHERE management_id IN ({placeholders})",
|
||||
tuple(requested),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
if row is None or row[0] <= now:
|
||||
return "revoked"
|
||||
if row[1] + max(1, idle_timeout_seconds) <= now:
|
||||
return "idle"
|
||||
return "active"
|
||||
known = {row[0]: row[1:] for row in rows if row[0] in requested}
|
||||
idle_timeout = max(1, idle_timeout_seconds)
|
||||
statuses = {}
|
||||
for management_id in requested:
|
||||
row = known.get(management_id)
|
||||
if row is None or row[0] <= now:
|
||||
statuses[management_id] = "revoked"
|
||||
elif row[1] + idle_timeout <= now:
|
||||
statuses[management_id] = "idle"
|
||||
else:
|
||||
statuses[management_id] = "active"
|
||||
return statuses
|
||||
|
||||
def revoke_managed(self, management_id: str) -> bool:
|
||||
try:
|
||||
|
||||
+10
-2
@@ -30,10 +30,14 @@ class TodayStore:
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.recap_limit = recap_limit
|
||||
self.clock = clock
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
|
||||
connection.close()
|
||||
return
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
@@ -101,8 +105,12 @@ class TodayStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute("PRAGMA user_version = 1")
|
||||
connection.commit()
|
||||
return connection
|
||||
connection.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
|
||||
@@ -8,6 +8,8 @@ from tests.dashboard_bundle import dashboard_bundle_text
|
||||
def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
html = dashboard_bundle_text()
|
||||
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
|
||||
commands = (Path(__file__).parents[1] / "frontend" / "commands.js").read_text()
|
||||
api_paths += re.findall(r"return ['\"]([^'\"]*api/v1/[^'\"]*)['\"]", commands)
|
||||
|
||||
assert api_paths
|
||||
assert {
|
||||
@@ -18,6 +20,7 @@ def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/repositories/search?q=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BATCH_FIND_WORK = Path(__file__).parents[1] / "frontend" / "batch-find-work.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_batch_preflights_today_capacity_before_claiming_any_issue():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1,
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
onProgress:progress=>calls.push('progress:'+progress.status),
|
||||
}});
|
||||
flow.run([
|
||||
{{repository:'stackchain/dashboard',number:671}},
|
||||
{{repository:'stackchain/dashboard',number:672}},
|
||||
]).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "full",
|
||||
"selected": 2,
|
||||
"available": 1,
|
||||
"queued": [],
|
||||
"failed": [],
|
||||
},
|
||||
"calls": ["progress:full"],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_claims_in_order_continues_after_conflict_and_reports_truthfully():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
claim:item=>{{
|
||||
calls.push('claim:'+item.number);
|
||||
return item.number===672 ? Promise.reject(new Error('already claimed')) :
|
||||
Promise.resolve({{...item,assignees:['timmy']}});
|
||||
}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
onProgress:progress=>calls.push('progress:'+progress.status+':'+progress.processed),
|
||||
}});
|
||||
flow.run([671,672,673].map(number=>({{repository:'stackchain/dashboard',number}})))
|
||||
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete",
|
||||
"selected": 3,
|
||||
"available": 3,
|
||||
"queued": ["stackchain/dashboard#671", "stackchain/dashboard#673"],
|
||||
"failed": [{"key": "stackchain/dashboard#672", "reason": "already claimed"}],
|
||||
},
|
||||
"calls": [
|
||||
"claim:671", "queue:671", "progress:running:1",
|
||||
"claim:672", "progress:running:2",
|
||||
"claim:673", "queue:673", "progress:running:3",
|
||||
"progress:complete:3",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_does_not_reclaim_confirmed_issue_when_today_sync_needs_recovery():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1,
|
||||
claim:item=>{{calls.push('claim');return Promise.resolve({{...item,assignees:['timmy']}});}},
|
||||
queue:item=>{{calls.push('queue');return 'sync-unavailable';}},
|
||||
}});
|
||||
flow.run([{{repository:'stackchain/dashboard',number:671}}])
|
||||
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete",
|
||||
"selected": 1,
|
||||
"available": 1,
|
||||
"queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#671",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"calls": ["claim", "queue"],
|
||||
}
|
||||
|
||||
|
||||
def test_interrupted_batch_resumes_confirmed_assignment_without_claiming_twice():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const values={{}};
|
||||
const storage={{
|
||||
getItem:key=>values[key] || null,
|
||||
setItem:(key,value)=>{{values[key]=value;}},
|
||||
removeItem:key=>{{delete values[key];}},
|
||||
}};
|
||||
const calls=[];
|
||||
const item={{repository:'stackchain/dashboard',number:679,title:'Recover me'}};
|
||||
const first=createBatchFindWork({{
|
||||
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||
claim:value=>{{calls.push('claim');return Promise.resolve({{...value,assignees:['timmy']}});}},
|
||||
queue:()=>{{calls.push('queue-failed');return 'sync-unavailable';}},
|
||||
}});
|
||||
first.run([item]).then(firstResult=>{{
|
||||
const second=createBatchFindWork({{
|
||||
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||
claim:()=>{{calls.push('duplicate-claim');return Promise.resolve(item);}},
|
||||
queue:value=>{{calls.push('queue-resumed:'+value.assignees[0]);return 'queued';}},
|
||||
}});
|
||||
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
|
||||
firstResult, resumed, calls, keys:Object.keys(values),
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"firstResult": {
|
||||
"status": "complete", "selected": 1, "available": 1, "queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#679",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"resumed": {
|
||||
"status": "complete", "selected": 1, "available": 1,
|
||||
"queued": ["stackchain/dashboard#679"], "failed": [],
|
||||
},
|
||||
"calls": ["claim", "queue-failed", "queue-resumed:timmy"],
|
||||
"keys": [],
|
||||
}
|
||||
|
||||
|
||||
def test_interrupted_batch_restores_operation_context_for_idempotent_recovery():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const values={{}};
|
||||
const storage={{
|
||||
getItem:key=>values[key] || null,
|
||||
setItem:(key,value)=>{{values[key]=value;}},
|
||||
removeItem:key=>{{delete values[key];}},
|
||||
}};
|
||||
const calls=[];
|
||||
const options={{
|
||||
capacity:()=>2, storage, owner:()=> 'timmy', journalName:'search-later-batch',
|
||||
queueFailureReason:'assigned but Later could not be saved',
|
||||
claim:item=>Promise.resolve({{...item,assigned_to_me:true}}),
|
||||
}};
|
||||
const first=createBatchFindWork({{...options, queue:(item,context)=>{{
|
||||
calls.push(['first',item.number,context?.until]);
|
||||
return item.number===813 ? 'queued' : 'unavailable';
|
||||
}}}});
|
||||
const items=[813,814].map(number=>({{repository:'stackchain/dashboard',number}}));
|
||||
first.run(items, {{}}, {{until:'2026-08-15T09:00:00.000Z'}}).then(firstResult=>{{
|
||||
const second=createBatchFindWork({{...options, queue:(item,context)=>{{
|
||||
calls.push(['resume',item.number,context?.until]);
|
||||
return 'queued';
|
||||
}}}});
|
||||
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
|
||||
firstResult,resumed,calls,keys:Object.keys(values),
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["firstResult"]["queued"] == ["stackchain/dashboard#813"]
|
||||
assert result["firstResult"]["failed"] == [{
|
||||
"key": "stackchain/dashboard#814",
|
||||
"reason": "assigned but Later could not be saved",
|
||||
"assigned": True,
|
||||
}]
|
||||
assert result["resumed"]["queued"] == [
|
||||
"stackchain/dashboard#813", "stackchain/dashboard#814",
|
||||
]
|
||||
assert result["calls"] == [
|
||||
["first", 813, "2026-08-15T09:00:00.000Z"],
|
||||
["first", 814, "2026-08-15T09:00:00.000Z"],
|
||||
["resume", 814, "2026-08-15T09:00:00.000Z"],
|
||||
]
|
||||
assert result["keys"] == []
|
||||
|
||||
|
||||
def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const key='stackchain.find-work-batch.v1.timmy';
|
||||
const values={{[key]:JSON.stringify({{
|
||||
owner:'timmy', available:2, items:[
|
||||
{{item:{{repository:'stackchain/dashboard',number:678}},state:'queued'}},
|
||||
{{item:{{repository:'stackchain/dashboard',number:679}},state:'pending'}},
|
||||
],
|
||||
}})}};
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>2, owner:()=> 'timmy',
|
||||
storage:{{getItem:key=>values[key]||null,setItem:(key,value)=>values[key]=value,removeItem:key=>delete values[key]}},
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
}});
|
||||
flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 2, "available": 2,
|
||||
"queued": ["stackchain/dashboard#678", "stackchain/dashboard#679"],
|
||||
"failed": [],
|
||||
},
|
||||
"calls": ["claim:679", "queue:679"],
|
||||
}
|
||||
|
||||
|
||||
def test_search_batch_uses_an_isolated_owner_journal_and_can_resume_without_dom_mounting():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const values={{}};
|
||||
const calls=[];
|
||||
const storage={{
|
||||
getItem:key=>values[key] || null,
|
||||
setItem:(key,value)=>{{values[key]=value;}},
|
||||
removeItem:key=>{{delete values[key];}},
|
||||
}};
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1, owner:()=> 'timmy', storage,
|
||||
journalName:'search-today-batch', autoMount:false,
|
||||
claim:item=>Promise.resolve({{...item,assigned_to_me:true}}),
|
||||
queue:()=>{{calls.push('queue');return 'sync-unavailable';}},
|
||||
}});
|
||||
flow.run([{{repository:'stackchain/dashboard',number:809}}]).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,keys:Object.keys(values),calls,pending:flow.pending()}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 1, "available": 1, "queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#809",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"keys": ["stackchain.search-today-batch.v1.timmy"],
|
||||
"calls": ["queue"],
|
||||
"pending": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_time_budget_blocks_claims_until_every_estimate_fits():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
timeBudget:()=>({{capacity_minutes:90,planned_minutes:30}}),
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
}});
|
||||
const items=[701,702].map(number=>({{repository:'stackchain/dashboard',number}}));
|
||||
Promise.all([
|
||||
flow.run(items, {{'stackchain/dashboard#701':30}}),
|
||||
flow.run(items, {{'stackchain/dashboard#701':30,'stackchain/dashboard#702':45}}),
|
||||
]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"results": [
|
||||
{
|
||||
"status": "estimates-required", "selected": 2, "available": 3,
|
||||
"queued": [], "failed": [], "remaining_minutes": 60,
|
||||
"requested_minutes": 30,
|
||||
"invalid": ["stackchain/dashboard#702"],
|
||||
},
|
||||
{
|
||||
"status": "over-budget", "selected": 2, "available": 3,
|
||||
"queued": [], "failed": [], "remaining_minutes": 60,
|
||||
"requested_minutes": 75, "over_minutes": 15,
|
||||
},
|
||||
],
|
||||
"calls": [],
|
||||
}
|
||||
|
||||
|
||||
def test_time_budget_persists_estimates_only_for_successfully_queued_claims():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
timeBudget:()=>({{capacity_minutes:120,planned_minutes:30}}),
|
||||
claim:item=>item.number===702 ? Promise.reject(new Error('already claimed')) : Promise.resolve(item),
|
||||
queue:item=>'queued',
|
||||
persistEstimate:(item,minutes)=>calls.push(item.number+':'+minutes),
|
||||
}});
|
||||
flow.run([701,702].map(number=>({{repository:'stackchain/dashboard',number}})),{{
|
||||
'stackchain/dashboard#701':30,
|
||||
'stackchain/dashboard#702':45,
|
||||
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 2, "available": 3,
|
||||
"queued": ["stackchain/dashboard#701"],
|
||||
"failed": [{"key": "stackchain/dashboard#702", "reason": "already claimed"}],
|
||||
},
|
||||
"calls": ["701:30"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_has_preclaim_time_review_controls():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-estimate-review"' in html
|
||||
assert 'id="find-work-estimate-list"' in html
|
||||
assert 'id="confirm-find-work-estimates"' in html
|
||||
assert 'inputmode="numeric"' in dashboard
|
||||
assert "timeBudget:" in dashboard
|
||||
assert "persistEstimate:" in dashboard
|
||||
assert ".find-work-estimate-review" in css
|
||||
assert ".find-work-estimate-review input" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_selection_survives_loaded_pages_and_removes_confirmed_claims():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const pages={{
|
||||
1:{{items:[{{id:1,repository:'stackchain/dashboard',number:671}}],page:1,total:2,has_more:true}},
|
||||
2:{{items:[{{id:2,repository:'stackchain/dashboard',number:672}}],page:2,total:2,has_more:false}},
|
||||
}};
|
||||
const controller=createFindWork({{
|
||||
fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) :
|
||||
Promise.resolve(pages[path.includes('page=2') ? 2 : 1]),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
return controller.loadMore();
|
||||
}}).then(()=>{{
|
||||
controller.toggleSelection(controller.items()[1]);
|
||||
return controller.claim(controller.items()[0]);
|
||||
}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
selecting:controller.selection().active,
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"selected": [672], "selecting": True}
|
||||
|
||||
|
||||
def test_fill_today_selects_ranked_matches_up_to_remaining_capacity():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const states=[];
|
||||
const controller=createFindWork({{
|
||||
fetchJson:()=>Promise.resolve({{items:[
|
||||
{{id:1,repository:'stackchain/dashboard',number:701}},
|
||||
{{id:2,repository:'stackchain/dashboard',number:702}},
|
||||
{{id:3,repository:'stackchain/dashboard',number:703}},
|
||||
{{id:4,repository:'stackchain/dashboard',number:704}},
|
||||
],page:1,total:4,has_more:false}}),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}},
|
||||
onSelection:state=>states.push(state),
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[1]);
|
||||
const outcome=controller.fillSelection(3);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
outcome,
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
lastState:states.at(-1),
|
||||
}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"outcome": {"selected": 3, "added": 2, "limit": 3},
|
||||
"selected": [702, 701, 703],
|
||||
"lastState": {
|
||||
"active": True,
|
||||
"count": 3,
|
||||
"ids": [
|
||||
"stackchain/dashboard#702",
|
||||
"stackchain/dashboard#701",
|
||||
"stackchain/dashboard#703",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_fill_today_with_no_capacity_does_not_change_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const controller=createFindWork({{
|
||||
fetchJson:()=>Promise.resolve({{items:[{{id:1,repository:'stackchain/dashboard',number:701}}]}}),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
const outcome=controller.fillSelection(0);
|
||||
process.stdout.write(JSON.stringify({{outcome,selection:controller.selection()}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"outcome": {"selected": 0, "added": 0, "limit": 0},
|
||||
"selection": {"active": False, "count": 0, "ids": []},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert 'id="select-find-work"' in html
|
||||
assert 'id="batch-find-work-actions"' in html
|
||||
assert 'id="claim-selected-work"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "createBatchFindWork({" in dashboard
|
||||
assert "findWorkController.startSelection()" in dashboard
|
||||
assert "findWorkController.toggleSelection(item)" in dashboard
|
||||
assert ".find-work-batch-actions" in css
|
||||
assert "position:sticky" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert "BASE + 'static/batch-find-work.js'" in worker
|
||||
assert '<script src="static/batch-find-work.js"></script>' in html
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_touch_accessible_batch_recovery():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="resume-find-work-batch"' in html
|
||||
assert 'aria-describedby="find-work-status"' in html
|
||||
assert "mountRecovery(" in BATCH_FIND_WORK.read_text()
|
||||
assert "typeof localStorage === 'undefined'" in BATCH_FIND_WORK.read_text()
|
||||
assert ".find-work-batch-recovery" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="fill-find-work-today"' in html
|
||||
assert 'aria-describedby="find-work-selection-status"' in html
|
||||
assert "findWorkController.fillSelection(remainingSlots)" in dashboard
|
||||
assert "openFindWorkEstimateReview(findWorkController.selectedItems())" in dashboard
|
||||
assert ".fill-find-work-today" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_search_ignores_stale_response_and_preserves_cross_query_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const pending=[];
|
||||
const rendered=[];
|
||||
const fetchJson=path=>new Promise(resolve=>pending.push({{path,resolve}}));
|
||||
const controller=createFindWork({{
|
||||
fetchJson,
|
||||
onItems:items=>rendered.push(items.map(item=>item.number)),
|
||||
onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
const first=controller.search('api');
|
||||
const second=controller.search('dashboard');
|
||||
pending[1].resolve({{items:[{{id:2,repository:'stackchain/dashboard',number:673}}],page:1,total:1,has_more:false}});
|
||||
second.then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
const third=controller.search('worker');
|
||||
pending[2].resolve({{items:[{{id:3,repository:'stackchain/worker',number:674}}],page:1,total:1,has_more:false}});
|
||||
return third;
|
||||
}}).then(()=>{{
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
pending[0].resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false}});
|
||||
return first;
|
||||
}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
calls:pending.map(entry=>entry.path),
|
||||
rendered,
|
||||
items:controller.items().map(item=>item.number),
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": [
|
||||
"api/v1/available-issues?page=1&facets=true&q=api",
|
||||
"api/v1/available-issues?page=1&facets=true&q=dashboard",
|
||||
"api/v1/available-issues?page=1&facets=true&q=worker",
|
||||
],
|
||||
"rendered": [[673], [674]],
|
||||
"items": [674],
|
||||
"selected": [673, 674],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_search_has_clear_live_results_and_touch_targets():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-search"' in html
|
||||
assert 'id="clear-find-work-search"' in html
|
||||
assert 'id="find-work-match-status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "findWorkController.search(" in dashboard
|
||||
assert "#find-work-search" in dashboard
|
||||
assert ".find-work-search" in css
|
||||
assert ".find-work-search button" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_facets_serialize_filters_and_preserve_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const calls=[];
|
||||
const controller=createFindWork({{
|
||||
fetchJson:path=>{{ calls.push(path); return Promise.resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false,facets:{{repositories:['stackchain/api'],labels:['critical']}}}}); }},
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}}, onFacets:()=>{{}},
|
||||
}});
|
||||
controller.reset({{items:[{{id:9,repository:'stackchain/old',number:9}}],page:1,total:1,has_more:false}});
|
||||
controller.startSelection(); controller.toggleSelection(controller.items()[0]);
|
||||
controller.setFilters({{repositories:['stackchain/api'],labels:['critical']}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
calls, filters:controller.filters(), selected:controller.selectedItems().map(item=>item.number)
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": ["api/v1/available-issues?page=1&facets=true&repository=stackchain%2Fapi&label=critical"],
|
||||
"filters": {"repositories": ["stackchain/api"], "labels": ["critical"]},
|
||||
"selected": [9],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_accessible_touch_sized_facets():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-filters"' in html
|
||||
assert 'id="find-work-repository-filters"' in html
|
||||
assert 'id="find-work-label-filters"' in html
|
||||
assert 'id="clear-find-work-filters"' in html
|
||||
assert 'aria-label="Filter available work"' in html
|
||||
assert "setFilters(next)" in PICK_WORK.read_text()
|
||||
assert ".find-work-filters" in css
|
||||
assert ".find-work-filter-option" in css and "min-height:44px" in css
|
||||
@@ -11,6 +11,7 @@ FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
COMMANDS = FRONTEND / "commands.js"
|
||||
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
||||
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
|
||||
SEARCH_DEFER = FRONTEND / "search-defer.js"
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
@@ -52,6 +53,80 @@ def test_command_script_resolves_inside_dashboard_subpath():
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
|
||||
|
||||
|
||||
def test_mobile_search_renders_touch_sized_type_and_status_scope_controls():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
commands = COMMANDS.read_text()
|
||||
|
||||
assert '<fieldset class="cmd-search-scope"' in html
|
||||
assert '<legend>Filter search results</legend>' in html
|
||||
assert '<label for="cmd-search-kind">Type</label>' in html
|
||||
assert '<select id="cmd-search-kind"' in html
|
||||
assert '<option value="pull">Pull requests</option>' in html
|
||||
assert '<label for="cmd-search-state">Status</label>' in html
|
||||
assert '<select id="cmd-search-state"' in html
|
||||
assert '.cmd-search-scope' in css
|
||||
assert '#cmd-search-kind, #cmd-search-state { min-height:44px;' in css
|
||||
assert "commandSearch.setScope(scope)" in html
|
||||
assert "taskOverlayHistory.update({ scope })" in html
|
||||
assert "'&kind=' + encodeURIComponent(scope.kind)" in commands
|
||||
assert "'&state=' + encodeURIComponent(scope.state)" in commands
|
||||
|
||||
|
||||
def test_mobile_search_exposes_accessible_repository_scope_and_forwards_it():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
commands = COMMANDS.read_text()
|
||||
|
||||
assert '<label for="cmd-search-repository">Repository</label>' in html
|
||||
assert '<input id="cmd-search-repository"' in html
|
||||
assert 'list="cmd-search-repositories"' in html
|
||||
assert '<datalist id="cmd-search-repositories">' in html
|
||||
assert "api/v1/repositories/search?q=" in html
|
||||
assert "repository:qs('#cmd-search-repository').value" in html
|
||||
assert "'&repository=' + encodeURIComponent(scope.repository)" in commands
|
||||
assert '#cmd-search-repository' in css
|
||||
|
||||
|
||||
def test_mobile_search_preview_exposes_a_keyboard_safe_reply_and_continue_composer():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
preview = SEARCH_PREVIEW.read_text()
|
||||
|
||||
assert '<section class="search-preview-reply"' in html
|
||||
assert '<textarea id="search-preview-reply"' in html
|
||||
assert '<button id="send-search-preview-reply"' in html
|
||||
assert '<button id="send-search-preview-reply-next"' in html
|
||||
assert "searchPreviewReplyOptions(fetchReviewJson" in html
|
||||
assert "root.searchPreviewReplyPath(item)" in preview
|
||||
assert "api.saveReplyDraft" in preview
|
||||
assert "api.reply({ advance:true })" in preview
|
||||
assert ".search-preview-reply textarea" in css
|
||||
assert "scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom))" in css
|
||||
|
||||
|
||||
def test_remote_command_search_repository_change_aborts_and_restarts_scoped_search():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const pending = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{signal, scope, resolve}})),
|
||||
onState() {{}},
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setScope({{kind:'all', state:'all', repository:'stackchain/api'}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (!pending[0].signal.aborted) throw new Error('repository change did not abort old request');
|
||||
if (pending[1].scope.repository !== 'stackchain/api') throw new Error('repository was not forwarded');
|
||||
pending[1].resolve({{items:[],has_more:false,next_page:2}});
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_ignores_stale_responses():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
@@ -85,6 +160,37 @@ if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result'
|
||||
)
|
||||
|
||||
|
||||
def test_remote_command_search_scope_change_aborts_and_restarts_without_stale_results():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const pending = [];
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{query, signal, page, scope, resolve}})),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setScope({{kind:'pull', state:'open'}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (!pending[0].signal.aborted) throw new Error('scope change did not abort old request');
|
||||
if (JSON.stringify(pending[1].scope) !== JSON.stringify({{kind:'pull', state:'open'}})) {{
|
||||
throw new Error('new request did not receive scope');
|
||||
}}
|
||||
pending[1].resolve({{items:[{{kind:'pull',repository:'a/b',number:2}}],has_more:false,next_page:2}});
|
||||
pending[0].resolve({{items:[{{kind:'issue',repository:'a/b',number:1}}],has_more:false,next_page:2}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.filter(state => state.status === 'ready').at(-1);
|
||||
if (ready.items.length !== 1 || ready.items[0].kind !== 'pull') throw new Error('stale scope result entered list');
|
||||
if (ready.scope.kind !== 'pull' || ready.scope.state !== 'open') throw new Error('scope missing from state');
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_aborts_superseded_and_cleared_queries():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
@@ -142,9 +248,83 @@ if (!ready || ready.items.length !== 1 || ready.partial !== true) {{
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_retries_partial_continuation_without_losing_results():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const requests = [];
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal, page, scope, continuation) => {{
|
||||
requests.push({{page, continuation}});
|
||||
if (requests.length === 1) return Promise.resolve({{
|
||||
items:[{{kind:'pull',repository:'a/b',number:2}}], partial:true,
|
||||
has_more:true, next_page:2, continuation:{{issues:1,pulls:null}}, failed_streams:['issue']
|
||||
}});
|
||||
return Promise.resolve({{
|
||||
items:[{{kind:'issue',repository:'a/b',number:1}}], partial:false,
|
||||
has_more:false, next_page:2, continuation:{{issues:null,pulls:null}}, failed_streams:[]
|
||||
}});
|
||||
}},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await controller.loadMore();
|
||||
const ready = states.filter(state => state.status === 'ready').at(-1);
|
||||
if (JSON.stringify(requests[1].continuation) !== JSON.stringify({{issues:1,pulls:null}})) {{
|
||||
throw new Error('retry did not preserve failed stream page: ' + JSON.stringify(requests));
|
||||
}}
|
||||
if (ready.partial || ready.items.length !== 2 || ready.items[0].kind !== 'pull' || ready.items[1].kind !== 'issue') {{
|
||||
throw new Error('recovered results were not appended: ' + JSON.stringify(ready));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_appends_deduplicated_pages_and_ignores_stale_load_more():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const pending = new Map();
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal, page) => new Promise(resolve => pending.set(query + ':' + page, resolve)),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get('mobile:1')({{ items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2 }});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.loadMore();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setQuery('release');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get('mobile:2')({{ items:[{{kind:'issue',repository:'a/b',number:1}},{{kind:'pull',repository:'a/b',number:2}}], has_more:false, next_page:3 }});
|
||||
pending.get('release:1')({{ items:[{{kind:'issue',repository:'a/b',number:3}}], has_more:false, next_page:2 }});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.filter(state => state.status === 'ready').at(-1);
|
||||
if (ready.query !== 'release' || ready.items.length !== 1 || ready.items[0].number !== 3) {{
|
||||
throw new Error('stale page entered current query: ' + JSON.stringify(states));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_command_selection_wraps_for_arrow_keys():
|
||||
script = f"""
|
||||
const commands = require({json.dumps(str(COMMANDS))});
|
||||
if (commands.searchUrl('mobile', 2, {{kind:'all',state:'open'}}, {{issues:1,pulls:3}}) !==
|
||||
'api/v1/search?q=mobile&limit=10&page=2&kind=all&state=open&issues_page=1&pulls_page=3') {{
|
||||
throw new Error('independent continuation query was not encoded');
|
||||
}}
|
||||
if (commands.nextSelection(-1, 'ArrowDown', 3) !== 0) throw new Error('should select first');
|
||||
if (commands.nextSelection(2, 'ArrowDown', 3) !== 0) throw new Error('should wrap down');
|
||||
if (commands.nextSelection(0, 'ArrowUp', 3) !== 2) throw new Error('should wrap up');
|
||||
@@ -160,16 +340,287 @@ def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath()
|
||||
assert 'role="combobox"' in html
|
||||
assert 'aria-controls="cmd-results"' in html
|
||||
assert 'role="listbox"' in html
|
||||
assert "fetch('api/v1/search?q='" in html
|
||||
assert "async function searchGlobalWork(query, signal)" in html
|
||||
assert "fetch(filterCommands.searchUrl(" in html
|
||||
assert "async function searchGlobalWork(query, signal, page = 1, scope" in html
|
||||
assert "signal," in html
|
||||
assert "Some results are temporarily unavailable." in html
|
||||
assert "Retry missing results" in html
|
||||
assert 'id="cmd-load-more"' in html
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/",
|
||||
"api/v1/search?q=mobile",
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile"
|
||||
|
||||
|
||||
def test_search_preview_navigates_a_loaded_result_session_and_reports_position():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const items = [1, 2, 3].map(number => ({{repository:'stackchain/api', number, kind:'issue'}}));
|
||||
const states = [];
|
||||
const navigated = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
|
||||
mutate: () => Promise.resolve(),
|
||||
getSession: () => ({{items, more:false}}),
|
||||
loadMore: () => Promise.resolve(),
|
||||
onNavigate: item => navigated.push(item.number),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open(items[1]);
|
||||
await preview.previous();
|
||||
await preview.next();
|
||||
const ready = states.filter(state => state.status === 'ready');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
navigated,
|
||||
positions:ready.map(state => state.navigation && [state.navigation.position, state.navigation.total,
|
||||
state.navigation.hasPrevious, state.navigation.hasNext]),
|
||||
current:ready.at(-1).detail.number,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"navigated": [1, 2],
|
||||
"positions": [[2, 3, True, True], [1, 3, False, True], [2, 3, True, True]],
|
||||
"current": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const item = {{repository:'stackchain/api', number:42, kind:'pull'}};
|
||||
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),
|
||||
}};
|
||||
const calls = [];
|
||||
const states = [];
|
||||
let attempt = 0;
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: candidate => Promise.resolve({{...candidate, title:'Review reply'}}),
|
||||
mutate: () => Promise.resolve(),
|
||||
postReply: (candidate, body, operationId) => {{
|
||||
calls.push([candidate.number, body, operationId]);
|
||||
attempt += 1;
|
||||
return attempt === 1 ? Promise.reject(new Error('offline')) : Promise.resolve({{
|
||||
id:91, author:'timmy', body, created_at:'2026-08-14T12:30:00Z'
|
||||
}});
|
||||
}},
|
||||
storage,
|
||||
createOperationId: () => 'reply-operation-42',
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open(item);
|
||||
preview.saveReplyDraft('Ready to merge.');
|
||||
try {{ await preview.reply(); }} catch (_) {{}}
|
||||
const draftAfterFailure = preview.replyDraft();
|
||||
await preview.reply();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
draftAfterFailure,
|
||||
draftAfterSuccess:preview.replyDraft(),
|
||||
comments:states.filter(state => state.conversation).at(-1).conversation.comments,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": [
|
||||
[42, "Ready to merge.", "reply-operation-42"],
|
||||
[42, "Ready to merge.", "reply-operation-42"],
|
||||
],
|
||||
"draftAfterFailure": "Ready to merge.",
|
||||
"draftAfterSuccess": "",
|
||||
"comments": [{
|
||||
"id": 91,
|
||||
"author": "timmy",
|
||||
"body": "Ready to merge.",
|
||||
"created_at": "2026-08-14T12:30:00Z",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_reply_and_next_advances_only_after_server_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const items = [1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}}));
|
||||
const values = new Map();
|
||||
let confirm;
|
||||
const navigated = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson:item => Promise.resolve(item), mutate:() => Promise.resolve(),
|
||||
postReply:() => new Promise(resolve => {{ confirm = resolve; }}),
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
createOperationId:() => 'reply-next-1',
|
||||
getSession:() => ({{items,more:false}}), loadMore:() => Promise.resolve(),
|
||||
onNavigate:item => navigated.push(item.number), onState:() => {{}},
|
||||
}});
|
||||
await preview.open(items[0]);
|
||||
preview.saveReplyDraft('Done.');
|
||||
const pending = preview.reply({{advance:true}});
|
||||
await Promise.resolve();
|
||||
const before = [...navigated];
|
||||
confirm({{id:7,author:'timmy',body:'Done.'}});
|
||||
await pending;
|
||||
process.stdout.write(JSON.stringify({{before,after:navigated,draft:preview.replyDraft()}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": [],
|
||||
"after": [2],
|
||||
"draft": "",
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_keeps_confirmed_reply_when_earlier_conversation_load_finishes_late():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const values = new Map();
|
||||
let finishConversation;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson:item => Promise.resolve({{...item,title:'Race'}}),
|
||||
fetchConversation:() => new Promise(resolve => {{ finishConversation = resolve; }}),
|
||||
mutate:() => Promise.resolve(),
|
||||
postReply:(_item,body) => Promise.resolve({{id:9,author:'timmy',body}}),
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
createOperationId:() => 'reply-race-9', onState:state => states.push(state),
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api',number:9,kind:'issue'}});
|
||||
preview.saveReplyDraft('Confirmed reply');
|
||||
await preview.reply();
|
||||
finishConversation({{comments:[{{id:3,author:'alex',body:'Earlier'}}],older_page:null}});
|
||||
await Promise.resolve();
|
||||
const comments = states.filter(state => state.conversation?.status === 'ready').at(-1).conversation.comments;
|
||||
process.stdout.write(JSON.stringify(comments.map(comment => comment.id)));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [3, 9]
|
||||
|
||||
|
||||
def test_search_preview_next_loads_one_page_at_boundary_then_announces_completion():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let session = {{items:[1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}})), more:true}};
|
||||
let loads = 0;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(),
|
||||
getSession: () => session,
|
||||
loadMore: async () => {{
|
||||
loads += 1;
|
||||
session = {{items:[...session.items, {{repository:'stackchain/api', number:3, kind:'issue'}}], more:false}};
|
||||
}},
|
||||
onNavigate() {{}}, onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open(session.items[1]);
|
||||
await preview.next();
|
||||
await preview.next();
|
||||
const final = states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{loads, current:final.detail.number, complete:final.complete,
|
||||
navigation:final.navigation}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"loads": 1,
|
||||
"current": 3,
|
||||
"complete": True,
|
||||
"navigation": {"position": 3, "total": 3, "hasPrevious": True, "hasNext": False},
|
||||
}
|
||||
|
||||
|
||||
def test_remote_search_load_more_is_awaitable_for_preview_boundary_navigation():
|
||||
script = f"""
|
||||
const commands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const states = [];
|
||||
const controller = commands.createGlobalSearchController({{
|
||||
delay:0,
|
||||
search: async (_query, _signal, page) => page === 1
|
||||
? {{items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2}}
|
||||
: {{items:[{{kind:'issue',repository:'a/b',number:2}}], has_more:false, next_page:3}},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
const loaded = await controller.loadMore();
|
||||
process.stdout.write(JSON.stringify({{numbers:loaded.items.map(item => item.number), more:loaded.more}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"numbers": [1, 2], "more": False}
|
||||
|
||||
|
||||
def test_mobile_search_preview_progressively_renders_a_retryable_paged_conversation():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
preview_source = SEARCH_PREVIEW.read_text()
|
||||
|
||||
assert 'id="search-preview-conversation"' in html
|
||||
assert 'id="search-preview-comments"' in html
|
||||
assert 'id="search-preview-conversation-status"' in html
|
||||
assert 'id="retry-search-preview-conversation"' in html
|
||||
assert 'id="load-older-search-preview-comments"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "fetchConversation:" in html
|
||||
assert "api.retryConversation()" in preview_source
|
||||
assert "api.loadOlderConversation()" in preview_source
|
||||
assert "renderMarkdown(comment.body || '')" in preview_source
|
||||
assert ".search-preview-conversation" in css
|
||||
assert ".search-preview-comment" in css
|
||||
assert "min-height:44px" in css
|
||||
|
||||
|
||||
def test_mobile_search_preview_renders_session_navigation_and_advances_after_planning():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="previous-search-result"' in html
|
||||
assert 'id="search-preview-position"' in html
|
||||
assert 'id="next-search-result"' in html
|
||||
assert 'aria-label="Search result navigation"' in html
|
||||
assert ".search-preview-navigation" in css
|
||||
assert ".search-preview-header button, .search-preview-actions button" in css
|
||||
assert "session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})]" in html
|
||||
preview_source = SEARCH_PREVIEW.read_text()
|
||||
assert "api.previous()" in preview_source
|
||||
assert "api.next()" in preview_source
|
||||
|
||||
defer_flow = html.split("if (context === 'search')", 1)[1].split(
|
||||
"if (context === 'detail')", 1
|
||||
)[0]
|
||||
queue_flow = html.split(
|
||||
"qs('#queue-search-result').addEventListener('click'", 1
|
||||
)[1].split("qs('#start-search-result')", 1)[0]
|
||||
assert "next()" in defer_flow
|
||||
assert "next()" in queue_flow
|
||||
assert "closeSearchPreview()" not in defer_flow
|
||||
assert "if (outcome === 'queued') await next()" in queue_flow
|
||||
|
||||
|
||||
def test_search_preview_ignores_stale_result_details():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
@@ -197,6 +648,85 @@ if (ready.length !== 1 || ready[0].detail.number !== 2) {{
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_publishes_detail_before_progressive_conversation_and_ignores_stale_comments():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const pending = new Map();
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
|
||||
fetchConversation: item => new Promise(resolve => pending.set(item.number, resolve)),
|
||||
mutate: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api', number:1, kind:'issue'}});
|
||||
const firstReady = states.find(state => state.status === 'ready' && state.detail?.number === 1);
|
||||
if (!firstReady || firstReady.conversation?.status !== 'loading') throw new Error('detail waited for conversation');
|
||||
await preview.open({{repository:'stackchain/api', number:2, kind:'issue'}});
|
||||
pending.get(1)({{comments:[{{id:1,body:'stale'}}], page:1, older_page:null}});
|
||||
pending.get(2)({{comments:[{{id:2,body:'current'}}], page:1, older_page:null}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const final = states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
number:final.detail.number,
|
||||
status:final.conversation.status,
|
||||
comments:final.conversation.comments.map(comment => comment.body),
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"number": 2,
|
||||
"status": "ready",
|
||||
"comments": ["current"],
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_retries_failed_conversation_and_prepends_older_messages_without_duplicates():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let calls = 0;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve({{...item, title:'Issue'}}),
|
||||
fetchConversation: (_item, page) => {{
|
||||
calls += 1;
|
||||
if (calls === 1) return Promise.reject(new Error('offline'));
|
||||
if (page === 1) return Promise.resolve({{comments:[{{id:1,body:'old'}},{{id:3,body:'duplicate'}}], older_page:null}});
|
||||
return Promise.resolve({{comments:[{{id:3,body:'newer'}},{{id:4,body:'newest'}}], older_page:1}});
|
||||
}},
|
||||
mutate: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api', number:42, kind:'issue'}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (states.at(-1).conversation.status !== 'error') throw new Error('failure unavailable');
|
||||
await preview.retryConversation();
|
||||
await preview.loadOlderConversation();
|
||||
const final = states.at(-1).conversation;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
status:final.status,
|
||||
ids:final.comments.map(comment => comment.id),
|
||||
olderPage:final.olderPage,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": 3,
|
||||
"status": "ready",
|
||||
"ids": [1, 3, 4],
|
||||
"olderPage": None,
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_claim_is_single_flight_and_reports_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
@@ -249,6 +779,71 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_shares_canonical_url_without_closing_the_preview():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const calls = [];
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item),
|
||||
mutate: () => Promise.resolve(),
|
||||
share: async url => {{ calls.push(url); return 'shared'; }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
const detail = {{ repository:'stackchain/api', number:42, kind:'issue' }};
|
||||
await preview.open(detail);
|
||||
const result = await preview.share('https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42');
|
||||
process.stdout.write(JSON.stringify({{ result, calls, states }}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["result"] == "shared"
|
||||
assert payload["calls"] == [
|
||||
"https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42"
|
||||
]
|
||||
assert payload["states"][-1]["status"] == "shared"
|
||||
assert payload["states"][-1]["detail"]["number"] == 42
|
||||
|
||||
|
||||
def test_search_preview_share_is_single_flight_and_cancellation_restores_ready_context():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let calls = 0;
|
||||
let rejectShare;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(),
|
||||
share: () => {{ calls += 1; return new Promise((_resolve, reject) => rejectShare = reject); }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
|
||||
const first = preview.share('https://forge.example/dashboard/?search=x');
|
||||
const second = preview.share('https://forge.example/dashboard/?search=x');
|
||||
const canceled = new Error('canceled'); canceled.name = 'AbortError'; rejectShare(canceled);
|
||||
await Promise.allSettled([first, second]);
|
||||
const state = states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{ calls, same:first === second, status:state.status,
|
||||
item:state.item, detail:state.detail, errorName:state.error?.name }}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": 1,
|
||||
"same": True,
|
||||
"status": "share-canceled",
|
||||
"item": {"repository": "stackchain/api", "number": 42, "kind": "issue"},
|
||||
"detail": {"repository": "stackchain/api", "number": 42, "kind": "issue"},
|
||||
"errorName": "AbortError",
|
||||
}
|
||||
|
||||
|
||||
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
@@ -261,12 +856,60 @@ def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
assert "qs('#cmd-input').value = ''" not in remote_branch
|
||||
|
||||
|
||||
def test_search_preview_share_action_uses_canonical_stackchain_url_and_keeps_context():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "share: url => createWorkRoute.share(url, navigator, navigator.clipboard)" in html
|
||||
handler = html.split("qs('#share-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#claim-search-result')", 1
|
||||
)[0]
|
||||
assert "canonicalSearchPreviewUrl()" in handler
|
||||
assert "searchPreview.share" in handler
|
||||
assert "closeSearchPreview" not in handler
|
||||
assert "taskOverlayHistory.close" not in handler
|
||||
|
||||
|
||||
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
|
||||
assert "claimButton.hidden = !(detail.claimable || detail.assigned_to_me)" in html
|
||||
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
|
||||
assert "openPreviewIssueInMyWork" in html
|
||||
assert "openPreviewWorkInMyWork" in html
|
||||
|
||||
|
||||
def test_assigned_pull_preview_hands_off_to_existing_my_work_sheet_without_claiming():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "claimButton.hidden = !(detail.claimable || detail.assigned_to_me)" in html
|
||||
assert "async function openPreviewWorkInMyWork(detail)" in html
|
||||
handoff = html.split("async function openPreviewWorkInMyWork(detail)", 1)[1].split(
|
||||
"function runCommandItem", 1
|
||||
)[0]
|
||||
assert "candidate.kind === detail.kind" in handoff
|
||||
assert "candidate.key === detail.repository + '#' + detail.number" in handoff
|
||||
|
||||
click_handler = html.split(
|
||||
"qs('#claim-search-result').addEventListener('click'", 1
|
||||
)[1].split("qs('#start-search-result')", 1)[0]
|
||||
assert "if (claimed.claimable) await searchPreview.claim(claimed)" in click_handler
|
||||
assert "await openPreviewWorkInMyWork(claimed)" in click_handler
|
||||
|
||||
|
||||
def test_requested_pull_preview_opens_native_review_without_assignment_mutation():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert "startButton.hidden = !(detail.reviewable" in html
|
||||
assert "detail.reviewable ? 'Review now'" in html
|
||||
handler = html.split(
|
||||
"qs('#start-search-result').addEventListener('click'", 1
|
||||
)[1].split("qs('#close-whiteboard')", 1)[0]
|
||||
assert "detail.kind = 'review'" in handler
|
||||
assert "openRoutedWork" in handler
|
||||
assert "{ replace:true }" in handler
|
||||
assert "searchPreview.claim" not in handler
|
||||
assert "fetchReviewJson" not in handler
|
||||
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
|
||||
|
||||
|
||||
def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
@@ -281,10 +924,144 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
|
||||
|
||||
def test_search_preview_queues_eligible_issue_and_continues_preserved_search():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="queue-search-result"' in html
|
||||
assert "queueButton.hidden = !(detail.kind === 'issue'" in html
|
||||
assert "detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today'" in html
|
||||
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
|
||||
"const searchAssignAndStart", 1
|
||||
)[0]
|
||||
assert "queue: confirmed => queueToday(acceptClaimedIssue(confirmed))" in orchestrator
|
||||
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#start-search-result')", 1
|
||||
)[0]
|
||||
assert "destination: 'queue'" in handler
|
||||
assert "alreadyOwned: detail.assigned_to_me" in handler
|
||||
assert "next()" in handler
|
||||
assert "closeSearchPreview()" not in handler
|
||||
assert "mobileSearchViewport.restoreScroll()" in html
|
||||
assert "@media (max-width:420px)" in css
|
||||
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
|
||||
|
||||
|
||||
def test_search_queue_confirmation_is_accessible_and_existing_today_item_is_not_readded():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'id="cmd-search-action-status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
|
||||
"const searchAssignAndStart", 1
|
||||
)[0]
|
||||
assert "qs('#cmd-search-action-status').textContent = message" in orchestrator
|
||||
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#start-search-result')", 1
|
||||
)[0]
|
||||
assert "todayWork.contains(detail)" in handler
|
||||
assert "Already in Today." in handler
|
||||
assert handler.index("todayWork.contains(detail)") < handler.index("searchAssignAndStart.run")
|
||||
|
||||
|
||||
def test_search_defer_claims_once_then_schedules_the_canonical_issue_without_touching_today():
|
||||
script = f"""
|
||||
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
|
||||
(async () => {{
|
||||
const calls = [];
|
||||
let releaseClaim;
|
||||
const flow = createSearchDefer({{
|
||||
claim: detail => {{
|
||||
calls.push(['claim', detail.number]);
|
||||
return new Promise(resolve => releaseClaim = resolve);
|
||||
}},
|
||||
accept: confirmed => {{
|
||||
calls.push(['accept', confirmed.key]);
|
||||
return {{kind:'issue', repository:confirmed.repository, number:confirmed.number, key:confirmed.key}};
|
||||
}},
|
||||
defer: (item, until) => {{ calls.push(['defer', item.key, until.toISOString()]); return 'deferred'; }},
|
||||
refresh: () => calls.push(['refresh']),
|
||||
announce: message => calls.push(['announce', message]),
|
||||
formatTime: value => value.toISOString(),
|
||||
}});
|
||||
const detail = {{kind:'issue', repository:'stackchain/api', number:42, claimable:true}};
|
||||
const until = new Date('2026-08-15T09:00:00.000Z');
|
||||
const first = flow.run(detail, until);
|
||||
const second = flow.run(detail, until);
|
||||
if (first !== second) throw new Error('confirmation was not single-flight');
|
||||
releaseClaim({{kind:'issue', repository:'stackchain/api', number:42, key:'stackchain/api#42'}});
|
||||
const outcome = await first;
|
||||
process.stdout.write(JSON.stringify({{outcome, calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"outcome": "deferred",
|
||||
"calls": [
|
||||
["claim", 42],
|
||||
["accept", "stackchain/api#42"],
|
||||
["defer", "stackchain/api#42", "2026-08-15T09:00:00.000Z"],
|
||||
["refresh"],
|
||||
["announce", "Assigned and deferred until 2026-08-15T09:00:00.000Z."],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_opens_later_picker_before_claiming_an_eligible_issue():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="defer-search-result"' in html
|
||||
assert "const deferButton = qs('#defer-search-result')" in html
|
||||
assert "deferButton.hidden = !(detail.kind === 'issue'" in html
|
||||
assert "detail.assigned_to_me ? 'Defer' : 'Assign & defer'" in html
|
||||
handler = html.split("qs('#defer-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#queue-search-result')", 1
|
||||
)[0]
|
||||
assert "laterPicker.open(detail, event.currentTarget, 'search')" in handler
|
||||
assert "searchPreview.claim" not in handler
|
||||
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
|
||||
assert "@media (max-width:420px)" in css
|
||||
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
|
||||
|
||||
|
||||
def test_search_defer_reports_recoverable_partial_outcome_when_later_storage_fails_after_claim():
|
||||
script = f"""
|
||||
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
|
||||
(async () => {{
|
||||
const messages = [];
|
||||
let refreshes = 0;
|
||||
const flow = createSearchDefer({{
|
||||
claim: () => Promise.resolve({{kind:'issue', repository:'stackchain/api', number:42}}),
|
||||
accept: issue => issue,
|
||||
defer: () => 'unavailable',
|
||||
refresh: () => refreshes += 1,
|
||||
announce: message => messages.push(message),
|
||||
formatTime: value => value.toISOString(),
|
||||
}});
|
||||
const outcome = await flow.run({{kind:'issue', repository:'stackchain/api', number:42, claimable:true}},
|
||||
new Date('2026-08-15T09:00:00.000Z'));
|
||||
process.stdout.write(JSON.stringify({{outcome, refreshes, messages}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"outcome": "unavailable",
|
||||
"refreshes": 1,
|
||||
"messages": [
|
||||
"Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry."
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "startButton.textContent = detail.reopenable ? 'Reopen & resume'" in html
|
||||
assert "detail.reopenable ? 'Reopen & resume'" in html
|
||||
assert "mutate: (detail, action) => fetchReviewJson(" in html
|
||||
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-whiteboard')", 1
|
||||
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v97" in worker
|
||||
assert "stackchain-dashboard-shell-v101" in worker
|
||||
@@ -876,6 +876,107 @@ async def test_anonymous_share_target_redirect_preserves_only_bounded_capture_fi
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_app_shortcut_preserves_only_a_known_launch_action(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
valid = await client.get("/", params={"launch": "agenda", "next": "https://evil.example"})
|
||||
invalid = await client.get("/", params={"launch": "delete-account"})
|
||||
|
||||
assert valid.status_code == 303
|
||||
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
|
||||
"continue": ["./?launch=agenda"]
|
||||
}
|
||||
assert invalid.status_code == 303
|
||||
assert invalid.headers["location"] == "login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_search_preview_preserves_only_bounded_canonical_continuation(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
valid = await client.get(
|
||||
"/", params={"search": "release blocker", "preview": "issue:stackchain/api:42"}
|
||||
)
|
||||
malformed = await client.get(
|
||||
"/", params={"search": "release blocker", "preview": "issue:stackchain/api:0"}
|
||||
)
|
||||
duplicate = await client.get(
|
||||
"/?search=release&search=other&preview=issue%3Astackchain%2Fapi%3A42"
|
||||
)
|
||||
oversized = await client.get(
|
||||
"/", params={"search": "release", "preview": f"issue:{'x' * 201}/api:42"}
|
||||
)
|
||||
|
||||
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
|
||||
"continue": [
|
||||
"./?search=release+blocker&preview=issue%3Astackchain%2Fapi%3A42"
|
||||
]
|
||||
}
|
||||
assert malformed.headers["location"] == "login"
|
||||
assert duplicate.headers["location"] == "login"
|
||||
assert oversized.headers["location"] == "login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_search_preview_preserves_only_valid_search_scope(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
valid = await client.get("/", params={
|
||||
"search": "release blocker", "preview": "pull:stackchain/api:42",
|
||||
"search_kind": "pull", "search_state": "open",
|
||||
"search_repository": "stackchain/api",
|
||||
})
|
||||
invalid = await client.get("/", params={
|
||||
"search": "release blocker", "preview": "pull:stackchain/api:42",
|
||||
"search_kind": "script", "search_state": "secret",
|
||||
})
|
||||
|
||||
valid_continue = parse_qs(urlsplit(valid.headers["location"]).query)["continue"][0]
|
||||
invalid_continue = parse_qs(urlsplit(invalid.headers["location"]).query)["continue"][0]
|
||||
assert valid_continue.endswith(
|
||||
"search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42&"
|
||||
"search_kind=pull&search_state=open&search_repository=stackchain%2Fapi"
|
||||
)
|
||||
assert invalid_continue == (
|
||||
"./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
valid = await client.get(
|
||||
"/",
|
||||
params={
|
||||
"launch": "new",
|
||||
"shared": "image",
|
||||
"title": "Broken checkout",
|
||||
"text": "Steps to reproduce",
|
||||
},
|
||||
)
|
||||
wrong_launch = await client.get(
|
||||
"/", params={"launch": "agenda", "shared": "image"}
|
||||
)
|
||||
wrong_marker = await client.get(
|
||||
"/", params={"launch": "new", "shared": "document"}
|
||||
)
|
||||
duplicate_marker = await client.get(
|
||||
"/?launch=new&shared=image&shared=image"
|
||||
)
|
||||
|
||||
assert valid.status_code == 303
|
||||
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
|
||||
"continue": [
|
||||
"./?title=Broken+checkout&text=Steps+to+reproduce&launch=new&shared=image"
|
||||
]
|
||||
}
|
||||
assert wrong_launch.headers["location"] == "login"
|
||||
assert wrong_marker.headers["location"] == "login"
|
||||
assert duplicate_marker.headers["location"] == "login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_oversized_share_target_is_not_carried_through_login(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -2046,6 +2147,7 @@ async def test_public_routes_remain_available_and_readiness_hides_identity(acces
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
await main._check_readiness()
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
health = await client.get("/healthz")
|
||||
|
||||
@@ -777,6 +777,12 @@ def test_security_activity_explains_pending_outcome_confirmation():
|
||||
assert "event.status === 'pending'" in source
|
||||
|
||||
|
||||
def test_security_activity_labels_permanent_comment_deletion():
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "comment_deleted: 'Comment deleted'" in source
|
||||
|
||||
|
||||
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
from src.push_notifications import PushConfiguration, dispatch_deadline_reminders
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplicates(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="America/New_York", reminder_hour=9
|
||||
)
|
||||
sent = []
|
||||
|
||||
async def assigned():
|
||||
return {
|
||||
"complete": True,
|
||||
"items": [
|
||||
{
|
||||
"id": 42,
|
||||
"title": "Private launch plan",
|
||||
"repository": {"full_name": "private/repo"},
|
||||
"due_date": "2026-08-14T12:00:00Z",
|
||||
},
|
||||
{"id": 43, "due_date": "2026-08-20T12:00:00Z"},
|
||||
],
|
||||
}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload))
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
now = datetime(2026, 8, 13, 13, 5, tzinfo=timezone.utc)
|
||||
|
||||
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 1
|
||||
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 0
|
||||
assert sent == [{
|
||||
"title": "1 deadline needs attention",
|
||||
"body": "Open Agenda to review or replan it.",
|
||||
"route": "#/my-work/agenda",
|
||||
"protect_route": "#/my-work/agenda/protect-today",
|
||||
"tag": "stackchain-deadline-digest-2026-08-13",
|
||||
"deadline_count": 1,
|
||||
}]
|
||||
assert "Private launch plan" not in json.dumps(sent)
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for device, timezone_name in (("tokyo", "Asia/Tokyo"), ("la", "America/Los_Angeles")):
|
||||
store.upsert(device, {
|
||||
"endpoint": f"https://push.example/{device}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(device, enabled=True, timezone=timezone_name, reminder_hour=0)
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [
|
||||
{"id": 1, "due_date": "2026-08-15T23:59:59Z"},
|
||||
{"id": 2, "due_date": "2026-08-16T23:59:59Z"},
|
||||
]}
|
||||
|
||||
sent = {}
|
||||
async def send(subscription, payload):
|
||||
device = subscription["endpoint"].rsplit("/", 1)[-1]
|
||||
sent[device] = json.loads(payload)["deadline_count"]
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store, PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned, send, now=datetime(2026, 8, 13, 23, 30, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert delivered == 2
|
||||
assert sent == {"tokyo": 2, "la": 1}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_uses_each_devices_confirmed_horizon(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for device, reminder_days in (("today", 0), ("week", 7)):
|
||||
store.upsert(device, {
|
||||
"endpoint": f"https://push.example/{device}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
device,
|
||||
enabled=True,
|
||||
timezone="UTC",
|
||||
reminder_hour=9,
|
||||
reminder_days=reminder_days,
|
||||
)
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [
|
||||
{"id": 1, "due_date": "2026-08-13"},
|
||||
{"id": 2, "due_date": "2026-08-15"},
|
||||
{"id": 3, "due_date": "2026-08-20"},
|
||||
{"id": 4, "due_date": "2026-08-21"},
|
||||
]}
|
||||
|
||||
sent = {}
|
||||
|
||||
async def send(subscription, payload):
|
||||
device = subscription["endpoint"].rsplit("/", 1)[-1]
|
||||
sent[device] = json.loads(payload)["deadline_count"]
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
send,
|
||||
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert delivered == 2
|
||||
assert sent == {"today": 1, "week": 3}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9
|
||||
)
|
||||
sent = []
|
||||
|
||||
async def incomplete():
|
||||
return {"complete": False, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
|
||||
|
||||
async def complete():
|
||||
return {"complete": True, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(payload)
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_deadline_reminders(
|
||||
store, config, incomplete, send,
|
||||
now=datetime(2026, 8, 13, 18, 0, tzinfo=timezone.utc),
|
||||
) == 0
|
||||
assert await dispatch_deadline_reminders(
|
||||
store, config, complete, send,
|
||||
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
|
||||
) == 0
|
||||
assert sent == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_skips_snapshot_until_a_device_reaches_its_local_hour(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9
|
||||
)
|
||||
snapshot_calls = 0
|
||||
|
||||
async def assigned():
|
||||
nonlocal snapshot_calls
|
||||
snapshot_calls += 1
|
||||
return {"complete": True, "items": []}
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert delivered == 0
|
||||
assert snapshot_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_skips_snapshot_after_every_device_was_delivered_today(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id, timezone_name in (
|
||||
("device-a", "UTC"),
|
||||
("device-b", "America/New_York"),
|
||||
):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
session_id, enabled=True, timezone=timezone_name, reminder_hour=9
|
||||
)
|
||||
store.mark_deadline_reminder_delivered(session_id, "2026-08-13")
|
||||
snapshot_calls = 0
|
||||
|
||||
async def assigned():
|
||||
nonlocal snapshot_calls
|
||||
snapshot_calls += 1
|
||||
return {"complete": True, "items": []}
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert delivered == 0
|
||||
assert snapshot_calls == 0
|
||||
|
||||
|
||||
def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="Europe/London", reminder_hour=8
|
||||
)
|
||||
|
||||
assert store.deadline_preferences("device-a") == {
|
||||
"enabled": True,
|
||||
"timezone": "Europe/London",
|
||||
"reminder_hour": 8,
|
||||
"reminder_days": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
|
||||
database = tmp_path / "push.sqlite3"
|
||||
import sqlite3
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.executescript("""
|
||||
CREATE TABLE push_subscriptions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
subscription_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE push_deadline_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
delivered_local_day TEXT
|
||||
);
|
||||
INSERT INTO push_subscriptions VALUES ('device-a', 'https://push.example/a', '{}');
|
||||
INSERT INTO push_deadline_preferences VALUES ('device-a', 1, 'UTC', 8, NULL);
|
||||
""")
|
||||
|
||||
store = PushSubscriptionStore(database)
|
||||
|
||||
assert store.deadline_preferences("device-a")["reminder_days"] == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch):
|
||||
pages = {
|
||||
1: {"items": [{"id": 1}], "total": 2, "has_more": True},
|
||||
2: {"items": [{"id": 2}], "total": 2, "has_more": False},
|
||||
}
|
||||
|
||||
async def work_page(stream, page, limit):
|
||||
assert stream == "issue"
|
||||
assert limit == 1
|
||||
return pages[page]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
assert await gitea_proxy.assigned_issue_snapshot(limit=1) == {
|
||||
"items": [{"id": 1}, {"id": 2}],
|
||||
"complete": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_deadline_snapshot_fetches_remaining_pages_concurrently(monkeypatch):
|
||||
active = 0
|
||||
peak = 0
|
||||
remaining_started = asyncio.Event()
|
||||
|
||||
async def work_page(stream, page, limit):
|
||||
nonlocal active, peak
|
||||
assert stream == "issue"
|
||||
assert limit == 1
|
||||
if page == 1:
|
||||
return {"items": [{"id": 1}], "total": 5, "has_more": True}
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
if active == 2:
|
||||
remaining_started.set()
|
||||
await asyncio.wait_for(remaining_started.wait(), timeout=0.2)
|
||||
await asyncio.sleep(0)
|
||||
active -= 1
|
||||
return {"items": [{"id": page}], "total": 5, "has_more": page < 5}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
assert await gitea_proxy.assigned_issue_snapshot(limit=1, max_concurrency=2) == {
|
||||
"items": [{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5}],
|
||||
"complete": True,
|
||||
}
|
||||
assert peak == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"remaining",
|
||||
[
|
||||
{"items": [{"id": 2}], "total": 3, "has_more": True},
|
||||
{"items": [{"id": 1}], "total": 2, "has_more": False},
|
||||
],
|
||||
)
|
||||
async def test_assigned_deadline_snapshot_rejects_changed_or_duplicate_pages(
|
||||
monkeypatch, remaining
|
||||
):
|
||||
async def work_page(_stream, page, _limit):
|
||||
if page == 1:
|
||||
return {"items": [{"id": 1}], "total": 2, "has_more": True}
|
||||
return remaining
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
with pytest.raises(ValueError, match="incomplete|changed"):
|
||||
await gitea_proxy.assigned_issue_snapshot(limit=1)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_deadline_snapshot_enforces_aggregate_deadline(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def work_page(_stream, _page, _limit):
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
await gitea_proxy.assigned_issue_snapshot(deadline_seconds=0.01)
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_competing_workers_send_one_deadline_digest(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
first = PushSubscriptionStore(path)
|
||||
second = PushSubscriptionStore(path)
|
||||
first.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
first.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
sending = __import__("asyncio").Event()
|
||||
release = __import__("asyncio").Event()
|
||||
sent = []
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(payload)
|
||||
sending.set()
|
||||
await release.wait()
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
now = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc)
|
||||
active = __import__("asyncio").create_task(
|
||||
dispatch_deadline_reminders(first, config, assigned, send, now=now)
|
||||
)
|
||||
await sending.wait()
|
||||
competing = await dispatch_deadline_reminders(second, config, assigned, send, now=now)
|
||||
release.set()
|
||||
|
||||
assert competing == 0
|
||||
assert await active == 1
|
||||
assert len(sent) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminders_bound_fanout_and_isolate_failed_devices(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for index in range(4):
|
||||
session_id = f"device-{index}"
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
active = 0
|
||||
peak = 0
|
||||
two_active = asyncio.Event()
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
nonlocal active, peak
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
if active == 2:
|
||||
two_active.set()
|
||||
await asyncio.wait_for(two_active.wait(), timeout=0.5)
|
||||
active -= 1
|
||||
if subscription["endpoint"].endswith("device-1"):
|
||||
raise RuntimeError("provider failed")
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
send,
|
||||
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
|
||||
max_concurrency=2,
|
||||
)
|
||||
|
||||
assert delivered == 3
|
||||
assert peak == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminders_bulk_authorize_due_devices_and_prune_inactive(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("active-device", "revoked-device"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
authorization_calls = []
|
||||
sent = []
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
|
||||
|
||||
async def session_statuses(management_ids):
|
||||
authorization_calls.append(list(management_ids))
|
||||
return {
|
||||
management_id: "active" if management_id == "active-device" else "revoked"
|
||||
for management_id in management_ids
|
||||
}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
sent.append(subscription["endpoint"])
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
send,
|
||||
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
|
||||
session_statuses=session_statuses,
|
||||
)
|
||||
|
||||
assert delivered == 1
|
||||
assert authorization_calls == [["active-device", "revoked-device"]]
|
||||
assert sent == ["https://push.example/active-device"]
|
||||
assert store.is_subscribed("revoked-device") is False
|
||||
@@ -66,10 +66,16 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
security_center = first.feature_bundles["security-center"]
|
||||
assert b"function attachSecurityCenter" not in first.runtime_bytes
|
||||
assert b"function attachSecurityCenter" in security_center.runtime_bytes
|
||||
assert b"function createGlobalSearchController" not in first.runtime_bytes
|
||||
assert b"function createGlobalSearchController" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createAssignAndStart" not in first.runtime_bytes
|
||||
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createQueueToday" not in first.runtime_bytes
|
||||
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
|
||||
assert len(first.runtime_gzip_bytes) <= 96 * 1024
|
||||
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.
|
||||
assert len(first.runtime_gzip_bytes) <= 98 * 1024
|
||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||
@@ -187,7 +193,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v97';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v100';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -254,6 +254,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"number": 7, "state": "open", "assignees": [],
|
||||
"body": "Deploy fails after **three** retries.",
|
||||
})
|
||||
if request.url.path.endswith("/issues/7/subscriptions/check"):
|
||||
return httpx.Response(200, json={"subscribed": True, "ignored": False})
|
||||
if request.url.path.endswith("/issues/comments/9"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 9,
|
||||
@@ -282,7 +284,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/subscriptions/check",
|
||||
]
|
||||
assert result == {
|
||||
"id": 42,
|
||||
@@ -300,18 +302,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
},
|
||||
"issue": {"number": 7, "assignees": [], "claimable": True},
|
||||
"acknowledge_supported": True,
|
||||
"conversation": {
|
||||
"comments": [{
|
||||
"id": 9,
|
||||
"author": "alexander",
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
}],
|
||||
"page": 1,
|
||||
"older_page": None,
|
||||
"total": 1,
|
||||
},
|
||||
"mute_supported": True,
|
||||
"conversation_available": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -398,3 +390,37 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
|
||||
"created_at": "2026-08-07T19:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mute_notification_unsubscribes_current_user_from_trusted_thread_target():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path.endswith("/notifications/threads/42"):
|
||||
return httpx.Response(200, json={
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Pull",
|
||||
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/pulls/7",
|
||||
},
|
||||
})
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "DELETE":
|
||||
return httpx.Response(204)
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.mute_notification(42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {"id": 42, "repository": "stackchain/api", "number": 7, "muted": True}
|
||||
assert requests == [
|
||||
("GET", "/api/v1/notifications/threads/42"),
|
||||
("GET", "/api/v1/user"),
|
||||
("DELETE", "/api/v1/repos/stackchain/api/issues/7/subscriptions/timmy"),
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -245,6 +246,7 @@ async def test_application_lifespan_opens_and_closes_gitea_transport(monkeypatch
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
@@ -270,6 +272,7 @@ async def test_application_shutdown_finishes_snapshot_before_closing_transport(m
|
||||
calls.append("snapshot cancelled")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
assert main._live_snapshot_task is not None
|
||||
@@ -306,6 +309,7 @@ async def test_application_shutdown_cancels_available_work_scan_before_transport
|
||||
calls.append("available scan cancelled")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
task = main._available_issue_snapshot_task
|
||||
@@ -341,6 +345,7 @@ async def test_application_shutdown_drains_authored_mutation_before_transport(mo
|
||||
return {"id": 461}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
@@ -382,6 +387,7 @@ async def test_application_shutdown_bounds_and_settles_stalled_authored_mutation
|
||||
|
||||
monkeypatch.setattr(main, "AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS", 0.01)
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
|
||||
@@ -215,6 +215,120 @@ async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkey
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_search_filters_full_catalog_before_pagination(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{
|
||||
"repository": "stackchain/api",
|
||||
"number": number,
|
||||
"title": "Routine API maintenance",
|
||||
}
|
||||
for number in range(1, 52)
|
||||
] + [{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 673,
|
||||
"title": "Search the full Find Work catalog",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
by_repo = await client.get("/api/v1/available-issues?page=1&q=DASHBOARD")
|
||||
by_number = await client.get("/api/v1/available-issues?page=1&q=%23673")
|
||||
by_title = await client.get("/api/v1/available-issues?page=1&q=full%20find")
|
||||
|
||||
expected = [{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 673,
|
||||
"title": "Search the full Find Work catalog",
|
||||
}]
|
||||
assert by_repo.json() == {"items": expected, "page": 1, "total": 1, "has_more": False}
|
||||
assert by_number.json() == by_repo.json()
|
||||
assert by_title.json() == by_repo.json()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_filter_before_pagination_and_describe_catalog(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]},
|
||||
{"repository": "stackchain/api", "number": 2, "title": "API docs", "labels": ["docs"]},
|
||||
{"repository": "stackchain/dashboard", "number": 3, "title": "Critical UI", "labels": ["critical", "frontend"]},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("repository", "stackchain/api"), ("label", "critical")],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]}],
|
||||
"page": 1,
|
||||
"total": 1,
|
||||
"has_more": False,
|
||||
"facets": {
|
||||
"repositories": ["stackchain/api", "stackchain/dashboard"],
|
||||
"labels": ["backend", "critical", "docs", "frontend"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_or_within_each_facet_and_compose_with_search(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{"repository": "stackchain/api", "number": 1, "title": "Repair worker", "labels": ["backend"]},
|
||||
{"repository": "stackchain/web", "number": 2, "title": "Repair mobile", "labels": ["frontend"]},
|
||||
{"repository": "stackchain/docs", "number": 3, "title": "Repair guide", "labels": ["docs"]},
|
||||
{"repository": "stackchain/api", "number": 4, "title": "Routine task", "labels": ["backend"]},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("q", "repair"), ("repository", "stackchain/api"), ("repository", "stackchain/web"),
|
||||
("label", "backend"), ("label", "frontend")],
|
||||
)
|
||||
|
||||
assert [item["number"] for item in response.json()["items"]] == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_reject_excess_values_before_scanning(monkeypatch):
|
||||
async def must_not_scan():
|
||||
raise AssertionError("invalid facets must be rejected before catalog scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("label", f"label-{index}") for index in range(11)],
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch):
|
||||
async def must_not_scan():
|
||||
raise AssertionError("invalid query must be rejected before catalog scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues", params={"q": "x" * 101})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_reuses_catalog_published_by_another_worker(
|
||||
monkeypatch, tmp_path
|
||||
@@ -240,6 +354,82 @@ async def test_available_issue_endpoint_reuses_catalog_published_by_another_work
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_waits_for_slow_cold_catalog_from_another_worker(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
now = 100.0
|
||||
path = tmp_path / "shared-available.sqlite3"
|
||||
owner_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
|
||||
worker_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
|
||||
owner = owner_store.try_acquire_refresh(lease_seconds=6)
|
||||
monkeypatch.setattr(main, "_available_issue_snapshot_store", worker_store)
|
||||
|
||||
sleep_calls = 0
|
||||
|
||||
async def publish_after_existing_one_second_window(_delay):
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls == 60:
|
||||
owner_store.publish(
|
||||
owner,
|
||||
items=[{"repository": "stackchain/api", "number": 8}],
|
||||
)
|
||||
|
||||
async def must_not_scan():
|
||||
raise AssertionError("a valid cross-worker lease must prevent a duplicate scan")
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", publish_after_existing_one_second_window)
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert sleep_calls > 50
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"] == [
|
||||
{"repository": "stackchain/api", "number": 8}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_takes_over_released_cold_refresh(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
now = 100.0
|
||||
path = tmp_path / "shared-available.sqlite3"
|
||||
owner_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
|
||||
worker_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
|
||||
owner = owner_store.try_acquire_refresh(lease_seconds=6)
|
||||
monkeypatch.setattr(main, "_available_issue_snapshot_store", worker_store)
|
||||
|
||||
sleep_calls = 0
|
||||
scan_calls = 0
|
||||
|
||||
async def release_failed_owner(_delay):
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls == 3:
|
||||
owner_store.release_refresh(owner)
|
||||
|
||||
async def available():
|
||||
nonlocal scan_calls
|
||||
scan_calls += 1
|
||||
return [{"repository": "stackchain/web", "number": 9}]
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", release_failed_owner)
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"] == [
|
||||
{"repository": "stackchain/web", "number": 9}
|
||||
]
|
||||
assert scan_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_coalesces_cold_scan_and_reuses_it_for_pages(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
+413
-6
@@ -6,12 +6,146 @@ import pytest
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_endpoint_scopes_every_page_to_an_exact_repository(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def search(query, limit, page, kind, state, repository):
|
||||
requested.append((query, limit, page, kind, state, repository))
|
||||
return {"items": [], "partial": False, "has_more": False, "next_page": 4}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/search?q=mobile&limit=7&page=3&kind=pull&state=open&repository=stackchain%2Fapi"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert requested == [("mobile", 7, 3, "pull", "open", "stackchain/api")]
|
||||
assert response.json()["scope"] == {
|
||||
"kind": "pull", "state": "open", "repository": "stackchain/api"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_endpoint_forwards_independent_stream_continuation(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def search(query, limit, page, kind, state, repository, continuation):
|
||||
requested.append((query, limit, page, kind, state, repository, continuation))
|
||||
return {
|
||||
"items": [], "partial": False, "has_more": False, "next_page": 3,
|
||||
"continuation": {"issues": None, "pulls": None}, "failed_streams": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/search?q=mobile&limit=7&page=2&issues_page=1&pulls_page=3"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert requested == [
|
||||
("mobile", 7, 2, "all", "all", None, {"issues": 1, "pulls": 3})
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_rejects_malformed_repository_scope_before_upstream(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def search(*args):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/search?q=mobile&repository=stackchain%2Fapi%2Fsecrets"
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_repository_scope_reaches_issue_and_pull_streams():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(dict(request.url.params))
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
await gitea_proxy.global_search(
|
||||
"mobile", limit=6, page=2, repository="stackchain/api"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 2
|
||||
assert all(request["owner"] == "stackchain" and request["repo"] == "api" for request in requests)
|
||||
assert all(request["page"] == "2" for request in requests)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_endpoint_forwards_valid_type_and_status_scope(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def search(query, limit, page, kind, state):
|
||||
requested.append((query, limit, page, kind, state))
|
||||
return {"items": [], "partial": False, "has_more": False, "next_page": 2}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/search?q=mobile&limit=7&page=1&kind=pull&state=open"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert requested == [("mobile", 7, 1, "pull", "open")]
|
||||
assert response.json()["scope"] == {"kind": "pull", "state": "open"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_specific_type_uses_one_full_width_scoped_stream():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(dict(request.url.params))
|
||||
return httpx.Response(200, json=[{
|
||||
"number": 9,
|
||||
"title": "Review mobile search",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.global_search(
|
||||
"mobile", limit=7, page=2, kind="pull", state="open"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [{
|
||||
"q": "mobile", "type": "pulls", "state": "open", "limit": "7", "page": "2"
|
||||
}]
|
||||
assert [item["kind"] for item in result["items"]] == ["pull"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def search(query, limit):
|
||||
requested.append((query, limit))
|
||||
async def search(query, limit, page, kind, state):
|
||||
requested.append((query, limit, page, kind, state))
|
||||
return {
|
||||
"items": [{
|
||||
"kind": "issue",
|
||||
@@ -27,12 +161,12 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
|
||||
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/search?q=mobile&limit=7")
|
||||
response = await client.get("/api/v1/search?q=mobile&limit=7&page=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert requested == [("mobile", 7)]
|
||||
assert response.json() == {"query": "mobile", "items": [{
|
||||
assert requested == [("mobile", 7, 2, "all", "all")]
|
||||
assert response.json() == {"query": "mobile", "scope": {"kind": "all", "state": "all"}, "items": [{
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
@@ -76,6 +210,121 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
|
||||
assert response.json()["claimable"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("kind", ["issue", "pull"])
|
||||
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(
|
||||
monkeypatch, kind
|
||||
):
|
||||
requested = []
|
||||
|
||||
async def conversation(repository, number, page, limit):
|
||||
requested.append((repository, number, page, limit))
|
||||
return {
|
||||
"comments": [{
|
||||
"id": 7,
|
||||
"author": "alexander",
|
||||
"body": "Current **decision**",
|
||||
"created_at": "2026-08-14T12:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42#issuecomment-7",
|
||||
}],
|
||||
"page": 3,
|
||||
"older_page": 2,
|
||||
"total": 41,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "issue_conversation_page", conversation, raising=False
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/api/v1/repos/stackchain/api/issues/42/preview/conversation?kind={kind}&limit=20"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["comments"][0]["body"] == "Current **decision**"
|
||||
assert requested == [("stackchain/api", 42, None, 20)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_reply_is_idempotent_for_an_authorized_pull(monkeypatch):
|
||||
main._idempotency_ledger.clear()
|
||||
previews = []
|
||||
comments = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
previews.append((repository, kind, number))
|
||||
return {
|
||||
"repository": repository,
|
||||
"kind": "pull",
|
||||
"number": number,
|
||||
"title": "Review mobile reply",
|
||||
"state": "open",
|
||||
}
|
||||
|
||||
async def comment(repository, number, body):
|
||||
comments.append((repository, number, body))
|
||||
return {
|
||||
"id": 91,
|
||||
"author": "timmy",
|
||||
"body": body,
|
||||
"created_at": "2026-08-14T12:30:00Z",
|
||||
"url": "https://forge.example/stackchain/api/pulls/42#issuecomment-91",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "search-reply-pull-42"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
|
||||
json={"body": "Ready to merge."},
|
||||
headers=headers,
|
||||
)
|
||||
replay = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
|
||||
json={"body": "Ready to merge."},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert first.status_code == replay.status_code == 201
|
||||
assert replay.json() == first.json()
|
||||
assert previews == [("stackchain/api", "pull", 42)]
|
||||
assert comments == [("stackchain/api", 42, "Ready to merge.")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_reply_rejects_a_mismatched_target_before_commenting(monkeypatch):
|
||||
main._idempotency_ledger.clear()
|
||||
comments = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
return {
|
||||
"repository": repository,
|
||||
"kind": "issue",
|
||||
"number": number,
|
||||
"title": "Different target",
|
||||
}
|
||||
|
||||
async def comment(*args):
|
||||
comments.append(args)
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
|
||||
json={"body": "Do not misroute this."},
|
||||
headers={"Idempotency-Key": "search-reply-mismatch-42"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert comments == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
|
||||
requests = []
|
||||
@@ -110,7 +359,11 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert {request["type"] for request in requests} == {"issues", "pulls"}
|
||||
assert all(request["q"] == "mobile queue" and request["limit"] == "7" for request in requests)
|
||||
assert {request["type"]: request["limit"] for request in requests} == {
|
||||
"issues": "4", "pulls": "3"
|
||||
}
|
||||
assert all(request["q"] == "mobile queue" and request["state"] == "all"
|
||||
for request in requests)
|
||||
assert results == {
|
||||
"items": [{
|
||||
"kind": "issue", "repository": "stackchain/api", "number": 42,
|
||||
@@ -122,6 +375,10 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
|
||||
"url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}],
|
||||
"partial": False,
|
||||
"has_more": False,
|
||||
"next_page": 2,
|
||||
"continuation": {"issues": None, "pulls": None},
|
||||
"failed_streams": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -154,9 +411,93 @@ async def test_global_search_returns_healthy_stream_when_other_stream_fails():
|
||||
"url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}],
|
||||
"partial": True,
|
||||
"has_more": True,
|
||||
"next_page": 2,
|
||||
"continuation": {"issues": 1, "pulls": None},
|
||||
"failed_streams": ["issue"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_retries_a_failed_stream_without_advancing_its_page():
|
||||
requests = []
|
||||
issue_attempts = 0
|
||||
|
||||
async def handler(request):
|
||||
nonlocal issue_attempts
|
||||
params = dict(request.url.params)
|
||||
requests.append((params["type"], params["page"], params["limit"]))
|
||||
if params["type"] == "issues":
|
||||
issue_attempts += 1
|
||||
if issue_attempts == 1:
|
||||
return httpx.Response(503)
|
||||
number = int(params["page"])
|
||||
return httpx.Response(200, json=[{
|
||||
"number": number,
|
||||
"title": f'{params["type"]} {number}',
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}',
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
partial = await gitea_proxy.global_search("search", limit=4)
|
||||
recovered = await gitea_proxy.global_search(
|
||||
"search", limit=4, continuation=partial["continuation"]
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert partial["partial"] is True
|
||||
assert partial["has_more"] is True
|
||||
assert partial["continuation"] == {"issues": 1, "pulls": None}
|
||||
assert recovered["partial"] is False
|
||||
assert [item["kind"] for item in recovered["items"]] == ["issue"]
|
||||
assert requests == [
|
||||
("issues", "1", "2"), ("pulls", "1", "2"), ("issues", "1", "2")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_odd_limit_keeps_both_streams_contiguous_across_pages():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
params = dict(request.url.params)
|
||||
stream_limit = int(params["limit"])
|
||||
page = int(params["page"])
|
||||
requests.append((params["type"], page, stream_limit))
|
||||
start = (page - 1) * stream_limit + 1
|
||||
return httpx.Response(200, json=[{
|
||||
"number": number,
|
||||
"title": f'{params["type"]} {number}',
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}',
|
||||
} for number in range(start, start + stream_limit)])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
first = await gitea_proxy.global_search("search", limit=7)
|
||||
second = await gitea_proxy.global_search(
|
||||
"search", limit=7, continuation=first["continuation"]
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
pulls = [item["number"] for result in (first, second)
|
||||
for item in result["items"] if item["kind"] == "pull"]
|
||||
issues = [item["number"] for result in (first, second)
|
||||
for item in result["items"] if item["kind"] == "issue"]
|
||||
assert pulls == [1, 2, 3, 4, 5, 6]
|
||||
assert issues == [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
assert requests == [
|
||||
("issues", 1, 4), ("pulls", 1, 3),
|
||||
("issues", 2, 4), ("pulls", 2, 3),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_caps_combined_results_to_requested_limit():
|
||||
async def handler(request):
|
||||
@@ -180,6 +521,38 @@ async def test_global_search_caps_combined_results_to_requested_limit():
|
||||
assert result["partial"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_pages_each_stream_and_balances_results():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
params = dict(request.url.params)
|
||||
requests.append(params)
|
||||
kind = params["type"]
|
||||
page = int(params["page"])
|
||||
items = [{
|
||||
"number": page * 10 + number,
|
||||
"title": f"{kind} page {page} result {number}",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": f"https://forge.example/stackchain/web/{kind}/{page * 10 + number}",
|
||||
} for number in (1, 2, 3, 4)]
|
||||
return httpx.Response(200, json=items)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.global_search("search", limit=4, page=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert {(request["type"], request["page"]) for request in requests} == {
|
||||
("issues", "2"), ("pulls", "2")
|
||||
}
|
||||
assert [item["kind"] for item in result["items"]] == ["issue", "pull", "issue", "pull"]
|
||||
assert result["next_page"] == 3
|
||||
assert result["has_more"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_propagates_cancellation_to_stop_obsolete_work():
|
||||
async def handler(request):
|
||||
@@ -231,6 +604,8 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
|
||||
"claimable": True,
|
||||
"reopenable": False,
|
||||
"assigned_to_me": False,
|
||||
"reviewable": False,
|
||||
"commentable": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +633,38 @@ async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
|
||||
assert preview["claimable"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_marks_authoritative_requested_pull_review_actionable():
|
||||
requested_paths = []
|
||||
|
||||
async def handler(request):
|
||||
requested_paths.append(request.url.path)
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/pulls/9"):
|
||||
return httpx.Response(200, json={
|
||||
"state": "open",
|
||||
"requested_reviewers": [{"login": "timmy"}, None],
|
||||
})
|
||||
return httpx.Response(200, json={
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
"pull_request": {"merged": False},
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/web", "issue", 9)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview["reviewable"] is True
|
||||
assert "/api/v1/repos/stackchain/web/pulls/9" in requested_paths
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_offers_reopen_only_for_closed_issues():
|
||||
async def handler(request):
|
||||
|
||||
@@ -11,6 +11,49 @@ def test_health_endpoint_reports_service_liveness_without_gitea_access():
|
||||
assert main.health() == {"status": "ok", "service": "stackchain-dashboard"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_never_contacts_gitea_for_a_request(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def unexpected_user_lookup():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise AssertionError("request-time readiness must not contact Gitea")
|
||||
|
||||
monkeypatch.setattr(main, "_readiness_state", None, raising=False)
|
||||
monkeypatch.setattr(main, "current_user", unexpected_user_lookup)
|
||||
|
||||
first = await main.readiness()
|
||||
second = await main.readiness()
|
||||
|
||||
assert first.status_code == 503
|
||||
assert second.status_code == 503
|
||||
assert calls == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_monitor_refreshes_on_an_interval_and_cancels(monkeypatch):
|
||||
calls = 0
|
||||
refreshed_twice = asyncio.Event()
|
||||
|
||||
async def check():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
refreshed_twice.set()
|
||||
|
||||
monkeypatch.setattr(main, "_check_readiness", check)
|
||||
monkeypatch.setattr(main, "READINESS_INTERVAL_SECONDS", 0.01, raising=False)
|
||||
|
||||
task = asyncio.create_task(main._readiness_monitor())
|
||||
await asyncio.wait_for(refreshed_twice.wait(), timeout=0.2)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert calls == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
|
||||
async def connected_user():
|
||||
@@ -18,6 +61,8 @@ async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(main, "current_user", connected_user)
|
||||
|
||||
await main._check_readiness()
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert any(getattr(route, "path", None) == "/readyz" for route in main.app.routes)
|
||||
@@ -35,6 +80,8 @@ async def test_readiness_endpoint_hides_unexpected_upstream_error_details(monkey
|
||||
|
||||
monkeypatch.setattr(main, "current_user", unavailable_user)
|
||||
|
||||
await main._check_readiness()
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
@@ -53,6 +100,8 @@ async def test_readiness_endpoint_returns_503_for_null_current_user_payload(monk
|
||||
|
||||
monkeypatch.setattr(main, "current_user", null_user)
|
||||
|
||||
await main._check_readiness()
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
@@ -77,6 +126,8 @@ async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monk
|
||||
monkeypatch.setattr(main, "READINESS_TIMEOUT_SECONDS", 0.01, raising=False)
|
||||
monkeypatch.setattr(main, "current_user", hanging_user)
|
||||
|
||||
await main._check_readiness()
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
@@ -87,3 +138,18 @@ async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monk
|
||||
"error": "Gitea readiness check timed out after 0.01s",
|
||||
}
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_expires_a_stale_healthy_snapshot(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_readiness_state",
|
||||
main.ReadinessState(checked_at=10.0, ready=True, login="timmy"),
|
||||
)
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: 76.0)
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert json.loads(response.body)["error"] == "Gitea readiness status is not available"
|
||||
@@ -132,3 +132,22 @@ process.stdout.write(JSON.stringify({{first, second, confirmations}}));
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"first": True, "second": False, "confirmations": 1}
|
||||
|
||||
|
||||
def test_picker_item_can_own_batch_confirmation_and_run_after_close():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const order=[];
|
||||
const picker=createLaterPicker({{
|
||||
now:() => new Date(2026,7,14,8,0),
|
||||
onConfirm:()=>{{order.push('global');return true;}},
|
||||
}});
|
||||
picker.open({{confirm:until=>() => order.push(until.toISOString())}},null,'search-batch');
|
||||
const saved=picker.submit('2026-08-15T09:00');
|
||||
process.stdout.write(JSON.stringify({{saved,order}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"saved": True,
|
||||
"order": ["2026-08-15T09:00:00.000Z"],
|
||||
}
|
||||
@@ -65,6 +65,23 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path
|
||||
) == {"revision": 2, "records": {}}
|
||||
|
||||
|
||||
def test_initialized_later_reads_remain_available_during_a_planning_write(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path, timeout=0.05)
|
||||
expected = store.apply(
|
||||
"timmy", "seed", "defer", "issue:r:1:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
|
||||
writer = sqlite3.connect(path)
|
||||
writer.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
assert LaterStore(path, timeout=0.05).get("timmy") == expected
|
||||
finally:
|
||||
writer.rollback()
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_batch_applies_in_one_ordered_idempotent_unit(tmp_path):
|
||||
store = LaterStore(tmp_path / "later.sqlite3")
|
||||
operations = [
|
||||
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -0,0 +1,101 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
STATUS = ROOT / "frontend" / "live-data-status.js"
|
||||
HTML = ROOT / "frontend" / "index.html"
|
||||
CSS = ROOT / "frontend" / "dashboard.css"
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_live_data_status_summarizes_each_feed_without_claiming_live():
|
||||
script = f"""
|
||||
const status = require({json.dumps(str(STATUS))});
|
||||
const healthy = {{fresh_for_seconds:8, sections:{{
|
||||
context:{{age_seconds:1}}, notifications:{{age_seconds:2}}, events:{{age_seconds:3}}
|
||||
}}}};
|
||||
const oneDelayed = {{fresh_for_seconds:8, retry_in_seconds:30, sections:{{
|
||||
context:{{age_seconds:1}}, notifications:{{age_seconds:14, stale:true, retry_in_seconds:30}}, events:{{age_seconds:3}}
|
||||
}}}};
|
||||
const twoDelayed = {{fresh_for_seconds:8, sections:{{
|
||||
context:{{age_seconds:12, degraded:true}}, notifications:{{age_seconds:14, stale:true}}, events:{{age_seconds:3}}
|
||||
}}}};
|
||||
process.stdout.write(JSON.stringify({{
|
||||
healthy:status.describe(healthy),
|
||||
one:status.describe(oneDelayed),
|
||||
two:status.describe(twoDelayed),
|
||||
unavailable:status.describe({{}}),
|
||||
}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
|
||||
assert result["healthy"]["summary"] == "Live"
|
||||
assert [feed["state"] for feed in result["healthy"]["feeds"]] == ["live"] * 3
|
||||
assert result["one"]["summary"] == "Updates delayed"
|
||||
assert result["one"]["nextRetrySeconds"] == 30
|
||||
assert result["one"]["feeds"][1] == {
|
||||
"key": "notifications", "label": "Updates", "state": "delayed", "ageSeconds": 14
|
||||
}
|
||||
assert result["two"]["summary"] == "2 data feeds delayed"
|
||||
assert result["unavailable"]["summary"] == "Live data unavailable"
|
||||
|
||||
|
||||
def test_live_data_status_controller_is_single_flight_and_reports_result():
|
||||
script = f"""
|
||||
const status = require({json.dumps(str(STATUS))});
|
||||
let resolveRefresh;
|
||||
let calls = 0;
|
||||
const states = [];
|
||||
const button = {{disabled:false}};
|
||||
const output = {{textContent:''}};
|
||||
const controller = status.createRefreshController({{
|
||||
button, output,
|
||||
refresh:() => {{ calls += 1; return new Promise(resolve => {{ resolveRefresh = resolve; }}); }},
|
||||
onState:value => states.push(value),
|
||||
}});
|
||||
(async () => {{
|
||||
const first = controller.run();
|
||||
const second = controller.run();
|
||||
const pending = {{calls, disabled:button.disabled, text:output.textContent}};
|
||||
resolveRefresh({{context:{{}}}});
|
||||
await Promise.all([first, second]);
|
||||
process.stdout.write(JSON.stringify({{pending, calls, disabled:button.disabled, text:output.textContent, states}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"pending": {"calls": 1, "disabled": True, "text": "Refreshing live data…"},
|
||||
"calls": 1,
|
||||
"disabled": False,
|
||||
"text": "Live data refreshed.",
|
||||
"states": ["refreshing", "success"],
|
||||
}
|
||||
|
||||
|
||||
def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
|
||||
html = HTML.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
assert 'id="open-live-data-status"' in html
|
||||
assert 'aria-controls="live-data-status-sheet"' in html
|
||||
assert 'id="live-data-status-sheet"' in html
|
||||
assert 'aria-labelledby="live-data-status-heading"' in html
|
||||
assert 'id="live-data-status-feeds"' in html
|
||||
assert 'id="refresh-live-data"' in html
|
||||
assert '<script src="static/live-data-status.js"></script>' in html
|
||||
assert ".live-data-status-panel" in css
|
||||
assert "width:min(560px,100%)" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "liveDataStatus.describe" in dashboard
|
||||
assert "liveDataStatus.createRefreshController" in dashboard
|
||||
assert "contextPoller.getState()" in dashboard
|
||||
@@ -169,6 +169,56 @@ const controller = createLoginController({{
|
||||
}
|
||||
|
||||
|
||||
def test_stalled_token_login_aborts_once_and_restores_both_sign_in_controls():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const state = {{ requests: 0, timeout: null, aborted: false }};
|
||||
const status = {{ textContent: '' }};
|
||||
const button = {{ disabled: false }};
|
||||
const passkeyButton = {{ disabled: false }};
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status, button, passkeyButton,
|
||||
credentials: {{ get: async () => {{ throw new Error('unused'); }} }},
|
||||
fetchImpl: (_url, options) => {{
|
||||
state.requests += 1;
|
||||
options.signal.addEventListener('abort', () => state.aborted = true);
|
||||
return new Promise((_resolve, reject) => {{
|
||||
options.signal.addEventListener('abort', () => reject(options.signal.reason));
|
||||
}});
|
||||
}},
|
||||
location: {{ replace: () => {{}} }},
|
||||
requestTimeoutMs: 25,
|
||||
setTimeoutImpl: callback => {{ state.timeout = callback; return 7; }},
|
||||
clearTimeoutImpl: () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
const first = controller.submit('operator-token');
|
||||
const duplicate = controller.submit('operator-token');
|
||||
state.during = {{ button: button.disabled, passkey: passkeyButton.disabled }};
|
||||
state.timeout();
|
||||
await Promise.all([first, duplicate]);
|
||||
state.after = {{
|
||||
button: button.disabled, passkey: passkeyButton.disabled,
|
||||
status: status.textContent,
|
||||
}};
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert state["requests"] == 1
|
||||
assert state["during"] == {"button": True, "passkey": True}
|
||||
assert state["aborted"] is True
|
||||
assert state["after"] == {
|
||||
"button": False,
|
||||
"passkey": False,
|
||||
"status": "Sign-in timed out. Check your connection and try again.",
|
||||
}
|
||||
|
||||
|
||||
def test_successful_login_resumes_valid_shared_capture_continuation():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
@@ -193,6 +243,53 @@ const controller = createLoginController({{
|
||||
)
|
||||
|
||||
|
||||
def test_successful_login_resumes_only_valid_search_preview_continuation():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
async function destination(continuation) {{
|
||||
let replaced = null;
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: value => replaced = value }}, continuation,
|
||||
}});
|
||||
await controller.submit('operator-token');
|
||||
return replaced;
|
||||
}}
|
||||
(async () => process.stdout.write(JSON.stringify({{
|
||||
valid:await destination('./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9&search_kind=pull&search_state=open&search_repository=stackchain%2Fapi'),
|
||||
malformed:await destination('./?search=release&preview=issue%3Astackchain%2Fapi%3A0'),
|
||||
unknown:await destination('./?search=release&next=https%3A%2F%2Fevil.example'),
|
||||
}})))().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"valid": "./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9&search_kind=pull&search_state=open&search_repository=stackchain%2Fapi",
|
||||
"malformed": "./",
|
||||
"unknown": "./",
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_login_explains_the_resumable_handoff():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const status = {{ textContent: '' }};
|
||||
createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: () => {{}} }},
|
||||
continuation: './?search=release&preview=issue%3Astackchain%2Fapi%3A42',
|
||||
}});
|
||||
process.stdout.write(status.textContent);
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == "Sign in to open the shared Search result."
|
||||
|
||||
|
||||
def test_passkey_login_uses_web_authentication_without_sending_the_operator_token():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
@@ -254,6 +351,68 @@ const controller = createLoginController({{
|
||||
assert "access_token" not in json.dumps(output)
|
||||
|
||||
|
||||
def test_stalled_passkey_network_phases_time_out_without_timing_out_biometrics():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
async function scenario(stalledPhase) {{
|
||||
const state = {{ requests: [], timers: [], credentialCalls: 0 }};
|
||||
const status = {{textContent:''}};
|
||||
const button = {{disabled:false}};
|
||||
const passkeyButton = {{disabled:false}};
|
||||
const credential = {{
|
||||
id:'credential-id', type:'public-key', rawId:Uint8Array.from([1]).buffer,
|
||||
response:{{authenticatorData:Uint8Array.from([2]).buffer,
|
||||
clientDataJSON:Uint8Array.from([3]).buffer,
|
||||
signature:Uint8Array.from([4]).buffer,userHandle:null}},
|
||||
}};
|
||||
const controller=createLoginController({{
|
||||
form:{{reset:()=>{{}}}},status,button,passkeyButton,
|
||||
credentials:{{get:async()=>{{state.credentialCalls+=1;return credential;}}}},
|
||||
fetchImpl:(url,options)=>{{
|
||||
state.requests.push(url);
|
||||
const phase=url.endsWith('/options') ? 'options' : 'verify';
|
||||
if (phase === stalledPhase) return new Promise((_resolve,reject)=>{{
|
||||
options.signal.addEventListener('abort',()=>reject(options.signal.reason));
|
||||
}});
|
||||
return Promise.resolve(new Response(JSON.stringify({{
|
||||
challenge:'AQID',allowCredentials:[]
|
||||
}}),{{status:200,headers:{{'Content-Type':'application/json'}}}}));
|
||||
}},
|
||||
location:{{replace:()=>{{}}}},requestTimeoutMs:25,
|
||||
setTimeoutImpl:callback=>{{state.timers.push(callback);return state.timers.length;}},
|
||||
clearTimeoutImpl:()=>{{}},
|
||||
}});
|
||||
const pending=controller.signInWithPasskey('Phone');
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
if (stalledPhase === 'verify') await new Promise(resolve=>setImmediate(resolve));
|
||||
state.timers.at(-1)();
|
||||
state.result=await pending;
|
||||
state.final={{status:status.textContent,button:button.disabled,passkey:passkeyButton.disabled}};
|
||||
return state;
|
||||
}}
|
||||
(async()=>{{
|
||||
process.stdout.write(JSON.stringify({{
|
||||
options:await scenario('options'),verify:await scenario('verify'),
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert state["options"]["credentialCalls"] == 0
|
||||
assert state["verify"]["credentialCalls"] == 1
|
||||
assert state["verify"]["timers"] == [None, None]
|
||||
for phase in ("options", "verify"):
|
||||
assert state[phase]["result"] is False
|
||||
assert state[phase]["final"] == {
|
||||
"status": "Passkey sign-in timed out. Check your connection and try again.",
|
||||
"button": False,
|
||||
"passkey": False,
|
||||
}
|
||||
|
||||
|
||||
def test_login_sends_a_bounded_device_label_with_the_access_token():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
@@ -343,7 +502,7 @@ const elements = {{
|
||||
'submit-sign-in': {{ disabled: false }},
|
||||
}};
|
||||
const context = {{
|
||||
URLSearchParams, Response, setInterval, clearInterval,
|
||||
URLSearchParams, Response, AbortController, setInterval, clearInterval, setTimeout, clearTimeout,
|
||||
FormData: function () {{ return {{ get: () => 'operator-token' }}; }},
|
||||
fetch: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
document: {{ getElementById: id => elements[id] }},
|
||||
@@ -370,6 +529,44 @@ setTimeout(() => process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_login_continues_only_bounded_shared_screenshot_capture():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
async function destination(continuation) {{
|
||||
let replaced = null;
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: value => replaced = value }}, continuation,
|
||||
}});
|
||||
await controller.submit('operator-token');
|
||||
return replaced;
|
||||
}}
|
||||
(async () => {{
|
||||
process.stdout.write(JSON.stringify({{
|
||||
valid: await destination('./?title=Broken&launch=new&shared=image'),
|
||||
wrongLaunch: await destination('./?launch=agenda&shared=image'),
|
||||
missingLaunch: await destination('./?shared=image'),
|
||||
wrongMarker: await destination('./?launch=new&shared=document'),
|
||||
duplicateMarker: await destination('./?launch=new&shared=image&shared=image'),
|
||||
launchWithoutMarker: await destination('./?launch=new'),
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"valid": "./?title=Broken&launch=new&shared=image",
|
||||
"wrongLaunch": "./",
|
||||
"missingLaunch": "./",
|
||||
"wrongMarker": "./",
|
||||
"duplicateMarker": "./",
|
||||
"launchWithoutMarker": "./?launch=new",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_page_loads_rate_limit_controller():
|
||||
html = await login()
|
||||
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v97" in worker
|
||||
assert "stackchain-dashboard-shell-v101" in worker
|
||||
@@ -0,0 +1,119 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REPLAN = ROOT / "frontend" / "agenda-replan.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_overdue_replan_sweep_orders_items_and_only_advances_after_confirmed_change():
|
||||
script = f"""
|
||||
const createSweep = require({json.dumps(str(REPLAN))});
|
||||
const calls = [];
|
||||
let fail = true;
|
||||
const items = [
|
||||
{{key:'o/r#3', repository:'o/r', number:3, due_date:'2026-08-11T18:00:00Z'}},
|
||||
{{key:'o/r#1', repository:'o/r', number:1, due_date:'2026-08-09T18:00:00Z'}},
|
||||
{{key:'o/r#2', repository:'o/r', number:2, due_date:'2026-08-10T18:00:00Z'}},
|
||||
];
|
||||
const sweep = createSweep({{
|
||||
now: () => new Date('2026-08-12T12:00:00'),
|
||||
update: async (item, due) => {{ calls.push([item.key, due]); if (fail) throw new Error('offline'); return {{due_date:due}}; }},
|
||||
}});
|
||||
async function run() {{
|
||||
const started = sweep.start(items);
|
||||
const failed = await sweep.tomorrow();
|
||||
const afterFailure = sweep.snapshot();
|
||||
fail = false;
|
||||
const changed = await sweep.tomorrow();
|
||||
const afterChange = sweep.snapshot();
|
||||
const kept = await sweep.keep();
|
||||
process.stdout.write(JSON.stringify({{started, failed, afterFailure, changed, afterChange, kept, calls}}));
|
||||
}}
|
||||
run();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"started": {"active": True, "index": 0, "total": 3, "current": "o/r#1", "pending": False},
|
||||
"failed": {"ok": False, "error": "offline"},
|
||||
"afterFailure": {"active": True, "index": 0, "total": 3, "current": "o/r#1", "pending": False},
|
||||
"changed": {"ok": True, "done": False},
|
||||
"afterChange": {"active": True, "index": 1, "total": 3, "current": "o/r#2", "pending": False},
|
||||
"kept": {"ok": True, "done": False},
|
||||
"calls": [["o/r#1", "2026-08-13T23:59:59Z"], ["o/r#1", "2026-08-13T23:59:59Z"]],
|
||||
}
|
||||
|
||||
|
||||
def test_overdue_replan_rejects_non_future_choice_and_prevents_parallel_updates():
|
||||
script = f"""
|
||||
const createSweep = require({json.dumps(str(REPLAN))});
|
||||
let resolveUpdate;
|
||||
let calls = 0;
|
||||
const sweep = createSweep({{
|
||||
now: () => new Date('2026-08-12T12:00:00'),
|
||||
update: async () => {{ calls += 1; await new Promise(resolve => resolveUpdate = resolve); return {{}}; }},
|
||||
}});
|
||||
async function run() {{
|
||||
sweep.start([{{key:'o/r#1', due_date:'2026-08-10T18:00:00Z'}}]);
|
||||
const invalid = await sweep.choose('2026-08-12');
|
||||
const first = sweep.choose('2026-08-14');
|
||||
const duplicate = await sweep.choose('2026-08-15');
|
||||
resolveUpdate();
|
||||
const confirmed = await first;
|
||||
process.stdout.write(JSON.stringify({{invalid, duplicate, confirmed, calls, final:sweep.snapshot()}}));
|
||||
}}
|
||||
run();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"invalid": {"ok": False, "error": "Choose a future date."},
|
||||
"duplicate": {"ok": False, "error": "Deadline update already in progress."},
|
||||
"confirmed": {"ok": True, "done": True},
|
||||
"calls": 1,
|
||||
"final": {"active": False, "index": 1, "total": 1, "current": None, "pending": False},
|
||||
}
|
||||
|
||||
|
||||
def test_overdue_replan_orders_by_calendar_day_without_timezone_drift():
|
||||
script = f"""
|
||||
const createSweep = require({json.dumps(str(REPLAN))});
|
||||
const sweep = createSweep({{update:async()=>({{}})}});
|
||||
const started = sweep.start([
|
||||
{{key:'o/r#2',repository:'o/r',number:2,due_date:'2026-08-13T01:00:00Z'}},
|
||||
{{key:'o/r#1',repository:'o/r',number:1,due_date:'2026-08-12T23:59:59Z'}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(started));
|
||||
"""
|
||||
environment = {**__import__('os').environ, "TZ": "Asia/Tokyo"}
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True, env=environment
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout)["current"] == "o/r#1"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_agenda_exposes_thumb_safe_replan_controls_and_wires_existing_mutation():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="start-agenda-replan"' in html
|
||||
assert 'id="agenda-replan-controls"' in html
|
||||
assert 'id="agenda-replan-progress"' in html
|
||||
assert 'id="agenda-replan-keep"' in html
|
||||
assert 'id="agenda-replan-tomorrow"' in html
|
||||
assert 'id="agenda-replan-date"' in html
|
||||
assert 'id="agenda-replan-choose"' in html
|
||||
assert 'id="agenda-replan-cancel"' in html
|
||||
assert "agendaReplan.start(overdue)" in html
|
||||
assert "issueController.updateDueDate(item, dueDate)" in html
|
||||
assert ".agenda-replan-actions button { min-height:44px;" in html
|
||||
assert "@media(max-width:360px)" in html
|
||||
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v97" in worker
|
||||
assert "stackchain-dashboard-shell-v101" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -15,16 +15,18 @@ class FakeTarget {
|
||||
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
|
||||
focus() { state.focused = this; }
|
||||
}
|
||||
const state = {installCalls:0, offlineCalls:0, pushCalls:0, focused:null};
|
||||
const state = {installCalls:0, offlineCalls:0, pushCalls:0, deadlineCalls:0, focused:null};
|
||||
const launcher = new FakeTarget();
|
||||
const closeButton = new FakeTarget();
|
||||
const sheet = new FakeTarget(); sheet.hidden = true;
|
||||
const installButton = new FakeTarget();
|
||||
const offlineButton = new FakeTarget();
|
||||
const pushButton = new FakeTarget();
|
||||
const deadlineButton = new FakeTarget();
|
||||
const installStatus = new FakeTarget();
|
||||
const offlineStatus = new FakeTarget();
|
||||
const pushStatus = new FakeTarget();
|
||||
const deadlineStatus = new FakeTarget();
|
||||
const readyStatus = new FakeTarget();
|
||||
const escapeTarget = new FakeTarget();
|
||||
const promptCard = new FakeTarget(); promptCard.hidden = true;
|
||||
@@ -41,16 +43,18 @@ let readiness = {
|
||||
install:{state:'complete', detail:'Stackchain is installed.'},
|
||||
offline:{state:'incomplete', detail:'Offline work is off.'},
|
||||
push:{state:'unavailable', detail:'Notifications are unavailable.'},
|
||||
deadline:{state:'unavailable', detail:'Deadline reminders are unavailable.'},
|
||||
};
|
||||
const setup = createMobileDeviceSetup({
|
||||
launcher, closeButton, sheet, installButton, offlineButton, pushButton,
|
||||
installStatus, offlineStatus, pushStatus, readyStatus, escapeTarget,
|
||||
launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton,
|
||||
installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
|
||||
promptCard, promptSummary, promptLauncher, promptDismiss,
|
||||
promptStorage, now:() => now,
|
||||
getReadiness:() => readiness,
|
||||
install:async () => { state.installCalls += 1; },
|
||||
enableOffline:async () => { state.offlineCalls += 1; },
|
||||
enablePush:async () => { state.pushCalls += 1; },
|
||||
enableDeadline:async () => { state.deadlineCalls += 1; },
|
||||
});
|
||||
(async () => { await setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
|
||||
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
|
||||
@@ -166,6 +170,29 @@ process.stdout.write(JSON.stringify({
|
||||
assert result == {"hidden": True, "launcherFocused": True}
|
||||
|
||||
|
||||
def test_deadline_step_runs_one_setup_action_and_uses_confirmed_readiness():
|
||||
result = run_scenario("""
|
||||
readiness.push = {state:'complete', detail:'New update notifications are enabled.'};
|
||||
readiness.deadline = {state:'incomplete', detail:'Choose when to receive deadline reminders.'};
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
readiness.deadline = {state:'complete', detail:'Deadline reminders enabled for 08:00 local time.'};
|
||||
await deadlineButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
calls:state.deadlineCalls,
|
||||
detail:deadlineStatus.textContent,
|
||||
hidden:deadlineButton.hidden,
|
||||
summary:readyStatus.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 1,
|
||||
"detail": "Deadline reminders enabled for 08:00 local time.",
|
||||
"hidden": True,
|
||||
"summary": "3 of 4 available steps ready.",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
@@ -180,13 +207,14 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert html.index('id="device-readiness-card"') < html.index('id="my-work-list"')
|
||||
assert 'id="device-setup-sheet"' in html
|
||||
assert 'aria-labelledby="device-setup-heading"' in html
|
||||
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push"))
|
||||
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline"))
|
||||
assert 'id="device-setup-deadline-hour"' in html
|
||||
assert '<script src="static/mobile-device-setup.js"></script>' in html
|
||||
assert "createMobileDeviceSetup.mount({" in dashboard
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v97" in worker
|
||||
assert "stackchain-dashboard-shell-v101" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -8,16 +8,44 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MOBILE_LAUNCH = Path(__file__).resolve().parents[1] / "frontend" / "mobile-launch.js"
|
||||
APP_SHORTCUTS = Path(__file__).resolve().parents[1] / "frontend" / "mobile-app-shortcuts.js"
|
||||
|
||||
|
||||
def test_installed_app_shortcut_accepts_only_bounded_actions_and_runs_once():
|
||||
script = f"""
|
||||
const shortcuts = require({json.dumps(str(APP_SHORTCUTS))});
|
||||
const calls = [];
|
||||
const controller = shortcuts.createController({{
|
||||
search:'?launch=continue',
|
||||
continueWork:() => calls.push('continue'),
|
||||
newIssue:() => calls.push('new'),
|
||||
agenda:() => calls.push('agenda'),
|
||||
}});
|
||||
Promise.resolve(controller.run()).then(() => controller.run()).then(() => {{
|
||||
process.stdout.write(JSON.stringify({{
|
||||
action:controller.action(), calls,
|
||||
invalid:shortcuts.parse('?launch=delete-account'),
|
||||
shared:shortcuts.parse('?launch=new&title=Shared'),
|
||||
}}));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"action": "continue", "calls": ["continue"], "invalid": None, "shared": "new"
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_launch_selects_priority_queue_without_overriding_saved_choice():
|
||||
script = f"""
|
||||
const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
today: mobileLaunch.chooseFilter({{saved:null, today:2, attention:4}}),
|
||||
attention: mobileLaunch.chooseFilter({{saved:null, today:0, attention:4}}),
|
||||
all: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0}}),
|
||||
saved: mobileLaunch.chooseFilter({{saved:'review', today:2, attention:4}}),
|
||||
today: mobileLaunch.chooseFilter({{saved:null, today:2, attention:4, agenda:6}}),
|
||||
attention: mobileLaunch.chooseFilter({{saved:null, today:0, attention:4, agenda:6}}),
|
||||
agenda: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0, agenda:6}}),
|
||||
all: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0, agenda:0}}),
|
||||
saved: mobileLaunch.chooseFilter({{saved:'agenda', today:2, attention:4, agenda:6}}),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
@@ -26,8 +54,9 @@ process.stdout.write(JSON.stringify({{
|
||||
assert json.loads(result.stdout) == {
|
||||
"today": "today",
|
||||
"attention": "attention",
|
||||
"agenda": "agenda",
|
||||
"all": "all",
|
||||
"saved": "review",
|
||||
"saved": "agenda",
|
||||
}
|
||||
|
||||
|
||||
@@ -61,9 +90,15 @@ async def test_mobile_launch_progressively_discloses_secondary_controls():
|
||||
assert 'id="work-settings-toggle"' in html
|
||||
assert 'id="active-work-queue"' in html
|
||||
assert '<script src="static/mobile-launch.js"></script>' in html
|
||||
assert '<script src="static/mobile-app-shortcuts.js"></script>' in html
|
||||
service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "BASE + 'static/mobile-launch.js'" in service_worker
|
||||
assert "BASE + 'static/mobile-app-shortcuts.js'" in service_worker
|
||||
bootstrap = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
assert "mobileAppShortcuts.createController" in bootstrap
|
||||
assert bootstrap.index("await load();\n await appShortcut.run();") < bootstrap.index("contextPoller.start();")
|
||||
assert "mobileLaunch.chooseFilter" in html
|
||||
assert "agenda: counts.agenda" in html
|
||||
assert "mobileLaunch.createDisclosure" in html
|
||||
assert "@media (max-width: 600px)" in html
|
||||
assert "header { min-height:56px; max-height:64px;" in html
|
||||
|
||||
+226
-18
@@ -13,7 +13,7 @@ QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queu
|
||||
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
|
||||
|
||||
|
||||
def test_mobile_work_entry_prioritizes_continue_resume_start_plan_then_fallback():
|
||||
def test_mobile_work_entry_prioritizes_continue_resume_start_plan_then_find():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const state = {{active:true, resumable:true, today:2, eligible:3}};
|
||||
@@ -27,7 +27,7 @@ const entry = createEntry({{
|
||||
resumeToday: () => calls.push('resume'),
|
||||
startToday: () => calls.push('start'),
|
||||
planToday: () => calls.push('plan'),
|
||||
openFallback: () => calls.push('fallback'),
|
||||
findWork: () => calls.push('find'),
|
||||
}});
|
||||
const modes = [];
|
||||
modes.push(entry.open());
|
||||
@@ -43,8 +43,45 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"modes": ["continue", "resume", "start", "plan", "work"],
|
||||
"calls": ["continue", "resume", "start", "plan", "fallback"],
|
||||
"modes": ["continue", "resume", "start", "plan", "find"],
|
||||
"calls": ["continue", "resume", "start", "plan", "find"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_entry_preserves_active_today_then_launches_highest_priority_queue():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const calls = [];
|
||||
const state = {{active:true, today:2, resumable:true, eligible:3, next:{{name:'attention'}}}};
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => state.active,
|
||||
isTodayResumable: () => state.resumable,
|
||||
getTodayCount: () => state.today,
|
||||
getEligibleCount: () => state.eligible,
|
||||
queueLauncher: {{recommend: () => state.next, open: name => calls.push('queue:' + name)}},
|
||||
continueToday: () => calls.push('continue'),
|
||||
resumeToday: () => calls.push('resume'),
|
||||
startToday: () => calls.push('start'),
|
||||
planToday: () => calls.push('plan'),
|
||||
|
||||
findWork: () => calls.push('find'),
|
||||
}});
|
||||
const modes = [entry.open()];
|
||||
state.active = false; modes.push(entry.open());
|
||||
state.next = {{name:'today'}}; modes.push(entry.open());
|
||||
state.today = 0; state.eligible = 0; state.next = {{name:'update'}}; modes.push(entry.open());
|
||||
state.next = {{name:'agenda'}}; modes.push(entry.open());
|
||||
state.next = {{name:'later'}}; modes.push(entry.open());
|
||||
state.next = {{name:'draft'}}; modes.push(entry.open());
|
||||
state.next = {{name:'find'}}; modes.push(entry.open());
|
||||
process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"modes": ["continue", "attention", "resume", "update", "agenda", "later", "draft", "find"],
|
||||
"calls": ["continue", "queue:attention", "resume", "queue:update", "queue:agenda", "queue:later", "queue:draft", "find"],
|
||||
}
|
||||
|
||||
|
||||
@@ -165,10 +202,10 @@ dock.updateWork('start', 0);
|
||||
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('plan', 0);
|
||||
const planning = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('work', 0);
|
||||
dock.updateWork('find', 0);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
continuing, resuming, starting, planning,
|
||||
fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
|
||||
finding:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
@@ -184,7 +221,7 @@ process.stdout.write(JSON.stringify({{
|
||||
"resuming": {"text": "Resume", "label": "Resume Today"},
|
||||
"starting": {"text": "Start", "label": "Start Today"},
|
||||
"planning": {"text": "Plan", "label": "Plan Today"},
|
||||
"fallback": {"text": "Work", "label": "Work"},
|
||||
"finding": {"text": "Find", "label": "Find work"},
|
||||
}
|
||||
|
||||
|
||||
@@ -293,18 +330,19 @@ sheet.showModal = function () {{ this.open = true; }};
|
||||
sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
|
||||
const close = new FakeElement();
|
||||
const badge = new FakeElement();
|
||||
const rows = Object.fromEntries(['today','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()]));
|
||||
const deadline = new FakeElement();
|
||||
const rows = Object.fromEntries(['today','agenda','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()]));
|
||||
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
|
||||
const selected = [];
|
||||
const utilities = [];
|
||||
const dock = createDock({{
|
||||
nav, buttons:{{queues}}, actions:{{queues:()=>{{}}}}, overlays:[sheet],
|
||||
queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge,
|
||||
queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge, deadlineBadge:deadline,
|
||||
onSelectQueue:(name,row)=>name === 'recaps' ? utilities.push(row === rows.recaps ? 'recaps:trigger' : 'recaps:missing-trigger') : selected.push(name),
|
||||
observe() {{}},
|
||||
}});
|
||||
dock.start();
|
||||
dock.updateQueues({{today:2, attention:1, update:5, later:3, draft:4}});
|
||||
dock.updateQueues({{today:2, agenda:5, attention:1, update:5, later:3, draft:4}});
|
||||
queues.click();
|
||||
const opened = sheet.open;
|
||||
rows.update.click();
|
||||
@@ -312,9 +350,26 @@ const closedAfterSelect = !sheet.open;
|
||||
queues.click(); rows.recaps.click();
|
||||
const closedAfterUtility = !sheet.open;
|
||||
queues.click(); close.click();
|
||||
const populated = {{
|
||||
badge:badge.textContent, badgeHidden:badge.hidden,
|
||||
deadline:deadline.textContent, deadlineHidden:deadline.hidden,
|
||||
agendaDue:rows.agenda.attributes['data-deadlines'] || null,
|
||||
badgeLabel:queues.attributes['aria-label'],
|
||||
}};
|
||||
dock.updateQueues({{today:0, agenda:0, attention:0, update:7, later:0, draft:0}});
|
||||
const updatesOnly = {{
|
||||
badge:badge.textContent, badgeHidden:badge.hidden,
|
||||
deadlineHidden:deadline.hidden,
|
||||
badgeLabel:queues.attributes['aria-label'],
|
||||
}};
|
||||
dock.updateQueues({{today:0, agenda:0, attention:0, update:0, later:0, draft:0}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
opened, closedAfterSelect, closedAfterUtility, selected, utilities,
|
||||
badge:badge.textContent, badgeLabel:queues.attributes['aria-label'],
|
||||
badge:badge.textContent, populated, updatesOnly,
|
||||
clearedBadgeHidden:badge.hidden,
|
||||
clearedDeadlineHidden:deadline.hidden,
|
||||
clearedAgendaDue:rows.agenda.attributes['data-deadlines'] || null,
|
||||
clearedBadgeLabel:queues.attributes['aria-label'],
|
||||
counts:Object.fromEntries(Object.entries(counts).map(([name, node]) => [name, node.textContent])),
|
||||
updateLabel:rows.update.attributes['aria-label'],
|
||||
queueFocuses:queues.focuses,
|
||||
@@ -330,11 +385,27 @@ process.stdout.write(JSON.stringify({{
|
||||
"closedAfterUtility": True,
|
||||
"selected": ["update"],
|
||||
"utilities": ["recaps:trigger"],
|
||||
# Updates are a drill-down within Attention and must not inflate the aggregate badge.
|
||||
"badge": "10",
|
||||
"badgeLabel": "Queues, 10 items",
|
||||
"counts": {"today": "2", "attention": "1", "update": "5", "later": "3", "draft": "4", "recaps": "0"},
|
||||
"updateLabel": "Updates, 5 unread conversations",
|
||||
"badge": "0 active",
|
||||
"populated": {
|
||||
"badge": "5 active",
|
||||
"badgeHidden": False,
|
||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Later 3, Drafts 4; 5 active queues",
|
||||
"deadline": "5 due",
|
||||
"deadlineHidden": False,
|
||||
"agendaDue": "true",
|
||||
},
|
||||
"updatesOnly": {
|
||||
"badge": "1 active",
|
||||
"badgeHidden": False,
|
||||
"deadlineHidden": True,
|
||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Later 0, Drafts 0; 1 active queue",
|
||||
},
|
||||
"clearedBadgeHidden": True,
|
||||
"clearedDeadlineHidden": True,
|
||||
"clearedAgendaDue": None,
|
||||
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
|
||||
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "later": "0", "draft": "0", "recaps": "0"},
|
||||
"updateLabel": "Updates, 0 unread conversations",
|
||||
"queueFocuses": 3,
|
||||
"columnState": None,
|
||||
}
|
||||
@@ -368,6 +439,106 @@ process.stdout.write(JSON.stringify({{opened, empty, calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let counts = {{attention: 2, today: 1, agenda: 5, later: 4, draft: 3}};
|
||||
const launcher = createLauncher({{
|
||||
openToday: () => calls.push('today'),
|
||||
openAgenda: () => {{ calls.push('agenda'); return 'opened'; }},
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: name => (counts[name] || 0) ? {{click() {{ calls.push('open:' + name); }}}} : null,
|
||||
announce: message => calls.push('announce:' + message),
|
||||
getCounts: () => counts,
|
||||
openFindWork: () => calls.push('find'),
|
||||
}});
|
||||
const first = launcher.recommend();
|
||||
counts = {{attention: 0, today: 0, agenda: 5, later: 4, draft: 3}};
|
||||
const agenda = launcher.recommend();
|
||||
const opened = launcher.continueWork();
|
||||
counts = {{attention: 0, today: 0, agenda: 0, later: 0, draft: 0}};
|
||||
const fallback = launcher.recommend();
|
||||
launcher.continueWork();
|
||||
process.stdout.write(JSON.stringify({{first, agenda, opened, fallback, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"first": {"name": "attention", "count": 2, "label": "Start Attention (2)"},
|
||||
"agenda": {"name": "agenda", "count": 5, "label": "Open Agenda (5)"},
|
||||
"opened": "opened",
|
||||
"fallback": {"name": "find", "count": 0, "label": "Find Work"},
|
||||
"calls": ["agenda", "find"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_prioritizes_updates_before_agenda_and_resumes_launcher():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let counts = {{attention:0, today:0, update:6, agenda:2, later:1, draft:1}};
|
||||
const launcher = createLauncher({{
|
||||
getCounts: () => counts,
|
||||
openUpdates: () => {{ calls.push('updates'); return 'resumed'; }},
|
||||
openAgenda: () => calls.push('agenda'),
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: () => null,
|
||||
announce: () => {{}},
|
||||
openFindWork: () => calls.push('find'),
|
||||
}});
|
||||
const recommendation = launcher.recommend();
|
||||
const opened = launcher.continueWork();
|
||||
process.stdout.write(JSON.stringify({{recommendation, opened, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"recommendation": {"name": "update", "count": 6, "label": "Resume Updates (6)"},
|
||||
"opened": "resumed",
|
||||
"calls": ["updates"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_labels_each_recommended_work_destination_accessibly():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const work = {{attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }}}};
|
||||
const workLabel = {{textContent:''}};
|
||||
const dock = createDock({{nav:{{}}, buttons:{{work}}, workLabel}});
|
||||
const labels = {{}};
|
||||
for (const mode of ['attention','update','agenda','later','draft']) {{
|
||||
dock.updateWork(mode);
|
||||
labels[mode] = [workLabel.textContent, work.attributes['aria-label']];
|
||||
}}
|
||||
process.stdout.write(JSON.stringify(labels));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"attention": ["Attention", "Open Attention"],
|
||||
"update": ["Updates", "Resume Updates"],
|
||||
"agenda": ["Agenda", "Open Agenda"],
|
||||
"later": ["Later", "Open Later"],
|
||||
"draft": ["Drafts", "Open Drafts"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_accessible_mobile_queue_completion_handoff():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mobile-queue-heading"' in html
|
||||
assert 'data-mobile-queue="find"' in html
|
||||
assert "mobileQueueLauncher.recommend()" in html
|
||||
assert "showMobileQueueCompletion('Updates')" in html
|
||||
assert "row.setAttribute('data-recommended', 'true')" in html
|
||||
assert "row.focus()" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
html = await dashboard()
|
||||
@@ -376,6 +547,12 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
assert 'id="mobile-queue-sheet"' in html
|
||||
assert 'aria-labelledby="mobile-queue-heading"' in html
|
||||
assert 'data-mobile-queue="today"' in html
|
||||
assert 'data-mobile-queue="agenda"' in html
|
||||
assert '<strong>Agenda</strong><small>Upcoming deadlines</small>' in html
|
||||
assert 'data-work-filter="agenda"' in html
|
||||
assert "const agendaItems = agendaMyWork(activeMyWork);" in html
|
||||
assert "counts.agenda = agendaItems.length;" in html
|
||||
assert 'data-agenda-group' in html
|
||||
assert 'data-mobile-queue="attention"' in html
|
||||
assert 'data-mobile-queue="update"' in html
|
||||
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
|
||||
@@ -384,14 +561,17 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
assert 'data-mobile-queue="recaps"' in html
|
||||
assert '<strong>Recaps</strong><small>History</small>' in html
|
||||
assert 'id="mobile-queue-count"' in html
|
||||
assert 'id="mobile-deadline-count"' in html
|
||||
assert 'mobileTaskDock.updateQueues(counts)' in html
|
||||
assert '<script src="static/mobile-queue-launcher.js"></script>' in html
|
||||
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
|
||||
assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html
|
||||
assert "onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert '.mobile-queue-sheet' in html
|
||||
assert '.my-work-actions { display:none;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-task-deadline' in html
|
||||
assert '[data-deadlines="true"]' in html
|
||||
assert '.mobile-task-dock[data-attention="true"]' not in html
|
||||
|
||||
|
||||
@@ -417,6 +597,17 @@ async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
|
||||
assert "runTodayTransition('continue')" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_work_dock_and_app_shortcut_to_live_queue_recommendation():
|
||||
html = await dashboard()
|
||||
|
||||
assert "queueLauncher: mobileQueueLauncher" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "continueWork: () => mobileWorkEntry.open()" in html
|
||||
assert "mobileQueueCounts = counts;" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
html = await dashboard()
|
||||
@@ -434,7 +625,7 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
@@ -445,6 +636,23 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_reader_keeps_primary_decisions_within_thumb_reach():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="update-decision-bar" aria-label="Update decisions"' in html
|
||||
assert 'id="keep-update-unread" type="button" aria-label="Keep unread and open next update"' in html
|
||||
assert 'id="mark-update-read-next" type="button" aria-label="Mark read and open next update"' in html
|
||||
assert 'id="focus-update-reply" type="button">Reply</button>' in html
|
||||
assert 'class="update-more-actions"' in html
|
||||
assert "qs('#focus-update-reply').addEventListener('click'" in html
|
||||
assert "qs('#update-reply').focus({ preventScroll:true })" in html
|
||||
assert '.update-decision-bar { position:fixed;' in html
|
||||
assert 'padding-bottom:calc(8px + env(safe-area-inset-bottom))' in html
|
||||
assert '.update-decision-bar button, .update-decision-bar summary { min-height:44px;' in html
|
||||
assert 'padding-bottom:calc(82px + env(safe-area-inset-bottom))' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_phone_safe_today_session_hud_above_task_dock():
|
||||
html = await dashboard()
|
||||
|
||||
+778
-17
@@ -8,6 +8,22 @@ import pytest
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_empty_all_work_queue_offers_find_and_create_actions():
|
||||
markup = (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
|
||||
source = await dashboard()
|
||||
|
||||
assert 'id="empty-work-start"' in markup
|
||||
assert 'Ready for something new?' in markup
|
||||
assert 'id="empty-work-find"' in markup
|
||||
assert 'id="empty-work-create"' in markup
|
||||
assert "qs('#empty-work-find').addEventListener('click'" in source
|
||||
assert "qs('#find-work').click()" in source
|
||||
assert "qs('#empty-work-create').addEventListener('click'" in source
|
||||
assert "qs('#new-issue').click()" in source
|
||||
assert "emptyWorkStart.hidden = !showEmptyStart" in source
|
||||
|
||||
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
NOTIFICATION_UNDO = Path(__file__).parents[1] / "frontend" / "notification-undo.js"
|
||||
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
||||
@@ -24,11 +40,360 @@ COMMENT_ACTIONS = Path(__file__).parents[1] / "frontend" / "comment-actions.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
|
||||
AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-session-launcher.js"
|
||||
UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js"
|
||||
|
||||
|
||||
def test_updates_rank_actionable_work_before_newer_routine_activity():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
"issues": [
|
||||
{"number": 1, "title": "Critical incident", "repository": "stackchain/api",
|
||||
"labels": ["P0"], "assignees": [], "updated_at": "2026-08-01T10:00:00Z"},
|
||||
{"number": 2, "title": "Overdue assignment", "repository": "stackchain/api",
|
||||
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z",
|
||||
"updated_at": "2026-08-05T10:00:00Z"},
|
||||
{"number": 3, "title": "Assigned follow-up", "repository": "stackchain/api",
|
||||
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z"},
|
||||
],
|
||||
"pull_requests": [
|
||||
{"number": 4, "title": "Requested review", "repository": "stackchain/web",
|
||||
"labels": [], "assignees": [], "work_reasons": ["review_requested"],
|
||||
"updated_at": "2026-08-02T10:00:00Z"},
|
||||
],
|
||||
"notifications": [
|
||||
{"id": 11, "number": 1, "repository": "stackchain/api", "unread": True,
|
||||
"subject_type": "Issue", "url": "https://forge.example/api/issues/1"},
|
||||
{"id": 12, "number": 2, "repository": "stackchain/api", "unread": True,
|
||||
"subject_type": "Issue", "url": "https://forge.example/api/issues/2"},
|
||||
{"id": 13, "number": 3, "repository": "stackchain/api", "unread": True,
|
||||
"subject_type": "Issue", "url": "https://forge.example/api/issues/3"},
|
||||
{"id": 14, "number": 4, "repository": "stackchain/web", "unread": True,
|
||||
"subject_type": "PullRequest", "url": "https://forge.example/web/pulls/4"},
|
||||
{"id": 15, "number": 5, "repository": "stackchain/web", "unread": True,
|
||||
"subject_type": "Issue", "title": "Newest routine update",
|
||||
"updated_at": "2026-08-07T11:00:00Z", "url": "https://forge.example/web/issues/5"},
|
||||
],
|
||||
}
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const updates = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'))
|
||||
.filter(item => item.has_update)
|
||||
.map(item => ({{title:item.title, update_reason:item.update_reason}}));
|
||||
process.stdout.write(JSON.stringify(updates));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [
|
||||
{"title": "Critical incident", "update_reason": "Critical"},
|
||||
{"title": "Requested review", "update_reason": "Review requested"},
|
||||
{"title": "Overdue assignment", "update_reason": "Overdue"},
|
||||
{"title": "Assigned follow-up", "update_reason": "Assigned to you"},
|
||||
{"title": "Newest routine update", "update_reason": ""},
|
||||
]
|
||||
|
||||
|
||||
def test_update_triage_launch_waits_for_complete_single_flight_discovery():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
|
||||
let release;
|
||||
let discoveries = 0;
|
||||
let opens = 0;
|
||||
const messages = [];
|
||||
const launcher = createLauncher({{
|
||||
selectUpdates: () => {{}},
|
||||
discover: () => {{ discoveries += 1; return new Promise(resolve => {{ release = resolve; }}); }},
|
||||
isUpdatesSelected: () => true,
|
||||
hasMore: () => false,
|
||||
hasCheckpoint: () => false,
|
||||
resume: () => {{ throw new Error('must start'); }},
|
||||
start: () => {{ opens += 1; return true; }},
|
||||
announce: message => messages.push(message),
|
||||
}});
|
||||
const first = launcher.open();
|
||||
const second = launcher.open();
|
||||
Promise.resolve().then(() => {{
|
||||
const before = {{discoveries, opens, same:first === second}};
|
||||
release(true);
|
||||
return Promise.all([first, second]).then(results =>
|
||||
process.stdout.write(JSON.stringify({{before, discoveries, opens, results, messages}}))
|
||||
);
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": {"discoveries": 1, "opens": 0, "same": True},
|
||||
"discoveries": 1,
|
||||
"opens": 1,
|
||||
"results": ["opened", "opened"],
|
||||
"messages": ["Checking all unread updates…"],
|
||||
}
|
||||
|
||||
|
||||
def test_update_triage_launch_preserves_checkpoint_when_discovery_fails():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
|
||||
const messages = [];
|
||||
let resumed = 0;
|
||||
const launcher = createLauncher({{
|
||||
selectUpdates: () => {{}},
|
||||
discover: async () => false,
|
||||
isUpdatesSelected: () => true,
|
||||
hasMore: () => true,
|
||||
hasCheckpoint: () => true,
|
||||
resume: () => {{ resumed += 1; return true; }},
|
||||
start: () => true,
|
||||
announce: message => messages.push(message),
|
||||
}});
|
||||
launcher.open().then(result => process.stdout.write(JSON.stringify({{result, resumed, messages}})));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": "incomplete",
|
||||
"resumed": 0,
|
||||
"messages": [
|
||||
"Checking all unread updates…",
|
||||
"Updates check paused. Retry to check older unread updates.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_update_triage_launch_applies_atomic_snapshot_before_starting():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
|
||||
const events = [];
|
||||
const snapshot = {{items:[{{id:1}},{{id:51}}],total:2,complete:true}};
|
||||
const launcher = createLauncher({{
|
||||
selectUpdates: () => events.push('selected'),
|
||||
discover: async () => snapshot,
|
||||
applySnapshot: value => events.push(['applied', value.items.map(item => item.id)]),
|
||||
isUpdatesSelected: () => true,
|
||||
hasMore: () => false,
|
||||
hasCheckpoint: () => false,
|
||||
resume: () => {{ throw new Error('must start'); }},
|
||||
start: () => {{ events.push('started'); return true; }},
|
||||
announce: () => {{}},
|
||||
}});
|
||||
launcher.open().then(result => process.stdout.write(JSON.stringify({{result, events}})));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": "opened",
|
||||
"events": ["selected", ["applied", [1, 51]], "started"],
|
||||
}
|
||||
|
||||
|
||||
def test_notification_pager_load_all_retries_from_failed_page_without_duplicates():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let items = [{{id:1}}];
|
||||
let fail = true;
|
||||
const requested = [];
|
||||
const pager = buildMyWork.createNotificationPager({{
|
||||
load: async page => {{
|
||||
requested.push(page);
|
||||
if (page === 2 && fail) {{ fail = false; throw new Error('offline'); }}
|
||||
return page === 2 ? {{page:2,total:101,has_more:true,items:[{{id:1}},{{id:51}}]}} :
|
||||
{{page:3,total:101,has_more:false,items:[{{id:101}}]}};
|
||||
}},
|
||||
onNotifications: value => {{ items = value; }},
|
||||
onPagination: () => {{}},
|
||||
onStatus: () => {{}},
|
||||
}});
|
||||
pager.reset({{page:1,total:101,has_more:true}});
|
||||
pager.loadAll(() => items).then(firstResult => pager.loadAll(() => items).then(secondResult =>
|
||||
process.stdout.write(JSON.stringify({{firstResult, secondResult, requested, ids:items.map(item => item.id)}}))
|
||||
));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"firstResult": False,
|
||||
"secondResult": True,
|
||||
"requested": [2, 2, 3],
|
||||
"ids": [1, 51, 101],
|
||||
}
|
||||
|
||||
|
||||
def test_agenda_session_launch_waits_for_complete_single_flight_discovery():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
|
||||
let finishDiscovery;
|
||||
let discoveries = 0;
|
||||
let opens = 0;
|
||||
let queue = 'agenda';
|
||||
const launcher = createLauncher({{
|
||||
selectAgenda: () => {{ queue = 'agenda'; }},
|
||||
discover: () => {{
|
||||
discoveries += 1;
|
||||
return new Promise(resolve => {{ finishDiscovery = resolve; }});
|
||||
}},
|
||||
isAgendaSelected: () => queue === 'agenda',
|
||||
hasMore: () => false,
|
||||
hasCheckpoint: () => true,
|
||||
resume: () => {{ opens += 1; return true; }},
|
||||
start: () => {{ throw new Error('must resume'); }},
|
||||
announce: () => {{}},
|
||||
}});
|
||||
const first = launcher.open();
|
||||
const second = launcher.open();
|
||||
Promise.resolve().then(() => {{
|
||||
const before = {{discoveries, opens, same:first === second}};
|
||||
finishDiscovery(true);
|
||||
return Promise.all([first, second]).then(results => {{
|
||||
process.stdout.write(JSON.stringify({{before, results, discoveries, opens}}));
|
||||
}});
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": {"discoveries": 1, "opens": 0, "same": True},
|
||||
"results": ["opened", "opened"],
|
||||
"discoveries": 1,
|
||||
"opens": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_agenda_session_launch_preserves_progress_when_discovery_fails_or_queue_changes():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
|
||||
async function scenario(mode) {{
|
||||
let queue = 'agenda';
|
||||
let opens = 0;
|
||||
const messages = [];
|
||||
const launcher = createLauncher({{
|
||||
selectAgenda: () => {{ queue = 'agenda'; }},
|
||||
discover: async () => {{
|
||||
if (mode === 'leave') queue = 'today';
|
||||
if (mode === 'throw') throw new Error('offline');
|
||||
return mode !== 'incomplete';
|
||||
}},
|
||||
isAgendaSelected: () => queue === 'agenda',
|
||||
hasMore: () => mode === 'incomplete',
|
||||
hasCheckpoint: () => true,
|
||||
resume: () => {{ opens += 1; return true; }},
|
||||
start: () => {{ opens += 1; return true; }},
|
||||
announce: message => messages.push(message),
|
||||
}});
|
||||
return {{result:await launcher.open(), opens, messages}};
|
||||
}}
|
||||
Promise.all(['incomplete', 'throw', 'leave'].map(scenario)).then(results =>
|
||||
process.stdout.write(JSON.stringify(results))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [
|
||||
{"result": "incomplete", "opens": 0, "messages": [
|
||||
"Agenda check paused. Retry to check older assigned deadlines."
|
||||
]},
|
||||
{"result": "incomplete", "opens": 0, "messages": [
|
||||
"Agenda check paused. Retry to check older assigned deadlines."
|
||||
]},
|
||||
{"result": "cancelled", "opens": 0, "messages": []},
|
||||
]
|
||||
|
||||
|
||||
def test_protect_today_route_is_an_explicit_agenda_action():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
process.stdout.write(JSON.stringify(routes.parse('#/my-work/agenda/protect-today')));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"kind": "queue", "filter": "agenda", "action": "protect-today"
|
||||
}
|
||||
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
|
||||
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
||||
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"
|
||||
|
||||
|
||||
def test_work_selection_selects_matching_items_in_order_with_an_explicit_cap():
|
||||
script = f"""
|
||||
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
|
||||
const selection = createWorkSelection({{limit:3}});
|
||||
selection.start();
|
||||
selection.select({{kind:'issue', repository:'stackchain/api', number:1}});
|
||||
const result = selection.selectMany([
|
||||
{{kind:'issue', repository:'stackchain/api', number:1}},
|
||||
{{kind:'pull', repository:'stackchain/web', number:2}},
|
||||
{{kind:'issue', repository:'stackchain/app', number:3}},
|
||||
{{kind:'issue', repository:'stackchain/overflow', number:4}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({{result, snapshot:selection.snapshot()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output == {
|
||||
"result": {"status": "limit", "added": 2, "count": 3, "limit": 3},
|
||||
"snapshot": {
|
||||
"active": True,
|
||||
"count": 3,
|
||||
"ids": [
|
||||
"issue:stackchain/api:1:",
|
||||
"pull:stackchain/web:2:",
|
||||
"issue:stackchain/app:3:",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_work_selection_clears_matches_without_leaving_selection_mode():
|
||||
script = f"""
|
||||
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
|
||||
const selection = createWorkSelection();
|
||||
selection.start();
|
||||
selection.selectMany([
|
||||
{{kind:'issue', repository:'stackchain/api', number:1}},
|
||||
{{kind:'pull', repository:'stackchain/web', number:2}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(selection.clear()));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"active": True, "count": 0, "ids": []}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_batch_planning_can_select_and_clear_active_queue_matches():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="select-matching-work"' in html
|
||||
assert 'id="clear-work-selection"' in html
|
||||
assert "workSelection.selectMany(visible)" in html
|
||||
assert "workSelection.clear()" in html
|
||||
assert "matches selected" in html
|
||||
assert ".selection-scope-actions button { min-height:44px;" in html
|
||||
assert "max-width:100%" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_sheet_formats_due_date_as_a_calendar_day_without_local_timestamp_conversion():
|
||||
source = await dashboard()
|
||||
|
||||
assert "formatCalendarDueDate(detail.due_date)" in source
|
||||
assert "new Date(detail.due_date).toLocaleDateString()" not in source
|
||||
|
||||
|
||||
def test_queue_finder_matches_repository_number_and_title_without_reordering():
|
||||
script = f"""
|
||||
const work = require({json.dumps(str(MY_WORK))});
|
||||
@@ -54,6 +419,259 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_agenda_groups_assigned_deadlines_in_local_seven_day_horizon():
|
||||
script = f"""
|
||||
const work = require({json.dumps(str(MY_WORK))});
|
||||
const now = new Date('2026-08-12T12:00:00');
|
||||
const items = [
|
||||
{{kind:'issue', key:'o/r#5', repository:'o/r', number:5, is_assigned:true, due_date:'2026-08-11T18:00:00'}},
|
||||
{{kind:'issue', key:'o/r#3', repository:'o/r', number:3, is_assigned:true, due_date:'2026-08-12T17:00:00'}},
|
||||
{{kind:'issue', key:'a/r#9', repository:'a/r', number:9, is_assigned:true, due_date:'2026-08-13T09:00:00'}},
|
||||
{{kind:'issue', key:'a/r#2', repository:'a/r', number:2, is_assigned:true, due_date:'2026-08-18T09:00:00'}},
|
||||
{{kind:'issue', key:'a/r#1', repository:'a/r', number:1, is_assigned:true, due_date:'2026-08-20T09:00:00'}},
|
||||
{{kind:'issue', key:'a/r#7', repository:'a/r', number:7, is_assigned:false, due_date:'2026-08-13T08:00:00'}},
|
||||
{{kind:'pull', key:'a/r#8', repository:'a/r', number:8, is_assigned:true, due_date:'2026-08-13T08:00:00'}},
|
||||
{{kind:'issue', key:'a/r#6', repository:'a/r', number:6, is_assigned:true}},
|
||||
];
|
||||
process.stdout.write(JSON.stringify(work.agendaMyWork(items, now).map(item => [item.key, item.agenda_group])));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [
|
||||
["o/r#5", "Overdue"], ["o/r#3", "Today"],
|
||||
["a/r#9", "Tomorrow"], ["a/r#2", "Next 7 days"],
|
||||
]
|
||||
|
||||
|
||||
def test_mobile_agenda_sorts_equal_deadlines_by_repository_and_number():
|
||||
script = f"""
|
||||
const work = require({json.dumps(str(MY_WORK))});
|
||||
const items = [
|
||||
{{kind:'issue', key:'z/r#1', repository:'z/r', number:1, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
|
||||
{{kind:'issue', key:'a/r#10', repository:'a/r', number:10, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
|
||||
{{kind:'issue', key:'a/r#2', repository:'a/r', number:2, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
|
||||
];
|
||||
process.stdout.write(JSON.stringify(work.agendaMyWork(items, new Date('2026-08-12T12:00:00')).map(item => item.key)));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == ["a/r#2", "a/r#10", "z/r#1"]
|
||||
|
||||
|
||||
def test_mobile_agenda_runs_as_an_ordered_non_durable_work_session():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const values = new Map([['stackchain.today-session.v1', 'preserve-today']]);
|
||||
const checkpoint = {{
|
||||
read:() => null,
|
||||
save:() => {{ throw new Error('Agenda must not save Today'); }},
|
||||
clear:() => {{ throw new Error('Agenda must not clear Today'); }},
|
||||
}};
|
||||
const active = [
|
||||
{{kind:'issue',repository:'o/r',number:3,title:'Tomorrow',is_assigned:true,due_date:'2026-08-14'}},
|
||||
{{kind:'issue',repository:'o/r',number:1,title:'Overdue',is_assigned:true,due_date:'2026-08-12'}},
|
||||
{{kind:'issue',repository:'o/r',number:2,title:'Today',is_assigned:true,due_date:'2026-08-13'}},
|
||||
{{kind:'issue',repository:'o/r',number:4,title:'No deadline',is_assigned:true}},
|
||||
];
|
||||
const opened = [];
|
||||
let finished = 0;
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems:() => buildMyWork.agendaMyWork(active, new Date('2026-08-13T12:00:00')),
|
||||
getFilter:() => 'all', checkpoint, checkpointEnabled:() => false,
|
||||
onOpen:item => opened.push(item.title), onProgress:() => {{}},
|
||||
onFinish:() => {{ finished += 1; }},
|
||||
}});
|
||||
const started = session.start();
|
||||
session.next();
|
||||
session.next();
|
||||
const completed = session.next();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
started, completed, opened, finished, active:session.active(), today:values.get('stackchain.today-session.v1'),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"started": True,
|
||||
"completed": False,
|
||||
"opened": ["Overdue", "Today", "Tomorrow"],
|
||||
"finished": 1,
|
||||
"active": False,
|
||||
"today": "preserve-today",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_agenda_checkpoint_is_account_bound_resumable_and_separate_from_today():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const values = new Map([['stackchain.today-session.v1', 'preserve-today']]);
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key, value) => values.set(key, value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let login = 'timmy';
|
||||
const agendaCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage, getLogin:() => login, key:'stackchain.agenda-session.v1',
|
||||
}});
|
||||
const todayCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage, getLogin:() => login, key:'stackchain.today-session.v1',
|
||||
}});
|
||||
let mode = 'agenda';
|
||||
let items = [
|
||||
{{kind:'issue',repository:'o/r',number:1,title:'Overdue'}},
|
||||
{{kind:'issue',repository:'o/r',number:2,title:'Today'}},
|
||||
{{kind:'issue',repository:'o/r',number:3,title:'Tomorrow'}},
|
||||
];
|
||||
const opened = [];
|
||||
const makeSession = () => buildMyWork.createWorkSession({{
|
||||
getItems:() => items, getFilter:() => 'all',
|
||||
checkpoint:() => mode === 'agenda' ? agendaCheckpoint : todayCheckpoint,
|
||||
checkpointEnabled:() => true, checkpointedEnabled:() => mode === 'today',
|
||||
onOpen:item => opened.push(item.title), onProgress:() => {{}}, onFinish:() => {{}},
|
||||
}});
|
||||
const first = makeSession();
|
||||
first.start();
|
||||
first.next();
|
||||
const saved = JSON.parse(values.get('stackchain.agenda-session.v1'));
|
||||
const todayWhileRunning = first.checkpointed();
|
||||
login = 'alexander';
|
||||
const hiddenFromOtherAccount = makeSession().resumable();
|
||||
login = 'timmy';
|
||||
items = items.filter(item => item.number !== 2);
|
||||
const resumedSession = makeSession();
|
||||
const resumed = resumedSession.resume();
|
||||
resumedSession.end();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
saved, todayWhileRunning, hiddenFromOtherAccount, resumed, opened,
|
||||
agendaCleared:!values.has('stackchain.agenda-session.v1'),
|
||||
today:values.get('stackchain.today-session.v1'),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
state = json.loads(result.stdout)
|
||||
assert state["saved"] == {
|
||||
"version": 1, "login": "timmy", "identity": "issue:o/r:2:", "index": 1,
|
||||
}
|
||||
assert state["todayWhileRunning"] is False
|
||||
assert state["hiddenFromOtherAccount"] is False
|
||||
assert state["resumed"] is True
|
||||
assert state["opened"] == ["Overdue", "Today", "Tomorrow"]
|
||||
assert state["agendaCleared"] is True
|
||||
assert state["today"] == "preserve-today"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_agenda_launcher_starts_or_resumes_the_durable_agenda_session():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/agenda-session-launcher.js"></script>' in html
|
||||
assert "openAgenda: openAgendaSession" in html
|
||||
assert "const agendaSessionLauncher = createAgendaSessionLauncher({" in html
|
||||
assert "discover: completeAgendaIssues" in html
|
||||
assert "hasMore: () => Boolean(workPagination.issue?.has_more)" in html
|
||||
assert "key: 'stackchain.agenda-session.v1'" in html
|
||||
assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
|
||||
assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
|
||||
assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
|
||||
assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
|
||||
assert "window.location.hash = '#/my-work/agenda'" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_agenda_launcher_starts_the_rendered_queue_and_finishes_in_agenda():
|
||||
html = await dashboard()
|
||||
|
||||
assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork)" in html
|
||||
assert "selectedWorkFilter === 'agenda' ? 'all'" in html
|
||||
assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
|
||||
assert "const sessionItems = workSession.items()" in html
|
||||
assert "qs('#my-work-action-status').textContent = 'Agenda complete.'" in html
|
||||
assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timezone_name", ["Asia/Tokyo", "America/Los_Angeles"])
|
||||
def test_mobile_agenda_preserves_the_gitea_calendar_day_in_every_timezone(timezone_name):
|
||||
script = f"""
|
||||
const work = require({json.dumps(str(MY_WORK))});
|
||||
const now = new Date(2026, 7, 13, 12, 0, 0);
|
||||
const built = work({{
|
||||
user:{{login:'timmy'}}, notifications:[], pull_requests:[],
|
||||
issues:[{{number:1,title:'Ship',repository:'o/r',labels:[],assignees:['timmy'],due_date:'2026-08-13T23:59:59Z'}}],
|
||||
}}, now);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
dueLabel:built[0].due_label,
|
||||
agenda:work.agendaMyWork(built, now).map(item => [item.key, item.agenda_group]),
|
||||
}}));
|
||||
"""
|
||||
environment = {**os.environ, "TZ": timezone_name}
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True, env=environment
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"dueLabel": "Due today",
|
||||
"agenda": [["o/r#1", "Today"]],
|
||||
}
|
||||
|
||||
|
||||
def test_agenda_pager_loads_every_issue_page_single_flight_and_retries_failed_page():
|
||||
script = f"""
|
||||
const work = require({json.dumps(str(MY_WORK))});
|
||||
const calls = [];
|
||||
let failPage = 3;
|
||||
let items = [{{id:1, title:'first'}}];
|
||||
let pagination = {{issue:{{page:1,total:4,has_more:true}}}};
|
||||
const pager = work.createWorkPager({{
|
||||
load: async (stream, page) => {{
|
||||
calls.push(page);
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
if (page === failPage) throw new Error('offline');
|
||||
return {{page,total:4,has_more:page < 4,items:[{{id:page,title:'page '+page}}]}};
|
||||
}},
|
||||
onItems: (_stream, next) => {{ items = next; }},
|
||||
onPagination: next => {{ pagination = next; }},
|
||||
onStatus: () => {{}},
|
||||
}});
|
||||
pager.reset(pagination);
|
||||
async function run() {{
|
||||
const first = pager.loadAll('issue', () => items);
|
||||
const duplicate = pager.loadAll('issue', () => items);
|
||||
const failed = await first;
|
||||
const samePromise = first === duplicate;
|
||||
failPage = 0;
|
||||
const retried = await pager.loadAll('issue', () => items);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
failed, samePromise, retried, calls, ids:items.map(item => item.id), pagination,
|
||||
}}));
|
||||
}}
|
||||
run();
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"failed": False,
|
||||
"samePromise": True,
|
||||
"retried": True,
|
||||
"calls": [2, 3, 3, 4],
|
||||
"ids": [1, 2, 3, 4],
|
||||
"pagination": {"issue": {"page": 4, "total": 4, "has_more": False}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_agenda_activation_checks_all_issue_pages_before_showing_empty_state():
|
||||
html = await dashboard()
|
||||
|
||||
assert "workPager.loadAll('issue'" in html
|
||||
assert "Checking all assigned deadlines…" in html
|
||||
assert "Agenda check paused. Retry to check older assigned deadlines." in html
|
||||
assert "selectedWorkFilter === 'agenda' && workPagination.issue?.has_more" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
|
||||
html = await dashboard()
|
||||
@@ -540,12 +1158,12 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.p
|
||||
def test_every_mobile_queue_route_survives_hydration_and_detail_back_navigation():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
const canonical = ['today', 'attention', 'updates', 'later', 'drafts'];
|
||||
const canonical = ['today', 'agenda', 'attention', 'updates', 'later', 'drafts'];
|
||||
const parsed = canonical.map(name => [name, routes.parse('#/my-work/' + name)]);
|
||||
const listeners = {{}};
|
||||
const location = {{hash:'#/my-work/later'}};
|
||||
const location = {{hash:'#/my-work/agenda'}};
|
||||
const calls = [];
|
||||
const stack = ['#/my-work/later'];
|
||||
const stack = ['#/my-work/agenda'];
|
||||
let cursor = 0;
|
||||
const history = {{
|
||||
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }},
|
||||
@@ -574,16 +1192,17 @@ process.stdout.write(JSON.stringify({{parsed, calls, hash:location.hash}}));
|
||||
assert json.loads(result.stdout) == {
|
||||
"parsed": [
|
||||
["today", {"kind": "queue", "filter": "today"}],
|
||||
["agenda", {"kind": "queue", "filter": "agenda"}],
|
||||
["attention", {"kind": "queue", "filter": "attention"}],
|
||||
["updates", {"kind": "queue", "filter": "update"}],
|
||||
["later", {"kind": "queue", "filter": "later"}],
|
||||
["drafts", {"kind": "queue", "filter": "draft"}],
|
||||
],
|
||||
"calls": [
|
||||
["queue", "later"],
|
||||
["queue", "agenda"],
|
||||
["open", 42],
|
||||
["close"],
|
||||
["queue", "later"],
|
||||
["queue", "agenda"],
|
||||
["queue", "draft"],
|
||||
],
|
||||
"hash": "#/my-work/drafts",
|
||||
@@ -1254,7 +1873,7 @@ async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor
|
||||
assert 'id="save-issue-milestone"' in html
|
||||
assert 'class="pill milestone-badge"' in html
|
||||
assert '.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px;' in html
|
||||
assert 'getMilestone: () => selectedWorkMilestone' in html
|
||||
assert "getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone" in html
|
||||
assert 'buildMyWork.replaceIssueMilestone(' in html
|
||||
|
||||
|
||||
@@ -2943,8 +3562,8 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
||||
assert 'id="end-today-session"' in html
|
||||
assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
|
||||
assert 'getLogin: () => confirmedOwnerLogin' in html
|
||||
assert 'checkpoint: sessionCheckpoint' in html
|
||||
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
|
||||
assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
|
||||
assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
|
||||
assert "qs('#resume-today-session').addEventListener('click'" in html
|
||||
assert "qs('#end-today-session').addEventListener('click'" in html
|
||||
assert "workSession.resume(item)" in html
|
||||
@@ -3281,7 +3900,10 @@ controller.load().then(() => controller.loadMore()).then(() =>
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == ["api/v1/available-issues?page=1", "api/v1/available-issues?page=2"]
|
||||
assert output["calls"] == [
|
||||
"api/v1/available-issues?page=1&facets=true",
|
||||
"api/v1/available-issues?page=2&facets=true",
|
||||
]
|
||||
assert [item["id"] for item in output["items"]] == [1, 2]
|
||||
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
|
||||
|
||||
@@ -3958,10 +4580,12 @@ process.stdout.write(JSON.stringify({{
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert len(output["queue"]) == 2
|
||||
assert [item["key"] for item in output["updates"]] == ["stackchain/web#8", "stackchain/api#7"]
|
||||
assert output["updates"][0]["kind"] == "update"
|
||||
assert output["updates"][1]["kind"] == "issue"
|
||||
assert output["updates"][1]["url"].endswith("#issuecomment-9")
|
||||
assert [item["key"] for item in output["updates"]] == ["stackchain/api#7", "stackchain/web#8"]
|
||||
assert output["updates"][0]["kind"] == "issue"
|
||||
assert output["updates"][0]["update_reason"] == "Assigned to you"
|
||||
assert output["updates"][0]["url"].endswith("#issuecomment-9")
|
||||
assert output["updates"][1]["kind"] == "update"
|
||||
assert output["updates"][1]["update_reason"] == ""
|
||||
assert output["counts"] == {
|
||||
"all": 2, "attention": 2, "issue": 1, "pull": 0, "review": 0, "update": 2
|
||||
}
|
||||
@@ -4299,6 +4923,76 @@ reader.open(original[0], original).then(() =>
|
||||
assert output["result"]["next"]["notification_id"] == 43
|
||||
|
||||
|
||||
def test_notification_reader_consumes_one_bounded_prefetch_without_duplicate_load():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = [1,2,3].map(notification_id => ({{kind:'update', notification_id, has_update:true}}));
|
||||
const loaded = [], details = [], statuses = [];
|
||||
const releases = {{}};
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: id => {{
|
||||
loaded.push(id);
|
||||
if (id === 1) return Promise.resolve({{id}});
|
||||
return new Promise(resolve => {{ releases[id] = resolve; }});
|
||||
}},
|
||||
markRead: async () => {{}},
|
||||
onOpen: () => {{}}, onDetail: detail => details.push(detail.id), onItems: () => {{}},
|
||||
onStatus: status => statuses.push(status), onClose: () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
await reader.open(items[0]);
|
||||
const first = reader.prefetch(items[1]);
|
||||
const duplicate = reader.prefetch(items[1]);
|
||||
const rejected = reader.prefetch(items[2]);
|
||||
const advancing = reader.open(items[1]);
|
||||
await Promise.resolve();
|
||||
releases[2]({{id:2}});
|
||||
await advancing;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
loaded, details, same:first === duplicate, rejected, statuses,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output == {
|
||||
"loaded": [1, 2],
|
||||
"details": [1, 2],
|
||||
"same": True,
|
||||
"rejected": False,
|
||||
"statuses": ["Loading update…", "Update ready.", "Update ready."],
|
||||
}
|
||||
|
||||
|
||||
def test_notification_reader_never_consumes_prefetch_from_another_account_scope():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let scope = 'timmy';
|
||||
const loaded = [];
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: async id => {{ loaded.push([scope, id]); return {{owner:scope, id}}; }},
|
||||
getScope: () => scope,
|
||||
markRead: async () => {{}}, onOpen: () => {{}}, onItems: () => {{}}, onStatus: () => {{}},
|
||||
onDetail: detail => {{ if (detail.owner !== scope) throw new Error('cross-account detail'); }},
|
||||
onClose: () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
await reader.open({{notification_id:1}});
|
||||
await reader.prefetch({{notification_id:2}});
|
||||
scope = 'alexander';
|
||||
const opened = await reader.open({{notification_id:2}});
|
||||
process.stdout.write(JSON.stringify({{loaded, opened}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output == {"loaded": [["timmy", 1], ["timmy", 2], ["alexander", 2]], "opened": True}
|
||||
|
||||
|
||||
def test_notification_reader_acknowledges_once_and_opens_next_update():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
@@ -4413,6 +5107,21 @@ async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next(
|
||||
assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_sheet_mutes_future_updates_and_advances_only_on_read_success():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mute-update-next" type="button" hidden' in html
|
||||
assert ">Mute future updates & next</button>" in html
|
||||
assert "qs('#mute-update-next').hidden = !detail.mute_supported;" in html
|
||||
assert "async function muteNotification(notificationId)" in html
|
||||
assert "'/mute', { method: 'POST'" in html
|
||||
assert "notificationReader.acceptReadAndNext(lastMyWork, item)" in html
|
||||
assert "Future updates are muted; current item is still unread" in html
|
||||
assert "qs('#mute-update-next').disabled = offline;" in html
|
||||
assert ".update-sheet-actions #mute-update-next" in html
|
||||
|
||||
|
||||
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
@@ -4512,6 +5221,56 @@ reader.open(firstItem).then(async () => {{
|
||||
assert output["states"] == [[41], [90]]
|
||||
|
||||
|
||||
def test_notification_reader_renders_core_before_conversation_and_retries_failure_in_place():
|
||||
script = f"""
|
||||
const build = require({json.dumps(str(MY_WORK))});
|
||||
const createPager = require({json.dumps(str(CONVERSATION))});
|
||||
const events = [];
|
||||
let attempts = 0;
|
||||
let release;
|
||||
const reader = build.createNotificationReader({{
|
||||
load: async id => ({{id, title:'Core ready', conversation_available:true}}),
|
||||
createPager,
|
||||
loadConversation: () => {{
|
||||
attempts += 1;
|
||||
if (attempts === 1) return Promise.reject(new Error('comments offline'));
|
||||
return new Promise(resolve => {{ release = () => resolve({{
|
||||
comments:[{{id:47, body:'Newest'}}], page:3, older_page:2, total:47,
|
||||
}}); }});
|
||||
}},
|
||||
markRead: async () => {{}}, onOpen: () => events.push('open'),
|
||||
onDetail: detail => events.push(['detail', detail.title]),
|
||||
onConversation: state => events.push(['conversation', state.comments.map(item => item.id)]),
|
||||
onConversationStatus: status => events.push(['conversation-status', status]),
|
||||
onItems: () => {{}}, onStatus: status => events.push(['status', status]), onClose: () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
const opened = await reader.open({{notification_id:42}});
|
||||
const retrying = reader.retryConversation();
|
||||
await Promise.resolve();
|
||||
const beforeRelease = events.slice();
|
||||
release();
|
||||
const retried = await retrying;
|
||||
process.stdout.write(JSON.stringify({{opened, retried, attempts, beforeRelease, events}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["opened"] is True
|
||||
assert output["retried"] is True
|
||||
assert output["attempts"] == 2
|
||||
assert ["detail", "Core ready"] in output["beforeRelease"]
|
||||
assert ["status", "Update ready."] in output["beforeRelease"]
|
||||
assert ["conversation-status", "Conversation temporarily unavailable. Retry."] in output["beforeRelease"]
|
||||
assert ["conversation", [47]] not in output["beforeRelease"]
|
||||
assert output["events"][-2:] == [
|
||||
["conversation", [47]],
|
||||
["conversation-status", "1 of 47 messages loaded."],
|
||||
]
|
||||
|
||||
|
||||
def test_notification_reader_hydrates_saved_conversation_without_server_state_actions():
|
||||
script = f"""
|
||||
const build = require({json.dumps(str(MY_WORK))});
|
||||
@@ -5597,7 +6356,7 @@ async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels(
|
||||
assert '<script src="static/my-work.js"></script>' in html
|
||||
assert "buildMyWork(data)" in html
|
||||
assert "markMyWorkStale()" in html
|
||||
assert "filterMyWork(lastMyWork, selectedWorkFilter)" in html
|
||||
assert "const sessionItems = workSession.items()" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -5632,7 +6391,7 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
|
||||
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
||||
assert "createNotificationReader" in html
|
||||
assert "api/v1/notifications/" in html
|
||||
assert "notificationReader.markReadAndNext(lastMyWork)" in html
|
||||
assert "reader:notificationReader, items:()=>lastMyWork" in html
|
||||
|
||||
|
||||
def test_notification_replier_preserves_failed_draft_and_clears_only_after_success():
|
||||
@@ -5794,7 +6553,7 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
assert 'data-work-count="review"' in html
|
||||
assert 'data-work-count="update"' in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert "['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert "['all', 'today', 'agenda', 'attention', 'issue', 'pull', 'review', 'update', 'later', '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
|
||||
@@ -6469,7 +7228,9 @@ async def test_mobile_update_sheet_renders_and_pages_the_complete_conversation()
|
||||
assert 'id="load-older-update-comments"' in update_sheet
|
||||
assert 'id="update-conversation-status"' in update_sheet
|
||||
assert '.conversation-more' in html and 'min-height:44px' in html
|
||||
assert "'/conversation?page='" in html
|
||||
assert "'/conversation?limit=20' + pageQuery" in html
|
||||
assert 'id="retry-update-conversation"' in update_sheet
|
||||
assert "notificationReader.retryConversation()" in html
|
||||
assert "loadConversation: fetchNotificationConversation" in html
|
||||
assert "onConversation: renderUpdateConversation" in html
|
||||
assert "notificationReader.loadOlder()" in html
|
||||
|
||||
@@ -7,7 +7,7 @@ from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_opens_the_newest_conversation_page_in_chronological_order(monkeypatch):
|
||||
async def test_notification_detail_returns_core_without_loading_conversation(monkeypatch):
|
||||
requested_pages = []
|
||||
|
||||
def comment(comment_id):
|
||||
@@ -53,14 +53,9 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requested_pages == [1, 3]
|
||||
assert [item["id"] for item in result["conversation"]["comments"]] == list(range(41, 48))
|
||||
assert result["conversation"] == {
|
||||
"comments": result["conversation"]["comments"],
|
||||
"page": 3,
|
||||
"older_page": 2,
|
||||
"total": 47,
|
||||
}
|
||||
assert requested_pages == []
|
||||
assert result["conversation_available"] is True
|
||||
assert "conversation" not in result
|
||||
assert result["issue"] == {
|
||||
"number": 7,
|
||||
"assignees": [],
|
||||
@@ -130,6 +125,24 @@ async def test_notification_conversation_api_loads_one_bounded_older_page(monkey
|
||||
assert calls == [(42, 2, 20)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_conversation_api_opens_newest_page_when_page_is_omitted(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def conversation(thread_id, page, limit):
|
||||
calls.append((thread_id, page, limit))
|
||||
return {"comments": [{"id": 47}], "page": 3, "older_page": 2, "total": 47}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_page", conversation)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/42/conversation?limit=20")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["page"] == 3
|
||||
assert calls == [(42, None, 20)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch):
|
||||
async def detail(_thread_id):
|
||||
|
||||
@@ -31,3 +31,55 @@ async def test_notification_page_endpoint_loads_only_requested_page_and_is_not_c
|
||||
"has_more": True,
|
||||
}
|
||||
assert requested == [2]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_snapshot_endpoint_returns_one_complete_atomic_no_store_result(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def snapshot_loader(*, deadline_seconds):
|
||||
requested.append(deadline_seconds)
|
||||
return {
|
||||
"items": [
|
||||
{"id": 1, "title": "Newest update"},
|
||||
{"id": 51, "title": "Older update"},
|
||||
],
|
||||
"total": 2,
|
||||
"complete": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", snapshot_loader)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/snapshot")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"items": [
|
||||
{"id": 1, "title": "Newest update"},
|
||||
{"id": 51, "title": "Older update"},
|
||||
],
|
||||
"total": 2,
|
||||
"complete": True,
|
||||
}
|
||||
assert requested == [main.NOTIFICATION_PAGE_TIMEOUT_SECONDS]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("failure", [TimeoutError(), ValueError("pagination changed")])
|
||||
async def test_notification_snapshot_endpoint_is_bounded_and_retryable(monkeypatch, failure):
|
||||
async def snapshot_loader(*, deadline_seconds):
|
||||
raise failure
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", snapshot_loader)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/snapshot")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.json() == {
|
||||
"error": "Complete unread updates are temporarily unavailable. Please retry."
|
||||
}
|
||||
@@ -215,6 +215,34 @@ async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monke
|
||||
assert restored == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mute_notification_then_marks_read_and_reports_partial_success(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def mute(thread_id):
|
||||
calls.append(("mute", thread_id))
|
||||
return {"id": thread_id, "muted": True}
|
||||
|
||||
async def fail_read(thread_id):
|
||||
calls.append(("read", thread_id))
|
||||
raise httpx.HTTPError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mute_notification", mute, raising=False)
|
||||
monkeypatch.setattr(main, "mark_notification_read", fail_read)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post("/api/v1/notifications/42/mute")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"id": 42,
|
||||
"muted": True,
|
||||
"status": "unread",
|
||||
"error": "Future updates are muted; current item is still unread. Retry mark read & next.",
|
||||
}
|
||||
assert calls == [("mute", 42), ("read", 42)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -0,0 +1,106 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
PROTECT_TODAY = ROOT / "frontend" / "protect-today.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_protect_today_prioritizes_and_deduplicates_urgent_deadlines_before_existing_plan():
|
||||
script = f"""
|
||||
const protectToday = require({json.dumps(str(PROTECT_TODAY))});
|
||||
const issue = (number, group, due) => ({{
|
||||
kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number,
|
||||
is_assigned:true, state:'open', agenda_group:group, due_date:due,
|
||||
}});
|
||||
const urgentLater = issue(3, 'Today', '2026-08-13');
|
||||
const urgentFirst = issue(2, 'Overdue', '2026-08-12');
|
||||
const duplicate = issue(2, 'Overdue', '2026-08-12');
|
||||
const existingUrgent = issue(1, 'Today', '2026-08-13');
|
||||
const existingNormal = issue(8, null, null);
|
||||
const result = protectToday.propose({{
|
||||
agenda:[urgentLater, duplicate, urgentFirst, issue(4, 'Tomorrow', '2026-08-14')],
|
||||
today:[existingNormal, existingUrgent],
|
||||
identity:item => item.repository + '#' + item.number,
|
||||
limit:5,
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
selected:result.selected.map(item => item.number),
|
||||
protected:result.protected.map(item => item.number),
|
||||
displaced:result.displaced.map(item => item.number),
|
||||
summary:result.summary,
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"selected": [2, 1, 3, 8],
|
||||
"protected": [2, 1, 3],
|
||||
"displaced": [],
|
||||
"summary": "3 urgent items protected · 0 displaced",
|
||||
}
|
||||
|
||||
|
||||
def test_protect_today_reports_existing_work_displaced_by_five_item_limit():
|
||||
script = f"""
|
||||
const protectToday = require({json.dumps(str(PROTECT_TODAY))});
|
||||
const issue = (number, group = null) => ({{
|
||||
kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number,
|
||||
is_assigned:true, state:'open', agenda_group:group,
|
||||
due_date:group === 'Overdue' ? '2026-08-12' : group === 'Today' ? '2026-08-13' : null,
|
||||
}});
|
||||
const result = protectToday.propose({{
|
||||
agenda:[issue(10, 'Overdue'), issue(11, 'Today'), issue(12, 'Today')],
|
||||
today:[issue(1), issue(2), issue(3), issue(4), issue(5)],
|
||||
identity:item => item.repository + '#' + item.number,
|
||||
limit:5,
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
selected:result.selected.map(item => item.number),
|
||||
displaced:result.displaced.map(item => item.number),
|
||||
summary:result.summary,
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"selected": [10, 11, 12, 1, 2],
|
||||
"displaced": [3, 4, 5],
|
||||
"summary": "3 urgent items protected · 3 displaced for review",
|
||||
}
|
||||
|
||||
|
||||
def test_protect_today_excludes_closed_unassigned_and_nonurgent_items():
|
||||
script = f"""
|
||||
const protectToday = require({json.dumps(str(PROTECT_TODAY))});
|
||||
const items = [
|
||||
{{kind:'issue',repository:'r',number:1,is_assigned:true,state:'closed',agenda_group:'Overdue',due_date:'2026-08-01'}},
|
||||
{{kind:'issue',repository:'r',number:2,is_assigned:false,state:'open',agenda_group:'Today',due_date:'2026-08-13'}},
|
||||
{{kind:'issue',repository:'r',number:3,is_assigned:true,state:'open',agenda_group:'Tomorrow',due_date:'2026-08-14'}},
|
||||
{{kind:'pull',repository:'r',number:4,is_assigned:true,state:'open',agenda_group:'Overdue',due_date:'2026-08-01'}},
|
||||
];
|
||||
const result = protectToday.propose({{agenda:items,today:[],identity:item => item.repository + '#' + item.number}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"selected": [], "protected": [], "displaced": [],
|
||||
"summary": "No overdue or due-today work needs protection.",
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_renders_mobile_protect_today_entrypoint_and_preview_context():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
|
||||
|
||||
assert 'id="protect-today"' in html
|
||||
assert 'id="protect-today-status"' in html
|
||||
assert "protectToday.propose" in dashboard
|
||||
assert "pendingProtectToday" in dashboard
|
||||
assert "Protect Today" in dashboard
|
||||
assert ".protect-today" in css
|
||||
assert "min-height:44px" in css.replace(" ", "")
|
||||
assert '"static/protect-today.js"' in bundle
|
||||
+107
-1
@@ -14,6 +14,13 @@ const control = {
|
||||
checked:false, disabled:false,
|
||||
addEventListener:(_name, callback) => state.change = callback,
|
||||
};
|
||||
const deadlineControl = {
|
||||
checked:false, disabled:false,
|
||||
addEventListener:(_name, callback) => state.deadlineChange = callback,
|
||||
};
|
||||
const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callback) => state.deadlineHourChange = callback};
|
||||
const deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = callback};
|
||||
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
|
||||
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
||||
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
|
||||
const registration = {pushManager:{
|
||||
@@ -21,7 +28,7 @@ const registration = {pushManager:{
|
||||
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
||||
}};
|
||||
const feature = createPushNotifications({
|
||||
control, status,
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
||||
serviceWorker: {ready:Promise.resolve(registration)},
|
||||
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
||||
@@ -57,6 +64,100 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["text"] == "New update notifications enabled for this device."
|
||||
|
||||
|
||||
def test_deadline_opt_in_reuses_subscription_and_sends_local_timezone_without_second_prompt():
|
||||
result = run_scenario("""
|
||||
state.current = existing;
|
||||
state.server = {available:true,subscribed:true,deadline_enabled:false,public_key:'AQID'};
|
||||
await feature.init();
|
||||
deadlineControl.checked = true;
|
||||
await state.deadlineChange();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert result["prompts"] == 0
|
||||
assert result["subscriptions"] == []
|
||||
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/deadlines", "PUT"]
|
||||
body = json.loads(result["requests"][-1][2])
|
||||
assert body["enabled"] is True
|
||||
assert body["reminder_hour"] == 9
|
||||
assert isinstance(body["timezone"], str) and body["timezone"]
|
||||
assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 2 days."
|
||||
|
||||
|
||||
def test_deadline_setup_subscribes_once_and_persists_selected_local_hour():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'};
|
||||
await feature.init();
|
||||
deadlineHour.value = '8';
|
||||
await feature.enableDeadline();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert result["prompts"] == 1
|
||||
assert len(result["subscriptions"]) == 1
|
||||
assert [request[0] for request in result["requests"][-2:]] == [
|
||||
"api/v1/push-subscription", "api/v1/push-subscription/deadlines",
|
||||
]
|
||||
body = json.loads(result["requests"][-1][2])
|
||||
assert body["enabled"] is True
|
||||
assert body["reminder_hour"] == 8
|
||||
assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time, next 2 days."
|
||||
|
||||
|
||||
def test_deadline_setup_persists_and_restores_confirmed_horizon():
|
||||
saved = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
deadlineDays.value = '7';
|
||||
await feature.changeDeadline();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert json.loads(saved["requests"][-1][2])["reminder_days"] == 7
|
||||
assert saved["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 7 days."
|
||||
|
||||
restored = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:17,reminder_days:0,public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
process.stdout.write(JSON.stringify({days:deadlineDays.value, readiness:feature.deadlineReadiness()}));
|
||||
""")
|
||||
assert restored == {
|
||||
"days": "0",
|
||||
"readiness": {
|
||||
"state": "complete",
|
||||
"detail": "Deadline reminders enabled for 17:00 local time, due today.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_deadline_setup_restores_confirmed_hour_and_denial_stays_incomplete():
|
||||
restored = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:17,public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
process.stdout.write(JSON.stringify({hour:deadlineHour.value, checked:deadlineControl.checked, readiness:feature.deadlineReadiness()}));
|
||||
""")
|
||||
assert restored == {
|
||||
"hour": "17", "checked": True,
|
||||
"readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time, next 2 days."},
|
||||
}
|
||||
|
||||
denied = run_scenario("""
|
||||
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'};
|
||||
state.permission = 'denied';
|
||||
await feature.init();
|
||||
deadlineHour.value = '8';
|
||||
await feature.enableDeadline();
|
||||
process.stdout.write(JSON.stringify({checked:deadlineControl.checked, readiness:feature.deadlineReadiness(), requests:state.requests}));
|
||||
""")
|
||||
assert denied["checked"] is False
|
||||
assert denied["readiness"]["state"] == "incomplete"
|
||||
assert "blocked" in denied["readiness"]["detail"].lower()
|
||||
assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"]
|
||||
|
||||
|
||||
def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
@@ -68,6 +169,11 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
|
||||
assert 'id="push-updates"' in html
|
||||
assert 'id="push-update-status"' in html
|
||||
assert 'id="push-deadlines"' in html
|
||||
assert 'id="push-deadline-hour"' in html
|
||||
assert 'id="push-deadline-days"' in html
|
||||
assert 'id="device-setup-deadline-days"' in html
|
||||
assert 'id="push-deadline-status"' in html
|
||||
assert '<script src="static/push-notifications.js"></script>' in html
|
||||
assert "createPushNotifications({" in dashboard
|
||||
assert "BASE + 'static/push-notifications.js'" in worker
|
||||
|
||||
@@ -204,6 +204,24 @@ async def test_managed_session_active_applies_configured_idle_deadline(monkeypat
|
||||
assert calls == [("active-device", 321), ("idle-device", 321)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_managed_session_statuses_apply_configured_idle_deadline_in_one_call(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class Store:
|
||||
def managed_statuses(self, management_ids, *, idle_timeout_seconds):
|
||||
calls.append((list(management_ids), idle_timeout_seconds))
|
||||
return {management_id: "active" for management_id in set(management_ids)}
|
||||
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "321")
|
||||
monkeypatch.setattr(dashboard_auth, "_session_store", lambda: Store())
|
||||
|
||||
assert await dashboard_auth.managed_session_statuses(
|
||||
["device-b", "device-a", "device-b"]
|
||||
) == {"device-a": "active", "device-b": "active"}
|
||||
assert calls == [(["device-b", "device-a", "device-b"], 321)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch):
|
||||
captured = {}
|
||||
@@ -225,11 +243,141 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
|
||||
await main._push_poll_loop()
|
||||
|
||||
assert captured["unread"] is main.gitea_proxy.unread_notification_snapshot
|
||||
assert captured["session_active"] is dashboard_auth.managed_session_active
|
||||
assert captured["session_statuses"] is dashboard_auth.managed_session_statuses
|
||||
assert captured["max_concurrency"] == 3
|
||||
assert captured["max_individual_notifications"] == 4
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(monkeypatch):
|
||||
calls = []
|
||||
|
||||
sleeps = 0
|
||||
first_tick = asyncio.Event()
|
||||
|
||||
async def no_wait(_seconds):
|
||||
nonlocal sleeps
|
||||
sleeps += 1
|
||||
if sleeps <= 2:
|
||||
if sleeps == 2:
|
||||
first_tick.set()
|
||||
await first_tick.wait()
|
||||
else:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
async def fail_unread(*_args, **_kwargs):
|
||||
calls.append("unread")
|
||||
raise RuntimeError("unread unavailable")
|
||||
|
||||
async def dispatch_deadlines(*_args, **_kwargs):
|
||||
calls.append("deadline")
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await main._push_poll_loop()
|
||||
|
||||
assert sorted(calls) == ["deadline", "unread"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(monkeypatch):
|
||||
unread_started = asyncio.Event()
|
||||
release_unread = asyncio.Event()
|
||||
deadline_started = asyncio.Event()
|
||||
|
||||
async def no_wait(_seconds):
|
||||
return None
|
||||
|
||||
async def blocked_unread(*_args, **_kwargs):
|
||||
unread_started.set()
|
||||
await release_unread.wait()
|
||||
|
||||
async def dispatch_deadlines(*_args, **_kwargs):
|
||||
deadline_started.set()
|
||||
await release_unread.wait()
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
||||
|
||||
poll = asyncio.create_task(main._push_poll_loop())
|
||||
await asyncio.wait_for(unread_started.wait(), timeout=0.5)
|
||||
try:
|
||||
await asyncio.wait_for(deadline_started.wait(), timeout=0.5)
|
||||
finally:
|
||||
poll.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await poll
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch):
|
||||
intervals = []
|
||||
|
||||
async def capture_channel(_dispatch, *, interval):
|
||||
intervals.append(interval)
|
||||
|
||||
monkeypatch.setenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")
|
||||
monkeypatch.setenv("STACKCHAIN_DEADLINE_POLL_SECONDS", "600")
|
||||
monkeypatch.setattr(main, "_push_channel_loop", capture_channel)
|
||||
|
||||
await main._push_poll_loop()
|
||||
|
||||
assert sorted(intervals) == [30.0, 600.0]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_channel_loop_skips_missed_ticks_without_overlapping(monkeypatch):
|
||||
starts = []
|
||||
now = 0.0
|
||||
|
||||
class Loop:
|
||||
def time(self):
|
||||
return now
|
||||
|
||||
async def record_dispatch():
|
||||
nonlocal now
|
||||
starts.append("dispatch")
|
||||
now = 16.0 if len(starts) == 1 else now
|
||||
if len(starts) == 2:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
sleeps = []
|
||||
|
||||
async def capture_sleep(seconds):
|
||||
nonlocal now
|
||||
sleeps.append(seconds)
|
||||
now += seconds
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "get_running_loop", lambda: Loop())
|
||||
monkeypatch.setattr(main.asyncio, "sleep", capture_sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await main._push_channel_loop(record_dispatch, interval=5.0)
|
||||
|
||||
assert starts == ["dispatch", "dispatch"]
|
||||
assert sleeps == [5.0, 4.0]
|
||||
|
||||
|
||||
def test_dispatch_leases_are_isolated_by_channel(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
first = PushSubscriptionStore(path)
|
||||
second = PushSubscriptionStore(path)
|
||||
|
||||
assert first.acquire_dispatch_lease(
|
||||
"unread-owner", channel="unread", now=100, lease_seconds=30
|
||||
) is True
|
||||
assert second.acquire_dispatch_lease(
|
||||
"deadline-owner", channel="deadline", now=100, lease_seconds=30
|
||||
) is True
|
||||
assert second.acquire_dispatch_lease(
|
||||
"other-unread-owner", channel="unread", now=100, lease_seconds=30
|
||||
) is False
|
||||
|
||||
|
||||
def test_replacing_subscription_resets_delivery_cursor_and_revocation_removes_device(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
original = {
|
||||
@@ -751,19 +899,26 @@ async def test_dispatch_removes_inactive_sessions_without_blocking_active_device
|
||||
async def unread():
|
||||
return {"items": [{"id": 21}]}
|
||||
|
||||
async def session_active(management_id):
|
||||
return management_id == "active-device"
|
||||
authorization_calls = []
|
||||
|
||||
async def session_statuses(management_ids):
|
||||
authorization_calls.append(list(management_ids))
|
||||
return {
|
||||
management_id: "active" if management_id == "active-device" else "revoked"
|
||||
for management_id in management_ids
|
||||
}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
sent.append(subscription["endpoint"])
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(
|
||||
store, config, unread, send, session_active=session_active
|
||||
store, config, unread, send, session_statuses=session_statuses
|
||||
) == 1
|
||||
assert store.is_subscribed("expired-device") is False
|
||||
assert store.is_subscribed("active-device") is True
|
||||
assert sent == ["https://push.example/active-device"]
|
||||
assert authorization_calls == [["active-device", "expired-device"]]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -778,7 +933,7 @@ async def test_dispatch_fails_closed_and_retains_subscriptions_when_session_regi
|
||||
async def unread():
|
||||
return {"items": [{"id": 22}]}
|
||||
|
||||
async def registry_unavailable(_management_id):
|
||||
async def registry_unavailable(_management_ids):
|
||||
raise RuntimeError("session registry unavailable")
|
||||
|
||||
async def send(subscription, _payload):
|
||||
@@ -786,7 +941,7 @@ async def test_dispatch_fails_closed_and_retains_subscriptions_when_session_regi
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(
|
||||
store, config, unread, send, session_active=registry_unavailable
|
||||
store, config, unread, send, session_statuses=registry_unavailable
|
||||
) == 0
|
||||
assert store.is_subscribed("active-device") is True
|
||||
assert sent == []
|
||||
@@ -819,7 +974,13 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
)
|
||||
|
||||
assert await main.push_status(request) == {
|
||||
"available": True, "subscribed": False, "public_key": "public-vapid"
|
||||
"available": True,
|
||||
"subscribed": False,
|
||||
"public_key": "public-vapid",
|
||||
"deadline_enabled": False,
|
||||
"timezone": "UTC",
|
||||
"reminder_hour": 9,
|
||||
"reminder_days": 2,
|
||||
}
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||
assert (await main.push_status(request))["subscribed"] is True
|
||||
@@ -827,6 +988,34 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
assert (await main.push_status(request))["subscribed"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
|
||||
async def management_id(_session):
|
||||
return "session-a"
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
payload = main.DeadlineReminderPayload(
|
||||
enabled=True, timezone="America/New_York", reminder_hour=9, reminder_days=7
|
||||
)
|
||||
|
||||
assert await main.update_deadline_reminders(payload, request) == {
|
||||
"deadline_enabled": True,
|
||||
"timezone": "America/New_York",
|
||||
"reminder_hour": 9,
|
||||
"reminder_days": 7,
|
||||
}
|
||||
assert store.deadline_preferences("session-a")["enabled"] is True
|
||||
assert store.deadline_preferences("session-a")["reminder_days"] == 7
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SEARCH_BATCH_PLAN = Path(__file__).parents[1] / "frontend" / "search-batch-plan.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_search_batch_selection_keeps_open_issues_across_pages_and_rejects_ineligible_results():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const states=[];
|
||||
const flow=createSearchBatchPlan({{onChange:state=>states.push(state)}});
|
||||
const first={{kind:'issue',state:'open',repository:'stackchain/dashboard',number:809,title:'First'}};
|
||||
const second={{kind:'issue',state:'open',repository:'stackchain/api',number:17,title:'Second'}};
|
||||
const pull={{kind:'pull',state:'open',repository:'stackchain/dashboard',number:810}};
|
||||
const closed={{kind:'issue',state:'closed',repository:'stackchain/dashboard',number:808}};
|
||||
flow.start();
|
||||
const outcomes=[flow.toggle(first),flow.toggle(pull),flow.toggle(closed),flow.toggle(second)];
|
||||
process.stdout.write(JSON.stringify({{outcomes,snapshot:flow.snapshot(),states}}));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["outcomes"] == ["selected", "ineligible", "ineligible", "selected"]
|
||||
assert result["snapshot"] == {
|
||||
"active": True,
|
||||
"count": 2,
|
||||
"items": [
|
||||
{"kind": "issue", "state": "open", "repository": "stackchain/dashboard", "number": 809, "title": "First"},
|
||||
{"kind": "issue", "state": "open", "repository": "stackchain/api", "number": 17, "title": "Second"},
|
||||
],
|
||||
}
|
||||
assert result["states"][-1]["count"] == 2
|
||||
|
||||
|
||||
def test_search_batch_prepares_owned_issues_without_reclaiming_and_claims_unassigned_issues():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const calls=[];
|
||||
const details={{
|
||||
809:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:809,assigned_to_me:true}},
|
||||
810:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:810,claimable:true}},
|
||||
811:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:811,claimable:false}},
|
||||
}};
|
||||
const flow=createSearchBatchPlan({{
|
||||
resolve:item=>{{calls.push('resolve:'+item.number);return Promise.resolve(details[item.number]);}},
|
||||
claim:detail=>{{calls.push('claim:'+detail.number);return Promise.resolve({{...detail,assigned_to_me:true}});}},
|
||||
}});
|
||||
Promise.all([
|
||||
flow.prepare({{number:809}}),
|
||||
flow.prepare({{number:810}}),
|
||||
flow.prepare({{number:811}}).catch(error=>error.message),
|
||||
]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["calls"] == ["resolve:809", "resolve:810", "resolve:811", "claim:810"]
|
||||
assert result["results"][0]["assigned_to_me"] is True
|
||||
assert result["results"][1]["assigned_to_me"] is True
|
||||
assert result["results"][2] == "This issue is no longer available to assign."
|
||||
|
||||
|
||||
def test_release_planning_requires_selected_issues_from_one_repository():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const flow=createSearchBatchPlan();
|
||||
flow.start();
|
||||
flow.toggle({{kind:'issue',state:'open',repository:'stackchain/dashboard',number:815}});
|
||||
const one=flow.releaseRepository();
|
||||
flow.toggle({{kind:'issue',state:'open',repository:'stackchain/api',number:17}});
|
||||
process.stdout.write(JSON.stringify({{one,mixed:flow.releaseRepository()}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"one": "stackchain/dashboard", "mixed": None}
|
||||
|
||||
|
||||
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = (HTML.parent / "service-worker.js").read_text()
|
||||
controller = SEARCH_BATCH_PLAN.read_text()
|
||||
|
||||
assert 'id="select-search-results"' in html
|
||||
assert 'id="search-batch-actions"' in html
|
||||
assert 'id="queue-selected-search-results"' in html
|
||||
assert 'id="defer-selected-search-results"' in html
|
||||
assert 'id="plan-selected-search-results"' in html
|
||||
assert 'id="search-release-review"' in html
|
||||
assert 'id="search-release-milestone"' in html
|
||||
assert 'id="search-release-due-date"' in html
|
||||
assert 'id="resume-search-batch"' in html
|
||||
assert 'src="static/search-batch-plan.js"' in html
|
||||
assert "mountSearchBatchPlanning(" in dashboard
|
||||
assert "escapeHtml, escAttr, laterWork, laterPicker" in dashboard
|
||||
assert "journalName:'search-today-batch'" in controller
|
||||
assert "journalName:'search-later-batch'" in controller
|
||||
assert "journalName:'search-release-batch'" in controller
|
||||
assert "autoMount:false" in controller
|
||||
assert "plan.toggle(result)" in controller
|
||||
assert "processor.run(plan.snapshot().items, estimateValues())" in controller
|
||||
assert "timeBudget:()" in controller
|
||||
assert "persistEstimate" in controller
|
||||
assert 'id="search-batch-estimate-review"' in html
|
||||
assert 'data-search-batch-estimate' in controller
|
||||
assert "processor.resume()" in controller
|
||||
assert 'aria-label="Select ' in controller
|
||||
assert "result.kind === 'issue' && result.state === 'open'" in controller
|
||||
assert ".search-batch-actions" in css
|
||||
assert "position:sticky" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert ".cmd-select-result" in css and "min-height:44px" in css
|
||||
assert ".search-release-review" in css and "100dvh" in css
|
||||
assert ".search-release-actions" in css and "env(safe-area-inset-bottom)" in css
|
||||
assert "BASE + 'static/search-batch-plan.js'" in worker
|
||||
|
||||
|
||||
def test_selected_search_issues_choose_one_future_time_before_durable_batch_defer():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const listeners={{}};
|
||||
const elements={{}};
|
||||
for (const id of [
|
||||
'search-batch-actions','select-search-results','queue-selected-search-results',
|
||||
'defer-selected-search-results','plan-selected-search-results','search-selection-status','cmd-search-action-status',
|
||||
'resume-search-batch','open-palette','cmd-results','cancel-search-selection',
|
||||
'search-batch-estimate-review','search-batch-estimate-summary','search-batch-estimate-list',
|
||||
'cancel-search-batch-estimates','confirm-search-batch-estimates','search-release-review',
|
||||
'search-release-summary','search-release-milestone','search-release-due-date','search-release-list',
|
||||
'cancel-search-release','confirm-search-release'
|
||||
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',
|
||||
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
|
||||
const document={{querySelector:selector=>elements[selector] || null,querySelectorAll:()=>[]}};
|
||||
const processorOptions=[];
|
||||
const runs=[];
|
||||
const processors=[];
|
||||
const batchFactory=options=>{{
|
||||
processorOptions.push(options);
|
||||
const processor={{
|
||||
run:(items,estimates,context)=>{{runs.push({{items,estimates,context}});return Promise.resolve();}},
|
||||
resume:()=>Promise.resolve(),pending:()=>0,
|
||||
}};
|
||||
processors.push(processor);
|
||||
return processor;
|
||||
}};
|
||||
const planning={{capacity_minutes:null,estimates:{{}}}};
|
||||
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,
|
||||
replacePlanning:()=>{{}}}};
|
||||
const laterCalls=[];
|
||||
const laterWork={{defer:(item,until)=>{{laterCalls.push([item.number,until]);return 'deferred';}}}};
|
||||
let pickerRequest=null;
|
||||
const laterPicker={{open:(payload,trigger,context)=>{{pickerRequest={{payload,trigger,context}};return true;}}}};
|
||||
const item={{kind:'issue',state:'open',repository:'stackchain/dashboard',number:813,title:'Batch defer'}};
|
||||
const mounted=createSearchBatchPlan.mount(
|
||||
document,batchFactory,todayWork,()=> 'timmy',()=>Promise.resolve(),()=>'',()=>Promise.resolve('queued'),
|
||||
{{enqueueConfiguration:()=>{{}},flush:()=>{{}}}},value=>({{...value,accepted:true}}),()=>item,()=>{{}},
|
||||
value=>value,value=>value,laterWork,laterPicker
|
||||
);
|
||||
mounted.plan.start();
|
||||
mounted.plan.toggle(item);
|
||||
listeners['defer-selected-search-results:click']({{currentTarget:elements['#defer-selected-search-results']}});
|
||||
const before={{runs:runs.slice(),count:pickerRequest.payload.items.length,context:pickerRequest.context,journals:processorOptions.map(value=>value.journalName)}};
|
||||
pickerRequest.payload.confirm(new Date('2026-08-15T09:00:00.000Z'))();
|
||||
const laterResult=processorOptions[1].queue({{...item,assigned_to_me:true}},{{until:'2026-08-15T09:00:00.000Z'}});
|
||||
process.stdout.write(JSON.stringify({{before,runs,laterResult,laterCalls}}));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["before"] == {
|
||||
"runs": [],
|
||||
"count": 1,
|
||||
"context": "search-batch",
|
||||
"journals": ["search-today-batch", "search-later-batch", "search-release-batch"],
|
||||
}
|
||||
assert result["runs"] == [{
|
||||
"items": [{
|
||||
"kind": "issue", "state": "open", "repository": "stackchain/dashboard",
|
||||
"number": 813, "title": "Batch defer",
|
||||
}],
|
||||
"estimates": {},
|
||||
"context": {"until": "2026-08-15T09:00:00.000Z"},
|
||||
}]
|
||||
assert result["laterResult"] == "queued"
|
||||
assert result["laterCalls"] == [[813, "2026-08-15T09:00:00.000Z"]]
|
||||
|
||||
|
||||
def test_search_batch_reviews_time_estimates_before_any_assignment_and_persists_queued_estimates():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const listeners={{}};
|
||||
const elements={{}};
|
||||
for (const id of [
|
||||
'search-batch-actions','select-search-results','queue-selected-search-results',
|
||||
'defer-selected-search-results','plan-selected-search-results',
|
||||
'search-selection-status','cmd-search-action-status','resume-search-batch',
|
||||
'open-palette','cmd-results','cancel-search-selection','search-batch-estimate-review',
|
||||
'search-batch-estimate-summary','search-batch-estimate-list',
|
||||
'cancel-search-batch-estimates','confirm-search-batch-estimates','search-release-review',
|
||||
'search-release-summary','search-release-milestone','search-release-due-date','search-release-list',
|
||||
'cancel-search-release','confirm-search-release'
|
||||
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',
|
||||
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
|
||||
const estimateInputs=[
|
||||
{{dataset:{{searchBatchEstimate:'stackchain/dashboard#811'}},value:'25'}},
|
||||
{{dataset:{{searchBatchEstimate:'stackchain/api#12'}},value:'30'}},
|
||||
];
|
||||
const document={{
|
||||
querySelector:selector=>elements[selector] || null,
|
||||
querySelectorAll:selector=>selector==='[data-search-batch-estimate]' ? estimateInputs : [],
|
||||
}};
|
||||
const calls=[];
|
||||
const processorOptions=[];
|
||||
const processor={{
|
||||
run:(items,estimates)=>{{calls.push({{type:'run',items,estimates}});return Promise.resolve();}},
|
||||
resume:()=>Promise.resolve(),pending:()=>0,
|
||||
}};
|
||||
const batchFactory=options=>{{processorOptions.push(options);return processor;}};
|
||||
const planning={{capacity_minutes:90,estimates:{{'stackchain/existing#1':20}}}};
|
||||
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,
|
||||
replacePlanning:value=>Object.assign(planning,value)}};
|
||||
const todaySync={{enqueueConfiguration:()=>{{}},flush:()=>{{}}}};
|
||||
const items=[
|
||||
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:811,title:'Capacity'}},
|
||||
{{kind:'issue',state:'open',repository:'stackchain/api',number:12,title:'API'}},
|
||||
];
|
||||
let selected=items;
|
||||
const mounted=createSearchBatchPlan.mount(
|
||||
document,batchFactory,todayWork,()=> 'timmy',()=>Promise.resolve(),()=>'',()=>Promise.resolve('queued'),
|
||||
todaySync,item=>item,index=>selected[index],()=>{{}},value=>value,value=>value
|
||||
);
|
||||
mounted.plan.start();
|
||||
items.forEach(item=>mounted.plan.toggle(item));
|
||||
listeners['queue-selected-search-results:click']();
|
||||
const before={{
|
||||
calls:calls.slice(),
|
||||
reviewHidden:elements['#search-batch-estimate-review'].hidden,
|
||||
actionsHidden:elements['#search-batch-actions'].hidden,
|
||||
markup:elements['#search-batch-estimate-list'].innerHTML,
|
||||
budget:processorOptions[0].timeBudget(),
|
||||
}};
|
||||
listeners['confirm-search-batch-estimates:click']();
|
||||
processorOptions[0].persistEstimate(items[0],25);
|
||||
processorOptions[0].onProgress({{status:'complete',queued:['stackchain/dashboard#811','stackchain/api#12'],failed:[]}});
|
||||
const afterCompleteHidden=elements['#search-batch-estimate-review'].hidden;
|
||||
process.stdout.write(JSON.stringify({{before,calls,afterCompleteHidden}}));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["before"]["calls"] == []
|
||||
assert result["before"]["reviewHidden"] is False
|
||||
assert result["before"]["actionsHidden"] is True
|
||||
assert "Capacity" in result["before"]["markup"]
|
||||
assert "data-search-batch-estimate" in result["before"]["markup"]
|
||||
assert result["before"]["budget"] == {"capacity_minutes": 90, "planned_minutes": 20}
|
||||
assert result["calls"] == [{
|
||||
"type": "run",
|
||||
"items": [
|
||||
{"kind": "issue", "state": "open", "repository": "stackchain/dashboard", "number": 811, "title": "Capacity"},
|
||||
{"kind": "issue", "state": "open", "repository": "stackchain/api", "number": 12, "title": "API"},
|
||||
],
|
||||
"estimates": {"stackchain/dashboard#811": 25, "stackchain/api#12": 30},
|
||||
}]
|
||||
assert result["afterCompleteHidden"] is True
|
||||
|
||||
|
||||
def test_search_batch_reviews_and_applies_one_repository_release_plan_before_assignment():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const listeners={{}};
|
||||
const elements={{}};
|
||||
for (const id of [
|
||||
'search-batch-actions','select-search-results','queue-selected-search-results',
|
||||
'defer-selected-search-results','plan-selected-search-results','search-selection-status',
|
||||
'cmd-search-action-status','resume-search-batch','open-palette','cmd-results',
|
||||
'cancel-search-selection','search-batch-estimate-review','search-batch-estimate-summary',
|
||||
'search-batch-estimate-list','cancel-search-batch-estimates','confirm-search-batch-estimates',
|
||||
'search-release-review','search-release-summary','search-release-milestone',
|
||||
'search-release-due-date','search-release-list','cancel-search-release','confirm-search-release'
|
||||
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',
|
||||
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
|
||||
const document={{querySelector:selector=>elements[selector] || null,querySelectorAll:()=>[]}};
|
||||
const calls=[];
|
||||
const fetchJson=(url,options={{}})=>{{
|
||||
calls.push({{url,method:options.method || 'GET',body:options.body ? JSON.parse(options.body) : null}});
|
||||
if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}},{{id:7,title:'Old',state:'closed'}}]);
|
||||
const number=Number(url.split('/issues/')[1].split('/')[0]);
|
||||
if (url.endsWith('/milestone')) return Promise.resolve({{number,milestone:{{id:9,title:'August RC'}}}});
|
||||
if (url.endsWith('/due-date')) return Promise.resolve({{number,due_date:'2026-08-31T23:59:59Z'}});
|
||||
return Promise.resolve();
|
||||
}};
|
||||
const processorOptions=[];
|
||||
const processors=[];
|
||||
const batchFactory=options=>{{processorOptions.push(options);const processor={{
|
||||
run:(items,estimates,context)=>{{calls.push({{run:items.map(item=>item.number),context}});return Promise.resolve();}},
|
||||
resume:()=>Promise.resolve(),pending:()=>0,
|
||||
}};processors.push(processor);return processor;}};
|
||||
const planning={{capacity_minutes:null,estimates:{{}}}};
|
||||
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,replacePlanning:()=>{{}}}};
|
||||
const items=[
|
||||
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:815,title:'Release plan'}},
|
||||
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:816,title:'Second issue'}},
|
||||
];
|
||||
const mounted=createSearchBatchPlan.mount(
|
||||
document,batchFactory,todayWork,()=> 'timmy',fetchJson,()=>'',()=>Promise.resolve('queued'),
|
||||
{{enqueueConfiguration:()=>{{}},flush:()=>{{}}}},item=>item,index=>items[index],()=>{{}},
|
||||
value=>value,value=>value,null,null
|
||||
);
|
||||
mounted.plan.start();items.forEach(item=>mounted.plan.toggle(item));
|
||||
listeners['plan-selected-search-results:click']().then(async()=>{{
|
||||
const before={{calls:calls.slice(),reviewHidden:elements['#search-release-review'].hidden,
|
||||
actionsHidden:elements['#search-batch-actions'].hidden,options:elements['#search-release-milestone'].innerHTML,
|
||||
list:elements['#search-release-list'].innerHTML}};
|
||||
elements['#search-release-milestone'].value='9';
|
||||
elements['#search-release-milestone'].selectedIndex=0;
|
||||
elements['#search-release-milestone'].options=[{{textContent:'August RC'}}];
|
||||
elements['#search-release-due-date'].value='2026-08-31';
|
||||
listeners['confirm-search-release:click']();
|
||||
const queued=await processorOptions[2].queue({{...items[0],assigned_to_me:true}},
|
||||
{{milestone_id:9,milestone_title:'August RC',due_date:'2026-08-31T23:59:59Z'}});
|
||||
process.stdout.write(JSON.stringify({{before,calls,queued,journals:processorOptions.map(option=>option.journalName)}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["before"]["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/milestones", "method": "GET", "body": None,
|
||||
}]
|
||||
assert result["before"]["reviewHidden"] is False
|
||||
assert result["before"]["actionsHidden"] is True
|
||||
assert "August RC" in result["before"]["options"]
|
||||
assert "Old" not in result["before"]["options"]
|
||||
assert "Release plan" in result["before"]["list"]
|
||||
assert result["calls"][1] == {
|
||||
"run": [815, 816],
|
||||
"context": {
|
||||
"milestone_id": 9, "milestone_title": "August RC",
|
||||
"due_date": "2026-08-31T23:59:59Z",
|
||||
},
|
||||
}
|
||||
assert result["calls"][2:] == [
|
||||
{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/815/milestone",
|
||||
"method": "PATCH", "body": {"milestone_id": 9},
|
||||
},
|
||||
{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/815/due-date",
|
||||
"method": "PATCH", "body": {"due_date": "2026-08-31T23:59:59Z"},
|
||||
},
|
||||
]
|
||||
assert result["queued"] == "queued"
|
||||
assert result["journals"] == [
|
||||
"search-today-batch", "search-later-batch", "search-release-batch",
|
||||
]
|
||||
@@ -401,6 +401,217 @@ async def test_successful_protected_actions_record_only_bounded_targets(
|
||||
assert b"abc123" not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments/42",
|
||||
"/api/v1/repos/stackchain/api/pulls/7/comments/42",
|
||||
"/api/v1/notifications/91/comments/42",
|
||||
],
|
||||
)
|
||||
async def test_comment_deletion_requires_exact_fresh_authorization_before_gitea(
|
||||
security_access, monkeypatch, path
|
||||
):
|
||||
calls = []
|
||||
|
||||
async def assigned(*_args):
|
||||
return True
|
||||
|
||||
async def notification_target(thread_id):
|
||||
assert thread_id == 91
|
||||
return "stackchain/api", 7
|
||||
|
||||
async def delete(*args):
|
||||
calls.append(args)
|
||||
return {"id": 42, "deleted": True}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_target", notification_target)
|
||||
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
response = await client.delete(path, headers=headers)
|
||||
|
||||
assert response.status_code == 428
|
||||
assert response.json()["detail"] == {
|
||||
"detail": "Fresh authorization required",
|
||||
"code": "step_up_required",
|
||||
"action": "delete_comment",
|
||||
"target": "stackchain/api#7:42",
|
||||
}
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorized_comment_deletion_records_only_privacy_safe_target(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
async def assigned(*_args):
|
||||
return True
|
||||
|
||||
async def delete(*_args):
|
||||
return {"id": 42, "deleted": True}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "delete_comment",
|
||||
"target": "stackchain/api#7:42",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
response = await client.delete(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments/42",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
events = (await client.get("/api/v1/security-events")).json()["events"]
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (events[0]["kind"], events[0]["target"], events[0]["status"]) == (
|
||||
"comment_deleted", "stackchain/api#7:42", "completed"
|
||||
)
|
||||
persisted = (security_access / "security.sqlite3").read_bytes()
|
||||
assert b"private comment body" not in persisted
|
||||
assert b"correct horse battery staple" not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_comment_deletion_reservation_failure_prevents_gitea_mutation(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
calls = []
|
||||
|
||||
async def assigned(*_args):
|
||||
return True
|
||||
|
||||
async def delete(*args):
|
||||
calls.append(args)
|
||||
return {"id": 42, "deleted": True}
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "delete_comment",
|
||||
"target": "stackchain/api#7:42",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
response = await client.delete(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments/42",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("finalize_fails", [False, True])
|
||||
async def test_comment_deletion_journal_tracks_failed_and_confirmed_outcomes(
|
||||
security_access, monkeypatch, finalize_fails
|
||||
):
|
||||
journal_calls = []
|
||||
|
||||
async def assigned(*_args):
|
||||
return True
|
||||
|
||||
async def delete(*_args):
|
||||
if not finalize_fails:
|
||||
raise RuntimeError("upstream rejected deletion")
|
||||
return {"id": 42, "deleted": True}
|
||||
|
||||
class Journal:
|
||||
def reserve(self, kind, *, target):
|
||||
journal_calls.append(("reserve", kind, target))
|
||||
return "operation-42"
|
||||
|
||||
def discard(self, operation_id):
|
||||
journal_calls.append(("discard", operation_id))
|
||||
|
||||
def finalize(self, operation_id):
|
||||
journal_calls.append(("finalize", operation_id))
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "delete_comment",
|
||||
"target": "stackchain/api#7:42",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: Journal())
|
||||
response = await client.delete(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments/42",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
|
||||
assert journal_calls[0] == (
|
||||
"reserve", "comment_deleted", "stackchain/api#7:42"
|
||||
)
|
||||
if finalize_fails:
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": 42, "deleted": True}
|
||||
assert journal_calls[1] == ("finalize", "operation-42")
|
||||
else:
|
||||
assert response.status_code == 503
|
||||
assert journal_calls[1] == ("discard", "operation-42")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_approved_pull_review_records_a_privacy_safe_completed_event(
|
||||
security_access, monkeypatch
|
||||
|
||||
+134
-11
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], sharedRecords: {{}}, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
@@ -46,6 +46,11 @@ const context = {{
|
||||
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
||||
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
|
||||
__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15,
|
||||
__STACKCHAIN_SHARED_ATTACHMENT_STORE: {{
|
||||
put: async (id, value) => {{ state.sharedRecords[id] = value; }},
|
||||
get: async id => state.sharedRecords[id] || null,
|
||||
delete: async id => {{ delete state.sharedRecords[id]; }},
|
||||
}},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{
|
||||
@@ -145,7 +150,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -154,14 +159,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -169,7 +174,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -177,14 +182,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -193,24 +198,77 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
def test_image_share_target_stages_one_supported_image_and_redirects_to_capture():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const image = new Blob(['pixels'], {type:'image/png'});
|
||||
Object.defineProperty(image, 'name', {value:'bug.png'});
|
||||
const form = new FormData();
|
||||
form.append('title', 'Broken checkout');
|
||||
form.append('text', 'Steps to reproduce');
|
||||
form.append('url', 'https://example.test/checkout');
|
||||
form.append('image', image, 'bug.png');
|
||||
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
|
||||
Object.defineProperty(request, 'mode', {value:'navigate'});
|
||||
const response = await dispatch('fetch', request);
|
||||
const [id, record] = Object.entries(state.sharedRecords)[0];
|
||||
process.stdout.write(JSON.stringify({
|
||||
status:response.status, location:response.headers.get('Location'), id,
|
||||
filename:record.filename, contentType:record.contentType, size:record.blob.size,
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": 303,
|
||||
"location": "/dashboard/?launch=new&shared=image&title=Broken+checkout&text=Steps+to+reproduce&url=https%3A%2F%2Fexample.test%2Fcheckout",
|
||||
"id": "shared-image",
|
||||
"filename": "bug.png",
|
||||
"contentType": "image/png",
|
||||
"size": 6,
|
||||
}
|
||||
|
||||
|
||||
def test_image_share_target_rejects_multiple_images_without_staging_private_data():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const form = new FormData();
|
||||
form.append('image', new Blob(['one'], {type:'image/png'}), 'one.png');
|
||||
form.append('image', new Blob(['two'], {type:'image/jpeg'}), 'two.jpg');
|
||||
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
|
||||
Object.defineProperty(request, 'mode', {value:'navigate'});
|
||||
const response = await dispatch('fetch', request);
|
||||
process.stdout.write(JSON.stringify({
|
||||
status:response.status, location:response.headers.get('Location'), records:Object.keys(state.sharedRecords),
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": 303,
|
||||
"location": "/dashboard/?launch=new&shared=multiple",
|
||||
"records": [],
|
||||
}
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -459,6 +517,55 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c
|
||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_private_copy():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchPush({
|
||||
title:'must-not-render', body:'private details must-not-render',
|
||||
tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda',
|
||||
protect_route:'#/my-work/agenda/protect-today', deadline_count:3,
|
||||
});
|
||||
await dispatchNotificationClick('#/my-work/agenda/protect-today', 'protect-today', null, 'stackchain-deadline-digest-2026-08-13');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == [{
|
||||
"title": "3 deadlines need attention",
|
||||
"options": {
|
||||
"body": "Open Agenda to review or replan them.",
|
||||
"tag": "stackchain-deadline-digest-2026-08-13",
|
||||
"actions": [
|
||||
{"action": "protect-today", "title": "Protect Today"},
|
||||
{"action": "open-agenda", "title": "Open Agenda"},
|
||||
],
|
||||
"data": {
|
||||
"route": "#/my-work/agenda",
|
||||
"protectRoute": "#/my-work/agenda/protect-today",
|
||||
},
|
||||
},
|
||||
}]
|
||||
assert result["opened"] == [
|
||||
"https://forge.example/dashboard/#/my-work/agenda/protect-today"
|
||||
]
|
||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_deadline_digest_open_agenda_action_preserves_browsing_flow():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchPush({
|
||||
tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda',
|
||||
protect_route:'#/my-work/agenda/protect-today', deadline_count:1,
|
||||
});
|
||||
await dispatchNotificationClick('#/my-work/agenda', 'open-agenda', null, 'stackchain-deadline-digest-2026-08-13');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["opened"] == ["https://forge.example/dashboard/#/my-work/agenda"]
|
||||
|
||||
|
||||
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -654,7 +761,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -681,9 +788,11 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/markdown.js",
|
||||
"/dashboard/static/commands.js",
|
||||
"/dashboard/static/search-preview.js",
|
||||
"/dashboard/static/search-defer.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/unfiled-captures.js",
|
||||
"/dashboard/static/shared-image-capture.js",
|
||||
"/dashboard/static/draft-filing-session.js",
|
||||
"/dashboard/static/draft-capacity-dialog.js",
|
||||
"/dashboard/static/outbox-coordinator.js",
|
||||
@@ -695,6 +804,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/offline-today.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/agenda-replan.js",
|
||||
"/dashboard/static/protect-today.js",
|
||||
"/dashboard/static/notification-undo.js",
|
||||
"/dashboard/static/card-planning.js",
|
||||
"/dashboard/static/work-selection.js",
|
||||
@@ -718,6 +829,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/detail-defer.js",
|
||||
"/dashboard/static/later-picker.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/batch-find-work.js",
|
||||
"/dashboard/static/search-batch-plan.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/comment-actions.js",
|
||||
"/dashboard/static/issue-attachment.js",
|
||||
@@ -731,10 +844,20 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/work-route.js",
|
||||
"/dashboard/static/task-overlay-history.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/live-data-status.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
"/dashboard/static/update-triage-session.js",
|
||||
"/dashboard/static/update-review-handoff.js",
|
||||
"/dashboard/static/update-read-position.js",
|
||||
"/dashboard/static/work-detail-position.js",
|
||||
"/dashboard/static/update-triage-launcher.js",
|
||||
"/dashboard/static/update-triage-gesture.js",
|
||||
"/dashboard/static/update-decision-transaction.js",
|
||||
"/dashboard/static/agenda-session-launcher.js",
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/mobile-app-shortcuts.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/mobile-device-setup.js",
|
||||
"/dashboard/static/mobile-search-viewport.js",
|
||||
|
||||
@@ -102,6 +102,45 @@ def test_managed_session_status_enforces_absolute_and_idle_expiry(tmp_path):
|
||||
assert store.managed_status("missing-device", idle_timeout_seconds=900) == "revoked"
|
||||
|
||||
|
||||
def test_managed_session_statuses_classify_a_batch_with_one_read(tmp_path, monkeypatch):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
store.activate("active-session", 3_000, management_id="active-device")
|
||||
store.activate("idle-session", 3_000, management_id="idle-device")
|
||||
store.activate("expired-session", 1_500, management_id="expired-device")
|
||||
now[0] = 2_000.0
|
||||
store.touch("active-session", 3_000, idle_timeout_seconds=1_500)
|
||||
statements = []
|
||||
connect = sqlite3.connect
|
||||
|
||||
def traced_connect(*args, **kwargs):
|
||||
connection = connect(*args, **kwargs)
|
||||
connection.set_trace_callback(statements.append)
|
||||
return connection
|
||||
|
||||
monkeypatch.setattr(session_store.sqlite3, "connect", traced_connect)
|
||||
|
||||
assert store.managed_statuses(
|
||||
["active-device", "idle-device", "expired-device", "missing-device", "active-device"],
|
||||
idle_timeout_seconds=900,
|
||||
) == {
|
||||
"active-device": "active",
|
||||
"idle-device": "idle",
|
||||
"expired-device": "revoked",
|
||||
"missing-device": "revoked",
|
||||
}
|
||||
assert [statement for statement in statements if statement.lstrip().upper().startswith("SELECT")] == [
|
||||
next(statement for statement in statements if "FROM active_sessions" in statement)
|
||||
]
|
||||
|
||||
|
||||
def test_managed_session_statuses_skips_database_for_an_empty_batch(tmp_path, monkeypatch):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
monkeypatch.setattr(store, "_connect", lambda *args, **kwargs: pytest.fail("database opened"))
|
||||
|
||||
assert store.managed_statuses([], idle_timeout_seconds=900) == {}
|
||||
|
||||
|
||||
def test_touch_extends_only_the_matching_live_session(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).resolve().parents[1] / "frontend" / "shared-image-capture.js"
|
||||
|
||||
|
||||
def test_shared_image_is_consumed_once_and_restored_into_capture():
|
||||
script = f"""
|
||||
const shared = require({json.dumps(str(MODULE))});
|
||||
const calls=[];
|
||||
const store={{
|
||||
get:async id=>({{filename:'bug.png',contentType:'image/png',blob:{{size:6,type:'image/png'}}}}),
|
||||
delete:async id=>calls.push(['delete',id]),
|
||||
}};
|
||||
shared.consume({{
|
||||
marker:'image', store,
|
||||
restore:value=>calls.push(['restore',value.filename]),
|
||||
status:message=>calls.push(['status',message]),
|
||||
}}).then(first=>shared.consume({{marker:'',store,restore:()=>calls.push(['restore-again'])}}).then(second=>{{
|
||||
process.stdout.write(JSON.stringify({{first,second,calls}}));
|
||||
}}));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"first": True,
|
||||
"second": False,
|
||||
"calls": [
|
||||
["restore", "bug.png"],
|
||||
["delete", "shared-image"],
|
||||
["status", "Shared screenshot ready to file with this issue."],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_invalid_shared_image_reports_error_without_touching_open_capture():
|
||||
script = f"""
|
||||
const shared = require({json.dumps(str(MODULE))});
|
||||
const calls=[];
|
||||
shared.consume({{
|
||||
marker:'unsupported', store:{{get:async()=>null,delete:async()=>calls.push('delete')}},
|
||||
restore:()=>calls.push('restore'), status:message=>calls.push(message),
|
||||
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"result": False,
|
||||
"calls": ["Share one PNG, JPEG, or WebP screenshot."],
|
||||
}
|
||||
@@ -207,8 +207,137 @@ def test_dashboard_routes_mobile_task_overlays_through_browser_history():
|
||||
assert "createTaskOverlayHistory({" in html
|
||||
assert "taskOverlayHistory.open('new')" in html
|
||||
assert "taskOverlayHistory.open('find')" in html
|
||||
assert "taskOverlayHistory.open('search')" in html
|
||||
assert "taskOverlayHistory.open('search', { scope:currentSearchScope() })" in html
|
||||
assert "taskOverlayHistory.open('search-preview')" in html
|
||||
assert "taskOverlayHistory.close()" in html
|
||||
assert "saveIssueCaptureDraft();" in html
|
||||
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
|
||||
|
||||
|
||||
def test_search_history_serializes_bounded_query_and_stable_preview_identity():
|
||||
script = f"""
|
||||
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
||||
const pushed = [];
|
||||
const replaced = [];
|
||||
const location = {{ pathname:'/dashboard/', search:'?keep=1', hash:'' }};
|
||||
const history = {{
|
||||
state: {{ page:'dashboard' }},
|
||||
pushState(state, title, url) {{ this.state = state; pushed.push([state, url]); }},
|
||||
replaceState(state, title, url) {{ this.state = state; replaced.push([state, url]); }},
|
||||
back() {{}},
|
||||
}};
|
||||
const controller = createTaskOverlayHistory({{
|
||||
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
|
||||
}});
|
||||
controller.open('search', {{ query:' release blocker ' }});
|
||||
controller.update({{ query:'release candidate' }});
|
||||
controller.open('search-preview', {{
|
||||
query:'release candidate', preview:{{kind:'issue', repository:'stackchain/api', number:123,
|
||||
title:'must not be serialized', body:'secret'}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{pushed, replaced, current:controller.currentState()}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert len(payload["pushed"]) == 2
|
||||
assert len(payload["replaced"]) == 1
|
||||
assert payload["pushed"][0][1] == "/dashboard/?keep=1&search=release+blocker"
|
||||
assert payload["replaced"][0][1] == "/dashboard/?keep=1&search=release+candidate"
|
||||
assert payload["pushed"][1][1] == (
|
||||
"/dashboard/?keep=1&search=release+candidate&preview=issue%3Astackchain%2Fapi%3A123"
|
||||
)
|
||||
assert payload["current"] == {
|
||||
"kind": "search-preview",
|
||||
"query": "release candidate",
|
||||
"preview": {"kind": "issue", "repository": "stackchain/api", "number": 123},
|
||||
}
|
||||
|
||||
|
||||
def test_search_history_restores_direct_url_and_rejects_malformed_or_oversized_state():
|
||||
script = f"""
|
||||
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
||||
function restore(search) {{
|
||||
const changes = [];
|
||||
const location = {{ pathname:'/dashboard/', search, hash:'' }};
|
||||
const history = {{ state:null, replaceState(state) {{ this.state = state; }}, back() {{}} }};
|
||||
const controller = createTaskOverlayHistory({{
|
||||
history, location, eventTarget:{{addEventListener() {{}}}},
|
||||
onChange(kind, previous, detail) {{ changes.push([kind, previous, detail]); }},
|
||||
}});
|
||||
controller.start();
|
||||
return {{ state:controller.currentState(), changes }};
|
||||
}}
|
||||
process.stdout.write(JSON.stringify({{
|
||||
valid:restore('?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9'),
|
||||
malformed:restore('?search=ok&preview=issue%3Ainvalid%3A0'),
|
||||
oversized:restore('?search=' + 'x'.repeat(201)),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
expected = {
|
||||
"kind": "search-preview",
|
||||
"query": "release blocker",
|
||||
"preview": {"kind": "pull", "repository": "stackchain/api", "number": 9},
|
||||
}
|
||||
assert payload["valid"]["state"] == expected
|
||||
assert payload["valid"]["changes"] == [["search-preview", None, expected]]
|
||||
assert payload["malformed"]["state"] == {"kind": "search", "query": "ok"}
|
||||
assert payload["oversized"]["state"] == {"kind": None}
|
||||
|
||||
|
||||
def test_search_history_round_trips_bounded_type_status_and_repository_scope():
|
||||
script = f"""
|
||||
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
||||
function restore(search) {{
|
||||
const location = {{pathname:'/dashboard/', search, hash:''}};
|
||||
const history = {{state:null, replaceState(state, title, url) {{this.state=state; this.url=url;}}, back() {{}}}};
|
||||
const controller = createTaskOverlayHistory({{
|
||||
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
|
||||
}});
|
||||
return {{state:controller.currentState(), url:history.url}};
|
||||
}}
|
||||
const location = {{pathname:'/dashboard/', search:'', hash:''}};
|
||||
const history = {{state:null, pushState(state,title,url) {{this.state=state; this.url=url;}}, replaceState() {{}}, back() {{}}}};
|
||||
const controller = createTaskOverlayHistory({{
|
||||
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
|
||||
}});
|
||||
controller.open('search', {{query:'mobile', scope:{{kind:'pull',state:'open',repository:'stackchain/api'}}}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
opened:controller.currentState(), url:history.url,
|
||||
valid:restore('?search=mobile&search_kind=issue&search_state=closed&search_repository=stackchain%2Fapi'),
|
||||
invalid:restore('?search=mobile&search_kind=script&search_state=secret&search_repository=stackchain%2Fapi%2Fprivate'),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["opened"] == {
|
||||
"kind": "search", "query": "mobile", "scope": {
|
||||
"kind": "pull", "state": "open", "repository": "stackchain/api"
|
||||
}
|
||||
}
|
||||
assert payload["url"] == (
|
||||
"/dashboard/?search=mobile&search_kind=pull&search_state=open&"
|
||||
"search_repository=stackchain%2Fapi"
|
||||
)
|
||||
assert payload["valid"]["state"]["scope"] == {
|
||||
"kind": "issue", "state": "closed", "repository": "stackchain/api"
|
||||
}
|
||||
assert payload["invalid"]["state"]["scope"] == {
|
||||
"kind": "all", "state": "all", "repository": ""
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_persists_search_query_and_restores_canonical_preview():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "taskOverlayHistory.update({ query:e.target.value })" in html
|
||||
assert "scope:currentSearchScope(), preview:item.result" in html
|
||||
assert "detail?.query" in html
|
||||
assert "searchPreview.open(detail.preview).catch" in html
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v97';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v101';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
+20
-10
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -275,6 +274,8 @@ async def test_recap_time_logging_attempts_multiple_entries_with_bounded_concurr
|
||||
active = 0
|
||||
peak = 0
|
||||
attempted = []
|
||||
first_wave_started = asyncio.Event()
|
||||
release_first_wave = asyncio.Event()
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
@@ -284,8 +285,10 @@ async def test_recap_time_logging_attempts_multiple_entries_with_bounded_concurr
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
attempted.append(identity)
|
||||
if len(attempted) == 3:
|
||||
first_wave_started.set()
|
||||
try:
|
||||
await asyncio.sleep(0.1)
|
||||
await release_first_wave.wait()
|
||||
finally:
|
||||
active -= 1
|
||||
|
||||
@@ -305,17 +308,24 @@ async def test_recap_time_logging_attempts_multiple_entries_with_bounded_concurr
|
||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
grant = await recap_time_grant(client, headers, payload)
|
||||
started = time.perf_counter()
|
||||
response = await client.post(
|
||||
"/api/v1/today/recaps/log-time",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
request = asyncio.create_task(
|
||||
client.post(
|
||||
"/api/v1/today/recaps/log-time",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
)
|
||||
elapsed = time.perf_counter() - started
|
||||
await asyncio.wait_for(first_wave_started.wait(), timeout=1)
|
||||
await asyncio.sleep(0)
|
||||
assert active == 3
|
||||
assert peak == 3
|
||||
assert len(attempted) == 3
|
||||
assert not request.done()
|
||||
release_first_wave.set()
|
||||
response = await request
|
||||
|
||||
assert response.status_code == 200
|
||||
assert elapsed < 0.35
|
||||
assert 1 < peak <= 3
|
||||
assert peak == 3
|
||||
assert set(attempted) == set(identities)
|
||||
assert response.json()["time_logs"] == [
|
||||
{"identity": identity, "status": "logged"} for identity in identities
|
||||
|
||||
@@ -67,6 +67,20 @@ def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
|
||||
}
|
||||
|
||||
|
||||
def test_initialized_today_reads_remain_available_during_a_planning_write(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, timeout=0.05)
|
||||
expected = store.apply("timmy", "seed", "add", "issue:r:1:")
|
||||
|
||||
writer = sqlite3.connect(path)
|
||||
writer.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
assert TodayStore(path, timeout=0.05).get("timmy") == expected
|
||||
finally:
|
||||
writer.rollback()
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_capacity_and_estimates_are_durable_account_scoped_and_follow_item_identity(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, limit=3)
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -344,7 +344,8 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||
assert "const todayWork = createTodayWork({" in html
|
||||
assert "getTodayCount: () => todayMyWork.length" in html
|
||||
assert "startToday: startTodaySession" in html
|
||||
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
|
||||
assert "selectedWorkFilter === 'today' ? todayMyWork :" in html
|
||||
assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork) : activeMyWork" in html
|
||||
assert 'data-today-add' in html
|
||||
assert 'data-today-remove' in html
|
||||
assert 'data-today-move="up"' in html
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
POSITIONER = Path(__file__).resolve().parents[1] / "frontend" / "update-read-position.js"
|
||||
|
||||
|
||||
def run_positioner(script: str) -> dict:
|
||||
source = f"const createPositioner = require({json.dumps(str(POSITIONER))});\n" + script
|
||||
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_new_update_scrolls_to_newest_activity_once_without_reusing_previous_offset():
|
||||
result = run_positioner("""
|
||||
const listeners = {};
|
||||
const panel = {scrollTop:480, addEventListener:(name, fn)=>listeners[name]=fn};
|
||||
const jump = {hidden:true, focus(){}};
|
||||
const first = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
|
||||
const second = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
|
||||
const positioner = createPositioner({panel, jump});
|
||||
positioner.open('101');
|
||||
positioner.ready('101', first);
|
||||
positioner.ready('101', first);
|
||||
panel.scrollTop = 920;
|
||||
positioner.open('102');
|
||||
positioner.ready('102', second);
|
||||
process.stdout.write(JSON.stringify({first:first.calls, second:second.calls, jumpHidden:jump.hidden}));
|
||||
""")
|
||||
|
||||
assert result == {"first": 1, "second": 1, "jumpHidden": True}
|
||||
|
||||
|
||||
def test_user_scroll_during_loading_preserves_position_until_explicit_jump():
|
||||
result = run_positioner("""
|
||||
const listeners = {};
|
||||
const panel = {scrollTop:120, addEventListener:(name, fn)=>listeners[name]=fn};
|
||||
const jump = {hidden:true, focus(){}};
|
||||
const target = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
|
||||
const positioner = createPositioner({panel, jump});
|
||||
positioner.open('201');
|
||||
panel.scrollTop = 260;
|
||||
listeners.scroll();
|
||||
positioner.ready('201', target);
|
||||
const before = {calls:target.calls, hidden:jump.hidden, top:panel.scrollTop};
|
||||
positioner.jump();
|
||||
process.stdout.write(JSON.stringify({before, after:{calls:target.calls, hidden:jump.hidden}}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"before": {"calls": 0, "hidden": False, "top": 260},
|
||||
"after": {"calls": 1, "hidden": True},
|
||||
}
|
||||
|
||||
|
||||
def test_stale_or_prefetched_conversation_cannot_move_the_visible_update():
|
||||
result = run_positioner("""
|
||||
const panel = {scrollTop:0, addEventListener(){}};
|
||||
const jump = {hidden:true};
|
||||
const stale = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
|
||||
const current = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}};
|
||||
const positioner = createPositioner({panel, jump});
|
||||
positioner.open('302');
|
||||
positioner.ready('301', stale);
|
||||
positioner.ready('302', current);
|
||||
process.stdout.write(JSON.stringify({stale:stale.calls, current:current.calls}));
|
||||
""")
|
||||
|
||||
assert result == {"stale": 0, "current": 1}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_mounts_mobile_new_activity_positioning_flow():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/update-read-position.js"></script>' in html
|
||||
assert 'id="jump-update-new-activity"' in html
|
||||
assert "updateReadPosition.open(String(item.notification_id))" in html
|
||||
assert "updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest)" in html
|
||||
assert ".update-new-activity" in html
|
||||
assert "BASE + 'static/update-read-position.js'" in (POSITIONER.parent / "service-worker.js").read_text()
|
||||
@@ -0,0 +1,96 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
HANDOFF = Path(__file__).resolve().parents[1] / "frontend" / "update-review-handoff.js"
|
||||
|
||||
|
||||
def run_handoff(script):
|
||||
source = f"const createHandoff = require({json.dumps(str(HANDOFF))});\n" + script
|
||||
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_review_handoff_only_admits_review_requested_pull_updates():
|
||||
result = run_handoff("""
|
||||
const handoff = createHandoff({openReview:()=>{}, admitRead:async()=>{}, advance:()=>{}});
|
||||
const cases = [
|
||||
{is_review:true, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:41},
|
||||
{is_review:true, update_reason:'Mentioned', subject_type:'Pull', repository:'acme/app', number:7, notification_id:42},
|
||||
{is_review:true, update_reason:'Review requested', subject_type:'Issue', repository:'acme/app', number:7, notification_id:43},
|
||||
{is_review:false, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:44},
|
||||
];
|
||||
process.stdout.write(JSON.stringify(cases.map(item => handoff.eligible(item))));
|
||||
""")
|
||||
|
||||
assert result == [True, False, False, False]
|
||||
|
||||
|
||||
def test_review_handoff_preserves_update_on_cancel_and_advances_after_read_admission():
|
||||
result = run_handoff("""
|
||||
(async () => {
|
||||
const events = [];
|
||||
const update = {is_review:true, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:41, title:'Ship it'};
|
||||
const handoff = createHandoff({
|
||||
openReview:item=>events.push(['open', item.repository, item.number]),
|
||||
restoreUpdate:item=>events.push(['restore', item.notification_id]),
|
||||
admitRead:async id=>events.push(['read', id]),
|
||||
advance:()=>events.push(['advance']),
|
||||
});
|
||||
handoff.begin(update);
|
||||
handoff.cancel();
|
||||
handoff.begin(update);
|
||||
const completed = await handoff.complete();
|
||||
process.stdout.write(JSON.stringify({events, completed, active:handoff.active()}));
|
||||
})().catch(error => { console.error(error); process.exit(1); });
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"events": [
|
||||
["open", "acme/app", 7],
|
||||
["restore", 41],
|
||||
["open", "acme/app", 7],
|
||||
["read", 41],
|
||||
["advance"],
|
||||
],
|
||||
"completed": True,
|
||||
"active": False,
|
||||
}
|
||||
|
||||
|
||||
def test_review_handoff_keeps_current_update_when_read_admission_fails():
|
||||
result = run_handoff("""
|
||||
(async () => {
|
||||
const events = [];
|
||||
const update = {is_review:true, update_reason:'Review requested', subject_type:'Pull', repository:'acme/app', number:7, notification_id:41};
|
||||
const handoff = createHandoff({
|
||||
openReview:()=>events.push('open'),
|
||||
admitRead:async()=>{ events.push('read'); throw new Error('queue unavailable'); },
|
||||
advance:()=>events.push('advance'),
|
||||
});
|
||||
handoff.begin(update);
|
||||
let message = '';
|
||||
try { await handoff.complete(); } catch (error) { message = error.message; }
|
||||
process.stdout.write(JSON.stringify({events, message, active:handoff.active()}));
|
||||
})().catch(error => { console.error(error); process.exit(1); });
|
||||
""")
|
||||
|
||||
assert result == {"events": ["open", "read"], "message": "queue unavailable", "active": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_review_requested_update_into_existing_review_workspace():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/update-review-handoff.js"></script>' in html
|
||||
assert 'id="review-update-now"' in html
|
||||
assert "updateReviewHandoff.begin(selectedUpdate)" in html
|
||||
assert "await updateReviewHandoff.complete()" in html
|
||||
assert "updateReviewHandoff.cancel()" in html
|
||||
assert "updateReviewHandoff.eligible(detail" in html
|
||||
@@ -0,0 +1,178 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
GESTURE = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-gesture.js"
|
||||
TRANSACTION = Path(__file__).resolve().parents[1] / "frontend" / "update-decision-transaction.js"
|
||||
|
||||
|
||||
def run_gesture(script):
|
||||
source = f"const createGesture = require({json.dumps(str(GESTURE))});\n" + script
|
||||
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def run_transaction(script):
|
||||
source = f"const createTransaction = require({json.dumps(str(TRANSACTION))});\n" + script
|
||||
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_update_decision_transaction_locks_every_action_until_success():
|
||||
result = run_transaction(r"""
|
||||
const controls = ['keep', 'read', 'reply', 'more'].map(name => ({name, disabled:false, focus(){}}));
|
||||
const status = {textContent:'Ready'};
|
||||
let resolveAction;
|
||||
let calls = 0;
|
||||
const transaction = createTransaction({controls, status});
|
||||
const pending = transaction.run('Marking read…', () => new Promise(resolve => {
|
||||
calls += 1;
|
||||
resolveAction = resolve;
|
||||
}));
|
||||
const locked = controls.every(control => control.disabled) && transaction.busy();
|
||||
const rejected = transaction.run('Keeping unread…', () => { calls += 1; });
|
||||
resolveAction(true);
|
||||
Promise.all([pending, rejected]).then(([completed, second]) => process.stdout.write(JSON.stringify({
|
||||
locked, pendingStatus: 'Marking read…', calls, completed, second,
|
||||
unlocked: controls.every(control => !control.disabled) && !transaction.busy(),
|
||||
finalStatus: status.textContent,
|
||||
})));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"locked": True,
|
||||
"pendingStatus": "Marking read…",
|
||||
"calls": 1,
|
||||
"completed": True,
|
||||
"second": False,
|
||||
"unlocked": True,
|
||||
"finalStatus": "Marking read…",
|
||||
}
|
||||
|
||||
|
||||
def test_update_decision_transaction_restores_focus_and_announces_failure():
|
||||
result = run_transaction(r"""
|
||||
let focused = 0;
|
||||
let focusedWhileEnabled = false;
|
||||
const origin = {disabled:false, focus(){ focused += 1; focusedWhileEnabled = !this.disabled; }};
|
||||
const other = {disabled:false, focus(){}};
|
||||
const status = {textContent:''};
|
||||
const transaction = createTransaction({controls:[origin, other], status});
|
||||
transaction.run('Marking read…', async () => false, {
|
||||
origin,
|
||||
failure:'Could not mark update read. Retry.',
|
||||
}).then(completed => process.stdout.write(JSON.stringify({
|
||||
completed, focused, focusedWhileEnabled, status:status.textContent,
|
||||
unlocked:!origin.disabled && !other.disabled && !transaction.busy(),
|
||||
})));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"completed": False,
|
||||
"focused": 1,
|
||||
"focusedWhileEnabled": True,
|
||||
"status": "Could not mark update read. Retry.",
|
||||
"unlocked": True,
|
||||
}
|
||||
|
||||
|
||||
def test_committed_horizontal_swipes_decide_once_and_lock_while_pending():
|
||||
result = run_gesture(r"""
|
||||
class Surface {
|
||||
constructor() { this.listeners = {}; this.dataset = {}; }
|
||||
addEventListener(name, callback) { this.listeners[name] = callback; }
|
||||
removeEventListener(name) { delete this.listeners[name]; }
|
||||
setPointerCapture() {}
|
||||
releasePointerCapture() {}
|
||||
}
|
||||
const surface = new Surface();
|
||||
const calls = [];
|
||||
let enabled = true;
|
||||
let resolveRead;
|
||||
let lockedCalls;
|
||||
const gesture = createGesture({
|
||||
surface,
|
||||
enabled: () => enabled,
|
||||
keepUnread: () => calls.push('keep'),
|
||||
markRead: () => new Promise(resolve => { calls.push('read'); resolveRead = resolve; }),
|
||||
viewportWidth: () => 390,
|
||||
});
|
||||
const target = {closest: () => null, parentElement: null, scrollWidth: 100, clientWidth: 100};
|
||||
const event = (x, y, id=1) => ({pointerId:id, pointerType:'touch', clientX:x, clientY:y, target, preventDefault(){}});
|
||||
surface.listeners.pointerdown(event(180, 200));
|
||||
surface.listeners.pointermove(event(250, 205));
|
||||
surface.listeners.pointerup(event(270, 206));
|
||||
surface.listeners.pointerdown(event(280, 200));
|
||||
surface.listeners.pointermove(event(205, 204));
|
||||
surface.listeners.pointerup(event(190, 205));
|
||||
surface.listeners.pointerdown(event(280, 200));
|
||||
surface.listeners.pointermove(event(190, 203));
|
||||
surface.listeners.pointerup(event(180, 203));
|
||||
Promise.resolve().then(() => {
|
||||
lockedCalls = [...calls];
|
||||
resolveRead();
|
||||
return Promise.resolve();
|
||||
}).then(() => process.stdout.write(JSON.stringify({calls, lockedCalls, direction:surface.dataset.triageGesture || null})));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": ["keep", "read"],
|
||||
"lockedCalls": ["keep", "read"],
|
||||
"direction": None,
|
||||
}
|
||||
|
||||
|
||||
def test_scrolls_short_drags_edges_and_interactive_targets_never_decide():
|
||||
result = run_gesture(r"""
|
||||
class Surface {
|
||||
constructor() { this.listeners = {}; this.dataset = {}; }
|
||||
addEventListener(name, callback) { this.listeners[name] = callback; }
|
||||
removeEventListener(name) { delete this.listeners[name]; }
|
||||
setPointerCapture() {}
|
||||
releasePointerCapture() {}
|
||||
}
|
||||
const surface = new Surface();
|
||||
const calls = [];
|
||||
createGesture({surface, enabled:()=>true, keepUnread:()=>calls.push('keep'), markRead:()=>calls.push('read'), viewportWidth:()=>390});
|
||||
const plain = {closest: () => null, parentElement: null, scrollWidth:100, clientWidth:100};
|
||||
const interactive = {closest: selector => selector.includes('button') ? {} : null, parentElement:null, scrollWidth:100, clientWidth:100};
|
||||
const scroller = {closest: () => null, parentElement:null, scrollWidth:400, clientWidth:100};
|
||||
const event = (x,y,target=plain,id=1) => ({pointerId:id,pointerType:'touch',clientX:x,clientY:y,target,preventDefault(){}});
|
||||
function drag(fromX, fromY, toX, toY, target=plain) {
|
||||
surface.listeners.pointerdown(event(fromX,fromY,target));
|
||||
surface.listeners.pointermove(event(toX,toY,target));
|
||||
surface.listeners.pointerup(event(toX,toY,target));
|
||||
}
|
||||
drag(180,100,205,104);
|
||||
drag(180,100,220,190);
|
||||
drag(8,100,120,104);
|
||||
drag(180,100,280,104,interactive);
|
||||
drag(180,100,280,104,scroller);
|
||||
surface.listeners.pointerdown(event(180,100));
|
||||
surface.listeners.pointercancel(event(280,104));
|
||||
process.stdout.write(JSON.stringify({calls, direction:surface.dataset.triageGesture || null}));
|
||||
""")
|
||||
|
||||
assert result == {"calls": [], "direction": None}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_accessible_phone_only_update_swipe_triage():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/update-triage-gesture.js"></script>' in html
|
||||
assert '<script src="static/update-decision-transaction.js"></script>' in html
|
||||
assert 'id="update-gesture-status"' in html
|
||||
assert "createUpdateTriageGesture({" in html
|
||||
assert "createUpdateDecisionActions({" in html
|
||||
assert "keepUnread: keepUpdateUnread" in html
|
||||
assert "markRead: markUpdateRead" in html
|
||||
assert "data-triage-gesture" in html
|
||||
assert "prefers-reduced-motion: reduce" in html
|
||||
@@ -0,0 +1,191 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
SESSION = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-session.js"
|
||||
|
||||
|
||||
def run_session(script):
|
||||
source = f"const createSession = require({json.dumps(str(SESSION))});\n" + script
|
||||
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_update_triage_progress_exposes_the_current_actionability_reason():
|
||||
result = run_session("""
|
||||
const values = new Map();
|
||||
const items = [
|
||||
{notification_id:7, update_reason:'Review requested'},
|
||||
{notification_id:8, update_reason:''},
|
||||
];
|
||||
const progress = [];
|
||||
const session = createSession({
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:()=> 'timmy', getItems:()=>items, onOpen:()=>{},
|
||||
onProgress:value=>progress.push(value), onFinish:()=>{},
|
||||
});
|
||||
session.start();
|
||||
session.completeAndNext();
|
||||
process.stdout.write(JSON.stringify(progress));
|
||||
""")
|
||||
|
||||
assert result == [
|
||||
{"index": 1, "total": 2, "reason": "Review requested"},
|
||||
{"index": 2, "total": 2},
|
||||
]
|
||||
|
||||
|
||||
def test_update_triage_persists_account_bound_pass_and_resumes_by_identity():
|
||||
result = run_session("""
|
||||
const values = new Map();
|
||||
let login = 'timmy';
|
||||
let items = [1,2,3].map(id => ({notification_id:id,title:'Update '+id}));
|
||||
const opened = [], progress = [];
|
||||
const options = {
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:()=>login, getItems:()=>items,
|
||||
onOpen:item=>opened.push(item.notification_id), onProgress:value=>progress.push(value), onFinish:()=>opened.push('done'),
|
||||
};
|
||||
let session = createSession(options);
|
||||
session.start();
|
||||
session.keepUnreadAndNext();
|
||||
const saved = JSON.parse(values.get('stackchain.update-triage.v1'));
|
||||
session = createSession(options);
|
||||
const resumable = session.resumable();
|
||||
session.resume();
|
||||
login = 'other';
|
||||
const isolated = session.resumable();
|
||||
process.stdout.write(JSON.stringify({opened, progress, saved, resumable, isolated}));
|
||||
""")
|
||||
|
||||
assert result["opened"] == [1, 2, 2]
|
||||
assert result["progress"] == [
|
||||
{"index": 1, "total": 3},
|
||||
{"index": 2, "total": 3},
|
||||
{"index": 2, "total": 3},
|
||||
]
|
||||
assert result["saved"] == {
|
||||
"version": 2,
|
||||
"login": "timmy",
|
||||
"identities": ["1", "2", "3"],
|
||||
"current": "2",
|
||||
"completed": ["1"],
|
||||
"kept": ["1"],
|
||||
}
|
||||
assert result["resumable"] is True
|
||||
assert result["isolated"] is False
|
||||
|
||||
|
||||
def test_update_triage_reconciles_removed_items_and_does_not_admit_new_arrivals():
|
||||
result = run_session("""
|
||||
const values = new Map();
|
||||
let items = [1,2,3].map(notification_id => ({notification_id}));
|
||||
const opened = [], progress = [];
|
||||
const session = createSession({
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:()=> 'timmy', getItems:()=>items,
|
||||
onOpen:item=>opened.push(item.notification_id), onProgress:value=>progress.push(value), onFinish:()=>opened.push('done'),
|
||||
});
|
||||
session.start();
|
||||
items = [1,3,4].map(notification_id => ({notification_id}));
|
||||
session.completeAndNext();
|
||||
session.completeAndNext();
|
||||
process.stdout.write(JSON.stringify({opened, progress, stored:values.get('stackchain.update-triage.v1') || null}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"opened": [1, 3, "done"],
|
||||
"progress": [{"index": 1, "total": 3}, {"index": 3, "total": 3}],
|
||||
"stored": None,
|
||||
}
|
||||
|
||||
|
||||
def test_update_triage_exposes_only_the_next_surviving_snapshot_item():
|
||||
result = run_session("""
|
||||
const values = new Map();
|
||||
let items = [1,2,3].map(notification_id => ({notification_id}));
|
||||
const session = createSession({
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:()=> 'timmy', getItems:()=>items,
|
||||
onOpen:()=>{}, onProgress:()=>{}, onFinish:()=>{},
|
||||
});
|
||||
session.start();
|
||||
const first = session.next()?.notification_id;
|
||||
items = [1,3,4].map(notification_id => ({notification_id}));
|
||||
const afterRemoval = session.next()?.notification_id;
|
||||
session.keepUnreadAndNext();
|
||||
const afterAdvance = session.next();
|
||||
process.stdout.write(JSON.stringify({first, afterRemoval, afterAdvance:afterAdvance || null}));
|
||||
""")
|
||||
|
||||
assert result == {"first": 2, "afterRemoval": 3, "afterAdvance": None}
|
||||
|
||||
|
||||
def test_update_triage_reports_truthful_outcomes_and_reopens_only_surviving_kept_items():
|
||||
result = run_session("""
|
||||
const values = new Map();
|
||||
let items = [1,2,3].map(notification_id => ({notification_id}));
|
||||
const opened = [], outcomes = [];
|
||||
const session = createSession({
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:()=> 'timmy', getItems:()=>items,
|
||||
onOpen:item=>opened.push(item.notification_id), onProgress:()=>{}, onFinish:value=>outcomes.push(value),
|
||||
});
|
||||
session.start();
|
||||
session.keepUnreadAndNext();
|
||||
session.completeAndNext();
|
||||
items = [1,3].map(notification_id => ({notification_id}));
|
||||
session.keepUnreadAndNext();
|
||||
const reviewed = session.reviewKept();
|
||||
process.stdout.write(JSON.stringify({opened, outcomes, reviewed}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"opened": [1, 2, 3, 1],
|
||||
"outcomes": [{"reviewed": 3, "resolved": 1, "kept": 2, "keptIdentities": ["1", "3"]}],
|
||||
"reviewed": True,
|
||||
}
|
||||
|
||||
|
||||
def test_update_triage_reconciles_kept_outcomes_before_reporting_cleared():
|
||||
result = run_session("""
|
||||
const values = new Map();
|
||||
let items = [1,2].map(notification_id => ({notification_id}));
|
||||
const outcomes = [];
|
||||
const session = createSession({
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:()=> 'timmy', getItems:()=>items,
|
||||
onOpen:()=>{}, onProgress:()=>{}, onFinish:value=>outcomes.push(value),
|
||||
});
|
||||
session.start();
|
||||
session.keepUnreadAndNext();
|
||||
items = [2].map(notification_id => ({notification_id}));
|
||||
session.completeAndNext();
|
||||
process.stdout.write(JSON.stringify(outcomes[0]));
|
||||
""")
|
||||
|
||||
assert result == {"reviewed": 2, "resolved": 2, "kept": 0, "keptIdentities": []}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_resumable_updates_triage_mobile_flow():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/update-triage-session.js"></script>' in html
|
||||
assert 'id="update-triage-progress"' in html
|
||||
assert 'id="keep-update-unread" type="button" aria-label="Keep unread and open next update">Keep unread</button>' in html
|
||||
assert "openUpdates: openUpdateTriage" in html
|
||||
assert "updateTriage.acceptCompleted()" in html
|
||||
assert "triage:updateTriage" in html
|
||||
assert "updateTriage.reconcile()" in html
|
||||
assert 'id="mobile-update-outcome"' in html
|
||||
assert 'id="review-kept-updates"' in html
|
||||
assert "updateTriage.reviewKept(keptUpdateIdentities)" in html
|
||||
assert "showUpdateTriageOutcome(outcome)" in html
|
||||
assert ".update-triage-progress" in html
|
||||
@@ -41,8 +41,10 @@ async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
|
||||
assert 'id="search-preview" role="dialog" aria-modal="true"' in html
|
||||
assert 'aria-labelledby="search-preview-title"' in html
|
||||
assert 'id="claim-search-result"' in html
|
||||
assert 'id="share-search-result"' in html
|
||||
assert 'id="open-search-result-gitea"' in html
|
||||
assert "static/search-preview.js" in FRONTEND_BUILD.page_sources
|
||||
assert ".search-preview-panel" in css
|
||||
assert "height:100dvh" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
|
||||
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
POSITIONER = Path(__file__).resolve().parents[1] / "frontend" / "work-detail-position.js"
|
||||
|
||||
|
||||
def run_positioner(script: str) -> dict:
|
||||
source = f"const createPositioner = require({json.dumps(str(POSITIONER))});\n" + script
|
||||
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_different_work_item_resets_to_top_while_same_item_keeps_position():
|
||||
result = run_positioner("""
|
||||
const panel = {scrollTop:640};
|
||||
const positioner = createPositioner({panel});
|
||||
const first = positioner.open('issue:stackchain/dashboard#41');
|
||||
panel.scrollTop = 380;
|
||||
const same = positioner.open('issue:stackchain/dashboard#41');
|
||||
const changed = positioner.open('pull:stackchain/dashboard#42');
|
||||
process.stdout.write(JSON.stringify({first, same, changed, top:panel.scrollTop}));
|
||||
""")
|
||||
|
||||
assert result == {"first": True, "same": False, "changed": True, "top": 0}
|
||||
|
||||
|
||||
def test_each_panel_tracks_its_own_visible_identity():
|
||||
result = run_positioner("""
|
||||
const issuePanel = {scrollTop:500};
|
||||
const pullPanel = {scrollTop:700};
|
||||
const issue = createPositioner({panel:issuePanel});
|
||||
const pull = createPositioner({panel:pullPanel});
|
||||
issue.open('issue:stackchain/dashboard#8');
|
||||
pull.open('pull:stackchain/dashboard#9');
|
||||
issuePanel.scrollTop = 220;
|
||||
pullPanel.scrollTop = 330;
|
||||
issue.open('issue:stackchain/dashboard#8');
|
||||
pull.open('review:stackchain/dashboard#9');
|
||||
process.stdout.write(JSON.stringify({issueTop:issuePanel.scrollTop, pullTop:pullPanel.scrollTop}));
|
||||
""")
|
||||
|
||||
assert result == {"issueTop": 220, "pullTop": 0}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_positions_issue_pull_and_review_openers():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/work-detail-position.js"></script>' in html
|
||||
assert "issueDetailPosition.open(workDetailIdentity('issue', item))" in html
|
||||
assert "pullDetailPosition.open(workDetailIdentity('pull', item))" in html
|
||||
assert "reviewDetailPosition.open(workDetailIdentity('review', item))" in html
|
||||
assert "BASE + 'static/work-detail-position.js'" in (POSITIONER.parent / "service-worker.js").read_text()
|
||||
@@ -79,15 +79,41 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da
|
||||
assert manifest.json()["start_url"] == "./"
|
||||
assert manifest.json()["scope"] == "./"
|
||||
assert manifest.json()["share_target"] == {
|
||||
"action": "./", "method": "GET", "enctype": "application/x-www-form-urlencoded",
|
||||
"params": {"title": "title", "text": "text", "url": "url"},
|
||||
"action": "./", "method": "POST", "enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title", "text": "text", "url": "url",
|
||||
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}],
|
||||
},
|
||||
}
|
||||
assert manifest.json()["shortcuts"] == [
|
||||
{
|
||||
"name": "Continue work", "short_name": "Continue",
|
||||
"description": "Resume the highest-priority mobile work flow.",
|
||||
"url": "./?launch=continue",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
|
||||
},
|
||||
{
|
||||
"name": "New issue", "short_name": "New",
|
||||
"description": "Capture work now and file it online or offline.",
|
||||
"url": "./?launch=new",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
|
||||
},
|
||||
{
|
||||
"name": "Open Agenda", "short_name": "Agenda",
|
||||
"description": "Check every assigned deadline and continue the Agenda session.",
|
||||
"url": "./?launch=agenda",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
|
||||
},
|
||||
]
|
||||
assert worker.status_code == 200
|
||||
assert worker.headers["content-type"].startswith("application/javascript")
|
||||
assert worker.headers["service-worker-allowed"] == "/"
|
||||
assert "request.url.includes('/api/')" in worker.text
|
||||
assert "request.method !== 'GET'" in worker.text
|
||||
assert "new URL('./', self.location.href).pathname" in worker.text
|
||||
assert "acceptSharedContent(request)" in worker.text
|
||||
assert "sharedImageCapture.consume" in (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
assert '<script src="static/shared-image-capture.js"></script>' in (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
Reference in new issue
Block a user