Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd637d0141 | ||
|
|
785fb990b4 | ||
|
|
f8c2c080f9 | ||
|
|
47220929bf | ||
|
|
3b4bb618dd | ||
|
|
9adfcd01b6 | ||
|
|
64e764b996 | ||
|
|
76eed47487 | ||
|
|
05b2f769e4 | ||
|
|
40b124bc2c | ||
|
|
4ab0ffde58 | ||
|
|
ab77beb3fe | ||
|
|
170828b308 | ||
|
|
08139bc3ee | ||
|
|
4589c46f16 | ||
|
|
6b924a4757 | ||
|
|
1602528e47 | ||
|
|
d28140bbd1 | ||
|
|
3f559a50ea | ||
|
|
e7a4eb3331 | ||
|
|
7b27f85b39 | ||
|
|
7204a426af | ||
|
|
2aad1a9bef | ||
|
|
dbadc73a57 | ||
|
|
59bc13c5b0 | ||
|
|
5c370cbe33 | ||
|
|
475739328c | ||
|
|
411e40d117 | ||
|
|
88e5f3f33b | ||
|
|
72dcdc6c52 | ||
|
|
d8f2b803c7 | ||
|
|
fdd3cf03b4 | ||
|
|
06184f1cb2 | ||
|
|
c26f4bdeeb | ||
|
|
04ae7480cf | ||
|
|
21705a0859 | ||
|
|
a2076cf9a9 | ||
|
|
d4aafbf351 | ||
|
|
7351363982 | ||
|
|
c86a63c54b | ||
|
|
3c5c58fc38 | ||
|
|
498d7e772a | ||
|
|
c594cc20fc | ||
|
|
5105fb40d3 | ||
|
|
b13eeb9cc0 | ||
|
|
49ef3dad78 | ||
|
|
a60f2cfac3 | ||
|
|
2eea42859e | ||
|
|
64f549848d | ||
|
|
5201b18cae | ||
|
|
8f92286cce | ||
|
|
27be5d43a2 | ||
|
|
e64541463c | ||
|
|
6a03dfd7ac | ||
|
|
f01dfa5f93 | ||
|
|
a57aa879e2 | ||
|
|
403190e888 | ||
|
|
6acc8866ed | ||
|
|
8e7a21f316 | ||
|
|
8411e907af | ||
|
|
eb88355229 | ||
|
|
1b68223798 |
No files matched your search
@@ -85,6 +85,22 @@ device can reopen the exact Search with one tap while stale writes surface a con
|
||||
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
|
||||
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
||||
`.stackchain-state/saved-searches.sqlite3` path.
|
||||
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
|
||||
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and
|
||||
Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day
|
||||
checkpoint: finishing Agenda, Updates, or the final Filed review returns to a focused handoff using fresh queue
|
||||
counts, while reopening the queue sheet resumes the next live phase. **Finish for now** removes only that local
|
||||
checkpoint. Once urgent review is clear the pass continues the existing Today plan, or opens Find Work when Today
|
||||
is empty; the briefing and checkpoint never change Gitea state.
|
||||
Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the
|
||||
queue without erasing the delegated-work record. Reviewed cards reopen the existing read-only issue detail and
|
||||
conversation, while the Filed badge continues to count actionable outcomes only. A later Gitea update moves that
|
||||
issue back to Needs review automatically. Acknowledgements synchronize the exact Gitea `updated_at` revision in
|
||||
bounded batches to the confirmed account and preserve the same Reviewed state on other signed-in devices. Offline
|
||||
or failed synchronization keeps the local acknowledgement and retries on the next healthy dashboard refresh
|
||||
without blocking **Acknowledge & next**. Set
|
||||
`STACKCHAIN_COMPLETED_FILED_REVIEW_DB` to override the default
|
||||
`.stackchain-state/completed-filed-reviews.sqlite3` path.
|
||||
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
|
||||
@@ -99,7 +115,12 @@ explicit **Add blocked item anyway** override, while an unavailable dependency l
|
||||
as unknown rather than unblocked. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
After a reload or installed-app
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open
|
||||
assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps
|
||||
**Add step** directly beside the plan. A new step is normalized, checked for duplicates, and appended without
|
||||
exposing or replacing the full issue description. Offline additions enter the same account-bound durable
|
||||
issue-content queue as checklist toggles; reconnect conflict review applies unique local additions to the
|
||||
latest remote body while preserving remote prose and task-state changes.
|
||||
**Comment & next** on that current issue or pull request posts the handoff online or admits it
|
||||
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||
one without closing or merging it. **Reply & next** provides the same one-action continuation
|
||||
@@ -156,7 +177,12 @@ and runs up to three deliveries concurrently, but claims a record only when a de
|
||||
is ready. Claims have unique fencing tokens and are renewed before every network stage;
|
||||
completion, release, failure, and delivery checkpoints are token-fenced so an expired worker
|
||||
cannot alter a newer crash-recovery claim. Device purge cancels an active drain before closing
|
||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. All private SQLite stores enforce a
|
||||
filesystem boundary independently of the service umask: the database directory is repaired to
|
||||
owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database
|
||||
paths are rejected before access. This protects state from unrelated local accounts, but it is not
|
||||
encryption at rest; secure host access, encrypted volumes, and private backups are still required.
|
||||
Set `STACKCHAIN_STATE_DIR` to a
|
||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
||||
writes run outside the request event loop, and lock admission is bounded to 100 ms by
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
||||
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, mergeChecklistConflict, now = () => Date.now(), maxItems = 50 }) {
|
||||
const storageKey = 'stackchain.authored-outbox.v1';
|
||||
const makeId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
|
||||
|
||||
function checklistOperation(value) {
|
||||
const action = String(value?.action || '');
|
||||
if (!['rename', 'remove', 'move-earlier', 'move-later'].includes(action)) return null;
|
||||
return {
|
||||
action,
|
||||
index:Number(value.index),
|
||||
...(action === 'rename' ? { label:String(value.label || '') } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
@@ -61,6 +71,23 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
|
||||
}
|
||||
}
|
||||
if (message.kind === 'issue-content') {
|
||||
const queuedContent = items.find(item => item.kind === 'issue-content' && item.status === 'queued' &&
|
||||
item.ownerLogin === ownerLogin && item.repository === String(message.repository || '') &&
|
||||
item.number === Number(message.number || 0));
|
||||
if (queuedContent) {
|
||||
const operation = checklistOperation(message.checklistOperation);
|
||||
const replacement = {
|
||||
...queuedContent,
|
||||
operationId: String(requestedOperationId || makeId()).slice(0, 128),
|
||||
title: String(message.title || ''),
|
||||
body: String(message.body || ''),
|
||||
...(operation ? { checklistOperations:[...(queuedContent.checklistOperations || []), operation] } : {}),
|
||||
};
|
||||
write(items.map(item => item.id === queuedContent.id ? replacement : item), mirror);
|
||||
return replacement;
|
||||
}
|
||||
}
|
||||
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
|
||||
const id = String(requestedOperationId || makeId()).slice(0, 128);
|
||||
const item = {
|
||||
@@ -96,6 +123,14 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
blockerNumber: Number(message.blockerNumber || 0),
|
||||
present: message.present === true,
|
||||
} : {}),
|
||||
...(message.kind === 'issue-content' ? {
|
||||
title: String(message.title || ''),
|
||||
baseBody: String(message.baseBody ?? message.body ?? ''),
|
||||
expectedUpdatedAt: String(message.expectedUpdatedAt || ''),
|
||||
...(checklistOperation(message.checklistOperation) ? {
|
||||
checklistOperations:[checklistOperation(message.checklistOperation)],
|
||||
} : {}),
|
||||
} : {}),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
@@ -103,6 +138,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
}
|
||||
|
||||
async function enqueueDurably(message) {
|
||||
const previousItems = read();
|
||||
const item = enqueue(message, false);
|
||||
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
|
||||
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
|
||||
@@ -124,7 +160,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
try {
|
||||
await backgroundSync.reconcile(durableItems, 'authored');
|
||||
} catch (error) {
|
||||
write(read().filter(candidate => candidate.id !== item.id), false);
|
||||
write(previousItems, false);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
@@ -171,6 +207,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
if (item.kind === 'issue-blocker') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
|
||||
}
|
||||
if (item.kind === 'issue-content') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content';
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
@@ -246,9 +285,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
decision: item.decision,
|
||||
expected_head_sha: item.expectedHeadSha,
|
||||
comments: item.comments,
|
||||
} : item.kind === 'issue-content' ? {
|
||||
title:item.title, body:item.body, expected_updated_at:item.expectedUpdatedAt,
|
||||
} : { body: item.body };
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
method: item.kind === 'issue-content' ? 'PATCH' : 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
@@ -256,6 +297,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (item.kind === 'issue-content' &&
|
||||
(result?.number !== item.number || result?.title !== item.title || result?.body !== item.body)) {
|
||||
throw new Error('Checklist update was not confirmed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!result) return { blocked: true };
|
||||
@@ -334,6 +379,51 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
return retryItem(id, currentLogin);
|
||||
}
|
||||
|
||||
async function resolveIssueContentConflict(id, latest, currentLogin) {
|
||||
const previousItems = read();
|
||||
const item = previousItems.find(candidate => candidate.id === id);
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
if (!item || item.kind !== 'issue-content' || item.status !== 'attention') {
|
||||
throw new Error('This checklist conflict is no longer available.');
|
||||
}
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
throw new Error('Confirm the account that queued this checklist update.');
|
||||
}
|
||||
if (typeof mergeChecklistConflict !== 'function') {
|
||||
throw new Error('Checklist conflict review is unavailable.');
|
||||
}
|
||||
const merged = mergeChecklistConflict({
|
||||
baseBody: String(item.baseBody ?? item.body ?? ''),
|
||||
localBody: String(item.body || ''),
|
||||
remoteBody: String(latest?.body || ''),
|
||||
});
|
||||
if (merged.conflicts?.length) return merged;
|
||||
if (!String(latest?.updated_at || '')) throw new Error('Latest issue revision is unavailable.');
|
||||
const rebased = {
|
||||
...item,
|
||||
operationId: String(makeId()).slice(0, 128),
|
||||
title: String(latest?.title || ''),
|
||||
baseBody: String(latest?.body || ''),
|
||||
body: merged.body,
|
||||
expectedUpdatedAt: String(latest.updated_at),
|
||||
status: 'queued',
|
||||
};
|
||||
delete rebased.error;
|
||||
delete rebased.deliveryState;
|
||||
const nextItems = previousItems.map(candidate => candidate.id === id ? rebased : candidate);
|
||||
write(nextItems, false);
|
||||
if (backgroundSync?.reconcile) {
|
||||
try { await backgroundSync.reconcile(nextItems, 'authored'); }
|
||||
catch (error) {
|
||||
write(previousItems, false);
|
||||
throw error;
|
||||
}
|
||||
try { await backgroundSync.requestSync?.(); }
|
||||
catch (_error) { /* Foreground retry remains available. */ }
|
||||
}
|
||||
return { ...merged, item: rebased };
|
||||
}
|
||||
|
||||
function reconcileBackground(records) {
|
||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
@@ -359,7 +449,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
return { enqueue, enqueueDurably, update, discard, flush, retry, resolveIssueContentConflict,
|
||||
reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
|
||||
@@ -105,6 +105,11 @@ function createIssueSyncStore({
|
||||
};
|
||||
incoming.set(current.id, replacement);
|
||||
}
|
||||
if (current.kind === 'issue-content' && replacement.kind === 'issue-content' &&
|
||||
current.operationId !== replacement.operationId && current.status === 'sending' &&
|
||||
Number(current.claimUntil) > Number(now())) {
|
||||
throw new Error('Checklist sync is already delivering. Retry this change.');
|
||||
}
|
||||
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(['attention', 'authorization'].includes(current.status) &&
|
||||
replacement.status === current.status) ||
|
||||
@@ -410,6 +415,24 @@ function createBackgroundIssueSync({
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-content') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content',
|
||||
options: {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title,
|
||||
body: item.body,
|
||||
expected_updated_at: item.expectedUpdatedAt,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||
@@ -706,6 +729,12 @@ function createBackgroundIssueSync({
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (item.kind === 'issue-content' &&
|
||||
(delivered?.number !== item.number || delivered?.title !== item.title || delivered?.body !== item.body)) {
|
||||
const error = new Error('Checklist update was not confirmed.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await completeClaim(item, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
function mergeChecklistConflict({ baseBody, localBody, remoteBody, operations = [] }) {
|
||||
const taskPattern = /^(\s*[-*+]\s+\[)([ xX])(\]\s+)(.*)$/;
|
||||
|
||||
function tasks(body) {
|
||||
const entries = [];
|
||||
String(body || '').split('\n').forEach((line, lineIndex) => {
|
||||
const match = line.match(taskPattern);
|
||||
if (!match) return;
|
||||
const label = match[4].trim();
|
||||
const key = label.replace(/\s+/g, ' ').toLocaleLowerCase();
|
||||
if (!key) return;
|
||||
entries.push({ key, label, checked: match[2].toLowerCase() === 'x', lineIndex, match });
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
function grouped(entries) {
|
||||
const result = new Map();
|
||||
entries.forEach(entry => result.set(entry.key, [...(result.get(entry.key) || []), entry]));
|
||||
return result;
|
||||
}
|
||||
|
||||
function replayOperations(body, requested, reportChanges) {
|
||||
let lines = String(body || '').split('\n');
|
||||
const visibleTasks = value => tasks(value).filter(entry => /^[-*+]/.test(entry.match[1]));
|
||||
const logical = visibleTasks(baseBody).map(entry => ({ key:entry.key, label:entry.label }));
|
||||
const replayed = [];
|
||||
for (const operation of requested) {
|
||||
const index = Number(operation?.index);
|
||||
const target = logical[index];
|
||||
if (!target) return { body:null, changes:replayed, conflict:{ label:'Checklist step', reason:'missing' } };
|
||||
let entries = visibleTasks(lines.join('\n'));
|
||||
const matches = entries.filter(entry => entry.key === target.key);
|
||||
if (matches.length !== 1) {
|
||||
return { body:null, changes:replayed, conflict:{
|
||||
label:target.label, reason:matches.length ? 'ambiguous' : 'missing',
|
||||
} };
|
||||
}
|
||||
const match = matches[0];
|
||||
if (operation.action === 'rename') {
|
||||
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
|
||||
const key = label.toLocaleLowerCase();
|
||||
if (!label || entries.some(entry => entry.key === key && entry.lineIndex !== match.lineIndex)) {
|
||||
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'ambiguous' } };
|
||||
}
|
||||
lines[match.lineIndex] = match.match[1] + match.match[2] + match.match[3] + label;
|
||||
if (reportChanges) replayed.push({ label:target.label, renamed:label });
|
||||
target.label = label;
|
||||
target.key = key;
|
||||
} else if (operation.action === 'remove') {
|
||||
lines.splice(match.lineIndex, 1);
|
||||
logical.splice(index, 1);
|
||||
if (reportChanges) replayed.push({ label:target.label, removed:true });
|
||||
} else if (operation.action === 'move-earlier' || operation.action === 'move-later') {
|
||||
const neighborIndex = operation.action === 'move-earlier' ? index - 1 : index + 1;
|
||||
const neighbor = logical[neighborIndex];
|
||||
const neighborMatches = neighbor ? entries.filter(entry => entry.key === neighbor.key) : [];
|
||||
if (neighborMatches.length !== 1 || Math.abs(neighborMatches[0].lineIndex - match.lineIndex) !== 1) {
|
||||
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'order-changed' } };
|
||||
}
|
||||
const neighborLine = neighborMatches[0].lineIndex;
|
||||
[lines[match.lineIndex], lines[neighborLine]] = [lines[neighborLine], lines[match.lineIndex]];
|
||||
[logical[index], logical[neighborIndex]] = [logical[neighborIndex], logical[index]];
|
||||
if (reportChanges) replayed.push({
|
||||
label:target.label, moved:operation.action === 'move-earlier' ? 'earlier' : 'later',
|
||||
});
|
||||
}
|
||||
}
|
||||
return { body:lines.join('\n'), changes:replayed, conflict:null };
|
||||
}
|
||||
|
||||
if (Array.isArray(operations) && operations.length) {
|
||||
const baseReplay = replayOperations(baseBody, operations, false);
|
||||
const remoteReplay = replayOperations(remoteBody, operations, true);
|
||||
const conflict = baseReplay.conflict || remoteReplay.conflict;
|
||||
if (conflict) return { body:null, changes:remoteReplay.changes || [], conflicts:[conflict] };
|
||||
const residual = mergeChecklistConflict({
|
||||
baseBody:baseReplay.body, localBody, remoteBody:remoteReplay.body,
|
||||
});
|
||||
return {
|
||||
body:residual.body,
|
||||
changes:[...remoteReplay.changes, ...residual.changes],
|
||||
conflicts:residual.conflicts,
|
||||
};
|
||||
}
|
||||
|
||||
const base = grouped(tasks(baseBody));
|
||||
const local = grouped(tasks(localBody));
|
||||
const remoteEntries = tasks(remoteBody);
|
||||
const remote = grouped(remoteEntries);
|
||||
const changes = [];
|
||||
const conflicts = [];
|
||||
|
||||
for (const [key, baseMatches] of base) {
|
||||
const localMatches = local.get(key) || [];
|
||||
if (baseMatches.length !== 1 || localMatches.length !== 1) continue;
|
||||
if (baseMatches[0].checked === localMatches[0].checked) continue;
|
||||
const remoteMatches = remote.get(key) || [];
|
||||
if (remoteMatches.length !== 1) {
|
||||
conflicts.push({
|
||||
label: baseMatches[0].label,
|
||||
reason: remoteMatches.length ? 'ambiguous' : 'missing',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
changes.push({ label: remoteMatches[0].label, checked: localMatches[0].checked });
|
||||
}
|
||||
|
||||
for (const [key, localMatches] of local) {
|
||||
if (base.has(key)) continue;
|
||||
if (localMatches.length !== 1) {
|
||||
conflicts.push({ label:localMatches[0].label, reason:'ambiguous' });
|
||||
continue;
|
||||
}
|
||||
const remoteMatches = remote.get(key) || [];
|
||||
if (remoteMatches.length > 1) {
|
||||
conflicts.push({ label: localMatches[0].label, reason: 'ambiguous' });
|
||||
continue;
|
||||
}
|
||||
if (remoteMatches.length === 0) {
|
||||
changes.push({ label: localMatches[0].label, checked:localMatches[0].checked, added:true });
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length) return { body: null, changes, conflicts };
|
||||
const desired = new Map(changes.map(change => [
|
||||
change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked,
|
||||
]));
|
||||
const lines = String(remoteBody || '').split('\n');
|
||||
remoteEntries.forEach(entry => {
|
||||
if (!desired.has(entry.key)) return;
|
||||
const marker = desired.get(entry.key) ? 'x' : ' ';
|
||||
lines[entry.lineIndex] = entry.match[1] + marker + entry.match[3] + entry.match[4];
|
||||
});
|
||||
changes.filter(change => change.added).forEach(change => {
|
||||
lines.push('- [' + (change.checked ? 'x' : ' ') + '] ' + change.label);
|
||||
});
|
||||
return { body: lines.join('\n'), changes, conflicts: [] };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = mergeChecklistConflict;
|
||||
@@ -43,6 +43,85 @@ function buildRelatedDraft(value = {}, template = null) {
|
||||
return draft;
|
||||
}
|
||||
|
||||
function relatedChecklistDraft(item, label) {
|
||||
const title = String(label || '').trim().replace(/\s+/g, ' ').slice(0, 240);
|
||||
if (!title) throw new Error('Choose a checklist step to file.');
|
||||
const repository = String(item?.repository || '');
|
||||
const number = Number(item?.number);
|
||||
const url = String(item?.url || '');
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
||||
!Number.isInteger(number) || number < 1 || !/^https:\/\//.test(url)) {
|
||||
throw new Error('The parent issue link is unavailable.');
|
||||
}
|
||||
return {repository, title,
|
||||
body:'Related to [' + repository + '#' + number + '](' + url + ').', labelIds:[]};
|
||||
}
|
||||
|
||||
function linkChecklistTask(raw, targetIndex, child) {
|
||||
const url = String(child?.url || '');
|
||||
if (!/^https:\/\//.test(url)) throw new Error('The related issue link is unavailable.');
|
||||
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let taskIndex = 0;
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const line = parts[index];
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^(\s*[-*+]\s+\[[ xX]\]\s+)(.*)$/);
|
||||
if (!task) continue;
|
||||
if (taskIndex === Number(targetIndex)) {
|
||||
if (/^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(task[2])) throw new Error('That checklist step is already linked.');
|
||||
const label = task[2].trim().replace(/\s+/g, ' ');
|
||||
parts[index] = task[1] + '[' + label + '](' + url + ')';
|
||||
return parts.join('');
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
throw new Error('The checklist step is no longer available.');
|
||||
}
|
||||
|
||||
function createChecklistPromotion({ issueController, issueCapture, clearAttachments, onLinked, onStatus }) {
|
||||
let pending = null;
|
||||
const persistedPromotion = value => ({
|
||||
item:{repository:value.repository, number:value.number, url:value.url},
|
||||
detail:{title:value.title, body:value.body, updated_at:value.updatedAt}, taskIndex:value.taskIndex,
|
||||
});
|
||||
return {
|
||||
pending:() => Boolean(pending),
|
||||
start(context) {
|
||||
pending = {item:{...context.item}, detail:{...context.detail}, taskIndex:Number(context.taskIndex)};
|
||||
const draft = issueController.relatedTaskDraft(context.item, context.detail, context.taskIndex, context.label);
|
||||
issueCapture.saveDraft(draft);
|
||||
clearAttachments();
|
||||
return draft;
|
||||
},
|
||||
cancel() { if (!pending) return false; pending = null; issueCapture.clearDraft(); return true; },
|
||||
deliveryContext() {
|
||||
if (!pending) return null;
|
||||
return {repository:pending.item.repository, number:pending.item.number, url:pending.item.url,
|
||||
title:pending.detail.title, body:pending.detail.body, updatedAt:pending.detail.updated_at,
|
||||
taskIndex:pending.taskIndex};
|
||||
},
|
||||
async finish(child, persisted = null) {
|
||||
const promotion = pending || (persisted ? persistedPromotion(persisted) : null);
|
||||
if (!promotion || !child) return false;
|
||||
pending = null;
|
||||
try {
|
||||
const confirmed = await issueController.linkRelatedTask(
|
||||
promotion.item, promotion.detail, promotion.taskIndex, child
|
||||
);
|
||||
onLinked(promotion, confirmed);
|
||||
onStatus('Related issue created and linked from its parent checklist.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
onStatus('Related issue created, but the parent link needs attention.' +
|
||||
(child.url ? ' ' + child.url : '') + ' ' + error.message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
|
||||
const NO_OWNER = '__unassigned__';
|
||||
const select = documentRef.querySelector('#create-issue-assignee');
|
||||
@@ -758,5 +837,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
||||
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
|
||||
createIssueCapture.buildRelatedDraft = buildRelatedDraft;
|
||||
createIssueCapture.relatedChecklistDraft = relatedChecklistDraft;
|
||||
createIssueCapture.linkChecklistTask = linkChecklistTask;
|
||||
createIssueCapture.createChecklistPromotion = createChecklistPromotion;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
+53
-3
@@ -285,6 +285,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.queue-finder button { padding-inline:14px; }
|
||||
#search-older-work { width:100%; }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.filed-history-tabs { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin:10px 0; }
|
||||
.filed-history-tabs[hidden] { display:none; }
|
||||
.filed-history-tabs button { min-height:44px; min-width:0; }
|
||||
.filed-history-tabs button[aria-pressed="true"] { border-color:#60a5fa; background:#17365d; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
|
||||
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
@@ -465,7 +469,33 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr); align-items:center; gap:8px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
|
||||
.completed-filed-actions[hidden] { display:none; }
|
||||
.completed-filed-actions button { min-height:44px; min-width:0; }
|
||||
.completed-filed-buttons { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.completed-filed-buttons button { flex:1 1 180px; }
|
||||
#issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(170px + env(safe-area-inset-bottom)); }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.checklist-step-editor { display:grid; gap:8px; margin:10px 0 16px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1526; }
|
||||
.checklist-step-editor[hidden] { display:none; }
|
||||
.checklist-step-editor input { width:100%; min-width:0; box-sizing:border-box; }
|
||||
.checklist-step-editor-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.checklist-step-editor button, .checklist-step-editor input { min-height:44px; }
|
||||
#remove-checklist-step { border-color:#b45309; }
|
||||
#issue-sheet.read-only .checklist-step-editor { display:none; }
|
||||
.checklist-add { display:grid; gap:8px; margin:10px 0 16px; }
|
||||
.checklist-add form { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; }
|
||||
.checklist-add form[hidden] { display:none; }
|
||||
.checklist-add input { min-width:0; width:100%; box-sizing:border-box; }
|
||||
.checklist-add button, .checklist-add input { min-height:44px; }
|
||||
#issue-sheet.read-only .checklist-add { display:none; }
|
||||
@media(max-width:340px) { .checklist-add form { grid-template-columns:1fr 1fr; } .checklist-add input { grid-column:1 / -1; } }
|
||||
.checklist-completion { position:fixed; right:0; bottom:0; z-index:58; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr) repeat(2,minmax(0,auto)); align-items:center; gap:8px; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border-top:1px solid #4ade80; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
|
||||
.checklist-completion[hidden] { display:none; }
|
||||
.checklist-completion button { min-height:44px; min-width:0; }
|
||||
#issue-sheet:has(.checklist-completion:not([hidden])) .issue-sheet-panel { padding-bottom:calc(118px + env(safe-area-inset-bottom)); }
|
||||
#issue-sheet:has(.checklist-completion:not([hidden])) .work-session-nav { display:none; }
|
||||
@media(max-width:420px) { .checklist-completion { grid-template-columns:1fr 1fr; } .checklist-completion span { grid-column:1 / -1; } }
|
||||
.issue-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; }
|
||||
.issue-blockers h2 { margin-top:0; }
|
||||
.issue-blocker-list { display:grid; gap:8px; }
|
||||
@@ -480,6 +510,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-blocker-manager [role="option"] { text-align:left; overflow-wrap:anywhere; }
|
||||
#manage-issue-blockers, #start-unblocked-issue { min-height:44px; width:100%; margin-top:10px; }
|
||||
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
|
||||
.issue-content-editor { max-width:100%; overflow-x:hidden; margin-top:16px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
|
||||
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
.issue-planning-retry { min-height:44px; width:100%; }
|
||||
@@ -492,6 +523,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.issue-comment-composer textarea { min-height:96px; }
|
||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
.issue-attachment-controls { display:flex; max-width:100%; }
|
||||
@@ -521,15 +553,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
|
||||
#issue-sheet.read-only .issue-comment-composer,
|
||||
#issue-sheet.read-only .issue-attachment-controls,
|
||||
#issue-sheet.read-only .issue-attachment-preview,
|
||||
#issue-sheet.read-only #issue-planning,
|
||||
#issue-sheet.read-only #issue-handoff,
|
||||
#issue-sheet.read-only #release-issue,
|
||||
#issue-sheet.read-only #close-issue,
|
||||
#issue-sheet.read-only .detail-defer,
|
||||
#issue-sheet.read-only #issue-blockers,
|
||||
#issue-sheet.read-only #load-older-issue-comments,
|
||||
#issue-sheet.read-only #retry-issue-comment-actions { display:none; }
|
||||
#issue-sheet.read-only.filed-reassignable #issue-handoff { display:block; }
|
||||
.filed-claim-actions { position:sticky; bottom:0; z-index:4; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:14px; padding:10px 4px calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #4ade80; }
|
||||
.filed-claim-actions[hidden] { display:none; }
|
||||
.filed-claim-actions button { min-width:0; min-height:44px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
@@ -626,6 +661,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.markdown-content .task-list { padding-left:0; list-style:none; }
|
||||
.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; }
|
||||
.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; }
|
||||
.markdown-content .task-list-toggle { min-width:44px; min-height:44px; margin:-9px 0 -9px -9px; cursor:pointer; accent-color:#60a5fa; }
|
||||
.markdown-content .task-list-manage { min-width:44px; min-height:44px; margin:-9px -9px -9px auto; padding:6px 8px; flex:0 0 auto; }
|
||||
.markdown-content.checklist-pending .task-list-toggle { opacity:.65; cursor:wait; }
|
||||
.markdown-content.checklist-pending .task-list-manage { opacity:.65; cursor:wait; }
|
||||
.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; }
|
||||
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
@@ -634,6 +673,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.voice-issue-capture { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#101d31; }
|
||||
.voice-issue-capture[hidden], .voice-issue-review[hidden], .voice-issue-controls button[hidden] { display:none; }
|
||||
.voice-issue-heading, .voice-issue-review { display:grid; gap:6px; }
|
||||
.voice-issue-controls, .voice-issue-review-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.voice-issue-controls button, .voice-issue-review-actions button { min-width:0; min-height:44px; }
|
||||
#voice-issue-transcript { box-sizing:border-box; width:100%; min-height:96px; resize:vertical; }
|
||||
.create-issue-capture-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.create-issue-capture-actions[hidden] { display:none; }
|
||||
.create-issue-filing { display:grid; gap:12px; min-width:0; }
|
||||
@@ -837,6 +882,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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; }
|
||||
.mobile-queue-panel h2 { margin:0; }
|
||||
.mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); }
|
||||
.mobile-start-day h3, .mobile-start-day p { margin:0; }
|
||||
.mobile-start-day p + p { margin-top:4px; }
|
||||
.mobile-start-day-action { width:100%; min-height:48px; text-align:center; }
|
||||
.mobile-start-day-finish { min-height:44px; width:100%; background:transparent; }
|
||||
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
|
||||
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||
|
||||
+550
-68
File diff suppressed because it is too large.
Load diff
+4
-1
@@ -155,6 +155,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
.filter(Boolean).join('\n\n');
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
const checklistConflict = !quarantined && item.kind === 'issue-content' &&
|
||||
item.status === 'attention' && typeof item.baseBody === 'string';
|
||||
return {
|
||||
id: 'stackchain.authored-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
@@ -162,7 +164,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
|
||||
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
label: checklistConflict ? 'Checklist conflict' : item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
|
||||
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
||||
@@ -172,6 +174,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
title: target,
|
||||
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: copyText,
|
||||
checklist_conflict: checklistConflict,
|
||||
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
function filedClaimEligible(item, detail) {
|
||||
return Boolean(
|
||||
item?.is_filed && !item?.is_assigned && !item?.is_completed &&
|
||||
item?.state === 'open' && detail?.state === 'open' &&
|
||||
Array.isArray(detail?.assignees) && detail.assignees.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = filedClaimEligible;
|
||||
+85
-4
@@ -247,6 +247,10 @@
|
||||
</div>
|
||||
<button id="protect-today" type="button">Protect Today</button>
|
||||
</section>
|
||||
<div class="filed-history-tabs" id="filed-history-tabs" role="group" aria-label="Filed outcome view" hidden>
|
||||
<button type="button" data-filed-view="needs-review" aria-pressed="true">Needs review <span id="filed-needs-review-count">0</span></button>
|
||||
<button type="button" data-filed-view="reviewed" aria-pressed="false">Reviewed <span id="filed-reviewed-count">0</span></button>
|
||||
</div>
|
||||
<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>
|
||||
@@ -594,9 +598,44 @@
|
||||
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||||
</div>
|
||||
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
||||
<section class="completed-filed-actions" id="completed-filed-actions" aria-label="Completed Filed review" hidden>
|
||||
<span id="completed-filed-progress" role="status" aria-live="polite">Completed Filed issue ready for review.</span>
|
||||
<div class="completed-filed-buttons">
|
||||
<button id="complete-parent-and-acknowledge" type="button" hidden>Complete parent step & acknowledge</button>
|
||||
<button id="acknowledge-completed-filed" type="button">Acknowledge & next</button>
|
||||
</div>
|
||||
</section>
|
||||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
|
||||
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
||||
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
|
||||
<form class="checklist-step-editor" id="checklist-step-editor" aria-label="Manage checklist step" hidden>
|
||||
<label for="checklist-step-label">Checklist step</label>
|
||||
<input id="checklist-step-label" type="text" maxlength="240" autocomplete="off" />
|
||||
<div class="checklist-step-editor-actions">
|
||||
<button id="save-checklist-step-edit" type="submit">Rename</button>
|
||||
<button id="move-checklist-step-earlier" type="button">Move earlier</button>
|
||||
<button id="move-checklist-step-later" type="button">Move later</button>
|
||||
<button id="file-checklist-step-related" type="button">File as related issue</button>
|
||||
<button id="remove-checklist-step" type="button">Remove</button>
|
||||
<button id="cancel-checklist-step-edit" type="button">Cancel</button>
|
||||
</div>
|
||||
<div id="checklist-step-edit-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
<section class="checklist-add" aria-label="Add checklist step">
|
||||
<button id="open-add-checklist-step" type="button" disabled>Add step</button>
|
||||
<form id="add-checklist-step-form" hidden>
|
||||
<label class="visually-hidden" for="add-checklist-step">Checklist step</label>
|
||||
<input id="add-checklist-step" type="text" maxlength="240" autocomplete="off" placeholder="New checklist step" />
|
||||
<button id="save-checklist-step" type="submit">Add</button>
|
||||
<button id="cancel-checklist-step" type="button">Cancel</button>
|
||||
</form>
|
||||
<div id="add-checklist-step-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="checklist-completion" id="checklist-completion" aria-label="Completed checklist" hidden>
|
||||
<span id="checklist-completion-status" role="status" aria-live="polite">Checklist complete</span>
|
||||
<button id="complete-checklist-issue" type="button">Close issue</button>
|
||||
<button id="keep-checklist-issue-open" type="button">Keep open</button>
|
||||
</section>
|
||||
<section class="issue-blockers" id="issue-blockers" aria-labelledby="issue-blockers-title" hidden>
|
||||
<h2 id="issue-blockers-title">Blocked by</h2>
|
||||
<div class="issue-blocker-list" id="issue-blocker-list"></div>
|
||||
@@ -637,9 +676,7 @@
|
||||
</div>
|
||||
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<details class="issue-planning" id="issue-planning">
|
||||
<summary>Plan & edit</summary>
|
||||
<button class="issue-planning-retry" id="retry-issue-planning" type="button" hidden>Retry planning controls</button>
|
||||
<section class="issue-content-editor" aria-label="Issue content">
|
||||
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
|
||||
<form class="issue-edit-form" id="issue-edit-form" hidden>
|
||||
<label for="issue-edit-title">Title
|
||||
@@ -654,6 +691,10 @@
|
||||
</div>
|
||||
<div id="issue-edit-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
</section>
|
||||
<details class="issue-planning" id="issue-planning">
|
||||
<summary>Plan & edit</summary>
|
||||
<button class="issue-planning-retry" id="retry-issue-planning" type="button" hidden>Retry planning controls</button>
|
||||
<fieldset class="issue-label-editor" id="issue-label-editor" aria-describedby="issue-label-status">
|
||||
<legend>Labels</legend>
|
||||
<div class="issue-label-list" id="issue-label-list"></div>
|
||||
@@ -679,7 +720,7 @@
|
||||
</fieldset>
|
||||
</details>
|
||||
<details class="issue-handoff" id="issue-handoff">
|
||||
<summary>Hand off to teammate</summary>
|
||||
<summary id="issue-handoff-summary">Hand off to teammate</summary>
|
||||
<div>
|
||||
<button id="load-issue-handoff" type="button">Choose teammate</button>
|
||||
<label for="issue-handoff-recipient" class="small">Eligible repository assignee</label>
|
||||
@@ -688,6 +729,10 @@
|
||||
<div id="issue-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
|
||||
</div>
|
||||
</details>
|
||||
<section class="filed-claim-actions" id="filed-claim-actions" aria-label="Take ownership of filed issue" hidden>
|
||||
<button id="queue-filed-issue" type="button">Queue Today</button>
|
||||
<button id="start-filed-issue" type="button">Claim & start</button>
|
||||
</section>
|
||||
<div class="issue-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="release-issue" type="button">Release assignment</button>
|
||||
@@ -771,6 +816,27 @@
|
||||
<label for="create-issue-body">Note <span class="small">Optional</span>
|
||||
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<section class="voice-issue-capture" id="voice-issue-capture" aria-labelledby="voice-issue-heading" hidden>
|
||||
<div class="voice-issue-heading">
|
||||
<strong id="voice-issue-heading">Speak capture</strong>
|
||||
<span class="small">Your browser transcribes speech. Stackchain does not retain audio.</span>
|
||||
</div>
|
||||
<div class="voice-issue-controls">
|
||||
<button id="start-voice-issue-capture" type="button">Speak capture</button>
|
||||
<button id="stop-voice-issue-capture" type="button" hidden>Stop listening</button>
|
||||
</div>
|
||||
<div id="voice-issue-status" class="small" aria-live="polite"></div>
|
||||
<div class="voice-issue-review" id="voice-issue-review" hidden>
|
||||
<label for="voice-issue-transcript">Review transcript
|
||||
<textarea id="voice-issue-transcript" maxlength="10255"></textarea>
|
||||
</label>
|
||||
<div class="voice-issue-review-actions">
|
||||
<button id="append-voice-issue-transcript" type="button">Append to draft</button>
|
||||
<button id="replace-with-voice-issue-transcript" type="button">Replace draft</button>
|
||||
<button id="discard-voice-issue-transcript" type="button" class="secondary">Discard transcript</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="create-issue-capture-actions">
|
||||
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
|
||||
<button id="file-new-issue" type="button">File now</button>
|
||||
@@ -1209,6 +1275,15 @@
|
||||
<button id="review-kept-updates" type="button" hidden>Review kept unread</button>
|
||||
</div>
|
||||
<p class="small muted">Choose what to work through next.</p>
|
||||
<section class="mobile-start-day" aria-labelledby="mobile-start-day-heading">
|
||||
<div>
|
||||
<h3 id="mobile-start-day-heading">Prepare Today</h3>
|
||||
<p id="mobile-start-day-summary" class="small" aria-live="polite">Reviewing urgent queues…</p>
|
||||
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, and Filed</p>
|
||||
</div>
|
||||
<button class="mobile-start-day-action" id="mobile-start-day-action" type="button">Prepare Today</button>
|
||||
<button class="mobile-start-day-finish" id="finish-mobile-start-day" type="button" hidden>Finish for now</button>
|
||||
</section>
|
||||
<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>
|
||||
@@ -1233,6 +1308,7 @@
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/private-data-registry.js"></script>
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/feature-loader.js"></script>
|
||||
<script src="static/conversation-action-hydrator.js"></script>
|
||||
@@ -1252,6 +1328,7 @@
|
||||
<script src="static/outbox-coordinator.js"></script>
|
||||
<script src="static/background-issue-sync.js"></script>
|
||||
<script src="static/issue-outbox.js"></script>
|
||||
<script src="static/reconnect-outboxes.js"></script>
|
||||
<script src="static/authored-outbox.js"></script>
|
||||
<script src="static/offline-issue-close.js"></script>
|
||||
<script src="static/offline-issue-blocker.js"></script>
|
||||
@@ -1293,9 +1370,12 @@
|
||||
<script src="static/issue-attachment.js"></script>
|
||||
<script src="static/issue-filing-review.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/voice-transcript-store.js"></script>
|
||||
<script src="static/voice-issue-capture.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
<script src="static/assign-and-start.js"></script>
|
||||
<script src="static/filed-claim.js"></script>
|
||||
<script src="static/queue-today.js"></script>
|
||||
<script src="static/pull-sheet.js"></script>
|
||||
<script src="static/review-sheet.js"></script>
|
||||
@@ -1306,6 +1386,7 @@
|
||||
<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/mobile-start-day.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>
|
||||
|
||||
@@ -76,6 +76,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80);
|
||||
plan.capturedBody = '';
|
||||
}
|
||||
const promotion = value?.checklistPromotion;
|
||||
if (promotion?.repository && promotion?.number && promotion?.url && promotion?.updatedAt) {
|
||||
plan.checklistPromotion = {
|
||||
repository:String(promotion.repository), number:Number(promotion.number), url:String(promotion.url),
|
||||
title:String(promotion.title || ''), body:String(promotion.body || '').slice(0, 10000),
|
||||
updatedAt:String(promotion.updatedAt), taskIndex:Number(promotion.taskIndex),
|
||||
};
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
@@ -134,6 +142,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
if (blockers) item.blockers = blockers;
|
||||
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
|
||||
if (relatedDraft) item.relatedDraft = relatedDraft;
|
||||
|
||||
item.operationId = item.id;
|
||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||
item.milestoneId = Number(draft.milestoneId);
|
||||
@@ -496,6 +505,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const confirmed = [];
|
||||
const completions = [];
|
||||
const filings = [];
|
||||
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
@@ -505,6 +515,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
if (result.issue) {
|
||||
confirmed.push(result.issue);
|
||||
if (result.item?.relatedDraft) filings.push({issue:result.issue, relatedDraft:result.item.relatedDraft});
|
||||
|
||||
if (result.item?.completionIntent) completions.push({
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
@@ -546,6 +557,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return {
|
||||
confirmed: result.issue ? [result.issue] : [],
|
||||
filings: result.issue && result.item?.relatedDraft ? [{issue:result.issue, relatedDraft:result.item.relatedDraft}] : [],
|
||||
|
||||
completions: result.issue && result.item?.completionIntent ? [{
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
|
||||
+392
-9
@@ -18,11 +18,146 @@ function createPlanningLoader({ loadLabels, loadMilestones }) {
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
function escapeOptionHtml(value) {
|
||||
return String(value || '').replace(/[&<>"']/g, character => ({
|
||||
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||
})[character]);
|
||||
}
|
||||
|
||||
function appendChecklistTask(body, label) {
|
||||
const normalized = String(label || '').trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) throw new Error('Enter a checklist step.');
|
||||
const key = normalized.toLocaleLowerCase();
|
||||
const duplicate = String(body || '').split('\n').some(line => {
|
||||
const match = line.match(/^\s*[-*+]\s+\[[ xX]\]\s+(.*)$/);
|
||||
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
|
||||
});
|
||||
if (duplicate) throw new Error('That checklist step already exists.');
|
||||
const prefix = body ? String(body).replace(/\s+$/, '') + '\n' : '';
|
||||
return prefix + '- [ ] ' + normalized;
|
||||
}
|
||||
|
||||
function manageChecklistTask(raw, targetIndex, operation = {}) {
|
||||
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let taskIndex = 0;
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const line = parts[index];
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^([-*+]\s+\[[ xX]\]\s+)(.*)$/);
|
||||
if (!task) continue;
|
||||
if (taskIndex === Number(targetIndex)) {
|
||||
if (operation.action === 'rename') {
|
||||
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
|
||||
if (!label) throw new Error('Enter a checklist step.');
|
||||
const key = label.toLocaleLowerCase();
|
||||
let inFence = false;
|
||||
const duplicate = parts.some((candidate, candidateIndex) => {
|
||||
if (candidateIndex % 2) return false;
|
||||
if (/^\s*```/.test(candidate)) { inFence = !inFence; return false; }
|
||||
if (inFence || candidateIndex === index) return false;
|
||||
const match = candidate.match(/^[-*+]\s+\[[ xX]\]\s+(.*)$/);
|
||||
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
|
||||
});
|
||||
if (duplicate) throw new Error('That checklist step already exists.');
|
||||
parts[index] = task[1] + label;
|
||||
} else if (operation.action === 'remove') {
|
||||
if (index + 1 < parts.length) parts.splice(index, 2);
|
||||
else if (index > 0) parts.splice(index - 1, 2);
|
||||
} else if (operation.action === 'move-earlier' && index >= 2 && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index - 2])) {
|
||||
[parts[index - 2], parts[index]] = [parts[index], parts[index - 2]];
|
||||
} else if (operation.action === 'move-later' && index + 2 < parts.length && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index + 2])) {
|
||||
[parts[index], parts[index + 2]] = [parts[index + 2], parts[index]];
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function checklistIsComplete(raw) {
|
||||
const lines = String(raw || '').split(/(?:\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let found = false;
|
||||
for (const line of lines) {
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^\s*[-*+]\s+\[([ xX])\]\s+/);
|
||||
if (!task) continue;
|
||||
found = true;
|
||||
if (task[1].toLocaleLowerCase() !== 'x') return false;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function completeDelegatedParentTask(raw, childUrl) {
|
||||
const url = String(childUrl || '');
|
||||
if (!/^https:\/\/[^\s)]+$/.test(url)) throw new Error('The delegated issue link is unavailable.');
|
||||
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let taskIndex = 0;
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const line = parts[index];
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^(\s*[-*+]\s+\[)([ xX])(\]\s+.*)$/);
|
||||
if (!task) continue;
|
||||
const linkedTask = task[3].match(/^\]\s+\[[^\]]+\]\((https:\/\/[^\s)]+)\)\s*$/);
|
||||
if (linkedTask?.[1] === url) {
|
||||
const alreadyCompleted = task[2].toLocaleLowerCase() === 'x';
|
||||
if (!alreadyCompleted) parts[index] = task[1] + 'x' + task[3];
|
||||
const body = parts.join('');
|
||||
return { body, taskIndex, alreadyCompleted, checklistComplete:checklistIsComplete(body) };
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
throw new Error('The parent checklist no longer links to this delegated issue.');
|
||||
}
|
||||
|
||||
async function finishDelegatedParentReview({ parentItem, parentDetail, childUrl, updateContent, closeParent }) {
|
||||
const completion = completeDelegatedParentTask(parentDetail?.body, childUrl);
|
||||
if (!completion.alreadyCompleted) {
|
||||
await updateContent(parentItem, {
|
||||
title:parentDetail.title,
|
||||
body:completion.body,
|
||||
expectedUpdatedAt:parentDetail.updated_at,
|
||||
});
|
||||
}
|
||||
if (completion.checklistComplete) await closeParent(parentItem);
|
||||
return { updated:!completion.alreadyCompleted, closed:completion.checklistComplete };
|
||||
}
|
||||
|
||||
function delegatedParentReference(childDetail) {
|
||||
const body = String(childDetail?.body || '');
|
||||
const match = body.match(/(?:^|\n)Related to \[([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9]\d*)\]\((https:\/\/[^\s)]+)\)\.(?:\r?$|\s)/m);
|
||||
if (!match) return null;
|
||||
return { repository:match[1], number:Number(match[2]), url:match[3] };
|
||||
}
|
||||
|
||||
function resolveDelegatedParent(child, childDetail, parentDetail) {
|
||||
const parent = delegatedParentReference(childDetail);
|
||||
if (!parent) return null;
|
||||
try {
|
||||
const completion = completeDelegatedParentTask(parentDetail?.body, child?.url);
|
||||
return {
|
||||
parent,
|
||||
taskIndex:completion.taskIndex,
|
||||
alreadyCompleted:completion.alreadyCompleted,
|
||||
checklistComplete:completion.checklistComplete,
|
||||
};
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, manageTask: manageTaskTransform = manageChecklistTask, relatedTaskDraft: relatedTaskDraftTransform = (...args) => globalThis.createIssueCapture?.relatedChecklistDraft(...args), linkRelatedTask: linkRelatedTaskTransform = (...args) => globalThis.createIssueCapture?.linkChecklistTask(...args), enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
let handoffRequest = null;
|
||||
let reassignRequest = null;
|
||||
let labelRequest = null;
|
||||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
@@ -38,8 +173,11 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
delegatedParentReference,
|
||||
resolveDelegatedParent,
|
||||
completeDelegatedParentTask,
|
||||
readOnly(item) {
|
||||
return Boolean(item?.is_filed && !item?.is_assigned);
|
||||
return Boolean(item?.is_completed || (item?.is_filed && !item?.is_assigned));
|
||||
},
|
||||
load(item) {
|
||||
const access = this.readOnly(item) ? '?access=filed' : '';
|
||||
@@ -48,10 +186,12 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
});
|
||||
},
|
||||
conversation(item, initialPage) {
|
||||
const access = this.readOnly(item) ? '&access=filed' : '';
|
||||
const pager = createConversationPager({
|
||||
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
loadPage: page => fetchJson(
|
||||
issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20' + access,
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
),
|
||||
});
|
||||
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
|
||||
return pager;
|
||||
@@ -68,7 +208,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
});
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
return fetchJson(issuePath(item) + '/handoff-candidates', {
|
||||
const access = item?.is_filed ? '?access=filed' : '';
|
||||
return fetchJson(issuePath(item) + '/handoff-candidates' + access, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
@@ -115,7 +256,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
updateContent(item, draft) {
|
||||
if (editRequest) return editRequest;
|
||||
this.saveEditDraft(item, draft);
|
||||
editRequest = fetchJson(issuePath(item) + '/content', {
|
||||
const access = this.readOnly(item) ? '?access=filed' : '';
|
||||
editRequest = fetchJson(issuePath(item) + '/content' + access, {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -133,6 +275,218 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
}).finally(() => { editRequest = null; });
|
||||
return editRequest;
|
||||
},
|
||||
toggleTask(item, detail, taskIndex, checked) {
|
||||
if (typeof toggleTask !== 'function') return Promise.reject(new Error('Checklist updates are unavailable.'));
|
||||
return this.updateContent(item, {
|
||||
title: detail.title,
|
||||
body: toggleTask(detail.body, taskIndex, checked),
|
||||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
manageTask(item, detail, taskIndex, operation) {
|
||||
if (typeof manageTaskTransform !== 'function') return Promise.reject(new Error('Checklist management is unavailable.'));
|
||||
return this.updateContent(item, {
|
||||
title: detail.title,
|
||||
body: manageTaskTransform(detail.body, taskIndex, operation),
|
||||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
relatedTaskDraft(item, _detail, _taskIndex, label) {
|
||||
if (typeof relatedTaskDraftTransform !== 'function') throw new Error('Related issue capture is unavailable.');
|
||||
return relatedTaskDraftTransform(item, label);
|
||||
},
|
||||
linkRelatedTask(item, detail, taskIndex, child) {
|
||||
if (typeof linkRelatedTaskTransform !== 'function') return Promise.reject(new Error('Related issue linking is unavailable.'));
|
||||
return this.updateContent(item, {
|
||||
title:detail.title,
|
||||
body:linkRelatedTaskTransform(detail.body, taskIndex, child),
|
||||
expectedUpdatedAt:detail.updated_at,
|
||||
});
|
||||
},
|
||||
async addTask(item, detail, label) {
|
||||
const body = appendChecklistTask(detail.body, label);
|
||||
return this.updateContent(item, {
|
||||
title: detail.title,
|
||||
body,
|
||||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
async queueAddedTask(item, detail, label) {
|
||||
if (typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist updates are unavailable.');
|
||||
}
|
||||
const body = appendChecklistTask(detail.body, label);
|
||||
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
|
||||
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||
},
|
||||
async queueManagedTask(item, detail, taskIndex, operation) {
|
||||
if (typeof manageTaskTransform !== 'function' || typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist management is unavailable.');
|
||||
}
|
||||
const body = manageTaskTransform(detail.body, taskIndex, operation);
|
||||
const checklistOperation = {
|
||||
action:String(operation?.action || ''), index:Number(taskIndex),
|
||||
...(operation?.label ? { label:String(operation.label) } : {}),
|
||||
};
|
||||
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at, checklistOperation });
|
||||
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||
},
|
||||
async queueTask(item, detail, taskIndex, checked) {
|
||||
if (typeof toggleTask !== 'function' || typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist updates are unavailable.');
|
||||
}
|
||||
const body = toggleTask(detail.body, taskIndex, checked);
|
||||
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
|
||||
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||
},
|
||||
pendingTask(item, detail, items, ownerLogin) {
|
||||
const pending = [...(items || [])].reverse().find(candidate => candidate?.kind === 'issue-content' &&
|
||||
candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) &&
|
||||
candidate.ownerLogin === ownerLogin && ['queued', 'sending', 'attention'].includes(candidate.status));
|
||||
if (!pending) return { ...detail };
|
||||
return { ...detail, title:pending.title, body:pending.body, checklist_pending:true };
|
||||
},
|
||||
bindTaskToggles({ container, status, retry, current, confirmed, restore }) {
|
||||
container.addEventListener('change', async event => {
|
||||
const control = event.target.closest('input.task-list-toggle');
|
||||
const state = current();
|
||||
if (!control || !state?.item || !state?.detail?.updated_at) return;
|
||||
container.classList.add('checklist-pending');
|
||||
container.querySelectorAll('input.task-list-toggle').forEach(input => { input.disabled = true; });
|
||||
status.textContent = 'Updating checklist…';
|
||||
try {
|
||||
const result = await (state.offline ? this.queueTask : this.toggleTask).call(
|
||||
this, state.item, state.detail, Number(control.dataset.taskIndex), control.checked
|
||||
);
|
||||
const latest = current();
|
||||
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
|
||||
confirmed(state.item, state.detail, state.offline ? result.detail : result);
|
||||
if (state.offline) status.textContent = 'Checklist queued. Pending sync.';
|
||||
else status.textContent = 'Checklist updated.';
|
||||
}
|
||||
} catch (error) {
|
||||
const latest = current();
|
||||
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
|
||||
restore(state.detail);
|
||||
status.textContent = error.message + ' Checklist was not changed; reload latest or use Edit issue.';
|
||||
retry.hidden = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
bindTaskManagement({ container, editor, label, earlier, later, fileRelated, onFileRelated, remove, cancel, status, sheetStatus, retry, current, confirmed }) {
|
||||
let taskIndex = null;
|
||||
let trigger = null;
|
||||
const reset = (returnFocus = false) => {
|
||||
editor.hidden = true;
|
||||
status.textContent = '';
|
||||
if (returnFocus && trigger?.isConnected) trigger.focus();
|
||||
taskIndex = null;
|
||||
trigger = null;
|
||||
};
|
||||
container.addEventListener('click', event => {
|
||||
const control = event.target.closest('button.task-list-manage');
|
||||
if (!control || !current()?.detail?.updated_at) return;
|
||||
taskIndex = Number(control.dataset.taskIndex);
|
||||
trigger = control;
|
||||
label.value = control.dataset.taskLabel || '';
|
||||
earlier.disabled = control.dataset.taskFirst === 'true';
|
||||
later.disabled = control.dataset.taskLast === 'true';
|
||||
fileRelated.disabled = /^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(label.value);
|
||||
editor.hidden = false;
|
||||
status.textContent = fileRelated.disabled ? 'This step already links to related work.' :
|
||||
'Rename, reorder, remove, or file this step as related work.';
|
||||
label.focus();
|
||||
});
|
||||
const run = async operation => {
|
||||
const state = current();
|
||||
const selectedIndex = taskIndex;
|
||||
if (!state?.item || !state.detail?.updated_at || selectedIndex === null) return;
|
||||
if (operation.action === 'remove' && !globalThis.confirm('Remove this checklist step?')) return;
|
||||
const controls = editor.querySelectorAll('button,input');
|
||||
controls.forEach(control => { control.disabled = true; });
|
||||
status.textContent = state.offline ? 'Queueing checklist change…' : 'Updating checklist…';
|
||||
try {
|
||||
const result = await (state.offline ? this.queueManagedTask : this.manageTask).call(
|
||||
this, state.item, state.detail, selectedIndex, operation
|
||||
);
|
||||
if (current()?.item !== state.item) return;
|
||||
confirmed(state.item, state.detail, state.offline ? result.detail : result);
|
||||
reset();
|
||||
sheetStatus.textContent = state.offline ? 'Checklist change queued. Pending sync.' : 'Checklist step updated.';
|
||||
const focusIndex = operation.action === 'remove' ? Math.max(0, selectedIndex - 1) : selectedIndex;
|
||||
container.querySelector('button.task-list-manage[data-task-index="' + focusIndex + '"]')?.focus();
|
||||
} catch (error) {
|
||||
if (current()?.item === state.item) {
|
||||
status.textContent = error.message + ' Your change is still here; retry.';
|
||||
retry.hidden = false;
|
||||
label.focus();
|
||||
}
|
||||
} finally {
|
||||
if (!editor.hidden) controls.forEach(control => { control.disabled = false; });
|
||||
}
|
||||
};
|
||||
editor.addEventListener('submit', event => { event.preventDefault(); run({ action:'rename', label:label.value }); });
|
||||
earlier.addEventListener('click', () => run({ action:'move-earlier' }));
|
||||
later.addEventListener('click', () => run({ action:'move-later' }));
|
||||
fileRelated.addEventListener('click', async () => {
|
||||
const state = current();
|
||||
const selectedIndex = taskIndex;
|
||||
if (!state?.item || !state.detail?.updated_at || selectedIndex === null || fileRelated.disabled) return;
|
||||
try {
|
||||
await onFileRelated({ item:state.item, detail:state.detail, taskIndex:selectedIndex,
|
||||
label:label.value, trigger });
|
||||
reset();
|
||||
} catch (error) {
|
||||
status.textContent = error.message + ' The checklist step was not changed.';
|
||||
label.focus();
|
||||
}
|
||||
});
|
||||
remove.addEventListener('click', () => run({ action:'remove' }));
|
||||
cancel.addEventListener('click', () => reset(true));
|
||||
return { reset };
|
||||
},
|
||||
renderTasks(container, detail, interactive) {
|
||||
container.classList.toggle('checklist-pending', detail.checklist_pending === true);
|
||||
container.innerHTML = renderMarkdown(
|
||||
detail.body || 'No description provided.', {
|
||||
interactiveTasks: Boolean(interactive),
|
||||
manageTasks: Boolean(interactive),
|
||||
}
|
||||
);
|
||||
},
|
||||
checklistCompletion(container, { interactive, today, offline, dismissed = false }) {
|
||||
const tasks = Array.from(container.querySelectorAll('input.task-list-toggle'));
|
||||
const visible = Boolean(interactive && !dismissed && tasks.length && tasks.every(task => task.checked));
|
||||
return {
|
||||
visible,
|
||||
status: offline ? 'Checklist complete · pending sync' : 'Checklist complete',
|
||||
label: today ? (offline ? 'Queue close & next' : 'Close & next') :
|
||||
(offline ? 'Queue issue closure' : 'Close issue'),
|
||||
};
|
||||
},
|
||||
mergeContent(snapshot, item, detail, confirmed, replace) {
|
||||
return {
|
||||
snapshot: snapshot ? replace(snapshot, item.repository, item.number, confirmed) : snapshot,
|
||||
item: { ...item, ...confirmed, key:item.key },
|
||||
detail: { ...detail, ...confirmed },
|
||||
};
|
||||
},
|
||||
renderMilestoneEditor(item, confirmed, milestones, document = globalThis.document) {
|
||||
const select = document.querySelector('#issue-milestone');
|
||||
const status = document.querySelector('#issue-milestone-status');
|
||||
select.innerHTML = '<option value="">No milestone</option>' + milestones.map(milestone =>
|
||||
'<option value="' + Number(milestone.id) + '">' + escapeOptionHtml(milestone.title) + '</option>'
|
||||
).join('');
|
||||
select.value = String(this.loadMilestoneDraft(item) ?? confirmed?.id ?? '');
|
||||
select.disabled = false;
|
||||
document.querySelector('#save-issue-milestone').disabled = false;
|
||||
status.textContent = confirmed ? 'Planned for ' + confirmed.title + '.' : 'No milestone set.';
|
||||
},
|
||||
|
||||
loadDueDateDraft(item) {
|
||||
try {
|
||||
const raw = storage?.getItem(dueDateDraftKey(item));
|
||||
@@ -212,7 +566,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
},
|
||||
close(item) {
|
||||
if (closeRequest) return closeRequest;
|
||||
closeRequest = fetchJson(issuePath(item) + '/close', {
|
||||
const access = item?.is_filed && !item?.is_assigned ? '?access=filed' : '';
|
||||
closeRequest = fetchJson(issuePath(item) + '/close' + access, {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
@@ -258,6 +613,28 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
}).finally(() => { handoffRequest = null; });
|
||||
return handoffRequest;
|
||||
},
|
||||
reassign(item, recipient) {
|
||||
if (reassignRequest) return reassignRequest;
|
||||
const expectedAssignees = Array.isArray(item?.assignees) ? item.assignees.slice() : [];
|
||||
reassignRequest = fetchJson(issuePath(item) + '/reassign', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient, expected_assignees: expectedAssignees }),
|
||||
}).then(result => {
|
||||
if (
|
||||
result?.number !== item.number ||
|
||||
result?.recipient !== recipient ||
|
||||
!Array.isArray(result.assignees) ||
|
||||
result.assignees.length !== 1 ||
|
||||
result.assignees[0] !== recipient ||
|
||||
JSON.stringify(result.previous_assignees) !== JSON.stringify(expectedAssignees)
|
||||
) {
|
||||
throw new Error('Issue reassignment was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { reassignRequest = null; });
|
||||
return reassignRequest;
|
||||
},
|
||||
comment(item, body) {
|
||||
if (commentRequest) return commentRequest;
|
||||
this.saveDraft(item, body);
|
||||
@@ -266,7 +643,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(operationKey(item), operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
commentRequest = fetchJson(issuePath(item) + '/comments', {
|
||||
const access = this.readOnly(item) ? '?access=filed' : '';
|
||||
commentRequest = fetchJson(issuePath(item) + '/comments' + access, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
|
||||
body: JSON.stringify({ body }),
|
||||
@@ -284,4 +662,9 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
|
||||
createIssueSheet.createPlanningLoader = createPlanningLoader;
|
||||
|
||||
createIssueSheet.manageChecklistTask = manageChecklistTask;
|
||||
createIssueSheet.completeDelegatedParentTask = completeDelegatedParentTask;
|
||||
createIssueSheet.finishDelegatedParentReview = finishDelegatedParentReview;
|
||||
createIssueSheet.delegatedParentReference = delegatedParentReference;
|
||||
createIssueSheet.resolveDelegatedParent = resolveDelegatedParent;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
|
||||
@@ -36,7 +36,7 @@
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "./",
|
||||
"action": "./share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
|
||||
+43
-6
@@ -32,23 +32,33 @@
|
||||
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]);
|
||||
}
|
||||
|
||||
function renderList(lines) {
|
||||
function renderList(lines, options, firstTaskIndex) {
|
||||
const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line));
|
||||
const items = lines.map(line => {
|
||||
const items = lines.map((line, offset) => {
|
||||
let body = line.replace(/^[-*+]\s+/, '');
|
||||
if (!taskList) return '<li>' + renderInline(body) + '</li>';
|
||||
const checked = /^\[[xX]\]\s+/.test(body);
|
||||
body = body.replace(/^\[[ xX]\]\s+/, '');
|
||||
return '<li class="task-list-item"><input type="checkbox" disabled' +
|
||||
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
|
||||
const control = options.interactiveTasks ?
|
||||
' class="task-list-toggle" data-task-index="' + (firstTaskIndex + offset) +
|
||||
'" aria-label="Mark ' + escapeHtml(body) + (checked ? ' incomplete"' : ' complete"') :
|
||||
' disabled';
|
||||
const manage = options.interactiveTasks && options.manageTasks ?
|
||||
'<button type="button" class="task-list-manage" data-task-index="' + (firstTaskIndex + offset) +
|
||||
'" data-task-label="' + escapeHtml(body) + '" aria-label="Manage step: ' + escapeHtml(body) + '"' +
|
||||
(offset === 0 ? ' data-task-first="true"' : '') +
|
||||
(offset === lines.length - 1 ? ' data-task-last="true"' : '') + '>Manage</button>' : '';
|
||||
return '<li class="task-list-item"><input type="checkbox"' + control +
|
||||
(checked ? ' checked' : '') + '> ' + renderInline(body) + manage + '</li>';
|
||||
}).join('');
|
||||
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
|
||||
}
|
||||
|
||||
return function renderMarkdown(raw) {
|
||||
function renderMarkdown(raw, options = {}) {
|
||||
const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n');
|
||||
const output = [];
|
||||
let index = 0;
|
||||
let taskIndex = 0;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
@@ -90,7 +100,10 @@
|
||||
list.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
output.push(renderList(list));
|
||||
output.push(renderList(list, options, taskIndex));
|
||||
if (list.every(candidate => /^[-*+]\s+\[[ xX]\]\s+/.test(candidate))) {
|
||||
taskIndex += list.length;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const paragraph = [];
|
||||
@@ -103,5 +116,29 @@
|
||||
output.push('<p>' + renderInline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
|
||||
}
|
||||
return output.join('');
|
||||
}
|
||||
|
||||
renderMarkdown.toggleTask = function toggleTask(raw, targetIndex, checked) {
|
||||
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let taskIndex = 0;
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const line = parts[index];
|
||||
if (/^\s*```/.test(line)) {
|
||||
fenced = !fenced;
|
||||
continue;
|
||||
}
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^([-*+]\s+\[)([ xX])(\])/);
|
||||
if (!task) continue;
|
||||
if (taskIndex === Number(targetIndex)) {
|
||||
parts[index] = task[1] + (checked ? 'x' : ' ') + task[3] + line.slice(task[0].length);
|
||||
return parts.join('');
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
return parts.join('');
|
||||
};
|
||||
|
||||
return renderMarkdown;
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileStartDay = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileStartDay(options) {
|
||||
const checkpointKey = 'stackchain.mobile-start-day.v1';
|
||||
const storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null);
|
||||
const reviewOrder = [
|
||||
['agenda', 'Agenda'],
|
||||
['attention', 'Attention'],
|
||||
['update', 'Updates'],
|
||||
['filed', 'Filed'],
|
||||
];
|
||||
|
||||
function count(value) {
|
||||
return Math.max(0, Number(value) || 0);
|
||||
}
|
||||
|
||||
function localDay() {
|
||||
const now = new Date();
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
return now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate());
|
||||
}
|
||||
|
||||
function identity() {
|
||||
try {
|
||||
return {
|
||||
login: String(options.getLogin ? options.getLogin() : '').trim(),
|
||||
day: String(options.getDay ? options.getDay() : localDay()),
|
||||
};
|
||||
} catch (_) {
|
||||
return {login:'', day:''};
|
||||
}
|
||||
}
|
||||
|
||||
function checkpoint() {
|
||||
if (!storage) return null;
|
||||
const current = identity();
|
||||
if (!current.login || !current.day) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(checkpointKey) || 'null');
|
||||
return saved?.login === current.login && saved?.day === current.day ? saved : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveCheckpoint(phase = null) {
|
||||
if (!storage) return false;
|
||||
const current = identity();
|
||||
if (!current.login || !current.day) return false;
|
||||
try {
|
||||
storage.setItem(checkpointKey, JSON.stringify({...current, phase}));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCheckpoint() {
|
||||
if (!storage || !checkpoint()) return false;
|
||||
try {
|
||||
storage.removeItem(checkpointKey);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function briefing() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const phases = reviewOrder
|
||||
.map(([name, label]) => ({name, label, count: count(counts[name])}))
|
||||
.filter(phase => phase.count > 0);
|
||||
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
|
||||
const today = count(counts.today);
|
||||
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
|
||||
const nextLabel = phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work');
|
||||
return {
|
||||
total,
|
||||
next,
|
||||
label: nextLabel,
|
||||
summary: total ? total + ' items before Today · ' + today + ' planned' :
|
||||
(today ? 'Review clear · ' + today + ' planned' : 'Review clear · Today is empty'),
|
||||
phases,
|
||||
};
|
||||
}
|
||||
|
||||
function startNext() {
|
||||
const next = briefing().next;
|
||||
if (reviewOrder.some(([name]) => name === next)) saveCheckpoint(next);
|
||||
else clearCheckpoint();
|
||||
options.openQueue(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function state() {
|
||||
const current = briefing();
|
||||
return {active:Boolean(checkpoint()), next:current.next, label:current.label};
|
||||
}
|
||||
|
||||
function completePhase(phase) {
|
||||
const saved = checkpoint();
|
||||
if (!saved || (saved.phase && saved.phase !== phase)) return false;
|
||||
const current = render();
|
||||
saveCheckpoint();
|
||||
if (options.onHandoff) options.onHandoff(current);
|
||||
return true;
|
||||
}
|
||||
|
||||
function reconcile({authoritative = false} = {}) {
|
||||
const saved = checkpoint();
|
||||
if (!authoritative || !saved?.phase) return false;
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
if (count(counts[saved.phase]) > 0) return false;
|
||||
return completePhase(saved.phase);
|
||||
}
|
||||
|
||||
function finish() {
|
||||
const cleared = clearCheckpoint();
|
||||
render();
|
||||
return cleared;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const current = briefing();
|
||||
if (!options.elements) return current;
|
||||
options.elements.summary.textContent = current.summary;
|
||||
options.elements.phases.textContent = current.phases.length ?
|
||||
current.phases.map(phase => phase.label + ' ' + phase.count).join(' · ') :
|
||||
'All urgent queues reviewed';
|
||||
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
|
||||
if (options.elements.finish) options.elements.finish.hidden = !checkpoint();
|
||||
return current;
|
||||
}
|
||||
|
||||
function start() {
|
||||
render();
|
||||
if (options.elements) options.elements.action.addEventListener('click', startNext);
|
||||
}
|
||||
|
||||
return {briefing, completePhase, finish, reconcile, render, start, startNext, state};
|
||||
});
|
||||
+112
-2
@@ -71,20 +71,23 @@ function buildMyWork(data, now = new Date()) {
|
||||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
const isFiled = (item.work_reasons || []).includes('created_by_me');
|
||||
const isCompleted = isFiled && item.state === 'closed';
|
||||
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||
const normalized = {
|
||||
...item,
|
||||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
is_filed: isFiled,
|
||||
is_completed: isCompleted,
|
||||
is_assigned: assigned,
|
||||
has_update: false,
|
||||
...(due ? { due_label: due.label } : {}),
|
||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||
(due && due.priority < 4 ? due.label :
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work')))),
|
||||
(isReview ? 'Needs your review' : (isCompleted ? 'Completed · review outcome' :
|
||||
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work'))))),
|
||||
_priority: priorityLabel ? 0 :
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))),
|
||||
};
|
||||
normalized.needs_attention = needsAttention(normalized);
|
||||
normalized.attention_reason = attentionReason(normalized);
|
||||
@@ -666,6 +669,111 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
);
|
||||
}
|
||||
|
||||
function createCompletedFiledReview({
|
||||
storage,
|
||||
getLogin,
|
||||
key = 'stackchain.completed-filed-review.v1',
|
||||
}) {
|
||||
const login = () => String(getLogin?.() || '').trim();
|
||||
const ownerKey = () => key + ':' + login();
|
||||
const identity = item => String(item?.repository || '') + '#' + String(item?.number || '');
|
||||
const read = () => {
|
||||
if (!login()) return { items:{}, pending:{} };
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(ownerKey()) || '{}');
|
||||
return value && value.version === 1 && value.items && typeof value.items === 'object' ? {
|
||||
items:value.items,
|
||||
pending:value.pending && typeof value.pending === 'object' ? value.pending : {},
|
||||
} : { items:{}, pending:{} };
|
||||
} catch (_error) { return { items:{}, pending:{} }; }
|
||||
};
|
||||
const receipt = (item, stamp = String(item?.updated_at || '')) => ({
|
||||
repository:String(item?.repository || ''), number:item?.number, updated_at:stamp,
|
||||
});
|
||||
const save = value => storage?.setItem(ownerKey(), JSON.stringify({ version:1, ...value }));
|
||||
const partition = items => {
|
||||
const acknowledged = read().items;
|
||||
const needsReview = [];
|
||||
const reviewed = [];
|
||||
(items || []).forEach(item => {
|
||||
const isReviewed = item?.is_completed &&
|
||||
acknowledged[identity(item)] === String(item.updated_at || '');
|
||||
(isReviewed ? reviewed : needsReview).push(item);
|
||||
});
|
||||
reviewed.sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || '')));
|
||||
return { needsReview, reviewed };
|
||||
};
|
||||
return {
|
||||
partition,
|
||||
visible(items) {
|
||||
return partition(items).needsReview;
|
||||
},
|
||||
pending() {
|
||||
const state = read();
|
||||
return Object.entries(state.pending).map(([itemIdentity, stamp]) => {
|
||||
const boundary = itemIdentity.lastIndexOf('#');
|
||||
return receipt({
|
||||
repository:itemIdentity.slice(0, boundary),
|
||||
number:Number(itemIdentity.slice(boundary + 1)),
|
||||
}, stamp);
|
||||
});
|
||||
},
|
||||
adopt(snapshot) {
|
||||
const state = read();
|
||||
let changed = false;
|
||||
(snapshot?.receipts || []).forEach(remote => {
|
||||
const itemIdentity = identity(remote);
|
||||
const stamp = String(remote?.updated_at || '');
|
||||
if (!remote?.repository || !Number.isInteger(remote?.number) || !stamp) return;
|
||||
if (!state.items[itemIdentity] || state.items[itemIdentity] < stamp) {
|
||||
state.items[itemIdentity] = stamp;
|
||||
changed = true;
|
||||
}
|
||||
if (state.pending[itemIdentity] && state.pending[itemIdentity] <= stamp) {
|
||||
delete state.pending[itemIdentity];
|
||||
}
|
||||
});
|
||||
try { save(state); } catch (_error) { return false; }
|
||||
return changed;
|
||||
},
|
||||
acknowledge(item) {
|
||||
const stamp = String(item?.updated_at || '');
|
||||
if (!login() || !item?.is_completed || !item?.repository || !Number.isInteger(item?.number) || !stamp) {
|
||||
return false;
|
||||
}
|
||||
const state = read();
|
||||
const entries = Object.entries({ ...state.items, [identity(item)]: stamp }).slice(-200);
|
||||
const pending = Object.entries({ ...state.pending, [identity(item)]: stamp }).slice(-200);
|
||||
try {
|
||||
save({ items:Object.fromEntries(entries), pending:Object.fromEntries(pending) });
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFiledHistoryTabs({ root, review, onSelect }) {
|
||||
const buttons = Array.from(root.querySelectorAll('[data-filed-view]'));
|
||||
let partition = { needsReview:[], reviewed:[] };
|
||||
buttons.forEach(button => button.addEventListener('click', () => onSelect(button.dataset.filedView)));
|
||||
return {
|
||||
prepare(items) {
|
||||
partition = review.partition(items);
|
||||
root.querySelector('#filed-needs-review-count').textContent =
|
||||
partition.needsReview.filter(item => item.is_filed).length;
|
||||
root.querySelector('#filed-reviewed-count').textContent = partition.reviewed.length;
|
||||
return partition.needsReview;
|
||||
},
|
||||
render(selected, visible) {
|
||||
root.hidden = !visible;
|
||||
buttons.forEach(button =>
|
||||
button.setAttribute('aria-pressed', String(button.dataset.filedView === selected))
|
||||
);
|
||||
return selected === 'reviewed' ? partition.reviewed : partition.needsReview;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function agendaMyWork(items, now = new Date()) {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const today = localDay(start);
|
||||
@@ -981,6 +1089,8 @@ function countMyWork(items) {
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.createCompletedFiledReview = createCompletedFiledReview;
|
||||
buildMyWork.createFiledHistoryTabs = createFiledHistoryTabs;
|
||||
buildMyWork.agendaMyWork = agendaMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
(function (root) {
|
||||
const databases = Object.freeze([
|
||||
'stackchain-background-outbox-v1',
|
||||
'stackchain-offline-work-v2',
|
||||
'stackchain-unfiled-captures-v1',
|
||||
'stackchain-voice-transcripts-v1',
|
||||
]);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
|
||||
else root.stackchainPrivateDatabases = databases;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
@@ -1,5 +1,10 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = options => factory({
|
||||
...options,
|
||||
privateDatabases: require('./private-data-registry.js'),
|
||||
});
|
||||
}
|
||||
else root.stackchainPrivateDeviceData = factory({
|
||||
localStorage: root.localStorage,
|
||||
sessionStorage: root.sessionStorage,
|
||||
@@ -7,9 +12,10 @@
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
MessageChannel: root.MessageChannel,
|
||||
privateDatabases: root.stackchainPrivateDatabases,
|
||||
});
|
||||
})(typeof window !== 'undefined' ? window : this, function createPrivateDeviceDataPurger({
|
||||
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel,
|
||||
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, privateDatabases,
|
||||
}) {
|
||||
function removeOwnedStorage(storage) {
|
||||
if (!storage) return;
|
||||
@@ -52,9 +58,7 @@
|
||||
removeOwnedStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
for (const name of privateDatabases) await deletePrivateDatabase(name);
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
function createReconnectOutboxes({
|
||||
refresh,
|
||||
restoreIdentity,
|
||||
flushIssue,
|
||||
flushAuthored,
|
||||
flushNotificationReads,
|
||||
}) {
|
||||
return async function reconnectOutboxes() {
|
||||
const snapshot = await refresh();
|
||||
const freshness = snapshot?.freshness?.sections?.context;
|
||||
const identityFresh = snapshot?.context && !snapshot.context.error &&
|
||||
!freshness?.stale && !freshness?.degraded && !freshness?.revalidating;
|
||||
const login = identityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (!login) return false;
|
||||
restoreIdentity(login);
|
||||
await Promise.all([
|
||||
flushIssue(),
|
||||
flushAuthored(),
|
||||
flushNotificationReads(),
|
||||
]);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createReconnectOutboxes;
|
||||
+52
-38
@@ -1,10 +1,12 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v102';
|
||||
const CACHE = 'stackchain-dashboard-shell-v108';
|
||||
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;
|
||||
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
|
||||
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
@@ -30,6 +32,7 @@ const SHELL = [
|
||||
BASE + 'static/draft-capacity-dialog.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/reconnect-outboxes.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-issue-close.js',
|
||||
BASE + 'static/offline-issue-blocker.js',
|
||||
@@ -71,9 +74,13 @@ const SHELL = [
|
||||
BASE + 'static/issue-attachment.js',
|
||||
BASE + 'static/issue-filing-review.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/checklist-conflict.js',
|
||||
BASE + 'static/voice-transcript-store.js',
|
||||
BASE + 'static/voice-issue-capture.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
BASE + 'static/filed-claim.js',
|
||||
BASE + 'static/queue-today.js',
|
||||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
@@ -84,6 +91,7 @@ const SHELL = [
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-start-day.js',
|
||||
BASE + 'static/update-triage-session.js',
|
||||
BASE + 'static/update-review-handoff.js',
|
||||
BASE + 'static/update-read-position.js',
|
||||
@@ -109,6 +117,7 @@ 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 SHARE_TARGET_PATH = BASE + 'share-target';
|
||||
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
|
||||
|
||||
function boundedShareField(form, name, limit) {
|
||||
@@ -116,45 +125,46 @@ function boundedShareField(form, name, limit) {
|
||||
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 > 5) marker = 'multiple';
|
||||
else if (images.length > 0) {
|
||||
const supported = images.every(image =>
|
||||
SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES
|
||||
);
|
||||
if (!supported) {
|
||||
marker = 'unsupported';
|
||||
} else if (images.length === 1) {
|
||||
const image = images[0];
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
});
|
||||
marker = 'image';
|
||||
} else {
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
|
||||
attachments:images.map(image => ({
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
})),
|
||||
});
|
||||
marker = 'images';
|
||||
}
|
||||
}
|
||||
function sharedContentRedirect(marker) {
|
||||
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 acceptSharedContent(request) {
|
||||
if (request.headers.get('Sec-Fetch-Site') === 'cross-site') {
|
||||
return new Response('Cross-site share submissions are not accepted.', {
|
||||
status:403,
|
||||
headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'},
|
||||
});
|
||||
}
|
||||
const form = await request.formData();
|
||||
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
|
||||
const content = {
|
||||
title:boundedShareField(form, 'title', 255),
|
||||
text:boundedShareField(form, 'text', 10000),
|
||||
url:boundedShareField(form, 'url', 2048),
|
||||
};
|
||||
if (images.length > 5) return sharedContentRedirect('multiple');
|
||||
const supported = images.every(image =>
|
||||
SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES
|
||||
);
|
||||
const totalBytes = images.reduce((total, image) => total + image.size, 0);
|
||||
if (!supported || totalBytes > MAX_SHARED_IMAGE_BYTES) return sharedContentRedirect('unsupported');
|
||||
if (!images.length && !Object.values(content).some(Boolean)) return sharedContentRedirect('unsupported');
|
||||
const attachments = images.map(image => ({
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
}));
|
||||
try {
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {...content, attachments});
|
||||
} catch (_error) {
|
||||
return sharedContentRedirect('unavailable');
|
||||
}
|
||||
return sharedContentRedirect('bundle');
|
||||
}
|
||||
|
||||
async function fetchNavigation(request) {
|
||||
const controller = new AbortController();
|
||||
let timeout;
|
||||
@@ -210,9 +220,13 @@ async function deletePrivateDatabase(name) {
|
||||
});
|
||||
}
|
||||
|
||||
async function deletePrivateDatabases() {
|
||||
for (const name of PRIVATE_DATABASES) await deletePrivateDatabase(name);
|
||||
}
|
||||
|
||||
async function purgeRevokedSessionData() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
await deletePrivateDatabases();
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
@@ -249,7 +263,7 @@ async function offlineLeaseState(cache) {
|
||||
|
||||
async function expiredOfflineResponse() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
await deletePrivateDatabases();
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
@@ -370,7 +384,7 @@ self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
await deletePrivateDatabases();
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||
@@ -555,7 +569,7 @@ self.addEventListener('fetch', event => {
|
||||
const requestUrl = new URL(request.url);
|
||||
if (
|
||||
request.method === 'POST' && request.mode === 'navigate'
|
||||
&& requestUrl.origin === self.location.origin && requestUrl.pathname === BASE
|
||||
&& requestUrl.origin === self.location.origin && requestUrl.pathname === SHARE_TARGET_PATH
|
||||
) {
|
||||
event.respondWith(acceptSharedContent(request));
|
||||
return;
|
||||
|
||||
+17
-5
@@ -1,5 +1,10 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = options => factory({
|
||||
...options,
|
||||
privateDatabases: require('./private-data-registry.js'),
|
||||
});
|
||||
}
|
||||
else {
|
||||
const base = new URL('./', root.location.href).pathname;
|
||||
const originalFetch = root.fetch.bind(root);
|
||||
@@ -15,6 +20,7 @@
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
credentials: root.navigator?.credentials,
|
||||
MessageChannel: root.MessageChannel,
|
||||
privateDatabases: root.stackchainPrivateDatabases,
|
||||
location: root.location,
|
||||
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
|
||||
confirmAction: message => root.confirm(message),
|
||||
@@ -30,6 +36,7 @@
|
||||
root.fetch = boundary.fetch;
|
||||
const attach = () => {
|
||||
boundary.startActivityHeartbeat();
|
||||
boundary.startReconnectResume();
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
@@ -82,7 +89,7 @@
|
||||
root.stackchainSession = boundary;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, location, confirmAction, addActivityListener,
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, privateDatabases, location, confirmAction, addActivityListener,
|
||||
promptAuthorization = () => null,
|
||||
onExpired = () => {},
|
||||
onClearError = () => {},
|
||||
@@ -381,6 +388,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
function startReconnectResume() {
|
||||
addActivityListener?.('online', async () => {
|
||||
if (await refreshOfflineLease()) await resumeQueuedWork();
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshOfflineLease() {
|
||||
try {
|
||||
const response = await sessionFetch(base + 'api/v1/session');
|
||||
@@ -454,9 +467,7 @@
|
||||
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
||||
try {
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
for (const name of privateDatabases) await deletePrivateDatabase(name);
|
||||
} catch (_error) {
|
||||
const error = new Error('Could not clear private queued work from this device.');
|
||||
onClearError(error);
|
||||
@@ -613,6 +624,7 @@
|
||||
handleServiceWorkerMessage,
|
||||
refreshOfflineLease,
|
||||
resumeQueuedWork,
|
||||
startReconnectResume,
|
||||
recordActivity,
|
||||
startActivityHeartbeat,
|
||||
};
|
||||
|
||||
@@ -8,14 +8,35 @@
|
||||
const RECORD_ID = 'shared-image';
|
||||
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
async function consume({marker, store, restore, status = () => {}}) {
|
||||
async function consume({marker, store, restore, restoreContent = () => {}, status = () => {}}) {
|
||||
if (!marker) return false;
|
||||
const value = await store?.get(RECORD_ID);
|
||||
const attachments = Array.isArray(value?.attachments) ? value.attachments : null;
|
||||
if (marker === 'bundle') {
|
||||
const content = {
|
||||
title:typeof value?.title === 'string' ? value.title.slice(0, 255) : '',
|
||||
text:typeof value?.text === 'string' ? value.text.slice(0, 10000) : '',
|
||||
url:typeof value?.url === 'string' ? value.url.slice(0, 2048) : '',
|
||||
};
|
||||
const invalidAttachments = !attachments || attachments.length > 5 || attachments.some(attachment =>
|
||||
!attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')));
|
||||
if (invalidAttachments || (!attachments.length && !Object.values(content).some(Boolean))) {
|
||||
status('The shared content is unavailable. Share it again.');
|
||||
return false;
|
||||
}
|
||||
restoreContent(content);
|
||||
if (attachments.length) restore(attachments);
|
||||
await store.delete(RECORD_ID);
|
||||
const noun = attachments.length === 1 ? 'screenshot' : attachments.length > 1 ? 'screenshots' : 'content';
|
||||
status(attachments.length
|
||||
? 'Shared content and ' + (attachments.length > 1 ? attachments.length + ' ' : '') + noun + ' ready to file with this issue.'
|
||||
: 'Shared content ready to file with this issue.');
|
||||
return true;
|
||||
}
|
||||
if (!['image', 'images'].includes(marker)) {
|
||||
status('Share one PNG, JPEG, or WebP screenshot.');
|
||||
return false;
|
||||
}
|
||||
const value = await store?.get(RECORD_ID);
|
||||
const attachments = Array.isArray(value?.attachments) ? value.attachments : null;
|
||||
if (marker === 'images') {
|
||||
if (!attachments?.length || attachments.length > 5 || attachments.some(attachment =>
|
||||
!attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')))) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
function mapVoiceTranscript(value) {
|
||||
const transcript = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!transcript) return {title:'', body:''};
|
||||
const sentence = transcript.match(/^(.+?[.!?])(?:\s+|$)(.*)$/);
|
||||
const title = (sentence ? sentence[1] : transcript).trim().slice(0, 255);
|
||||
const body = (sentence ? sentence[2] : '').trim().slice(0, 10000);
|
||||
return {title, body};
|
||||
}
|
||||
|
||||
function createVoiceIssueCapture({
|
||||
Recognition, elements, createEvent = name => new Event(name, {bubbles:true}),
|
||||
transcriptStore = null, getLogin = () => '',
|
||||
}) {
|
||||
const supported = typeof Recognition === 'function';
|
||||
const login = String(getLogin() || '').trim();
|
||||
const ready = supported && login && transcriptStore ? Promise.resolve(transcriptStore.load(login)).then(value => {
|
||||
const recovered = String(value || '').replace(/\s+/g, ' ').trim().slice(0, 10255);
|
||||
if (recovered) showReview(recovered, {persist:false, recovered:true});
|
||||
}) : Promise.resolve();
|
||||
elements.root.hidden = !supported;
|
||||
if (!supported) {
|
||||
elements.status.textContent = 'Voice capture is unavailable; type the title and note instead.';
|
||||
return {supported:false, ready, cancel() {}};
|
||||
}
|
||||
let recognition = null;
|
||||
|
||||
function showReview(value, {persist = true, recovered = false} = {}) {
|
||||
elements.transcript.value = value;
|
||||
const activeLogin = String(getLogin() || '').trim();
|
||||
if (persist && activeLogin && transcriptStore) void transcriptStore.save(activeLogin, value);
|
||||
const hasDraft = Boolean(elements.title.value.trim() || elements.body.value.trim());
|
||||
elements.append.textContent = hasDraft ? 'Append to draft' : 'Use transcript';
|
||||
elements.replace.hidden = !hasDraft;
|
||||
elements.review.hidden = false;
|
||||
elements.status.textContent = recovered ?
|
||||
'Recovered transcript. Review it before using or discarding it.' :
|
||||
'Transcript ready. Review it before using it.';
|
||||
}
|
||||
|
||||
elements.start.addEventListener('click', () => {
|
||||
recognition = new Recognition();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = false;
|
||||
recognition.onresult = event => {
|
||||
const finalText = Array.from(event.results || [])
|
||||
.filter(result => result.isFinal)
|
||||
.map(result => result[0]?.transcript || '').join(' ').replace(/\s+/g, ' ').trim();
|
||||
if (finalText) {
|
||||
const accumulated = [elements.transcript.value.trim(), finalText]
|
||||
.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 10255);
|
||||
showReview(accumulated);
|
||||
}
|
||||
};
|
||||
recognition.onerror = event => {
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
elements.status.textContent = event.error === 'not-allowed' || event.error === 'service-not-allowed' ?
|
||||
'Microphone permission was denied. Your draft and transcript are unchanged.' :
|
||||
'Voice capture stopped. Your draft and transcript are unchanged.';
|
||||
};
|
||||
recognition.onend = () => {
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
};
|
||||
try {
|
||||
recognition.start();
|
||||
} catch (_) {
|
||||
recognition = null;
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
elements.status.textContent = 'Voice capture could not start. Your draft and transcript are unchanged.';
|
||||
return;
|
||||
}
|
||||
elements.start.hidden = true;
|
||||
elements.stop.hidden = false;
|
||||
elements.status.textContent = 'Listening… Tap stop when you are finished.';
|
||||
});
|
||||
elements.stop.addEventListener('click', () => {
|
||||
recognition?.stop();
|
||||
elements.status.textContent = 'Finishing transcript…';
|
||||
});
|
||||
|
||||
function clearCheckpoint() {
|
||||
const activeLogin = String(getLogin() || '').trim();
|
||||
if (activeLogin && transcriptStore) void transcriptStore.clear(activeLogin);
|
||||
}
|
||||
|
||||
function commit(mode) {
|
||||
const mapped = mapVoiceTranscript(elements.transcript.value);
|
||||
if (mode === 'replace') {
|
||||
elements.title.value = mapped.title;
|
||||
elements.body.value = mapped.body;
|
||||
} else if (elements.title.value.trim()) {
|
||||
elements.body.value = [elements.body.value.trim(), mapped.title, mapped.body]
|
||||
.filter(Boolean).join('\n\n').slice(0, 10000);
|
||||
} else {
|
||||
elements.title.value = mapped.title;
|
||||
elements.body.value = [elements.body.value.trim(), mapped.body]
|
||||
.filter(Boolean).join('\n\n').slice(0, 10000);
|
||||
}
|
||||
elements.title.dispatchEvent(createEvent('input'));
|
||||
elements.body.dispatchEvent(createEvent('input'));
|
||||
clearCheckpoint();
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = 'Transcript added. Review the title and note before saving.';
|
||||
}
|
||||
elements.append.addEventListener('click', () => commit('append'));
|
||||
elements.replace.addEventListener('click', () => commit('replace'));
|
||||
elements.discard?.addEventListener('click', () => {
|
||||
clearCheckpoint();
|
||||
elements.transcript.value = '';
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = 'Recovered transcript discarded.';
|
||||
});
|
||||
|
||||
return {supported:true, ready, cancel() { recognition?.abort(); recognition = null; }};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = {mapVoiceTranscript, createVoiceIssueCapture};
|
||||
@@ -0,0 +1,87 @@
|
||||
(function (root, factory) {
|
||||
const createVoiceTranscriptStore = factory(root.indexedDB);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createVoiceTranscriptStore;
|
||||
else root.createVoiceTranscriptStore = createVoiceTranscriptStore;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function buildFactory(defaultIndexedDB) {
|
||||
const dbName = 'stackchain-voice-transcripts-v1';
|
||||
const STORE_NAME = 'checkpoints';
|
||||
const MAX_TRANSCRIPT_LENGTH = 10255;
|
||||
|
||||
function requestResult(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Private transcript storage failed.'));
|
||||
});
|
||||
}
|
||||
|
||||
function createIndexedDbTransaction(indexedDB) {
|
||||
if (!indexedDB) return null;
|
||||
let databasePromise = null;
|
||||
function openDatabase() {
|
||||
if (databasePromise) return databasePromise;
|
||||
databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
||||
request.result.createObjectStore(STORE_NAME, {keyPath:'login'});
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Private transcript storage is unavailable.'));
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
return async (work, mode = 'readonly') => {
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction(STORE_NAME, mode);
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const completed = new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Private transcript storage failed.'));
|
||||
transaction.onabort = () => reject(transaction.error || new Error('Private transcript storage was interrupted.'));
|
||||
});
|
||||
const result = await work({
|
||||
get:key => requestResult(store.get(key)),
|
||||
put:value => requestResult(store.put(value)),
|
||||
delete:key => requestResult(store.delete(key)),
|
||||
});
|
||||
await completed;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLogin(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeTranscript(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TRANSCRIPT_LENGTH);
|
||||
}
|
||||
|
||||
function createVoiceTranscriptStore({transaction = createIndexedDbTransaction(defaultIndexedDB)} = {}) {
|
||||
async function load(login) {
|
||||
const key = normalizeLogin(login);
|
||||
if (!key || !transaction) return '';
|
||||
const record = await transaction(store => store.get(key));
|
||||
return normalizeTranscript(record?.transcript);
|
||||
}
|
||||
|
||||
async function save(login, transcript) {
|
||||
const key = normalizeLogin(login);
|
||||
const value = normalizeTranscript(transcript);
|
||||
if (!key || !value || !transaction) return;
|
||||
await transaction(store => store.put({login:key, transcript:value}), 'readwrite');
|
||||
}
|
||||
|
||||
async function clear(login) {
|
||||
const key = normalizeLogin(login);
|
||||
if (!key || !transaction) return;
|
||||
await transaction(store => store.delete(key), 'readwrite');
|
||||
}
|
||||
|
||||
return {load, save, clear};
|
||||
}
|
||||
|
||||
createVoiceTranscriptStore.databaseName = dbName;
|
||||
return createVoiceTranscriptStore;
|
||||
});
|
||||
@@ -3,14 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
class RefreshLeaseLost(RuntimeError):
|
||||
pass
|
||||
@@ -29,12 +29,8 @@ class AvailableIssueSnapshotStore:
|
||||
def __init__(self, path, *, clock=None):
|
||||
self.path = Path(path)
|
||||
self.clock = clock or time.time
|
||||
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(self.path.parent, stat.S_IRWXU)
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS available_issue_snapshot (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
@@ -54,15 +50,12 @@ class AvailableIssueSnapshotStore:
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO available_issue_snapshot VALUES (1, NULL, NULL, NULL)"
|
||||
)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO available_issue_snapshot VALUES (1, NULL, NULL, NULL)"
|
||||
)
|
||||
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
|
||||
connection = connect_private_sqlite(self.path, timeout=1.0, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA busy_timeout = 1000")
|
||||
return connection
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Durable, account-scoped completed Filed review receipts."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
|
||||
|
||||
class CompletedFiledReviewStore:
|
||||
def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS completed_filed_reviews (
|
||||
login TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
touched_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (login, repository, issue_number)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _receipt(raw: dict) -> tuple[str, int, str]:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("receipt must be an object")
|
||||
repository = raw.get("repository")
|
||||
number = raw.get("number")
|
||||
updated_at = raw.get("updated_at")
|
||||
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
||||
raise ValueError("repository is invalid")
|
||||
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||
raise ValueError("number is invalid")
|
||||
if not isinstance(updated_at, str):
|
||||
raise ValueError("updated_at is invalid")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
|
||||
except ValueError as error:
|
||||
raise ValueError("updated_at is invalid") from error
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("updated_at is invalid")
|
||||
return repository, number, updated_at
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(rows) -> dict:
|
||||
return {"receipts": [
|
||||
{"repository": row[0], "number": int(row[1]), "updated_at": row[2]}
|
||||
for row in rows
|
||||
]}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? ORDER BY touched_at",
|
||||
(self._login(login),),
|
||||
).fetchall()
|
||||
return self._snapshot(rows)
|
||||
|
||||
def merge(self, login: str, receipts: list[dict]) -> dict:
|
||||
login = self._login(login)
|
||||
if not isinstance(receipts, list) or len(receipts) > self.limit:
|
||||
raise ValueError(f"receipts are limited to {self.limit}")
|
||||
normalized = [self._receipt(receipt) for receipt in receipts]
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
touched = int(connection.execute(
|
||||
"SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?",
|
||||
(login,),
|
||||
).fetchone()[0])
|
||||
for repository, number, updated_at in normalized:
|
||||
current = connection.execute(
|
||||
"SELECT updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? AND repository = ? AND issue_number = ?",
|
||||
(login, repository, number),
|
||||
).fetchone()
|
||||
if current is not None and current[0] >= updated_at:
|
||||
continue
|
||||
touched += 1
|
||||
connection.execute(
|
||||
"INSERT INTO completed_filed_reviews "
|
||||
"(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(login, repository, issue_number) DO UPDATE SET "
|
||||
"updated_at=excluded.updated_at, touched_at=excluded.touched_at",
|
||||
(login, repository, number, updated_at, touched),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN ("
|
||||
"SELECT rowid FROM completed_filed_reviews WHERE login = ? "
|
||||
"ORDER BY touched_at DESC LIMIT ?)",
|
||||
(login, login, self.limit),
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? ORDER BY touched_at",
|
||||
(login,),
|
||||
).fetchall()
|
||||
return self._snapshot(rows)
|
||||
@@ -23,20 +23,20 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||
"issue-capture": (
|
||||
"static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.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-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.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/mobile-start-day.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/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/issue-attachment.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
|
||||
+122
-6
@@ -380,7 +380,8 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||
"/api/v1/repos/issues/search",
|
||||
headers=_auth(),
|
||||
params={
|
||||
"state": "open", selector.split("=", 1)[0]: "true",
|
||||
"state": "all" if stream == "filed" else "open",
|
||||
selector.split("=", 1)[0]: "true",
|
||||
"type": item_type, "limit": limit, "page": page,
|
||||
},
|
||||
)
|
||||
@@ -1252,13 +1253,16 @@ async def close_issue(repository: str, number: int) -> dict:
|
||||
issue = response.json()
|
||||
if not isinstance(issue, dict) or issue.get("state") != "closed":
|
||||
raise ValueError("Gitea did not confirm issue closure")
|
||||
return {
|
||||
result = {
|
||||
"number": issue.get("number"),
|
||||
"state": "closed",
|
||||
"closed_at": issue.get("closed_at", "")
|
||||
if isinstance(issue.get("closed_at"), str)
|
||||
else "",
|
||||
}
|
||||
if isinstance(issue.get("updated_at"), str):
|
||||
result["updated_at"] = issue["updated_at"]
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_issue_comment(comment: dict) -> dict:
|
||||
@@ -1839,6 +1843,63 @@ async def handoff_assigned_issue(
|
||||
}
|
||||
|
||||
|
||||
async def reassign_authored_issue(
|
||||
repository: str, number: int, recipient: str, expected_assignees: list[str]
|
||||
) -> dict:
|
||||
login, issue = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
author = issue.get("user")
|
||||
author_login = author.get("login") if isinstance(author, dict) else None
|
||||
assignees_value = issue.get("assignees")
|
||||
current_assignees = [
|
||||
item["login"] for item in assignees_value
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
] if isinstance(assignees_value, list) else []
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or issue.get("pull_request") is not None
|
||||
or not isinstance(author_login, str)
|
||||
or author_login.casefold() != login.casefold()
|
||||
or not current_assignees
|
||||
or current_assignees != expected_assignees
|
||||
):
|
||||
raise IssueNotAvailableError("Authored issue delegate changed")
|
||||
|
||||
eligible = {
|
||||
item["login"] for item in await issue_handoff_candidates(repository)
|
||||
}
|
||||
if recipient not in eligible or recipient in current_assignees:
|
||||
raise IssueNotAvailableError("Reassignment recipient is not eligible")
|
||||
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
headers=_auth(),
|
||||
json={"assignees": [recipient]},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
|
||||
confirmed_assignees = [
|
||||
item["login"] for item in confirmed_value
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
] if isinstance(confirmed_value, list) else []
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or confirmed_assignees != [recipient]
|
||||
):
|
||||
raise ValueError("Gitea did not confirm issue reassignment")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"assignees": confirmed_assignees,
|
||||
"recipient": recipient,
|
||||
"previous_assignees": expected_assignees,
|
||||
}
|
||||
|
||||
|
||||
async def pull_handoff_candidates(repository: str) -> list[dict]:
|
||||
candidates = await issue_handoff_candidates(repository)
|
||||
return [
|
||||
@@ -2288,16 +2349,58 @@ async def update_assigned_issue(
|
||||
title: str,
|
||||
body: str,
|
||||
expected_updated_at: str,
|
||||
) -> dict:
|
||||
return await _update_issue_content(
|
||||
repository, number, title, body, expected_updated_at, require_author=False
|
||||
)
|
||||
|
||||
|
||||
async def update_authored_issue(
|
||||
repository: str,
|
||||
number: int,
|
||||
title: str,
|
||||
body: str,
|
||||
expected_updated_at: str,
|
||||
) -> dict:
|
||||
return await _update_issue_content(
|
||||
repository, number, title, body, expected_updated_at, require_author=True
|
||||
)
|
||||
|
||||
|
||||
async def _update_issue_content(
|
||||
repository: str,
|
||||
number: int,
|
||||
title: str,
|
||||
body: str,
|
||||
expected_updated_at: str,
|
||||
*,
|
||||
require_author: bool,
|
||||
) -> dict:
|
||||
path = f"repos/{repository}/issues/{number}"
|
||||
login, issue = await _current_login_and_target(path)
|
||||
author = issue.get("user") if isinstance(issue.get("user"), dict) else {}
|
||||
authorized = (
|
||||
author.get("login") == login
|
||||
if require_author
|
||||
else _login_in_users(login, issue.get("assignees"))
|
||||
)
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or isinstance(issue.get("pull_request"), dict)
|
||||
or not _login_in_users(login, issue.get("assignees"))
|
||||
or not authorized
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
raise IssueNotAvailableError("issue not found")
|
||||
if issue.get("updated_at") != expected_updated_at:
|
||||
if issue.get("title") == title and issue.get("body", "") == body:
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"state": issue.get("state", "open"),
|
||||
"updated_at": issue.get("updated_at", ""),
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
}
|
||||
raise IssueEditConflictError("issue changed upstream")
|
||||
|
||||
response = await _get_client().patch(
|
||||
@@ -2371,8 +2474,9 @@ async def resolve_work_route(
|
||||
requested = _login_in_users(login, target.get("requested_reviewers"))
|
||||
author = target.get("user") if isinstance(target.get("user"), dict) else {}
|
||||
authored = author.get("login") == login
|
||||
state = target.get("state")
|
||||
eligible = (
|
||||
target.get("state") == "open"
|
||||
(state == "open" or (kind == "filed" and state == "closed"))
|
||||
and (
|
||||
(kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned)
|
||||
or (kind == "filed" and not isinstance(target.get("pull_request"), dict) and authored)
|
||||
@@ -2387,7 +2491,7 @@ async def resolve_work_route(
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": target.get("title", "") if isinstance(target.get("title"), str) else "",
|
||||
"state": "open",
|
||||
"state": state,
|
||||
"url": _safe_web_url(target.get("html_url")),
|
||||
**({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}),
|
||||
**({
|
||||
@@ -2410,6 +2514,18 @@ async def is_assigned_issue(repository: str, number: int) -> bool:
|
||||
|
||||
|
||||
async def is_authored_issue(repository: str, number: int) -> bool:
|
||||
login, issue = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
author = issue.get("user") if isinstance(issue.get("user"), dict) else {}
|
||||
return (
|
||||
issue.get("state") in {"open", "closed"}
|
||||
and not isinstance(issue.get("pull_request"), dict)
|
||||
and author.get("login") == login
|
||||
)
|
||||
|
||||
|
||||
async def is_open_authored_issue(repository: str, number: int) -> bool:
|
||||
login, issue = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
|
||||
+3
-2
@@ -5,6 +5,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reservation:
|
||||
@@ -33,11 +35,10 @@ class IdempotencyLedger:
|
||||
self.max_entries = max_entries
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
self.clock = clock
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection = connect_private_sqlite(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection.execute(
|
||||
f"PRAGMA busy_timeout = {max(1, int(self.lock_timeout_seconds * 1000))}"
|
||||
)
|
||||
|
||||
+4
-3
@@ -6,6 +6,8 @@ import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
class LaterStore:
|
||||
def __init__(
|
||||
@@ -25,8 +27,7 @@ class LaterStore:
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
connection = connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
|
||||
connection.close()
|
||||
return
|
||||
@@ -72,7 +73,7 @@ class LaterStore:
|
||||
connection.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
|
||||
+37
-46
@@ -6,12 +6,13 @@ import json
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import stat
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
SECTIONS = ("context", "events", "notifications")
|
||||
|
||||
|
||||
@@ -58,56 +59,46 @@ class LiveSnapshotStore:
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
|
||||
connection = connect_private_sqlite(self.path, timeout=1.0, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA busy_timeout = 1000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(self.path.parent, stat.S_IRWXU)
|
||||
except OSError:
|
||||
pass
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS live_snapshot (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
value_json TEXT,
|
||||
created_at_json TEXT NOT NULL,
|
||||
failure_count_json TEXT NOT NULL,
|
||||
retry_at_json TEXT NOT NULL,
|
||||
revisions_json TEXT NOT NULL,
|
||||
generation TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS live_refresh_lease (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
owner TEXT NOT NULL,
|
||||
sections_json TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS live_read_notification (
|
||||
notification_id INTEGER PRIMARY KEY
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""INSERT OR IGNORE INTO live_snapshot VALUES
|
||||
(1, NULL, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
json.dumps({section: None for section in SECTIONS}),
|
||||
json.dumps({section: 0 for section in SECTIONS}),
|
||||
json.dumps({section: None for section in SECTIONS}),
|
||||
json.dumps({section: 0 for section in SECTIONS}),
|
||||
secrets.token_hex(8),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS live_snapshot (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
value_json TEXT,
|
||||
created_at_json TEXT NOT NULL,
|
||||
failure_count_json TEXT NOT NULL,
|
||||
retry_at_json TEXT NOT NULL,
|
||||
revisions_json TEXT NOT NULL,
|
||||
generation TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS live_refresh_lease (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
owner TEXT NOT NULL,
|
||||
sections_json TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS live_read_notification (
|
||||
notification_id INTEGER PRIMARY KEY
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""INSERT OR IGNORE INTO live_snapshot VALUES
|
||||
(1, NULL, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
json.dumps({section: None for section in SECTIONS}),
|
||||
json.dumps({section: 0 for section in SECTIONS}),
|
||||
json.dumps({section: None for section in SECTIONS}),
|
||||
json.dumps({section: 0 for section in SECTIONS}),
|
||||
secrets.token_hex(8),
|
||||
),
|
||||
)
|
||||
|
||||
def try_acquire_refresh(
|
||||
self, sections: Iterable[str], *, lease_seconds: float
|
||||
|
||||
@@ -7,6 +7,8 @@ import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
class LoginAttemptStoreError(RuntimeError):
|
||||
"""Raised when sign-in throttling state cannot be accessed safely."""
|
||||
@@ -69,8 +71,7 @@ class LoginAttemptStore:
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection = connect_private_sqlite(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
|
||||
+163
-19
@@ -26,6 +26,7 @@ from starlette.datastructures import UploadFile
|
||||
|
||||
from src import dashboard_auth, gitea_proxy, passkeys
|
||||
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
||||
from src.completed_filed_review_store import CompletedFiledReviewStore
|
||||
from src.compression import NegotiatedGZipMiddleware
|
||||
from src.gitea_proxy import (
|
||||
activity_events,
|
||||
@@ -633,6 +634,20 @@ class SavedSearchCollection(BaseModel):
|
||||
views: list[SavedSearchView] = Field(max_length=20)
|
||||
|
||||
|
||||
class CompletedFiledReviewReceipt(BaseModel):
|
||||
repository: str = Field(
|
||||
min_length=3,
|
||||
max_length=200,
|
||||
pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
|
||||
)
|
||||
number: PositiveInt
|
||||
updated_at: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class CompletedFiledReviewBatch(BaseModel):
|
||||
receipts: list[CompletedFiledReviewReceipt] = Field(max_length=200)
|
||||
|
||||
|
||||
class UnfiledDraftBlocker(BaseModel):
|
||||
repository: str = Field(min_length=3, max_length=200)
|
||||
number: int = Field(ge=1)
|
||||
@@ -866,6 +881,22 @@ class IssueHandoff(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class IssueReassignment(IssueHandoff):
|
||||
expected_assignees: list[str] = Field(min_length=1, max_length=10)
|
||||
|
||||
@field_validator("expected_assignees")
|
||||
@classmethod
|
||||
def validate_expected_assignees(cls, values: list[str]) -> list[str]:
|
||||
if any(
|
||||
not isinstance(value, str)
|
||||
or not re.fullmatch(r"[A-Za-z0-9_.-]+", value)
|
||||
or len(value) > 255
|
||||
for value in values
|
||||
) or len(set(values)) != len(values):
|
||||
raise ValueError("expected assignees must be unique valid logins")
|
||||
return values
|
||||
|
||||
|
||||
class IssueBlockerUpdate(BaseModel):
|
||||
repository: str = Field(
|
||||
min_length=3,
|
||||
@@ -1137,6 +1168,16 @@ def _share_target_login_redirect(request: Request) -> str:
|
||||
continuation_values.append(("search_repository", repository_values[0]))
|
||||
continuation = urlencode(continuation_values)
|
||||
return f"login?{urlencode({'continue': f'./?{continuation}'})}"
|
||||
image_markers = request.query_params.getlist("shared")
|
||||
if image_markers:
|
||||
if (
|
||||
set(request.query_params.keys()) != {"launch", "shared"}
|
||||
or request.query_params.getlist("launch") != ["new"]
|
||||
or image_markers != ["bundle"]
|
||||
):
|
||||
return "login"
|
||||
continuation = urlencode([("launch", "new"), ("shared", "bundle")])
|
||||
return f"login?{urlencode({'continue': f'./?{continuation}'})}"
|
||||
limits = {"title": 200, "text": 8000, "url": 2048}
|
||||
if any(
|
||||
len(request.query_params.get(name, "")) > limit
|
||||
@@ -1151,14 +1192,6 @@ def _share_target_login_redirect(request: Request) -> str:
|
||||
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)}"
|
||||
@@ -1273,7 +1306,7 @@ async def require_operator_session(request: Request, call_next):
|
||||
async def prevent_live_api_caching(request, call_next):
|
||||
response = await call_next(request)
|
||||
path = dashboard_auth.application_path(request)
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||
path.startswith("/api/v1/repos/")
|
||||
and path.endswith("/review")
|
||||
) or path.startswith("/api/v1/notifications") or (
|
||||
@@ -1331,7 +1364,7 @@ async def enforce_browser_security_boundary(request: Request, call_next):
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["Permissions-Policy"] = (
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
||||
"camera=(), microphone=(self), geolocation=(), payment=(), usb=()"
|
||||
)
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
if dashboard_auth.mode() == dashboard_auth.OPERATOR_MODE:
|
||||
@@ -2210,6 +2243,15 @@ def _saved_search_store() -> SavedSearchStore:
|
||||
)
|
||||
|
||||
|
||||
def _completed_filed_review_store() -> CompletedFiledReviewStore:
|
||||
return CompletedFiledReviewStore(
|
||||
os.getenv(
|
||||
"STACKCHAIN_COMPLETED_FILED_REVIEW_DB",
|
||||
str(_state_dir / "completed-filed-reviews.sqlite3"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _unfiled_draft_store() -> UnfiledDraftStore:
|
||||
return UnfiledDraftStore(
|
||||
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3"))
|
||||
@@ -2231,6 +2273,40 @@ async def _confirmed_login() -> str:
|
||||
return login.strip().lower()
|
||||
|
||||
|
||||
@app.get("/api/v1/completed-filed-reviews")
|
||||
async def get_completed_filed_reviews(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
snapshot = await asyncio.to_thread(_completed_filed_review_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Completed Filed review synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return snapshot
|
||||
|
||||
|
||||
@app.post("/api/v1/completed-filed-reviews")
|
||||
async def merge_completed_filed_reviews(payload: CompletedFiledReviewBatch):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_completed_filed_review_store().merge,
|
||||
login,
|
||||
[receipt.model_dump() for receipt in payload.receipts],
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Completed Filed review synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/saved-searches")
|
||||
async def get_saved_searches(response: Response):
|
||||
login = await _confirmed_login()
|
||||
@@ -4311,12 +4387,20 @@ async def release_assigned_issue(
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff-candidates")
|
||||
async def issue_handoff_candidates(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
access: Literal["assigned", "filed"] = Query(default="assigned"),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_candidates():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
authorized = await (
|
||||
gitea_proxy.is_authored_issue(repository, number)
|
||||
if access == "filed"
|
||||
else gitea_proxy.is_assigned_issue(repository, number)
|
||||
)
|
||||
if not authorized:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await gitea_proxy.issue_handoff_candidates(repository)
|
||||
|
||||
@@ -4386,6 +4470,38 @@ async def handoff_assigned_issue(
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/reassign")
|
||||
async def reassign_authored_issue(
|
||||
reassignment: IssueReassignment,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.reassign_authored_issue(
|
||||
repository,
|
||||
number,
|
||||
reassignment.recipient,
|
||||
reassignment.expected_assignees,
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": "The delegate changed. Reload before reassigning."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The reassignment could not be confirmed. The current delegate is unchanged; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
|
||||
async def assigned_issue_detail(
|
||||
owner: str,
|
||||
@@ -4491,11 +4607,17 @@ async def update_assigned_issue_content(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
access: Literal["assigned", "filed"] = Query(default="assigned"),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
update_issue = (
|
||||
gitea_proxy.update_authored_issue
|
||||
if access == "filed"
|
||||
else gitea_proxy.update_assigned_issue
|
||||
)
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.update_assigned_issue(
|
||||
update_issue(
|
||||
repository,
|
||||
number,
|
||||
update.title,
|
||||
@@ -4852,11 +4974,17 @@ async def assigned_issue_conversation(
|
||||
number: int = PathParam(gt=0),
|
||||
page: int | None = Query(default=None, ge=1, le=100),
|
||||
limit: int = Query(default=20, ge=1, le=50),
|
||||
access: Literal["assigned", "filed"] = Query(default="assigned"),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_conversation():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
authorized = await (
|
||||
gitea_proxy.is_authored_issue(repository, number)
|
||||
if access == "filed"
|
||||
else gitea_proxy.is_assigned_issue(repository, number)
|
||||
)
|
||||
if not authorized:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await gitea_proxy.issue_conversation_page(repository, number, page, limit)
|
||||
|
||||
@@ -4881,11 +5009,17 @@ async def comment_on_assigned_issue(
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
access: Literal["assigned", "filed"] = Query(default="assigned"),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def post_comment():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
authorized = await (
|
||||
gitea_proxy.is_authored_issue(repository, number)
|
||||
if access == "filed"
|
||||
else gitea_proxy.is_assigned_issue(repository, number)
|
||||
)
|
||||
if not authorized:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
|
||||
|
||||
@@ -5226,6 +5360,7 @@ async def close_assigned_issue(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
access: Literal["assigned", "filed"] = "assigned",
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
@@ -5240,12 +5375,21 @@ async def close_assigned_issue(
|
||||
|
||||
target = f"{repository}#{number}"
|
||||
try:
|
||||
assigned = await asyncio.wait_for(
|
||||
gitea_proxy.is_assigned_issue(repository, number),
|
||||
authorized = await asyncio.wait_for(
|
||||
(
|
||||
gitea_proxy.is_open_authored_issue(repository, number)
|
||||
if access == "filed"
|
||||
else gitea_proxy.is_assigned_issue(repository, number)
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
if not assigned:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
if not authorized:
|
||||
detail = (
|
||||
"Open authored issue not found"
|
||||
if access == "filed"
|
||||
else "Assigned issue not found"
|
||||
)
|
||||
raise HTTPException(status_code=404, detail=detail)
|
||||
journal = _security_event_store()
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
|
||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.session_store import SessionStoreError
|
||||
|
||||
|
||||
@@ -40,8 +41,7 @@ class PasskeyStore:
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=0.1)
|
||||
connection = connect_private_sqlite(self.path, timeout=0.1)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS passkey_credentials (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Filesystem boundary for SQLite state containing private operator data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PRIVATE_DIRECTORY_MODE = stat.S_IRWXU
|
||||
PRIVATE_FILE_MODE = stat.S_IRUSR | stat.S_IWUSR
|
||||
|
||||
|
||||
def _reject_symlink(path: Path, *, label: str) -> None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if stat.S_ISLNK(metadata.st_mode):
|
||||
raise ValueError(f"private SQLite {label} must not be a symlink: {path}")
|
||||
|
||||
|
||||
def prepare_private_sqlite_path(path: str | Path, *, create: bool = True) -> Path:
|
||||
"""Create or repair a private SQLite path without following a database symlink."""
|
||||
database = Path(path)
|
||||
parent = database.parent
|
||||
_reject_symlink(parent, label="directory")
|
||||
if create:
|
||||
parent.mkdir(parents=True, mode=PRIVATE_DIRECTORY_MODE, exist_ok=True)
|
||||
elif not parent.exists():
|
||||
raise FileNotFoundError(database)
|
||||
_reject_symlink(parent, label="directory")
|
||||
if not parent.is_dir():
|
||||
raise ValueError(f"private SQLite parent is not a directory: {parent}")
|
||||
os.chmod(parent, PRIVATE_DIRECTORY_MODE, follow_symlinks=False)
|
||||
|
||||
_reject_symlink(database, label="database")
|
||||
flags = os.O_RDWR | (os.O_CREAT if create else 0)
|
||||
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(database, flags, PRIVATE_FILE_MODE)
|
||||
try:
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise ValueError(f"private SQLite database is not a regular file: {database}")
|
||||
os.fchmod(descriptor, PRIVATE_FILE_MODE)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
for suffix in ("-wal", "-shm", "-journal"):
|
||||
sidecar = Path(str(database) + suffix)
|
||||
_reject_symlink(sidecar, label="sidecar")
|
||||
try:
|
||||
os.chmod(sidecar, PRIVATE_FILE_MODE, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return database
|
||||
|
||||
|
||||
def connect_private_sqlite(
|
||||
path: str | Path, *, existing_only: bool = False, **kwargs: Any
|
||||
) -> sqlite3.Connection:
|
||||
"""Open SQLite only after enforcing its private directory and file modes."""
|
||||
database = prepare_private_sqlite_path(path, create=not existing_only)
|
||||
target: str | Path = database
|
||||
if existing_only:
|
||||
target = f"{database.resolve().as_uri()}?mode=rw"
|
||||
kwargs["uri"] = True
|
||||
connection = sqlite3.connect(target, **kwargs)
|
||||
# SQLite derives new journal/WAL/SHM permissions from the database. Repair
|
||||
# sidecars left by older releases as soon as a connection has opened them.
|
||||
prepare_private_sqlite_path(database, create=not existing_only)
|
||||
return connection
|
||||
@@ -1,10 +1,11 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushDelivery:
|
||||
@@ -57,8 +58,6 @@ class PushSubscriptionStore:
|
||||
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.path.parent, 0o700)
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
@@ -141,10 +140,8 @@ class PushSubscriptionStore:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
|
||||
)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.path, timeout=2)
|
||||
connection = connect_private_sqlite(self.path, timeout=2)
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
return connection
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_VIEW_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
@@ -26,8 +28,7 @@ class SavedSearchStore:
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(self.path, timeout=self.timeout) as connection:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
@@ -40,7 +41,7 @@ class SavedSearchStore:
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
|
||||
@@ -6,6 +6,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
class SecurityEventStoreError(RuntimeError):
|
||||
"""Raised when security activity cannot be persisted or read safely."""
|
||||
@@ -53,8 +55,7 @@ class SecurityEventStore:
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection = connect_private_sqlite(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS security_events (
|
||||
|
||||
@@ -7,6 +7,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
class SessionStoreError(RuntimeError):
|
||||
"""Raised when session state cannot be read or changed safely."""
|
||||
@@ -51,15 +53,14 @@ class SessionStore:
|
||||
def _connect(self, *, initialize: bool = False) -> sqlite3.Connection:
|
||||
try:
|
||||
if initialize:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(
|
||||
connection = connect_private_sqlite(
|
||||
self.path, timeout=self.lock_timeout_seconds
|
||||
)
|
||||
else:
|
||||
connection = sqlite3.connect(
|
||||
f"{self.path.resolve().as_uri()}?mode=rw",
|
||||
connection = connect_private_sqlite(
|
||||
self.path,
|
||||
timeout=self.lock_timeout_seconds,
|
||||
uri=True,
|
||||
existing_only=True,
|
||||
)
|
||||
if initialize:
|
||||
connection.execute(
|
||||
|
||||
+4
-3
@@ -6,6 +6,8 @@ import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
class TodayPlanFull(ValueError):
|
||||
"""Raised when an add would exceed the bounded Today plan."""
|
||||
@@ -33,8 +35,7 @@ class TodayStore:
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
connection = connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
|
||||
connection.close()
|
||||
return
|
||||
@@ -110,7 +111,7 @@ class TodayStore:
|
||||
connection.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
return connect_private_sqlite(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 @@ import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
_DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
@@ -38,8 +40,7 @@ class UnfiledDraftStore:
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(self.path, timeout=self.timeout) as connection:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS unfiled_drafts (
|
||||
@@ -50,7 +51,7 @@ class UnfiledDraftStore:
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ LOGIN_HTML = """<!doctype html>
|
||||
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
|
||||
<body><main><h1>Operator sign in</h1><p>Use an enrolled passkey, or enter the dashboard access token for bootstrap and recovery. The token is exchanged for a private, short-lived session and is never stored on this device.</p>
|
||||
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><button id="passkey-sign-in" type="button">Sign in with a passkey</button><p>Recovery</p><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in with access token</button><p id="status" role="status" aria-live="polite"></p></form></main>
|
||||
<script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""
|
||||
<script src="static/private-data-registry.js"></script><script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""
|
||||
|
||||
|
||||
class RevalidatingHTMLResponse(HTMLResponse):
|
||||
|
||||
@@ -22,6 +22,7 @@ class FakeGiteaServer(ThreadingHTTPServer):
|
||||
def __init__(self, address: tuple[str, int]):
|
||||
super().__init__(address, FakeGiteaHandler)
|
||||
self.created_issues: list[dict] = []
|
||||
self.issue_creation_enabled = False
|
||||
|
||||
|
||||
class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
@@ -62,6 +63,9 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
if path != "/api/v1/repos/acme/mobile/issues":
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
if not self.server.issue_creation_enabled:
|
||||
self._json(503, {"message": "release journey is still offline"})
|
||||
return
|
||||
self.server.created_issues.append(payload)
|
||||
assignee = payload.get("assignee")
|
||||
self._json(
|
||||
|
||||
@@ -24,7 +24,7 @@ from fake_gitea import FakeGiteaServer
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TITLE = "Offline artifact journey 863"
|
||||
TITLE = "Offline artifact journey 863."
|
||||
BODY = "Captured on a phone, retained offline, delivered exactly once."
|
||||
ACCESS_TOKEN = "artifact-browser-access-token-863"
|
||||
|
||||
@@ -148,6 +148,14 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
service_workers="allow",
|
||||
ignore_https_errors=True,
|
||||
)
|
||||
context.add_init_script("""
|
||||
class DeterministicSpeechRecognition {
|
||||
start() { window.__voiceRecognition = this; }
|
||||
stop() { if (this.onend) this.onend(); }
|
||||
abort() { if (this.onend) this.onend(); }
|
||||
}
|
||||
window.SpeechRecognition = DeterministicSpeechRecognition;
|
||||
""")
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
|
||||
@@ -178,8 +186,18 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
assert page.evaluate("() => navigator.serviceWorker.controller !== null")
|
||||
|
||||
new_action.click()
|
||||
page.locator("#create-issue-title").fill(TITLE)
|
||||
page.locator("#create-issue-body").fill(BODY)
|
||||
expect(page.locator("#voice-issue-capture")).to_be_visible()
|
||||
page.locator("#start-voice-issue-capture").click()
|
||||
page.evaluate("""([title, body]) => {
|
||||
const result = [{ transcript: title + ' ' + body }];
|
||||
result.isFinal = true;
|
||||
window.__voiceRecognition.onresult({ results: [result] });
|
||||
}""", [TITLE, BODY])
|
||||
expect(page.locator("#voice-issue-review")).to_be_visible()
|
||||
expect(page.locator("#append-voice-issue-transcript")).to_have_text("Use transcript")
|
||||
page.locator("#append-voice-issue-transcript").click()
|
||||
expect(page.locator("#create-issue-title")).to_have_value(TITLE)
|
||||
expect(page.locator("#create-issue-body")).to_have_value(BODY)
|
||||
page.locator("#file-new-issue").click()
|
||||
expect(page.locator("#create-issue-filing")).to_be_visible()
|
||||
page.locator("#create-issue-repository").select_option("acme/mobile")
|
||||
@@ -219,6 +237,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
|
||||
browser_errors.clear() # Chromium reports expected network errors while the context is offline.
|
||||
failed_responses.clear()
|
||||
fake.issue_creation_enabled = True
|
||||
context.set_offline(False)
|
||||
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||
for _ in range(80):
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
|
||||
ASSIGN_AND_START = Path(__file__).parents[1] / "frontend" / "assign-and-start.js"
|
||||
FILED_CLAIM = Path(__file__).parents[1] / "frontend" / "filed-claim.js"
|
||||
QUEUE_TODAY = Path(__file__).parents[1] / "frontend" / "queue-today.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
@@ -17,6 +18,47 @@ def run_node(script):
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_filed_claim_actions_require_open_authored_unassigned_canonical_issue():
|
||||
script = f"""
|
||||
const eligible=require({json.dumps(str(FILED_CLAIM))});
|
||||
const item={{is_filed:true,is_assigned:false,is_completed:false,state:'open'}};
|
||||
process.stdout.write(JSON.stringify({{
|
||||
eligible:eligible(item, {{state:'open',assignees:[]}}),
|
||||
assigned:eligible(item, {{state:'open',assignees:['alex']}}),
|
||||
closed:eligible(item, {{state:'closed',assignees:[]}}),
|
||||
ordinary:eligible({{...item,is_filed:false}}, {{state:'open',assignees:[]}}),
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"eligible": True,
|
||||
"assigned": False,
|
||||
"closed": False,
|
||||
"ordinary": False,
|
||||
}
|
||||
|
||||
|
||||
def test_filed_sheet_exposes_phone_safe_claim_actions_after_canonical_detail():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert 'id="filed-claim-actions"' in html
|
||||
assert 'id="queue-filed-issue"' in html
|
||||
assert 'id="start-filed-issue"' in html
|
||||
assert "filedClaimEligible(item, detail)" in dashboard
|
||||
assert "filedAssignAndStart.run(item, { destination:'queue' })" in dashboard
|
||||
assert "filedAssignAndStart.run(item)" in dashboard
|
||||
assert "Claim failed; this issue is still in Filed." in dashboard
|
||||
assert ".filed-claim-actions" in css
|
||||
action_rule = css.split(".filed-claim-actions", 1)[1].split("}", 1)[0]
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in action_rule
|
||||
assert "width:min(560px,100%)" in action_rule
|
||||
assert "BASE + 'static/filed-claim.js'" in worker
|
||||
assert '<script src="static/filed-claim.js"></script>' in html
|
||||
|
||||
|
||||
def test_repeated_assign_and_start_taps_claim_once_then_start_confirmed_issue():
|
||||
script = f"""
|
||||
const fs=require('fs');
|
||||
|
||||
@@ -77,6 +77,171 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_revision_checked_issue_content():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const calls = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
fetchJson: async (url, options) => {{
|
||||
calls.push({{url,method:options.method,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
return {{repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',updated_at:'2026-08-15T11:00:00Z'}};
|
||||
}},
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
kind:'issue-content',repository:'stackchain/dashboard',number:17,operationId:'check-op',
|
||||
title:'Ship',body:'- [x] Test',expectedUpdatedAt:'2026-08-15T10:00:00Z',
|
||||
}});
|
||||
const restored = createAuthoredOutbox({{storage}}).list()[0];
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{queued,restored,calls,result,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["expectedUpdatedAt"] == "2026-08-15T10:00:00Z"
|
||||
assert output["restored"]["title"] == "Ship"
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/content",
|
||||
"method": "PATCH",
|
||||
"key": "check-op",
|
||||
"body": {
|
||||
"title": "Ship", "body": "- [x] Test",
|
||||
"expected_updated_at": "2026-08-15T10:00:00Z",
|
||||
},
|
||||
}]
|
||||
assert output["result"]["confirmed"][0]["body"] == "- [x] Test"
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_durable_issue_content_admission_coalesces_latest_body_on_original_revision():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const mirrors = []; const ids = ['check-1', 'check-2'];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=>ids.shift(),
|
||||
backgroundSync:{{
|
||||
reconcile:async items=>mirrors.push(items.map(item=>({{...item}}))),
|
||||
requestSync:async()=>{{}},
|
||||
}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await outbox.enqueueDurably({{kind:'issue-content',repository:'stackchain/dashboard',number:17,
|
||||
title:'Ship',body:'- [x] Build\\n- [ ] Test',expectedUpdatedAt:'server-revision'}});
|
||||
const second = await outbox.enqueueDurably({{kind:'issue-content',repository:'stackchain/dashboard',number:17,
|
||||
title:'Ship',body:'- [x] Build\\n- [x] Test',expectedUpdatedAt:'pending-local-revision'}});
|
||||
process.stdout.write(JSON.stringify({{items:outbox.list(),second,mirrors}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert len(output["items"]) == 1
|
||||
assert output["items"][0]["id"] == "check-1"
|
||||
assert output["items"][0]["operationId"] == "check-2"
|
||||
assert output["items"][0]["body"] == "- [x] Build\n- [x] Test"
|
||||
assert output["items"][0]["expectedUpdatedAt"] == "server-revision"
|
||||
assert output["second"]["item"] == output["items"][0]
|
||||
assert len(output["mirrors"][1]) == 1
|
||||
assert output["mirrors"][1][0]["operationId"] == "check-2"
|
||||
|
||||
|
||||
def test_durable_issue_content_coalesces_ordered_checklist_management_intent():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const ids = ['manage-1','manage-2'];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage,getOwnerLogin:()=> 'timmy',createOperationId:()=>ids.shift(),
|
||||
backgroundSync:{{reconcile:async()=>{{}},requestSync:async()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await outbox.enqueueDurably({{kind:'issue-content',repository:'o/r',number:9,title:'Ship',
|
||||
baseBody:'- [ ] Build\\n- [ ] Test',body:'- [ ] Compile\\n- [ ] Test',expectedUpdatedAt:'old',
|
||||
checklistOperation:{{action:'rename',index:0,label:'Compile'}}}});
|
||||
await outbox.enqueueDurably({{kind:'issue-content',repository:'o/r',number:9,title:'Ship',
|
||||
baseBody:'- [ ] Compile\\n- [ ] Test',body:'- [ ] Test\\n- [ ] Compile',expectedUpdatedAt:'old',
|
||||
checklistOperation:{{action:'move-later',index:0}}}});
|
||||
process.stdout.write(JSON.stringify(outbox.list()[0]));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["baseBody"] == "- [ ] Build\n- [ ] Test"
|
||||
assert output["body"] == "- [ ] Test\n- [ ] Compile"
|
||||
assert output["checklistOperations"] == [
|
||||
{"action": "rename", "index": 0, "label": "Compile"},
|
||||
{"action": "move-later", "index": 0},
|
||||
]
|
||||
|
||||
|
||||
def test_failed_durable_issue_content_replacement_restores_previous_intent():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const ids = ['check-1', 'check-2']; let admissions = 0;
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=>ids.shift(),
|
||||
backgroundSync:{{
|
||||
reconcile:async()=>{{if (++admissions === 2) throw new Error('IndexedDB unavailable');}},
|
||||
requestSync:async()=>{{}},
|
||||
}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await outbox.enqueueDurably({{kind:'issue-content',repository:'o/r',number:9,
|
||||
title:'Ship',body:'- [x] Build\\n- [ ] Test',expectedUpdatedAt:'server-revision'}});
|
||||
let error = '';
|
||||
try {{
|
||||
await outbox.enqueueDurably({{kind:'issue-content',repository:'o/r',number:9,
|
||||
title:'Ship',body:'- [x] Build\\n- [x] Test',expectedUpdatedAt:'server-revision'}});
|
||||
}} catch (caught) {{ error = caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["error"] == "IndexedDB unavailable"
|
||||
assert len(output["items"]) == 1
|
||||
assert output["items"][0]["operationId"] == "check-1"
|
||||
assert output["items"][0]["body"] == "- [x] Build\n- [ ] Test"
|
||||
|
||||
|
||||
def test_issue_content_conflict_resolution_rebases_task_delta_and_mirrors_before_retry():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const mergeChecklistConflict = require({json.dumps(str(Path(__file__).parents[1] / 'frontend' / 'checklist-conflict.js'))});
|
||||
const initial = {{version:2,items:[{{
|
||||
id:'check-1',operationId:'old-op',kind:'issue-content',ownerLogin:'timmy',status:'attention',
|
||||
repository:'o/r',number:9,title:'Ship',baseBody:'Intro\\n- [ ] Build\\n- [ ] Test',
|
||||
body:'Intro\\n- [x] Build\\n- [ ] Test',expectedUpdatedAt:'old-revision',error:'Issue changed',
|
||||
}}]}};
|
||||
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify(initial)]]);
|
||||
const mirrors = []; let syncs = 0;
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'rebased-op', mergeChecklistConflict,
|
||||
backgroundSync:{{reconcile:async items=>mirrors.push(items.map(item=>({{...item}}))),requestSync:async()=>{{syncs += 1;}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const result = await outbox.resolveIssueContentConflict('check-1', {{
|
||||
title:'Ship safely', body:'Remote intro\\n- [ ] Test\\n- [ ] Build', updated_at:'latest-revision',
|
||||
}}, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{result,item:outbox.list()[0],mirrors,syncs}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["result"]["changes"] == [{"label": "Build", "checked": True}]
|
||||
assert output["item"]["status"] == "queued"
|
||||
assert output["item"]["operationId"] == "rebased-op"
|
||||
assert output["item"]["title"] == "Ship safely"
|
||||
assert output["item"]["body"] == "Remote intro\n- [ ] Test\n- [x] Build"
|
||||
assert output["item"]["baseBody"] == "Remote intro\n- [ ] Test\n- [ ] Build"
|
||||
assert output["item"]["expectedUpdatedAt"] == "latest-revision"
|
||||
assert "error" not in output["item"]
|
||||
assert output["mirrors"][-1][0] == output["item"]
|
||||
assert output["syncs"] == 1
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -96,6 +96,69 @@ const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_revision_checked_issue_content():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item = {{
|
||||
id:'check-1',operationId:'check-1',kind:'issue-content',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',expectedUpdatedAt:'2026-08-15T10:00:00Z',
|
||||
}};
|
||||
const state={{calls:[],completed:[]}};
|
||||
const store={{
|
||||
claimNext:async()=>item?{{...item}}:null,
|
||||
complete:async id=>{{state.completed.push(id);item=null;}},
|
||||
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
state.calls.push({{url,method:options.method,key:options.headers?.['Idempotency-Key']||'',body:options.body?JSON.parse(options.body):null}});
|
||||
if(url==='api/v1/background-identity') return {{login:'timmy'}};
|
||||
return {{repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',updated_at:'2026-08-15T11:00:00Z'}};
|
||||
}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["calls"][1] == {
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/content",
|
||||
"method": "PATCH",
|
||||
"key": "check-1",
|
||||
"body": {
|
||||
"title": "Ship", "body": "- [x] Test",
|
||||
"expected_updated_at": "2026-08-15T10:00:00Z",
|
||||
},
|
||||
}
|
||||
assert output["state"]["completed"] == ["check-1"]
|
||||
assert output["result"]["confirmed"][0]["body"] == "- [x] Test"
|
||||
|
||||
|
||||
def test_active_issue_content_delivery_rejects_a_false_durable_replacement():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records = new Map([['check-1', {{
|
||||
id:'check-1',operationId:'old-op',kind:'issue-content',outboxLane:'authored',
|
||||
ownerLogin:'timmy',status:'sending',claimUntil:200,body:'- [x] Build\\n- [ ] Test'
|
||||
}}]]); let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}});
|
||||
let error='';
|
||||
try {{ await store.reconcile([{{
|
||||
id:'check-1',operationId:'new-op',kind:'issue-content',ownerLogin:'timmy',status:'queued',
|
||||
body:'- [x] Build\\n- [x] Test'
|
||||
}}], 'authored'); }} catch (caught) {{ error=caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{error,snapshot:await store.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["error"] == "Checklist sync is already delivering. Retry this change."
|
||||
assert output["snapshot"][0]["operationId"] == "old-op"
|
||||
assert output["snapshot"][0]["body"] == "- [x] Build\n- [ ] Test"
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MERGER = Path(__file__).parents[1] / "frontend" / "checklist-conflict.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_merge_applies_local_task_delta_to_latest_body_without_reverting_remote_edits():
|
||||
script = f"""
|
||||
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||
const result = mergeChecklistConflict({{
|
||||
baseBody:'Intro\\n- [ ] Build\\n- [ ] Test',
|
||||
localBody:'Intro\\n- [x] Build\\n- [ ] Test',
|
||||
remoteBody:'Updated intro\\n- [ ] Test\\n- [ ] Build\\n- [x] Document',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"body": "Updated intro\n- [ ] Test\n- [x] Build\n- [x] Document",
|
||||
"changes": [{"label": "Build", "checked": True}],
|
||||
"conflicts": [],
|
||||
}
|
||||
|
||||
|
||||
def test_merge_reports_missing_or_ambiguous_changed_tasks_without_writing_a_body():
|
||||
script = f"""
|
||||
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||
const missing = mergeChecklistConflict({{
|
||||
baseBody:'- [ ] Ship', localBody:'- [x] Ship', remoteBody:'- [ ] Release',
|
||||
}});
|
||||
const duplicate = mergeChecklistConflict({{
|
||||
baseBody:'- [ ] Ship', localBody:'- [x] Ship', remoteBody:'- [ ] Ship\\n- [ ] Ship',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{missing,duplicate}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["missing"]["body"] is None
|
||||
assert output["missing"]["conflicts"] == [{"label": "Ship", "reason": "missing"}]
|
||||
assert output["duplicate"]["body"] is None
|
||||
assert output["duplicate"]["conflicts"] == [{"label": "Ship", "reason": "ambiguous"}]
|
||||
|
||||
|
||||
def test_merge_appends_a_locally_added_step_to_remote_edits_without_reverting_them():
|
||||
script = f"""
|
||||
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||
const result = mergeChecklistConflict({{
|
||||
baseBody:'Intro\\n- [ ] Build',
|
||||
localBody:'Intro\\n- [ ] Build\\n- [ ] Verify rollback',
|
||||
remoteBody:'Updated intro\\n- [x] Build\\n- [ ] Notify support',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"body": "Updated intro\n- [x] Build\n- [ ] Notify support\n- [ ] Verify rollback",
|
||||
"changes": [{"label": "Verify rollback", "checked": False, "added": True}],
|
||||
"conflicts": [],
|
||||
}
|
||||
|
||||
|
||||
def test_merge_rejects_an_ambiguous_locally_added_duplicate():
|
||||
script = f"""
|
||||
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||
const result = mergeChecklistConflict({{
|
||||
baseBody:'- [ ] Build',
|
||||
localBody:'- [ ] Build\\n- [ ] Verify rollback\\n- [ ] verify rollback ',
|
||||
remoteBody:'- [x] Build',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["body"] is None
|
||||
assert output["conflicts"] == [{"label": "Verify rollback", "reason": "ambiguous"}]
|
||||
|
||||
|
||||
def test_merge_replays_rename_and_reorder_intent_onto_unrelated_remote_edits():
|
||||
script = f"""
|
||||
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||
const result = mergeChecklistConflict({{
|
||||
baseBody:'Intro\\n- [ ] Build\\n- [ ] Test',
|
||||
localBody:'Intro\\n- [ ] Test\\n- [ ] Compile',
|
||||
remoteBody:'Updated intro\\n- [x] Build\\n- [ ] Test\\nRemote note',
|
||||
operations:[
|
||||
{{action:'rename',index:0,label:'Compile'}},
|
||||
{{action:'move-later',index:0}},
|
||||
],
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"body": "Updated intro\n- [ ] Test\n- [x] Compile\nRemote note",
|
||||
"changes": [
|
||||
{"label": "Build", "renamed": "Compile"},
|
||||
{"label": "Compile", "moved": "later"},
|
||||
],
|
||||
"conflicts": [],
|
||||
}
|
||||
@@ -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-v102" in worker
|
||||
assert "stackchain-dashboard-shell-v108" in worker
|
||||
@@ -0,0 +1,73 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.completed_filed_review_store import CompletedFiledReviewStore
|
||||
|
||||
|
||||
def receipt(repository="stackchain/api", number=9, updated_at="2026-08-15T12:00:00Z"):
|
||||
return {"repository": repository, "number": number, "updated_at": updated_at}
|
||||
|
||||
|
||||
def test_receipts_merge_without_lost_updates_and_remain_account_scoped(tmp_path):
|
||||
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3")
|
||||
|
||||
first = store.merge(" Timmy ", [receipt()])
|
||||
second = store.merge("timmy", [receipt("stackchain/web", 4)])
|
||||
|
||||
assert first == {"receipts": [receipt()]}
|
||||
assert second == {"receipts": [receipt(), receipt("stackchain/web", 4)]}
|
||||
assert store.get("TIMMY") == second
|
||||
assert store.get("alexander") == {"receipts": []}
|
||||
|
||||
|
||||
def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
|
||||
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2)
|
||||
newer = receipt(updated_at="2026-08-16T12:00:00Z")
|
||||
|
||||
store.merge("timmy", [newer])
|
||||
assert store.merge("timmy", [receipt()]) == {"receipts": [newer]}
|
||||
bounded = store.merge("timmy", [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)])
|
||||
|
||||
assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("candidate", [
|
||||
{"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"},
|
||||
{"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"},
|
||||
{"repository": "stackchain/api", "number": 1, "updated_at": "yesterday"},
|
||||
])
|
||||
def test_receipts_reject_invalid_records(tmp_path, candidate):
|
||||
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3")
|
||||
with pytest.raises(ValueError):
|
||||
store.merge("timmy", [candidate])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_completed_filed_review_api_is_authenticated_csrf_protected_and_no_store(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_COMPLETED_FILED_REVIEW_DB", str(tmp_path / "completed.sqlite3"))
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
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"})
|
||||
forbidden = await client.post("/api/v1/completed-filed-reviews", json={"receipts": [receipt()]})
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
saved = await client.post(
|
||||
"/api/v1/completed-filed-reviews", json={"receipts": [receipt()]}, headers=headers
|
||||
)
|
||||
fetched = await client.get("/api/v1/completed-filed-reviews")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert saved.status_code == 200
|
||||
assert saved.json() == {"receipts": [receipt()]}
|
||||
assert fetched.json() == saved.json()
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
@@ -944,33 +944,26 @@ async def test_anonymous_search_preview_preserves_only_valid_search_scope(access
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
|
||||
async def test_anonymous_shared_bundle_preserves_only_opaque_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",
|
||||
},
|
||||
params={"launch": "new", "shared": "bundle"},
|
||||
)
|
||||
wrong_launch = await client.get(
|
||||
"/", params={"launch": "agenda", "shared": "image"}
|
||||
"/", params={"launch": "agenda", "shared": "bundle"}
|
||||
)
|
||||
wrong_marker = await client.get(
|
||||
"/", params={"launch": "new", "shared": "document"}
|
||||
)
|
||||
duplicate_marker = await client.get(
|
||||
"/?launch=new&shared=image&shared=image"
|
||||
"/?launch=new&shared=bundle&shared=bundle"
|
||||
)
|
||||
|
||||
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"
|
||||
]
|
||||
"continue": ["./?launch=new&shared=bundle"]
|
||||
}
|
||||
assert wrong_launch.headers["location"] == "login"
|
||||
assert wrong_marker.headers["location"] == "login"
|
||||
@@ -1860,9 +1853,14 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
|
||||
async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
lookup_started = threading.Event()
|
||||
lookup_finished = threading.Event()
|
||||
|
||||
class SlowAttempts:
|
||||
def retry_after(self, source):
|
||||
time.sleep(0.15)
|
||||
lookup_started.set()
|
||||
time.sleep(0.3)
|
||||
lookup_finished.set()
|
||||
return 17
|
||||
|
||||
def record_blocked(self, *, method):
|
||||
@@ -1874,15 +1872,13 @@ async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
|
||||
sign_in = asyncio.create_task(
|
||||
client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
assert await asyncio.to_thread(lookup_started.wait, 1)
|
||||
await asyncio.sleep(0.01)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
assert not lookup_finished.is_set()
|
||||
response = await sign_in
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.headers["retry-after"] == "17"
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -349,7 +349,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedAtIdle"] == []
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
@@ -375,7 +375,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
@@ -506,7 +506,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -529,7 +529,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -625,7 +625,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
@@ -809,6 +809,28 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["workerMessages"] == [{"type": "stackchain-resume-outbox"}]
|
||||
|
||||
|
||||
def test_reconnect_requests_queued_delivery_resume_after_offline_reload():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
boundary.startReconnectResume();
|
||||
state.responsePayload = {authenticated:true, expires_at:200000, idle_expires_at:150000};
|
||||
await state.activityListeners.online();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["requests"][0]["url"] == "/dashboard/api/v1/session"
|
||||
assert result["workerMessages"] == [
|
||||
{
|
||||
"type": "stackchain-session-lease",
|
||||
"expiresAt": 200000,
|
||||
"idleExpiresAt": 150000,
|
||||
},
|
||||
{"type": "stackchain-resume-outbox"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -121,6 +121,28 @@ process.stdout.write(JSON.stringify(drafts));
|
||||
assert output[1]["label"] == "Queued issue"
|
||||
|
||||
|
||||
def test_draft_inbox_identifies_account_bound_checklist_conflicts_for_review():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const record = {{version:2,items:[{{
|
||||
id:'check-1',kind:'issue-content',ownerLogin:'timmy',status:'attention',repository:'o/r',number:7,
|
||||
title:'Ship',baseBody:'- [ ] Build',body:'- [x] Build',expectedUpdatedAt:'old',error:'Issue changed',queuedAt:12,
|
||||
}}]}};
|
||||
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify(record)]]);
|
||||
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const owned = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
const other = createDraftInbox({{storage,getCurrentLogin:()=>'alexander'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify({{owned,other}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["owned"]["label"] == "Checklist conflict"
|
||||
assert output["owned"]["checklist_conflict"] is True
|
||||
assert output["owned"]["quarantined"] is False
|
||||
assert output["other"]["checklist_conflict"] is False
|
||||
assert output["other"]["quarantined"] is True
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_queued_review_with_review_route_and_actionable_state():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
|
||||
@@ -300,7 +300,7 @@ def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules()
|
||||
|
||||
assert f"BASE + '{build.runtime_name}'" in build.service_worker_source
|
||||
for source in build.page_sources:
|
||||
if source == "static/background-issue-sync.js":
|
||||
if f"importScripts(BASE + '{source}')" in build.service_worker_source:
|
||||
continue
|
||||
assert f"BASE + '{source}'" not in build.service_worker_source
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ async def test_streaming_reads_share_the_same_bounded_read_capacity():
|
||||
try:
|
||||
await asyncio.wait_for(saturated.wait(), timeout=0.2)
|
||||
with pytest.raises(gitea_proxy.GiteaOverloadedError):
|
||||
await asyncio.wait_for(gitea_proxy.fetch("third"), timeout=0.1)
|
||||
await asyncio.wait_for(gitea_proxy.fetch("third"), timeout=1.0)
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.gather(*streams)
|
||||
|
||||
@@ -67,15 +67,16 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages(
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_filed_work_page_loads_open_authored_issues_with_a_distinct_reason():
|
||||
async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_distinct_reason():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(200, headers={"X-Total-Count": "61"}, json=[{
|
||||
"id": 870, "number": 870, "title": "Delegated filing", "state": "open",
|
||||
"id": 878, "number": 878, "title": "Completed delegation", "state": "closed",
|
||||
"updated_at": "2026-08-15T12:00:00Z",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "https://forge.example/stackchain/dashboard/issues/870",
|
||||
"html_url": "https://forge.example/stackchain/dashboard/issues/878",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
@@ -85,14 +86,15 @@ async def test_filed_work_page_loads_open_authored_issues_with_a_distinct_reason
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&created=true&type=issues&limit=50&page=2"
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=all&created=true&type=issues&limit=50&page=2"
|
||||
]
|
||||
assert result == {
|
||||
"stream": "filed", "page": 2, "total": 61, "has_more": False,
|
||||
"items": [{
|
||||
"id": 870, "number": 870, "title": "Delegated filing", "state": "open",
|
||||
"id": 878, "number": 878, "title": "Completed delegation", "state": "closed",
|
||||
"updated_at": "2026-08-15T12:00:00Z",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "https://forge.example/stackchain/dashboard/issues/870",
|
||||
"html_url": "https://forge.example/stackchain/dashboard/issues/878",
|
||||
"work_reasons": ["created_by_me"],
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -570,6 +570,66 @@ async def test_edit_assigned_issue_updates_title_and_body_at_expected_revision(m
|
||||
)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_edit_filed_issue_uses_open_author_authorization(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def update(repository, number, title, body, expected_updated_at):
|
||||
calls.append((repository, number, title, body, expected_updated_at))
|
||||
return {
|
||||
"repository": repository, "number": number, "title": title, "body": body,
|
||||
"state": "open", "updated_at": "2026-08-07T10:01:00Z",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "update_authored_issue", update, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/content?access=filed",
|
||||
json={
|
||||
"title": "Clarified filing", "body": "Canonical criteria",
|
||||
"expected_updated_at": "2026-08-07T10:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert calls == [(
|
||||
"stackchain/api", 17, "Clarified filing", "Canonical criteria",
|
||||
"2026-08-07T10:00:00Z",
|
||||
)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("target", [
|
||||
{"state": "closed", "user": {"login": "timmy"}, "pull_request": None},
|
||||
{"state": "open", "user": {"login": "alex"}, "pull_request": None},
|
||||
{"state": "open", "user": {"login": "timmy"}, "pull_request": {}},
|
||||
])
|
||||
async def test_gitea_edit_filed_issue_rejects_ineligible_target_without_patch(target):
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "title": "Old", "body": "Old body",
|
||||
"updated_at": "2026-08-07T10:00:00Z", **target,
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.IssueNotAvailableError):
|
||||
await gitea_proxy.update_authored_issue(
|
||||
"stackchain/api", 17, "Clarified", "New body",
|
||||
"2026-08-07T10:00:00Z",
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [request.method for request in requests] == ["GET", "GET"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_edit_issue_rejects_stale_revision_without_patch():
|
||||
requests = []
|
||||
@@ -604,6 +664,45 @@ async def test_gitea_edit_issue_rejects_stale_revision_without_patch():
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_edit_issue_confirms_already_applied_content_after_lost_response():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 17,
|
||||
"title": "My draft",
|
||||
"body": "Draft body",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-07T10:02:00Z",
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"pull_request": None,
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.update_assigned_issue(
|
||||
"stackchain/api", 17, "My draft", "Draft body",
|
||||
"2026-08-07T10:00:00Z",
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "title": "My draft",
|
||||
"body": "Draft body", "state": "open", "updated_at": "2026-08-07T10:02:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
|
||||
requests = []
|
||||
@@ -1845,6 +1944,70 @@ async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_reassigns_authored_issue_only_from_reviewed_delegate():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/issues/17" and request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "user": {"login": "timmy"},
|
||||
"assignees": [{"login": "alex"}],
|
||||
})
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/assignees":
|
||||
return httpx.Response(200, json=[
|
||||
{"login": "timmy", "full_name": "Timmy"},
|
||||
{"login": "alex", "full_name": "Alexander"},
|
||||
{"login": "casey", "full_name": "Casey"},
|
||||
])
|
||||
assert request.method == "PATCH"
|
||||
assert json.loads(request.content) == {"assignees": ["casey"]}
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "assignees": [{"login": "casey"}],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.reassign_authored_issue(
|
||||
"stackchain/api", 17, "casey", ["alex"]
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "state": "open",
|
||||
"assignees": ["casey"], "recipient": "casey", "previous_assignees": ["alex"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_reassign_authored_issue_rejects_stale_delegate_without_patch():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "user": {"login": "timmy"},
|
||||
"assignees": [{"login": "alex-new"}],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.IssueNotAvailableError):
|
||||
await gitea_proxy.reassign_authored_issue(
|
||||
"stackchain/api", 17, "casey", ["alex"]
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert not any(method == "PATCH" for method, _path in requests)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
|
||||
calls = []
|
||||
@@ -1916,6 +2079,72 @@ async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monk
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_filed_reassignment_endpoints_authorize_author_and_preserve_reviewed_delegate(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def authored(repository, number):
|
||||
calls.append(("authored", repository, number))
|
||||
return True
|
||||
|
||||
async def candidates(repository):
|
||||
calls.append(("candidates", repository))
|
||||
return [{"login": "alex", "name": "Alexander"}, {"login": "casey", "name": "Casey"}]
|
||||
|
||||
async def reassign(repository, number, recipient, expected_assignees):
|
||||
calls.append(("reassign", repository, number, recipient, expected_assignees))
|
||||
return {
|
||||
"repository": repository, "number": number, "state": "open",
|
||||
"assignees": [recipient], "recipient": recipient,
|
||||
"previous_assignees": expected_assignees,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
|
||||
monkeypatch.setattr(main.gitea_proxy, "reassign_authored_issue", reassign)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
listed = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/17/handoff-candidates?access=filed"
|
||||
)
|
||||
transferred = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/reassign",
|
||||
json={"recipient": "casey", "expected_assignees": ["alex"]},
|
||||
)
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.json() == [
|
||||
{"login": "alex", "name": "Alexander"},
|
||||
{"login": "casey", "name": "Casey"},
|
||||
]
|
||||
assert transferred.status_code == 200
|
||||
assert transferred.json()["previous_assignees"] == ["alex"]
|
||||
assert calls == [
|
||||
("authored", "stackchain/api", 17),
|
||||
("candidates", "stackchain/api"),
|
||||
("reassign", "stackchain/api", 17, "casey", ["alex"]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_filed_reassignment_returns_conflict_when_delegate_changed(monkeypatch):
|
||||
async def reassign(_repository, _number, _recipient, _expected_assignees):
|
||||
raise gitea_proxy.IssueNotAvailableError("stale")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "reassign_authored_issue", reassign)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/reassign",
|
||||
json={"recipient": "casey", "expected_assignees": ["alex"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": "The delegate changed. Reload before reassigning."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mention_candidate_endpoint_bounds_query_and_disables_caching(monkeypatch):
|
||||
calls = []
|
||||
@@ -2077,6 +2306,54 @@ async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_filed_issue_author_can_read_and_reply_but_other_issues_stay_hidden(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def authored(repository, number):
|
||||
calls.append(("authored", repository, number))
|
||||
return (repository, number) == ("stackchain/api", 8)
|
||||
|
||||
async def conversation(repository, number, page, limit):
|
||||
calls.append(("conversation", repository, number, page, limit))
|
||||
return {"comments": [{"id": 21}], "page": 2, "older_page": 1, "total": 21}
|
||||
|
||||
async def comment(repository, number, body):
|
||||
calls.append(("comment", repository, number, body))
|
||||
return {"id": 22, "author": "timmy", "body": body}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
|
||||
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:
|
||||
loaded = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/8/comments?access=filed&page=2&limit=20"
|
||||
)
|
||||
posted = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/8/comments?access=filed",
|
||||
json={"body": " One clarification "},
|
||||
headers={"Idempotency-Key": "filed-followup-876"},
|
||||
)
|
||||
hidden = await client.post(
|
||||
"/api/v1/repos/private/secret/issues/8/comments?access=filed",
|
||||
json={"body": "Do not post"},
|
||||
)
|
||||
|
||||
assert loaded.status_code == 200
|
||||
assert loaded.json()["older_page"] == 1
|
||||
assert posted.status_code == 201
|
||||
assert posted.json() == {"id": 22, "author": "timmy", "body": "One clarification"}
|
||||
assert hidden.status_code == 404
|
||||
assert calls == [
|
||||
("authored", "stackchain/api", 8),
|
||||
("conversation", "stackchain/api", 8, 2, 20),
|
||||
("authored", "stackchain/api", 8),
|
||||
("comment", "stackchain/api", 8, "One clarification"),
|
||||
("authored", "private/secret", 8),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
@@ -2197,6 +2474,60 @@ async def test_issue_close_endpoint_mutates_assigned_issue_only_after_confirmati
|
||||
assert calls == [("stackchain/api", 7)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_close_endpoint_withdraws_only_open_authored_filed_issue(monkeypatch):
|
||||
checks = []
|
||||
calls = []
|
||||
|
||||
async def authored(repository, number):
|
||||
checks.append((repository, number))
|
||||
return True
|
||||
|
||||
async def close(repository, number):
|
||||
calls.append((repository, number))
|
||||
return {
|
||||
"number": number,
|
||||
"state": "closed",
|
||||
"updated_at": "2026-08-15T10:00:00Z",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_open_authored_issue", authored)
|
||||
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/7/close?access=filed"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["state"] == "closed"
|
||||
assert checks == [("stackchain/api", 7)]
|
||||
assert calls == [("stackchain/api", 7)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_close_endpoint_does_not_withdraw_non_authored_or_closed_filed_issue(monkeypatch):
|
||||
close_calls = []
|
||||
|
||||
async def authored(_repository, _number):
|
||||
return False
|
||||
|
||||
async def close(repository, number):
|
||||
close_calls.append((repository, number))
|
||||
return {"number": number, "state": "closed"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_open_authored_issue", authored)
|
||||
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/7/close?access=filed"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert close_calls == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_close_issue_patches_state_and_confirms_closed_response():
|
||||
requests = []
|
||||
|
||||
@@ -72,6 +72,40 @@ outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result,
|
||||
assert "relatedDraft" not in output["sentBody"]
|
||||
|
||||
|
||||
def test_confirmed_delivery_returns_durable_checklist_promotion_without_sending_parent_context():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map(); let sentBody;
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
const outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'promote-operation',
|
||||
fetchJson:async (_url,options)=>{{sentBody=JSON.parse(options.body);return {{repository:'stackchain/dashboard',number:44,title:'Build',url:'https://forge.example/issues/44'}};}},
|
||||
}});
|
||||
outbox.enqueue({{
|
||||
repository:'stackchain/dashboard',title:'Build',body:'Related parent',
|
||||
relatedDraft:{{repository:'stackchain/dashboard',title:'',body:'',labelIds:[],
|
||||
checklistPromotion:{{repository:'stackchain/dashboard',number:17,url:'https://forge.example/issues/17',
|
||||
title:'Parent',body:'- [ ] Build',updatedAt:'old',taskIndex:0}}}},
|
||||
}});
|
||||
outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result,sentBody}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["result"]["filings"] == [{
|
||||
"issue": {"repository": "stackchain/dashboard", "number": 44,
|
||||
"title": "Build", "url": "https://forge.example/issues/44"},
|
||||
"relatedDraft": {
|
||||
"repository": "stackchain/dashboard", "title": "", "body": "", "labelIds": [],
|
||||
"checklistPromotion": {
|
||||
"repository": "stackchain/dashboard", "number": 17,
|
||||
"url": "https://forge.example/issues/17", "title": "Parent",
|
||||
"body": "- [ ] Build", "updatedAt": "old", "taskIndex": 0,
|
||||
},
|
||||
},
|
||||
}]
|
||||
assert "checklistPromotion" not in output["sentBody"]
|
||||
|
||||
|
||||
def test_editing_a_queued_issue_refreshes_its_related_plan():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -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-v102" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -100,7 +100,9 @@ const controller = createLoginController({{
|
||||
async def test_login_loads_private_data_purger_before_controller():
|
||||
html = await login()
|
||||
|
||||
assert '<script src="static/private-data-registry.js"></script>' in html
|
||||
assert '<script src="static/private-device-data.js"></script>' in html
|
||||
assert html.index('static/private-data-registry.js') < html.index('static/private-device-data.js')
|
||||
assert html.index('static/private-device-data.js') < html.index('static/login.js')
|
||||
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ FRONTEND = Path(__file__).parent.parent / "frontend"
|
||||
RENDERER = FRONTEND / "markdown.js"
|
||||
|
||||
|
||||
def render_markdown(payload):
|
||||
def render_markdown(payload, options=None):
|
||||
options = options or {}
|
||||
script = (
|
||||
f"const render = require({json.dumps(str(RENDERER))});"
|
||||
f"process.stdout.write(render({json.dumps(payload)}));"
|
||||
f"process.stdout.write(render({json.dumps(payload)}, {json.dumps(options)}));"
|
||||
)
|
||||
return subprocess.run(
|
||||
["node", "-e", script],
|
||||
@@ -23,6 +24,38 @@ def render_markdown(payload):
|
||||
).stdout
|
||||
|
||||
|
||||
def toggle_task(payload, task_index, checked):
|
||||
script = (
|
||||
f"const render = require({json.dumps(str(RENDERER))});"
|
||||
f"process.stdout.write(JSON.stringify(render.toggleTask({json.dumps(payload)}, "
|
||||
f"{task_index}, {json.dumps(checked)})));"
|
||||
)
|
||||
return json.loads(
|
||||
subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
)
|
||||
|
||||
|
||||
def manage_task(payload, task_index, operation):
|
||||
script = (
|
||||
f"const manage = require({json.dumps(str(FRONTEND / 'issue-sheet.js'))}).manageChecklistTask;"
|
||||
f"process.stdout.write(JSON.stringify(manage({json.dumps(payload)}, "
|
||||
f"{task_index}, {json.dumps(operation)})));"
|
||||
)
|
||||
return json.loads(
|
||||
subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
)
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -81,6 +114,92 @@ def test_markdown_renderer_preserves_mobile_reading_structure():
|
||||
)
|
||||
|
||||
|
||||
def test_interactive_markdown_tasks_expose_accessible_source_indices():
|
||||
rendered = render_markdown(
|
||||
"- [ ] Verify production\n- [x] Notify support",
|
||||
{"interactiveTasks": True},
|
||||
)
|
||||
|
||||
assert rendered == (
|
||||
'<ul class="task-list"><li class="task-list-item">'
|
||||
'<input type="checkbox" class="task-list-toggle" data-task-index="0" '
|
||||
'aria-label="Mark Verify production complete"> Verify production</li>'
|
||||
'<li class="task-list-item"><input type="checkbox" class="task-list-toggle" '
|
||||
'data-task-index="1" aria-label="Mark Notify support incomplete" checked> '
|
||||
'Notify support</li></ul>'
|
||||
)
|
||||
|
||||
|
||||
def test_manageable_markdown_tasks_expose_touch_action_with_position_boundaries():
|
||||
rendered = render_markdown(
|
||||
"- [ ] Verify production\n- [x] Notify support",
|
||||
{"interactiveTasks": True, "manageTasks": True},
|
||||
)
|
||||
|
||||
assert 'class="task-list-manage" data-task-index="0"' in rendered
|
||||
assert 'aria-label="Manage step: Verify production"' in rendered
|
||||
assert 'data-task-first="true"' in rendered
|
||||
assert 'class="task-list-manage" data-task-index="1"' in rendered
|
||||
assert 'data-task-last="true"' in rendered
|
||||
|
||||
|
||||
def test_interactive_task_toggle_changes_exact_source_marker_only():
|
||||
body = (
|
||||
"```md\r\n- [ ] example only\r\n```\r\n"
|
||||
"- [ ] Duplicate\r\n - [X] Nested duplicate\r\n- [ ] Duplicate\r\n"
|
||||
)
|
||||
|
||||
assert toggle_task(body, 1, True) == (
|
||||
"```md\r\n- [ ] example only\r\n```\r\n"
|
||||
"- [ ] Duplicate\r\n - [X] Nested duplicate\r\n- [x] Duplicate\r\n"
|
||||
)
|
||||
|
||||
|
||||
def test_manage_task_renames_exact_visible_task_and_preserves_nested_step_line_endings_and_fences():
|
||||
body = (
|
||||
"```md\r\n- [ ] example only\r\n```\r\n"
|
||||
"- [ ] Build\r\n - [X] Verify rollback\r\n- [ ] Release\r\n"
|
||||
)
|
||||
|
||||
assert manage_task(body, 1, {"action": "rename", "label": "Ship"}) == (
|
||||
"```md\r\n- [ ] example only\r\n```\r\n"
|
||||
"- [ ] Build\r\n - [X] Verify rollback\r\n- [ ] Ship\r\n"
|
||||
)
|
||||
|
||||
|
||||
def test_manage_task_removes_only_the_selected_task_line():
|
||||
body = "Plan\n- [ ] Build\n- [x] Test\n\nNotes"
|
||||
|
||||
assert manage_task(body, 0, {"action": "remove"}) == "Plan\n- [x] Test\n\nNotes"
|
||||
|
||||
|
||||
def test_manage_task_moves_a_step_within_its_list_without_changing_step_content():
|
||||
body = "Plan\n- [ ] Build\n- [x] Test\n- [ ] Release\n\nNotes"
|
||||
|
||||
assert manage_task(body, 2, {"action": "move-earlier"}) == (
|
||||
"Plan\n- [ ] Build\n- [ ] Release\n- [x] Test\n\nNotes"
|
||||
)
|
||||
assert manage_task(body, 0, {"action": "move-later"}) == (
|
||||
"Plan\n- [x] Test\n- [ ] Build\n- [ ] Release\n\nNotes"
|
||||
)
|
||||
|
||||
|
||||
def test_manage_task_rejects_an_empty_or_duplicate_renamed_label():
|
||||
script = f"""
|
||||
const manage = require({json.dumps(str(FRONTEND / 'issue-sheet.js'))}).manageChecklistTask;
|
||||
const body = '- [ ] Build\\n- [x] Test';
|
||||
const errors = ['', ' test '].map(label => {{
|
||||
try {{ manage(body, 0, {{action:'rename',label}}); return null; }}
|
||||
catch (error) {{ return error.message; }}
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(errors));
|
||||
"""
|
||||
|
||||
assert json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout) == ["Enter a checklist step.", "That checklist step already exists."]
|
||||
|
||||
|
||||
def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert():
|
||||
rendered = render_markdown(
|
||||
"[Forge](https://forge.example/work?q=1&safe=yes) "
|
||||
@@ -110,12 +229,12 @@ def test_all_read_only_work_bodies_use_the_shared_markdown_renderer():
|
||||
"renderMarkdown(detail.body || 'No description provided.')",
|
||||
"renderMarkdown(item.body || 'No description provided.')",
|
||||
"renderMarkdown(review.body)",
|
||||
"renderMarkdown(confirmed.body || 'No description provided.')",
|
||||
"issueController.renderTasks(qs('#issue-sheet-body'), detail,",
|
||||
)
|
||||
for path in expected_paths:
|
||||
assert path in dashboard
|
||||
|
||||
assert dashboard.count("renderMarkdown(detail.body || 'No description provided.')") == 4
|
||||
assert dashboard.count("renderMarkdown(detail.body || 'No description provided.')") == 3
|
||||
|
||||
|
||||
def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
@@ -137,4 +256,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-v102" in worker
|
||||
assert "stackchain-dashboard-shell-v108" in worker
|
||||
@@ -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-v102" in worker
|
||||
assert "stackchain-dashboard-shell-v108" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v102" in worker
|
||||
assert "stackchain-dashboard-shell-v108" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
START_DAY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-start-day.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_start_day_briefing_guides_urgent_review_before_today():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
let counts = {{agenda:2, attention:3, update:4, filed:1, today:5}};
|
||||
const opened = [];
|
||||
const controller = createStartDay({{
|
||||
getCounts: () => counts,
|
||||
openQueue: name => opened.push(name),
|
||||
}});
|
||||
const first = controller.briefing();
|
||||
controller.startNext();
|
||||
counts = {{agenda:0, attention:3, update:4, filed:1, today:5}};
|
||||
const second = controller.briefing();
|
||||
controller.startNext();
|
||||
counts = {{agenda:0, attention:0, update:0, filed:0, today:5}};
|
||||
const ready = controller.briefing();
|
||||
controller.startNext();
|
||||
process.stdout.write(JSON.stringify({{first, second, ready, opened}}));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
|
||||
assert result == {
|
||||
"first": {
|
||||
"total": 10,
|
||||
"next": "agenda",
|
||||
"label": "Review Agenda",
|
||||
"summary": "10 items before Today · 5 planned",
|
||||
"phases": [
|
||||
{"name": "agenda", "label": "Agenda", "count": 2},
|
||||
{"name": "attention", "label": "Attention", "count": 3},
|
||||
{"name": "update", "label": "Updates", "count": 4},
|
||||
{"name": "filed", "label": "Filed", "count": 1},
|
||||
],
|
||||
},
|
||||
"second": {
|
||||
"total": 8,
|
||||
"next": "attention",
|
||||
"label": "Review Attention",
|
||||
"summary": "8 items before Today · 5 planned",
|
||||
"phases": [
|
||||
{"name": "attention", "label": "Attention", "count": 3},
|
||||
{"name": "update", "label": "Updates", "count": 4},
|
||||
{"name": "filed", "label": "Filed", "count": 1},
|
||||
],
|
||||
},
|
||||
"ready": {
|
||||
"total": 0,
|
||||
"next": "today",
|
||||
"label": "Continue Today",
|
||||
"summary": "Review clear · 5 planned",
|
||||
"phases": [],
|
||||
},
|
||||
"opened": ["agenda", "attention", "today"],
|
||||
}
|
||||
|
||||
|
||||
def test_start_day_view_renders_refreshed_phases_and_launches_primary_action():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
const elements = {{
|
||||
summary: {{textContent:''}},
|
||||
phases: {{textContent:''}},
|
||||
action: {{textContent:'', listeners:{{}}, addEventListener(name, fn) {{ this.listeners[name] = fn; }}}},
|
||||
}};
|
||||
const opened = [];
|
||||
let counts = {{agenda:0, attention:2, update:1, filed:0, today:3}};
|
||||
const controller = createStartDay({{
|
||||
getCounts: () => counts,
|
||||
openQueue: name => opened.push(name),
|
||||
elements,
|
||||
}});
|
||||
controller.start();
|
||||
const first = {{summary:elements.summary.textContent, phases:elements.phases.textContent, action:elements.action.textContent}};
|
||||
elements.action.listeners.click();
|
||||
counts = {{agenda:0, attention:0, update:0, filed:0, today:3}};
|
||||
controller.render();
|
||||
const ready = {{summary:elements.summary.textContent, phases:elements.phases.textContent, action:elements.action.textContent}};
|
||||
process.stdout.write(JSON.stringify({{first, ready, opened}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"first": {
|
||||
"summary": "3 items before Today · 3 planned",
|
||||
"phases": "Attention 2 · Updates 1",
|
||||
"action": "Review Attention",
|
||||
},
|
||||
"ready": {
|
||||
"summary": "Review clear · 3 planned",
|
||||
"phases": "All urgent queues reviewed",
|
||||
"action": "Continue Today",
|
||||
},
|
||||
"opened": ["attention"],
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_today_pass_persists_per_account_and_hands_off_using_live_counts():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
const saved = new Map();
|
||||
const storage = {{
|
||||
getItem: key => saved.has(key) ? saved.get(key) : null,
|
||||
setItem: (key, value) => saved.set(key, value),
|
||||
removeItem: key => saved.delete(key),
|
||||
}};
|
||||
let login = 'timmy';
|
||||
let day = '2026-08-15';
|
||||
let counts = {{agenda:2, attention:1, update:1, filed:1, today:3}};
|
||||
const opened = [];
|
||||
const handoffs = [];
|
||||
const controller = createStartDay({{
|
||||
storage,
|
||||
getLogin: () => login,
|
||||
getDay: () => day,
|
||||
getCounts: () => counts,
|
||||
openQueue: name => opened.push(name),
|
||||
onHandoff: state => handoffs.push({{next:state.next, label:state.label}}),
|
||||
}});
|
||||
const started = controller.startNext();
|
||||
counts = {{agenda:0, attention:0, update:1, filed:1, today:3}};
|
||||
const handed = controller.completePhase('agenda');
|
||||
const resumed = createStartDay({{
|
||||
storage,
|
||||
getLogin: () => login,
|
||||
getDay: () => day,
|
||||
getCounts: () => counts,
|
||||
openQueue: name => opened.push(name),
|
||||
}}).state();
|
||||
login = 'alexander';
|
||||
const isolated = createStartDay({{
|
||||
storage,
|
||||
getLogin: () => login,
|
||||
getDay: () => day,
|
||||
getCounts: () => counts,
|
||||
openQueue: name => opened.push(name),
|
||||
}}).state();
|
||||
login = 'timmy';
|
||||
day = '2026-08-16';
|
||||
const expired = createStartDay({{
|
||||
storage,
|
||||
getLogin: () => login,
|
||||
getDay: () => day,
|
||||
getCounts: () => counts,
|
||||
openQueue: name => opened.push(name),
|
||||
}}).state();
|
||||
day = '2026-08-15';
|
||||
const finished = controller.finish();
|
||||
const cleared = controller.state();
|
||||
process.stdout.write(JSON.stringify({{started, handed, resumed, isolated, expired, finished, cleared, opened, handoffs}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"started": "agenda",
|
||||
"handed": True,
|
||||
"resumed": {"active": True, "next": "update", "label": "Review Updates"},
|
||||
"isolated": {"active": False, "next": "update", "label": "Review Updates"},
|
||||
"expired": {"active": False, "next": "update", "label": "Review Updates"},
|
||||
"finished": True,
|
||||
"cleared": {"active": False, "next": "update", "label": "Review Updates"},
|
||||
"opened": ["agenda"],
|
||||
"handoffs": [{"next": "update", "label": "Review Updates"}],
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_today_attention_hands_off_once_only_after_authoritative_refresh():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
const saved = new Map();
|
||||
const storage = {{
|
||||
getItem: key => saved.has(key) ? saved.get(key) : null,
|
||||
setItem: (key, value) => saved.set(key, value),
|
||||
removeItem: key => saved.delete(key),
|
||||
}};
|
||||
let counts = {{agenda:0, attention:1, update:2, filed:0, today:3}};
|
||||
const opened = [];
|
||||
const handoffs = [];
|
||||
const controller = createStartDay({{
|
||||
storage,
|
||||
getLogin: () => 'timmy',
|
||||
getDay: () => '2026-08-15',
|
||||
getCounts: () => counts,
|
||||
openQueue: name => opened.push(name),
|
||||
onHandoff: current => handoffs.push({{next:current.next, label:current.label}}),
|
||||
}});
|
||||
const launchedAttention = controller.startNext();
|
||||
counts = {{agenda:0, attention:0, update:2, filed:0, today:3}};
|
||||
const ignoredPartial = controller.reconcile({{authoritative:false}});
|
||||
const handedToUpdates = controller.reconcile({{authoritative:true}});
|
||||
const duplicateRefresh = controller.reconcile({{authoritative:true}});
|
||||
const launchedUpdates = controller.startNext();
|
||||
counts = {{agenda:0, attention:0, update:0, filed:0, today:3}};
|
||||
const handedToToday = controller.reconcile({{authoritative:true}});
|
||||
const duplicateReadyRefresh = controller.reconcile({{authoritative:true}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
launchedAttention, ignoredPartial, handedToUpdates, duplicateRefresh,
|
||||
launchedUpdates, handedToToday, duplicateReadyRefresh, opened, handoffs,
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"launchedAttention": "attention",
|
||||
"ignoredPartial": False,
|
||||
"handedToUpdates": True,
|
||||
"duplicateRefresh": False,
|
||||
"launchedUpdates": "update",
|
||||
"handedToToday": True,
|
||||
"duplicateReadyRefresh": False,
|
||||
"opened": ["attention", "update"],
|
||||
"handoffs": [
|
||||
{"next": "update", "label": "Review Updates"},
|
||||
{"next": "today", "label": "Continue Today"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_today_checkpoint_uses_the_device_local_day():
|
||||
script = f"""
|
||||
process.env.TZ = 'Pacific/Honolulu';
|
||||
const RealDate = Date;
|
||||
global.Date = class extends RealDate {{
|
||||
constructor(...args) {{ super(...(args.length ? args : ['2026-08-16T01:00:00Z'])); }}
|
||||
}};
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
const saved = new Map();
|
||||
const storage = {{
|
||||
getItem: key => saved.has(key) ? saved.get(key) : null,
|
||||
setItem: (key, value) => saved.set(key, value),
|
||||
removeItem: key => saved.delete(key),
|
||||
}};
|
||||
createStartDay({{
|
||||
storage,
|
||||
getLogin: () => 'timmy',
|
||||
getCounts: () => ({{agenda:1}}),
|
||||
openQueue: () => {{}},
|
||||
}}).startNext();
|
||||
process.stdout.write(JSON.stringify(JSON.parse(saved.get('stackchain.mobile-start-day.v1'))));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"login": "timmy", "day": "2026-08-15", "phase": "agenda"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile_bundle():
|
||||
html = await dashboard()
|
||||
service_worker = (START_DAY.parent / "service-worker.js").read_text()
|
||||
|
||||
assert 'class="mobile-start-day"' in html
|
||||
assert 'id="mobile-start-day-summary"' in html
|
||||
assert 'id="mobile-start-day-phases"' in html
|
||||
assert 'id="mobile-start-day-action"' in html
|
||||
assert 'id="finish-mobile-start-day"' in html
|
||||
assert '<script src="static/mobile-start-day.js"></script>' in html
|
||||
assert "const mobileStartDay = createMobileStartDay({" in html
|
||||
assert "getLogin: () => confirmedOwnerLogin" in html
|
||||
assert "onHandoff: current =>" in html
|
||||
assert "mobileStartDay.completePhase('agenda')" in html
|
||||
assert "mobileStartDay.completePhase('update')" in html
|
||||
assert "mobileStartDay.completePhase('filed')" in html
|
||||
assert "mobileStartDay.reconcile({authoritative:authoritativeMyWorkRefresh})" in html
|
||||
assert "mobileStartDay.finish()" in html
|
||||
assert "openQueue: name =>" in html
|
||||
assert "if (sheet.open) sheet.close();" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "mobileStartDay.render();" in html
|
||||
assert ".mobile-start-day-action { width:100%; min-height:48px;" in html
|
||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v108" in service_worker
|
||||
@@ -582,7 +582,7 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
assert '<strong>Filed</strong><small>Issues you delegated</small>' in html
|
||||
assert 'data-mobile-queue-count="filed"' in html
|
||||
assert "openFiled: openFiledFollowUp" in html
|
||||
assert "filedFollowUpTarget(lastMyWork)" in html
|
||||
assert "filedFollowUpTarget(completedFiledReview.visible(lastMyWork))" in html
|
||||
assert 'data-mobile-queue="later"' in html
|
||||
assert 'data-mobile-queue="draft"' in html
|
||||
assert 'data-mobile-queue="recaps"' in html
|
||||
|
||||
+1051
-11
File diff suppressed because it is too large.
Load diff
@@ -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-v102" in source
|
||||
assert "stackchain-dashboard-shell-v108" 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,44 @@
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
REGISTRY = FRONTEND / "private-data-registry.js"
|
||||
|
||||
|
||||
def test_private_database_registry_covers_every_shipped_indexeddb_store():
|
||||
harness = f"""
|
||||
const databases = require({json.dumps(str(REGISTRY))});
|
||||
process.stdout.write(JSON.stringify(databases));
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
registered = set(json.loads(completed.stdout))
|
||||
|
||||
opened = set()
|
||||
for path in FRONTEND.glob("*.js"):
|
||||
source = path.read_text()
|
||||
opened.update(re.findall(r"dbName\s*=\s*['\"](stackchain-[^'\"]+)['\"]", source))
|
||||
opened.update(re.findall(r"createIndexedDbTransaction\([^\n]+['\"](stackchain-[^'\"]+)['\"]", source))
|
||||
|
||||
assert registered == opened
|
||||
assert registered == {
|
||||
"stackchain-background-outbox-v1",
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
}
|
||||
|
||||
|
||||
def test_every_private_data_purge_context_consumes_the_shared_registry():
|
||||
for name in ("private-device-data.js", "session.js", "service-worker.js"):
|
||||
source = (FRONTEND / name).read_text()
|
||||
assert "stackchainPrivateDatabases" in source
|
||||
assert not re.search(r"deletePrivateDatabase\(['\"]stackchain-", source)
|
||||
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
assert html.index('static/private-data-registry.js') < html.index('static/session.js')
|
||||
@@ -47,6 +47,7 @@ const clear = createPrivateDeviceDataPurger({{
|
||||
"stackchain-background-outbox-v1",
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
||||
from src.completed_filed_review_store import CompletedFiledReviewStore
|
||||
from src.idempotency import IdempotencyLedger
|
||||
from src.later_store import LaterStore
|
||||
from src.live_snapshot_store import LiveSnapshotStore
|
||||
from src.login_attempt_store import LoginAttemptStore
|
||||
from src.passkey_store import PasskeyStore
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.saved_search_store import SavedSearchStore
|
||||
from src.security_event_store import SecurityEventStore
|
||||
from src.session_store import SessionStore
|
||||
from src.today_store import TodayStore
|
||||
from src.unfiled_draft_store import UnfiledDraftStore
|
||||
|
||||
|
||||
def mode(path: Path) -> int:
|
||||
return stat.S_IMODE(path.stat().st_mode)
|
||||
|
||||
|
||||
def test_private_sqlite_connection_enforces_directory_database_and_sidecar_modes(tmp_path):
|
||||
state = tmp_path / "state"
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
connection = connect_private_sqlite(state / "private.sqlite3")
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("CREATE TABLE secrets (value TEXT)")
|
||||
connection.execute("INSERT INTO secrets VALUES ('operator data')")
|
||||
connection.commit()
|
||||
|
||||
assert mode(state) == 0o700
|
||||
assert mode(state / "private.sqlite3") == 0o600
|
||||
assert mode(state / "private.sqlite3-wal") == 0o600
|
||||
assert mode(state / "private.sqlite3-shm") == 0o600
|
||||
finally:
|
||||
connection.close()
|
||||
os.umask(old_umask)
|
||||
|
||||
|
||||
def test_private_sqlite_connection_repairs_existing_permissive_modes_without_losing_rows(tmp_path):
|
||||
state = tmp_path / "state"
|
||||
state.mkdir(mode=0o755)
|
||||
database = state / "private.sqlite3"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute("CREATE TABLE secrets (value TEXT)")
|
||||
connection.execute("INSERT INTO secrets VALUES ('keep me')")
|
||||
state.chmod(0o755)
|
||||
database.chmod(0o644)
|
||||
|
||||
with connect_private_sqlite(database) as connection:
|
||||
value = connection.execute("SELECT value FROM secrets").fetchone()[0]
|
||||
|
||||
assert value == "keep me"
|
||||
assert mode(state) == 0o700
|
||||
assert mode(database) == 0o600
|
||||
|
||||
|
||||
def test_private_sqlite_connection_rejects_a_symlink_before_opening_target(tmp_path):
|
||||
target = tmp_path / "target.sqlite3"
|
||||
with sqlite3.connect(target) as connection:
|
||||
connection.execute("CREATE TABLE sentinel (value TEXT)")
|
||||
connection.execute("INSERT INTO sentinel VALUES ('unchanged')")
|
||||
link = tmp_path / "state" / "private.sqlite3"
|
||||
link.parent.mkdir()
|
||||
link.symlink_to(target)
|
||||
|
||||
with pytest.raises(ValueError, match="symlink"):
|
||||
connect_private_sqlite(link)
|
||||
|
||||
with sqlite3.connect(target) as connection:
|
||||
assert connection.execute("SELECT value FROM sentinel").fetchone()[0] == "unchanged"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"build",
|
||||
[
|
||||
TodayStore,
|
||||
LaterStore,
|
||||
SavedSearchStore,
|
||||
UnfiledDraftStore,
|
||||
CompletedFiledReviewStore,
|
||||
PushSubscriptionStore,
|
||||
LiveSnapshotStore,
|
||||
AvailableIssueSnapshotStore,
|
||||
lambda path: IdempotencyLedger(path, ttl_seconds=60, max_entries=10),
|
||||
],
|
||||
)
|
||||
def test_eager_private_stores_share_the_private_filesystem_boundary(tmp_path, build):
|
||||
state = tmp_path / "state"
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
build(state / "store.sqlite3")
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
assert mode(state) == 0o700
|
||||
assert mode(state / "store.sqlite3") == 0o600
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exercise",
|
||||
[
|
||||
lambda path: SecurityEventStore(path, clock=lambda: 1).record("sign_in"),
|
||||
lambda path: LoginAttemptStore(
|
||||
path, clock=lambda: 1, max_failures=3, window_seconds=60
|
||||
).record_failure("203.0.113.10"),
|
||||
lambda path: PasskeyStore(path, clock=lambda: 1).issue_challenge(
|
||||
b"challenge", session_id=None, purpose="authentication", action="sign_in",
|
||||
target="dashboard", source="browser", ttl_seconds=60,
|
||||
),
|
||||
lambda path: SessionStore(path, clock=lambda: 1).activate("session", 60),
|
||||
],
|
||||
)
|
||||
def test_on_demand_private_stores_share_the_private_filesystem_boundary(tmp_path, exercise):
|
||||
state = tmp_path / "state"
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
exercise(state / "store.sqlite3")
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
assert mode(state) == 0o700
|
||||
assert mode(state / "store.sqlite3") == 0o600
|
||||
@@ -0,0 +1,51 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RECONNECT = ROOT / "frontend" / "reconnect-outboxes.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], cwd=ROOT, text=True, capture_output=True, check=True
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_successful_reconnect_restores_identity_and_flushes_unchanged_outboxes():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createReconnectOutboxes = require({json.dumps(str(RECONNECT))});
|
||||
const calls = [];
|
||||
const reconnect = createReconnectOutboxes({{
|
||||
refresh: async () => ({{context:{{user:{{login:'timmy'}}}}}}),
|
||||
restoreIdentity: login => calls.push(['identity', login]),
|
||||
flushIssue: () => calls.push(['issue']),
|
||||
flushAuthored: () => calls.push(['authored']),
|
||||
flushNotificationReads: () => calls.push(['notification-read']),
|
||||
}});
|
||||
reconnect().then(result => process.stdout.write(JSON.stringify({{result, calls}})));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"result": True,
|
||||
"calls": [
|
||||
["identity", "timmy"],
|
||||
["issue"],
|
||||
["authored"],
|
||||
["notification-read"],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_uses_reconnect_flush_after_live_identity_refresh():
|
||||
index = (ROOT / "frontend" / "index.html").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert '<script src="static/reconnect-outboxes.js"></script>' in index
|
||||
assert "const reconnectOutboxes = createReconnectOutboxes({" in dashboard
|
||||
assert "restoreIdentity: login => { activeFlushLogin = login; confirmedOwnerLogin = login; }" in dashboard
|
||||
assert "window.addEventListener('online', reconnectOutboxes);" in dashboard
|
||||
@@ -14,7 +14,10 @@ REQUIRED_HEADERS = {
|
||||
def assert_browser_security_boundary(response: httpx.Response) -> None:
|
||||
for name, value in REQUIRED_HEADERS.items():
|
||||
assert response.headers[name] == value
|
||||
assert "camera=()" in response.headers["permissions-policy"]
|
||||
permissions_policy = response.headers["permissions-policy"]
|
||||
assert "camera=()" in permissions_policy
|
||||
assert "microphone=(self)" in permissions_policy
|
||||
assert "microphone=()" not in permissions_policy
|
||||
policy = response.headers["content-security-policy"]
|
||||
assert "default-src 'self'" in policy
|
||||
assert "script-src 'self'" in policy
|
||||
|
||||
+128
-20
@@ -6,6 +6,7 @@ import pytest
|
||||
|
||||
|
||||
WORKER = Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js"
|
||||
PRIVATE_DATA_REGISTRY = WORKER.parent / "private-data-registry.js"
|
||||
|
||||
|
||||
def run_worker_scenario(scenario: str) -> dict:
|
||||
@@ -13,7 +14,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: [], 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 state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], sharedRecords: {{}}, failSharedPut: false, 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,8 +47,12 @@ 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,
|
||||
stackchainPrivateDatabases: require({json.dumps(str(PRIVATE_DATA_REGISTRY))}),
|
||||
__STACKCHAIN_SHARED_ATTACHMENT_STORE: {{
|
||||
put: async (id, value) => {{ state.sharedRecords[id] = value; }},
|
||||
put: async (id, value) => {{
|
||||
if (state.failSharedPut) throw new Error('quota');
|
||||
state.sharedRecords[id] = value;
|
||||
}},
|
||||
get: async id => state.sharedRecords[id] || null,
|
||||
delete: async id => {{ delete state.sharedRecords[id]; }},
|
||||
}},
|
||||
@@ -150,7 +155,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -159,14 +164,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-v102" in source
|
||||
assert "stackchain-dashboard-shell-v108" 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-v102" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -174,22 +179,32 @@ 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-v102" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "stackchain-dashboard-shell-v108" 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-v102" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -198,21 +213,21 @@ 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-v102" in source
|
||||
assert "stackchain-dashboard-shell-v108" 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-v102" in source
|
||||
assert "stackchain-dashboard-shell-v108" 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-v102" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -226,21 +241,29 @@ def test_image_share_target_stages_one_supported_image_and_redirects_to_capture(
|
||||
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});
|
||||
const request = new Request('https://forge.example/dashboard/share-target', {
|
||||
method:'POST', body:form, headers:{'Sec-Fetch-Site':'none'},
|
||||
});
|
||||
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,
|
||||
title:record.title, text:record.text, url:record.url,
|
||||
filename:record.attachments[0].filename,
|
||||
contentType:record.attachments[0].contentType,
|
||||
size:record.attachments[0].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",
|
||||
"location": "/dashboard/?launch=new&shared=bundle",
|
||||
"id": "shared-image",
|
||||
"title": "Broken checkout",
|
||||
"text": "Steps to reproduce",
|
||||
"url": "https://example.test/checkout",
|
||||
"filename": "bug.png",
|
||||
"contentType": "image/png",
|
||||
"size": 6,
|
||||
@@ -253,7 +276,9 @@ def test_image_share_target_stages_ordered_evidence_bundle():
|
||||
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});
|
||||
const request = new Request('https://forge.example/dashboard/share-target', {
|
||||
method:'POST', body:form, headers:{'Sec-Fetch-Site':'same-origin'},
|
||||
});
|
||||
Object.defineProperty(request, 'mode', {value:'navigate'});
|
||||
const response = await dispatch('fetch', request);
|
||||
const record=state.sharedRecords['shared-image'];
|
||||
@@ -266,12 +291,80 @@ def test_image_share_target_stages_ordered_evidence_bundle():
|
||||
|
||||
assert result == {
|
||||
"status": 303,
|
||||
"location": "/dashboard/?launch=new&shared=images",
|
||||
"location": "/dashboard/?launch=new&shared=bundle",
|
||||
"names": ["one.png", "two.jpg"],
|
||||
"sizes": [3, 3],
|
||||
}
|
||||
|
||||
|
||||
def test_share_target_rejects_cross_site_posts_without_touching_staged_bundle():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.sharedRecords['shared-image']={title:'Keep me',text:'private',url:'',attachments:[]};
|
||||
const form=new FormData();form.append('title','Replace me');
|
||||
const request=new Request('https://forge.example/dashboard/share-target',{
|
||||
method:'POST',body:form,headers:{'Sec-Fetch-Site':'cross-site'},
|
||||
});
|
||||
Object.defineProperty(request,'mode',{value:'navigate'});
|
||||
const response=await dispatch('fetch',request);
|
||||
process.stdout.write(JSON.stringify({status:response.status,record:state.sharedRecords['shared-image']}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": 403,
|
||||
"record": {"title": "Keep me", "text": "private", "url": "", "attachments": []},
|
||||
}
|
||||
|
||||
|
||||
def test_invalid_share_target_preserves_the_previous_atomic_bundle():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.sharedRecords['shared-image']={title:'Keep me',text:'private',url:'',attachments:[]};
|
||||
const form=new FormData();
|
||||
form.append('image',new Blob(['payload'],{type:'application/pdf'}),'secret.pdf');
|
||||
const request=new Request('https://forge.example/dashboard/share-target',{
|
||||
method:'POST',body:form,headers:{'Sec-Fetch-Site':'none'},
|
||||
});
|
||||
Object.defineProperty(request,'mode',{value:'navigate'});
|
||||
const response=await dispatch('fetch',request);
|
||||
process.stdout.write(JSON.stringify({location:response.headers.get('Location'),record:state.sharedRecords['shared-image']}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"location": "/dashboard/?launch=new&shared=unsupported",
|
||||
"record": {"title": "Keep me", "text": "private", "url": "", "attachments": []},
|
||||
}
|
||||
|
||||
|
||||
def test_share_target_storage_failure_preserves_bundle_and_returns_recovery_marker():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.sharedRecords['shared-image']={title:'Keep me',text:'private',url:'',attachments:[]};
|
||||
state.failSharedPut=true;
|
||||
const form=new FormData();form.append('title','New private content');
|
||||
const request=new Request('https://forge.example/dashboard/share-target',{
|
||||
method:'POST',body:form,headers:{'Sec-Fetch-Site':'none'},
|
||||
});
|
||||
Object.defineProperty(request,'mode',{value:'navigate'});
|
||||
const response=await dispatch('fetch',request);
|
||||
process.stdout.write(JSON.stringify({location:response.headers.get('Location'),record:state.sharedRecords['shared-image']}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"location": "/dashboard/?launch=new&shared=unavailable",
|
||||
"record": {"title": "Keep me", "text": "private", "url": "", "attachments": []},
|
||||
}
|
||||
|
||||
|
||||
def test_share_target_manifest_uses_dedicated_admission_path():
|
||||
manifest = json.loads((WORKER.parent / "manifest.webmanifest").read_text())
|
||||
|
||||
assert manifest["share_target"]["action"] == "./share-target"
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -316,7 +409,10 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
|
||||
assert result["outcome"] == {"status": 401, "code": "session_revoked"}
|
||||
assert result["state"]["outboxPurges"] == 1
|
||||
assert result["state"]["deletedDatabases"] == [
|
||||
"stackchain-unfiled-captures-v1"
|
||||
"stackchain-background-outbox-v1",
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert result["state"]["clientMessages"] == [
|
||||
@@ -406,7 +502,10 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
|
||||
|
||||
assert result["state"]["outboxPurges"] == 1
|
||||
assert result["state"]["deletedDatabases"] == [
|
||||
"stackchain-unfiled-captures-v1"
|
||||
"stackchain-background-outbox-v1",
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["replies"] == [{"ok": True}]
|
||||
|
||||
@@ -794,7 +893,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-v102" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -833,6 +932,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/draft-capacity-dialog.js",
|
||||
"/dashboard/static/outbox-coordinator.js",
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/reconnect-outboxes.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/offline-issue-close.js",
|
||||
"/dashboard/static/offline-issue-blocker.js",
|
||||
@@ -874,9 +974,13 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/issue-attachment.js",
|
||||
"/dashboard/static/issue-filing-review.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
"/dashboard/static/checklist-conflict.js",
|
||||
"/dashboard/static/voice-transcript-store.js",
|
||||
"/dashboard/static/voice-issue-capture.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/create-and-start.js",
|
||||
"/dashboard/static/assign-and-start.js",
|
||||
"/dashboard/static/filed-claim.js",
|
||||
"/dashboard/static/queue-today.js",
|
||||
"/dashboard/static/pull-sheet.js",
|
||||
"/dashboard/static/review-sheet.js",
|
||||
@@ -887,6 +991,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
"/dashboard/static/mobile-start-day.js",
|
||||
"/dashboard/static/update-triage-session.js",
|
||||
"/dashboard/static/update-review-handoff.js",
|
||||
"/dashboard/static/update-read-position.js",
|
||||
@@ -999,7 +1104,10 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
|
||||
assert result["body"] == "Your Stackchain session expired. Reconnect and sign in."
|
||||
assert result["state"]["outboxPurges"] == 1
|
||||
assert result["state"]["deletedDatabases"] == [
|
||||
"stackchain-unfiled-captures-v1"
|
||||
"stackchain-background-outbox-v1",
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert "private cached dashboard" not in result["body"]
|
||||
|
||||
@@ -68,4 +68,28 @@ shared.consume({{
|
||||
assert json.loads(completed.stdout) == {
|
||||
"result": False,
|
||||
"calls": ["Share one PNG, JPEG, or WebP screenshot."],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_private_share_bundle_restores_metadata_and_ordered_images_once():
|
||||
script = f"""
|
||||
const shared=require({json.dumps(str(MODULE))});const calls=[];
|
||||
const store={{get:async()=>({{
|
||||
title:'Broken checkout',text:'Steps to reproduce',url:'https://example.test/private',
|
||||
attachments:[{{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}}],
|
||||
}}),delete:async id=>calls.push(['delete',id])}};
|
||||
(async()=>{{const result=await shared.consume({{
|
||||
marker:'bundle',store,
|
||||
restore:value=>calls.push(['restore',value.map(x=>x.filename)]),
|
||||
restoreContent:value=>calls.push(['content',value]),
|
||||
status:value=>calls.push(['status',value]),
|
||||
}});process.stdout.write(JSON.stringify({{result,calls}}));}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
|
||||
|
||||
assert output == {"result": True, "calls": [
|
||||
["content", {"title": "Broken checkout", "text": "Steps to reproduce", "url": "https://example.test/private"}],
|
||||
["restore", ["one.png"]],
|
||||
["delete", "shared-image"],
|
||||
["status", "Shared content and screenshot ready to file with this issue."],
|
||||
]}
|
||||
@@ -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-v102';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v108';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -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-v102" in source
|
||||
assert "stackchain-dashboard-shell-v108" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||
assert "todaySync.enqueue('add'" in html
|
||||
assert "todaySync.enqueue('remove'" in html
|
||||
assert "todaySync.enqueue('move'" in html
|
||||
assert "const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&" in html
|
||||
assert "const authoritativeMyWorkRefresh = liveMode && hasContextSnapshot &&" in html
|
||||
assert "!lastContextSnapshot?.error &&" in html
|
||||
assert "onPrune: retiredIds =>" in html
|
||||
assert "retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean)" in html
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
|
||||
|
||||
VOICE_CAPTURE = Path(__file__).parents[1] / "frontend" / "voice-issue-capture.js"
|
||||
VOICE_STORE = Path(__file__).parents[1] / "frontend" / "voice-transcript-store.js"
|
||||
FRONTEND = VOICE_CAPTURE.parent
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
completed = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_transcript_maps_first_sentence_to_title_and_remainder_to_note_with_field_limits():
|
||||
script = f"""
|
||||
const {{mapVoiceTranscript}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
const mapped = mapVoiceTranscript(' Lift outage on level four. Elevator B is trapped. ');
|
||||
const bounded = mapVoiceTranscript('x'.repeat(300) + '. ' + 'y'.repeat(10100));
|
||||
process.stdout.write(JSON.stringify({{mapped, titleLength:bounded.title.length, bodyLength:bounded.body.length}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"mapped": {
|
||||
"title": "Lift outage on level four.",
|
||||
"body": "Elevator B is trapped.",
|
||||
},
|
||||
"titleLength": 255,
|
||||
"bodyLength": 10000,
|
||||
}
|
||||
|
||||
|
||||
def test_explicit_tap_starts_recognition_and_final_transcript_waits_for_review():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ this.listeners.click?.({{currentTarget:this}}); }}
|
||||
dispatchEvent() {{}}
|
||||
}}
|
||||
let instance;
|
||||
class Recognition {{
|
||||
constructor() {{ instance=this; this.started=0; }}
|
||||
start() {{ this.started += 1; }}
|
||||
stop() {{}}
|
||||
abort() {{}}
|
||||
}}
|
||||
const elements = Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
elements.review.hidden=true;
|
||||
const controller = createVoiceIssueCapture({{Recognition,elements}});
|
||||
const before = {{started:Boolean(instance), rootHidden:elements.root.hidden}};
|
||||
elements.start.click();
|
||||
instance.onresult({{results:[Object.assign([{{transcript:'Lift outage. Elevator B is trapped.'}}], {{isFinal:true}})]}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
supported:controller.supported, before, started:instance.started,
|
||||
listening:elements.status.textContent, transcript:elements.transcript.value,
|
||||
reviewHidden:elements.review.hidden, title:elements.title.value, body:elements.body.value,
|
||||
appendLabel:elements.append.textContent, replaceHidden:elements.replace.hidden
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"supported": True,
|
||||
"before": {"started": False, "rootHidden": False},
|
||||
"started": 1,
|
||||
"listening": "Transcript ready. Review it before using it.",
|
||||
"transcript": "Lift outage. Elevator B is trapped.",
|
||||
"reviewHidden": False,
|
||||
"title": "",
|
||||
"body": "",
|
||||
"appendLabel": "Use transcript",
|
||||
"replaceHidden": True,
|
||||
}
|
||||
|
||||
|
||||
def test_final_transcript_is_checkpointed_for_the_confirmed_account():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ this.listeners.click?.({{currentTarget:this}}); }}
|
||||
dispatchEvent() {{}}
|
||||
}}
|
||||
let recognition;
|
||||
class Recognition {{
|
||||
constructor() {{ recognition=this; }} start() {{}} stop() {{}} abort() {{}}
|
||||
}}
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
elements.review.hidden=true;
|
||||
const saved=[];
|
||||
const transcriptStore={{
|
||||
async load() {{ return ''; }},
|
||||
async save(login, transcript) {{ saved.push({{login, transcript}}); }},
|
||||
async clear() {{}},
|
||||
}};
|
||||
(async () => {{
|
||||
const controller=createVoiceIssueCapture({{
|
||||
Recognition,elements,transcriptStore,getLogin:()=> 'timmy'
|
||||
}});
|
||||
await controller.ready;
|
||||
elements.start.click();
|
||||
recognition.onresult({{results:[Object.assign([{{transcript:'Lift outage.'}}], {{isFinal:true}})]}});
|
||||
await Promise.resolve();
|
||||
process.stdout.write(JSON.stringify({{saved, transcript:elements.transcript.value}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"saved": [{"login": "timmy", "transcript": "Lift outage."}],
|
||||
"transcript": "Lift outage.",
|
||||
}
|
||||
|
||||
|
||||
def test_pending_transcript_recovers_only_for_the_confirmed_account():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
dispatchEvent() {{}}
|
||||
}}
|
||||
class Recognition {{ start() {{}} stop() {{}} abort() {{}} }}
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
elements.review.hidden=true;
|
||||
const loads=[];
|
||||
(async () => {{
|
||||
const controller=createVoiceIssueCapture({{
|
||||
Recognition,elements,getLogin:()=> 'timmy',
|
||||
transcriptStore:{{
|
||||
async load(login) {{ loads.push(login); return 'Recovered lift outage.'; }},
|
||||
async save() {{}}, async clear() {{}},
|
||||
}},
|
||||
}});
|
||||
await controller.ready;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
loads, transcript:elements.transcript.value, reviewHidden:elements.review.hidden,
|
||||
status:elements.status.textContent
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"loads": ["timmy"],
|
||||
"transcript": "Recovered lift outage.",
|
||||
"reviewHidden": False,
|
||||
"status": "Recovered transcript. Review it before using or discarding it.",
|
||||
}
|
||||
|
||||
|
||||
def test_multiple_final_segments_accumulate_in_one_bounded_checkpoint():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ this.listeners.click?.(); }} dispatchEvent() {{}}
|
||||
}}
|
||||
let recognition;
|
||||
class Recognition {{ constructor() {{ recognition=this; }} start() {{}} stop() {{}} abort() {{}} }}
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
const saved=[];
|
||||
(async () => {{
|
||||
const controller=createVoiceIssueCapture({{Recognition,elements,getLogin:()=> 'timmy',transcriptStore:{{
|
||||
async load() {{ return ''; }}, async save(login,text) {{ saved.push(text); }}, async clear() {{}},
|
||||
}}}});
|
||||
await controller.ready; elements.start.click();
|
||||
recognition.onresult({{results:[Object.assign([{{transcript:'Lift outage.'}}],{{isFinal:true}})]}});
|
||||
recognition.onresult({{results:[Object.assign([{{transcript:'Elevator B is trapped.'}}],{{isFinal:true}})]}});
|
||||
await Promise.resolve();
|
||||
process.stdout.write(JSON.stringify({{transcript:elements.transcript.value,saved}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"transcript": "Lift outage. Elevator B is trapped.",
|
||||
"saved": ["Lift outage.", "Lift outage. Elevator B is trapped."],
|
||||
}
|
||||
|
||||
|
||||
def test_apply_and_discard_each_clear_the_consumed_checkpoint():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.listeners={{}}; }}
|
||||
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ this.listeners.click?.(); }} dispatchEvent() {{}}
|
||||
}}
|
||||
class Recognition {{ start(){{}} stop(){{}} abort(){{}} }}
|
||||
async function setup() {{
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
const cleared=[];
|
||||
const controller=createVoiceIssueCapture({{Recognition,elements,getLogin:()=> 'timmy',transcriptStore:{{
|
||||
async load() {{ return 'Recovered lift outage.'; }}, async save() {{}},
|
||||
async clear(login) {{ cleared.push(login); }},
|
||||
}}}});
|
||||
await controller.ready;
|
||||
return {{elements,cleared}};
|
||||
}}
|
||||
(async () => {{
|
||||
const applied=await setup(); applied.elements.append.click(); await Promise.resolve();
|
||||
const discarded=await setup(); discarded.elements.discard.click(); await Promise.resolve();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
applied:{{cleared:applied.cleared,reviewHidden:applied.elements.review.hidden,title:applied.elements.title.value}},
|
||||
discarded:{{cleared:discarded.cleared,reviewHidden:discarded.elements.review.hidden,transcript:discarded.elements.transcript.value,status:discarded.elements.status.textContent}}
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"applied": {
|
||||
"cleared": ["timmy"],
|
||||
"reviewHidden": True,
|
||||
"title": "Recovered lift outage.",
|
||||
},
|
||||
"discarded": {
|
||||
"cleared": ["timmy"],
|
||||
"reviewHidden": True,
|
||||
"transcript": "",
|
||||
"status": "Recovered transcript discarded.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_review_requires_explicit_append_or_replace_when_typed_content_exists():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; this.events=[]; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ this.listeners.click?.({{currentTarget:this}}); }}
|
||||
dispatchEvent(event) {{ this.events.push(event.type); }}
|
||||
}}
|
||||
function setup() {{
|
||||
let instance;
|
||||
class Recognition {{
|
||||
constructor() {{ instance=this; }} start() {{}} stop() {{}} abort() {{}}
|
||||
}}
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
elements.title.value='Existing title'; elements.body.value='Existing note'; elements.review.hidden=true;
|
||||
createVoiceIssueCapture({{Recognition,elements, createEvent:name=>({{type:name}})}});
|
||||
elements.start.click();
|
||||
instance.onresult({{results:[Object.assign([{{transcript:'Lift outage. Elevator B is trapped.'}}], {{isFinal:true}})]}});
|
||||
return elements;
|
||||
}}
|
||||
const appended=setup(); appended.append.click();
|
||||
const replaced=setup(); replaced.replace.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
appended:{{title:appended.title.value,body:appended.body.value,events:[appended.title.events,appended.body.events]}},
|
||||
replaced:{{title:replaced.title.value,body:replaced.body.value,events:[replaced.title.events,replaced.body.events]}}
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"appended": {
|
||||
"title": "Existing title",
|
||||
"body": "Existing note\n\nLift outage.\n\nElevator B is trapped.",
|
||||
"events": [["input"], ["input"]],
|
||||
},
|
||||
"replaced": {
|
||||
"title": "Lift outage.",
|
||||
"body": "Elevator B is trapped.",
|
||||
"events": [["input"], ["input"]],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_unsupported_browser_keeps_keyboard_flow_and_exposes_no_voice_control():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
const element=()=>({{hidden:false,textContent:'',addEventListener(){{}}}});
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,element()])
|
||||
);
|
||||
const controller=createVoiceIssueCapture({{Recognition:null,elements}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
supported:controller.supported, hidden:elements.root.hidden, guidance:elements.status.textContent
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"supported": False,
|
||||
"hidden": True,
|
||||
"guidance": "Voice capture is unavailable; type the title and note instead.",
|
||||
}
|
||||
|
||||
|
||||
def test_recognition_start_rejection_restores_controls_and_preserves_draft():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ this.listeners.click?.({{currentTarget:this}}); }} dispatchEvent() {{}}
|
||||
}}
|
||||
class Recognition {{
|
||||
start() {{ throw new Error('microphone blocked by browser policy'); }}
|
||||
stop() {{}} abort() {{}}
|
||||
}}
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
elements.title.value='Existing title';
|
||||
elements.body.value='Existing note';
|
||||
elements.transcript.value='Recovered transcript.';
|
||||
elements.start.hidden=false; elements.stop.hidden=true;
|
||||
createVoiceIssueCapture({{Recognition,elements}});
|
||||
elements.start.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
startHidden:elements.start.hidden, stopHidden:elements.stop.hidden,
|
||||
status:elements.status.textContent, title:elements.title.value,
|
||||
body:elements.body.value, transcript:elements.transcript.value
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"startHidden": False,
|
||||
"stopHidden": True,
|
||||
"status": "Voice capture could not start. Your draft and transcript are unchanged.",
|
||||
"title": "Existing title",
|
||||
"body": "Existing note",
|
||||
"transcript": "Recovered transcript.",
|
||||
}
|
||||
|
||||
|
||||
def test_stop_error_and_sheet_close_release_recognition_without_losing_transcript():
|
||||
script = f"""
|
||||
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ this.listeners.click?.({{currentTarget:this}}); }} dispatchEvent() {{}}
|
||||
}}
|
||||
let instance;
|
||||
class Recognition {{
|
||||
constructor() {{ instance=this; this.stops=0; this.aborts=0; }}
|
||||
start() {{}} stop() {{ this.stops+=1; }} abort() {{ this.aborts+=1; }}
|
||||
}}
|
||||
const elements=Object.fromEntries(
|
||||
['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,new Element()])
|
||||
);
|
||||
const controller=createVoiceIssueCapture({{Recognition,elements}});
|
||||
elements.start.click();
|
||||
instance.onresult({{results:[Object.assign([{{transcript:'Partial final transcript.'}}], {{isFinal:true}})]}});
|
||||
elements.stop.click();
|
||||
const afterStop=elements.status.textContent;
|
||||
instance.onerror({{error:'not-allowed'}});
|
||||
const afterError=elements.status.textContent;
|
||||
controller.cancel();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
stops:instance.stops, aborts:instance.aborts, afterStop, afterError,
|
||||
transcript:elements.transcript.value, reviewHidden:elements.review.hidden
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"stops": 1,
|
||||
"aborts": 1,
|
||||
"afterStop": "Finishing transcript…",
|
||||
"afterError": "Microphone permission was denied. Your draft and transcript are unchanged.",
|
||||
"transcript": "Partial final transcript.",
|
||||
"reviewHidden": False,
|
||||
}
|
||||
|
||||
|
||||
def test_private_store_isolates_bounded_transcripts_by_normalized_account():
|
||||
script = f"""
|
||||
const createVoiceTranscriptStore=require({json.dumps(str(VOICE_STORE))});
|
||||
const records=new Map();
|
||||
const transaction=async work=>work({{
|
||||
get:async key=>records.get(key),
|
||||
put:async value=>records.set(value.login,{{...value}}),
|
||||
delete:async key=>records.delete(key),
|
||||
}});
|
||||
(async()=>{{
|
||||
const store=createVoiceTranscriptStore({{transaction}});
|
||||
await store.save(' Timmy ', ' Lift outage. ' + 'x'.repeat(11000));
|
||||
await store.save('alexander', 'Private second account');
|
||||
const before={{timmy:await store.load('timmy'),alexander:await store.load('alexander')}};
|
||||
await store.clear('TIMMY');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
before,after:await store.load('timmy'),keys:[...records.keys()],length:before.timmy.length
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"before": {
|
||||
"timmy": "Lift outage. " + "x" * 10242,
|
||||
"alexander": "Private second account",
|
||||
},
|
||||
"after": "",
|
||||
"keys": ["alexander"],
|
||||
"length": 10255,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_new_sheet_ships_accessible_voice_review_inside_issue_capture_feature():
|
||||
build = build_frontend(FRONTEND)
|
||||
html = build.dashboard_html
|
||||
capture_bundle = build.feature_bundles["issue-capture"].runtime_bytes
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert 'id="voice-issue-capture"' in html
|
||||
assert 'id="start-voice-issue-capture"' in html
|
||||
assert 'id="stop-voice-issue-capture"' in html
|
||||
assert 'id="voice-issue-transcript"' in html
|
||||
assert 'id="voice-issue-status" class="small" aria-live="polite"' in html
|
||||
assert 'id="append-voice-issue-transcript"' in html
|
||||
assert 'id="replace-with-voice-issue-transcript"' in html
|
||||
assert 'id="discard-voice-issue-transcript"' in html
|
||||
assert b"function createVoiceIssueCapture" in capture_bundle
|
||||
assert b"function createVoiceTranscriptStore" in capture_bundle
|
||||
assert b"function createVoiceIssueCapture" not in build.runtime_bytes
|
||||
assert "window.SpeechRecognition || window.webkitSpeechRecognition" in dashboard
|
||||
assert "transcriptStore:createVoiceTranscriptStore()" in dashboard
|
||||
assert "getLogin:()=>confirmedOwnerLogin" in dashboard
|
||||
assert "voiceIssueCapture.cancel();" in dashboard
|
||||
registry = run_node(
|
||||
f"process.stdout.write(JSON.stringify(require({json.dumps(str(FRONTEND / 'private-data-registry.js'))})))"
|
||||
)
|
||||
assert "stackchain-voice-transcripts-v1" in registry
|
||||
@@ -103,7 +103,7 @@ 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": "POST", "enctype": "multipart/form-data",
|
||||
"action": "./share-target", "method": "POST", "enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title", "text": "text", "url": "url",
|
||||
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}],
|
||||
@@ -137,6 +137,9 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da
|
||||
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()
|
||||
dashboard = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
assert "restoreContent:content=>" in dashboard
|
||||
assert "issueCapture.stageSharedContent(content)" in dashboard
|
||||
assert '<script src="static/shared-image-capture.js"></script>' in (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
|
||||
|
||||
|
||||
|
||||
@@ -147,6 +147,88 @@ async def test_resolve_work_route_returns_an_open_issue_filed_by_the_current_use
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolve_work_route_returns_a_completed_issue_filed_by_the_current_user():
|
||||
async def upstream(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"id": 702,
|
||||
"number": 89,
|
||||
"title": "Completed delegation",
|
||||
"state": "closed",
|
||||
"html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/89",
|
||||
"assignees": [{"login": "alex"}],
|
||||
"user": {"login": "timmy"},
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.resolve_work_route(
|
||||
"filed", "stackchain/dashboard", 89, None
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"kind": "filed",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 89,
|
||||
"title": "Completed delegation",
|
||||
"state": "closed",
|
||||
"url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/89",
|
||||
"is_filed": True,
|
||||
"is_assigned": False,
|
||||
"work_reasons": ["created_by_me"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_completed_filed_issue_remains_authorized_for_detail_and_reply():
|
||||
async def upstream(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"state": "closed",
|
||||
"user": {"login": "timmy"},
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
authorized = await gitea_proxy.is_authored_issue("stackchain/dashboard", 89)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert authorized is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("state", "author", "authorized"),
|
||||
[("open", "timmy", True), ("closed", "timmy", False), ("open", "alex", False)],
|
||||
)
|
||||
async def test_only_open_authored_issues_are_authorized_for_withdrawal(
|
||||
state, author, authorized
|
||||
):
|
||||
async def upstream(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"state": state,
|
||||
"user": {"login": author},
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.is_open_authored_issue(
|
||||
"stackchain/dashboard", 89
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result is authorized
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolve_work_route_rejects_a_notification_that_is_already_read():
|
||||
async def upstream(request):
|
||||
|
||||
Reference in new issue
Block a user