Compare commits

...
Author SHA1 Message Date
timmy 39357263d0 feat: require operator sessions for privileged access (#258)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 03:41:30 +00:00
rockachopa e40c75b360 Merge pull request 'Defer from mobile detail sheets and continue triage' (#257) from timmy/256-mobile-detail-defer into main
CI / lint (push) Successful in 24s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-08 03:17:03 +00:00
timmy fdb39e43ad feat: defer from mobile detail sheets (#256)
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 03:16:01 +00:00
rockachopa 61168dbfd4 Defer mobile Attention items into a Later queue (#255)
CI / lint (push) Successful in 24s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
Closes #254
2026-08-08 02:42:22 +00:00
timmy 65a09b5c66 feat: add mobile Later queue (#254)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 02:41:09 +00:00
rockachopa 8e0389153c Merge pull request 'Deliver queued authored messages after the app closes' (#253) from timmy/252-background-authored-message-sync into main
CI / lint (push) Successful in 24s
Release / release-candidate (push) Successful in 6s
CI / build-frontend (push) Successful in 4s
2026-08-08 02:12:33 +00:00
timmy c3e8ead47b feat: deliver authored messages after app close (#252)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 02:11:26 +00:00
timmy 5f6dd265a5 Merge pull request 'Deliver queued issue captures after the app closes' (#251) from timmy/250-background-issue-sync into main
CI / lint (push) Successful in 24s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 6s
2026-08-08 01:45:29 +00:00
timmy a4beca2d9e feat: deliver offline issue captures after app close (#250)
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 4s
Closes #250
2026-08-08 01:44:20 +00:00
timmy 5f3f0176f1 Keep authored actions responsive during ledger contention (#249)
CI / lint (push) Successful in 21s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Closes #248
2026-08-08 01:02:39 +00:00
timmy b3c76dcd69 fix: keep authored actions responsive under ledger contention (#248)
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 01:01:31 +00:00
rockachopa 540584a417 Merge pull request 'Bound aggregate Gitea traffic and coalesce concurrent reads' (#247) from timmy/246-gitea-transport-bulkhead into main
CI / lint (push) Successful in 21s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-08 00:35:28 +00:00
timmy 4f6ba0302d feat: bound aggregate Gitea transport traffic (#246)
CI / lint (pull_request) Successful in 21s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 00:34:10 +00:00
rockachopa d7d6170c3e Merge pull request 'Add a one-tap mobile Attention queue' (#245) from timmy/244-mobile-attention-queue into main
CI / lint (push) Successful in 21s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-08 00:01:32 +00:00
timmy 51ac802d77 feat: add mobile attention queue (#244)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 00:00:21 +00:00
rockachopa e0bc7271fa Merge pull request 'Coordinate offline outboxes safely across dashboard tabs' (#243) from timmy/242-cross-tab-outbox-coordination into main
CI / lint (push) Successful in 22s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 23:35:24 +00:00
timmy ddd87e9051 feat: coordinate offline outboxes across tabs (#242)
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 23:34:21 +00:00
timmy 8b71de70bd Continue assigned reviews directly into merge (#241)
CI / lint (push) Successful in 20s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #240
2026-08-07 23:14:10 +00:00
timmy 2bd8426db8 feat: continue assigned approvals into merge (#240)
CI / lint (pull_request) Successful in 19s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 23:12:55 +00:00
rockachopa 9289434ad2 Merge pull request 'Bind offline outboxes to their originating Gitea account' (#239) from timmy/238-account-bound-outboxes into main
CI / lint (push) Successful in 20s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 22:59:05 +00:00
timmy a73b3bc773 feat: bind offline outboxes to Gitea identity (#238)
CI / lint (pull_request) Successful in 19s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 22:57:49 +00:00
rockachopa d155f0081f Merge pull request 'Add a thumb-reachable mobile task dock' (#237)
CI / lint (push) Successful in 21s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
Closes #236
2026-08-07 22:33:50 +00:00
timmy bad7d524f0 feat: add mobile task dock (#236)
CI / lint (pull_request) Successful in 21s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 22:32:41 +00:00
rockachopa ea7f3cf098 Merge pull request 'Queue authored conversation messages during connectivity failures' (#235) from timmy/234-authored-message-outbox into main
CI / lint (push) Successful in 21s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-07 22:15:48 +00:00
timmy d66c1b48ef feat: queue authored messages for reconnect (#234)
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 22:14:49 +00:00
rockachopa 8df41e2fe7 Merge pull request 'Queue and synchronize mobile issue captures offline' (#233) from timmy/232-offline-issue-outbox into main
CI / lint (push) Successful in 19s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 7s
2026-08-07 21:59:31 +00:00
timmy 51bd9d41f2 feat: sync offline issue captures (#232)
CI / lint (pull_request) Successful in 20s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 21:58:28 +00:00
timmy 7110b11c15 Merge pull request 'Keep cold launches usable during dashboard HTTP outages' (#231) from timmy/230-http-outage-offline-launch into main
CI / lint (push) Successful in 19s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 21:34:13 +00:00
timmy 3ed3a2be58 feat: survive dashboard HTTP outages (#230)
CI / lint (pull_request) Successful in 18s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 21:33:10 +00:00
timmy bb00d3cf35 Merge pull request 'Keep My Work available offline with a private read-only snapshot' (#229) from timmy/228-offline-my-work-snapshot into main
CI / lint (push) Successful in 19s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 6s
2026-08-07 21:15:32 +00:00
timmy 1b2a3c83af feat: keep My Work available offline (#228)
CI / lint (pull_request) Successful in 20s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 21:14:21 +00:00
timmy a579b95f47 Merge pull request 'Open installed dashboard offline with a versioned app shell' (#227) from timmy/226-offline-app-shell into main
CI / lint (push) Successful in 18s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 20:57:32 +00:00
timmy 6bf5512b47 feat: open mobile dashboard offline (#226)
CI / lint (pull_request) Successful in 19s
CI / build-frontend (pull_request) Successful in 6s
2026-08-07 20:56:13 +00:00
rockachopa 1c2dbd2168 Merge pull request 'Scope mobile share installation to the dashboard route' (#225)
CI / lint (push) Successful in 18s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-07 20:27:07 +00:00
timmy a25b547d20 fix: scope mobile share install to dashboard path (#223)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 20:26:14 +00:00
rockachopa 4499a7f947 Merge pull request 'Capture shared mobile content as a planned issue' (#224)
CI / lint (push) Successful in 18s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 20:23:35 +00:00
timmy f655a5115a feat: capture shared mobile content (#223)
CI / lint (pull_request) Successful in 19s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 20:22:21 +00:00
timmy bca27f0330 Merge pull request 'Plan new mobile issues at capture time' (#222) from timmy/221-plan-new-issues-at-capture into main
CI / lint (push) Successful in 18s
Release / release-candidate (push) Successful in 6s
CI / build-frontend (push) Successful in 6s
2026-08-07 19:19:38 +00:00
timmy 19dfc93b85 feat: plan issues during mobile capture (#221)
CI / lint (pull_request) Successful in 18s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 19:18:27 +00:00
timmy ce26f54618 Merge pull request 'Read complete unread-update conversations from mobile My Work' (#220) from timmy/219-unread-update-conversations into main
CI / lint (push) Successful in 20s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Read complete unread-update conversations from mobile My Work (#220)
2026-08-07 18:49:43 +00:00
timmy ea909ef222 feat: read full unread update conversations (#219)
CI / lint (pull_request) Successful in 20s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 18:48:37 +00:00
timmy 3824ff180a Merge pull request 'Make assigned pull request sheets read-first with on-demand review' (#218) from timmy/217-read-first-pull-sheet into main
CI / lint (push) Successful in 18s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-07 18:01:16 +00:00
42 changed files with 5808 additions and 152 deletions

No files matched your search

+98 -9
View File
@@ -16,37 +16,62 @@ python3 -m pip install -r requirements.txt
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, discover, claim, and release issue assignments,
set or clear due dates on assigned issues, create issue comments, close assigned issues,
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue
creation and assignment permission. Issue capture and authored mobile actions (issue
creation and assignment permission. The New issue sheet can optionally select an open
repository milestone and due date; the dashboard validates both and sends them with
self-assignment in the single create request, so planned work appears in its release
lane immediately. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. Results
are coordinated through a bounded SQLite ledger. 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`. Direct API callers should preserve the `Idempotency-Key`
header with the unchanged route and payload until a `201` response is confirmed. Closing an assigned issue, native Comment,
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
default. Tune it with `STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS`; keep the value below
route deadlines. Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
If contention occurs after the upstream mutation, the dashboard fails closed with
`Retry-After: 5` and asks the caller to verify the result before retrying. Direct API callers
should preserve the `Idempotency-Key` header with the unchanged route and payload until a
`201` response is confirmed. Closing an assigned issue, native Comment,
Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. Native Comment, Approve, and Request changes reviews support
head-scoped draft comments anchored to changed lines; the dashboard validates each
comment path and submits the summary, decision, and inline comments in one review
request. The dashboard rechecks the current pull-request head, CI success, draft
state, and mergeability immediately before every merge.
Serve the dashboard
only to trusted users on its own origin; cross-origin API
access is intentionally disabled. Then start the API and bundled frontend:
Serve the dashboard only to trusted users on its own origin; cross-origin API
access is intentionally disabled. For any deployment not already behind an
authenticated gateway, configure the built-in single-operator boundary with two
independent high-entropy secrets. The access token is entered at `/login`; the
browser receives only a short-lived signed session and CSRF proof. Generate the
values in your secret manager (for example, `openssl rand -hex 32`) and inject
them at runtime—never commit them:
```bash
export GITEA_URL='https://forge.example.com'
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
# Optional; defaults to eight hours.
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
uvicorn src.main:app --host 127.0.0.1 --port 8000
```
Open `http://127.0.0.1:8000/` for the dashboard. To verify the backend and its
Gitea connection directly, request
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath.
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
removing unrelated forge preferences. Rotate either dashboard secret by replacing
the injected value and restarting the service; changing the signing secret expires
all sessions immediately.
Open `http://127.0.0.1:8000/` only for an unprotected local development run. To
verify the backend and its Gitea connection directly, sign in and request
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard
to search commands plus issues and pull requests across every repository visible to
@@ -65,6 +90,70 @@ API process is running and does not contact Gitea. GET `/readyz` is the
readiness check: it validates the configured Gitea credentials and returns
HTTP 503 with an error when Gitea is unavailable or authentication fails.
The application-lifetime Gitea transport bounds aggregate upstream traffic and
coalesces identical concurrent GETs without caching completed responses. By
default, at most eight requests run at once, with one slot reserved for authored
mutations so polling and detail-read bursts cannot starve comments, reviews, or
other writes. Requests that cannot enter within 250 ms fail as retryable HTTP
503 responses. Tune these limits with `GITEA_MAX_CONCURRENCY` (minimum `2`) and
`GITEA_ADMISSION_TIMEOUT_SECONDS` (minimum `0.001`); keep the admission timeout
below the route deadlines. Streaming diff reads share the same read capacity,
while POST, PATCH, PUT, and DELETE requests are never coalesced.
## Offline mobile shell
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,
**Search**, and **Drafts** thumb-reachable. It reuses the existing sheets and My Work
filter, preserves the selected release lane, shows the current draft count, respects
the device safe area, and moves out of the way while a full-screen task is open.
Desktop layout is unchanged.
My Work also has a local **Later** queue. **Later today** defers an item for four
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone.
Deferred items leave normal and Attention queues without marking notifications read
or changing any Gitea issue or pull request. They automatically return to their
existing priority position at the wake time, and **Bring back now** restores them
early. Later state is stored only in this browser, scoped to the confirmed Gitea
login, and removed when fully loaded work confirms that an item no longer exists.
After one successful online load, the installed dashboard precaches a versioned,
subpath-scoped application shell. During a network outage or a dashboard HTTP
`500`, `502`, `503`, or `504` response, navigation falls back to that shell when
it is available so local issue and review drafts remain reachable. Authentication
and other client-error responses are never hidden. A visible, accessible offline
or server-outage notice distinguishes cached mode from live Gitea data, and the
dashboard keeps polling until it can restore its live snapshot automatically.
Users can explicitly enable **Keep My Work available offline**. Each healthy live
refresh then stores a seven-day, versioned snapshot containing only the signed-in
user identity and queue-card metadata for issues, pull requests, unread updates,
and pagination totals. Bodies, comments, diffs, credentials, repository catalogs,
events, and complete API responses are excluded. A cold offline launch labels the
saved time and renders this snapshot read-only; opening live details, pagination,
and server mutations remain disabled until reconnection. **Clear offline work
data** deletes the snapshot, and opting out deletes it automatically.
API responses and mutations are never cached by the service worker. New issue captures,
issue comments, pull-request comments, and unread-update replies use bounded local
outboxes when connectivity or a retryable server failure prevents delivery. Issue
captures and authored messages are also mirrored into account-bound IndexedDB lanes
and registered with Background Sync, so a supporting installed browser can deliver
new issues, issue comments, pull-request comments, and unread-update replies after
every dashboard client has closed. The worker verifies the current Gitea login, shares
an atomic delivery claim with the foreground path, and preserves the original
idempotency key. Browsers without
IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows
queued and needs-attention messages with explicit send/discard controls; reopening the
dashboard reconciles worker completions and permanent failures into the visible outbox.
Foreground delivery flushes messages sequentially with their original idempotency keys.
Tabs coordinate each queue through the browser lock manager (with an expiring local
lease fallback), so an installed PWA and a browser tab cannot submit the same operation
concurrently. Queue changes are broadcast to other tabs and refresh Drafts without
polling; an abandoned lease can be reclaimed after expiry. State-sensitive actions such
as reviews, merges, closures, labels, milestones, and assignments are never queued.
Service-worker upgrades are atomic and remove only older
`stackchain-dashboard-*` caches, preserving unrelated caches on the same origin.
Run the test suite with:
```bash
+188
View File
@@ -0,0 +1,188 @@
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
const storageKey = 'stackchain.authored-outbox.v1';
const makeId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']);
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && supportedKinds.has(item.kind));
} catch (_error) { return []; }
}
function write(items) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
coordinator?.notify('authored');
if (backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items, 'authored'))
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
}
}
function enqueue(message) {
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
const items = read();
const requestedOperationId = String(message.operationId || '').slice(0, 128);
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
if (existing) return { ...existing };
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 = {
id,
operationId: requestedOperationId || id,
kind: message.kind,
repository: String(message.repository || ''),
number: Number(message.number || 0),
notificationId: Number(message.notificationId || 0),
body: String(message.body || ''),
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
};
items.push(item);
write(items);
return item;
}
function update(id, changes) {
let updated = null;
write(read().map(item => {
if (item.id !== id) return item;
const body = String(changes?.body ?? item.body);
updated = {
...item,
body,
operationId: body === item.body ? item.operationId : String(makeId()).slice(0, 128),
status: 'queued',
};
delete updated.error;
return updated;
}));
return updated;
}
function discard(id) {
const items = read();
if (!items.some(item => item.id === id)) return false;
write(items.filter(item => item.id !== id));
return true;
}
function endpoint(item) {
if (item.kind === 'update-reply') {
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
}
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
}
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
const request = (async () => {
try {
let result;
if (backgroundSync?.send) {
const delivery = await backgroundSync.send(item, currentLogin);
if (delivery.attention) {
const error = delivery.error || new Error('Message needs attention');
error.status = Number(error.status || 422);
throw error;
}
result = delivery.message;
} else {
result = await fetchJson(endpoint(item), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
});
}
if (!result) return { blocked: true };
discard(item.id);
return { result };
} catch (error) {
const status = Number(error?.status || 0);
const permanent = status >= 400 && status < 500;
if (permanent) {
write(read().map(candidate => candidate.id === item.id ? {
...candidate,
status: 'attention',
error: String(error.message || 'Message needs attention').slice(0, 240),
} : candidate));
}
return { error, transient: !permanent };
}
})();
pending.set(item.id, request);
try { return await request; }
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
}
async function flushQueue(currentLogin) {
const confirmed = [];
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
if (item.status === 'attention') continue;
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
const outcome = await sendItem(item, currentLogin);
if (outcome.result) confirmed.push(outcome.result);
if (outcome.transient) break;
}
return { confirmed, remaining: read(), blocked };
}
async function flush(currentLogin) {
if (coordinator) return coordinator.runExclusive('authored', () => flushQueue(currentLogin));
return flushQueue(currentLogin);
}
async function retryItem(id, currentLogin) {
const item = read().find(candidate => candidate.id === id);
if (!item) return { confirmed: [], remaining: read() };
currentLogin = String(currentLogin || '').trim();
if (!currentLogin || item.ownerLogin !== currentLogin) {
return { confirmed: [], remaining: read(), blocked: 1 };
}
const queued = item.status === 'attention' ? update(id, item) : item;
const outcome = await sendItem(queued, currentLogin);
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 };
}
async function retry(id, currentLogin) {
if (coordinator) return coordinator.runExclusive('authored', () => retryItem(id, currentLogin));
return retryItem(id, currentLogin);
}
function reconcileBackground(records) {
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
if (background?.status === 'sent') return [];
if (background?.status === 'attention') return [{
...item,
status: 'attention',
error: String(background.error || 'Message needs attention').slice(0, 240),
}];
return [item];
});
write(items);
return items;
}
return { enqueue, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
+234
View File
@@ -0,0 +1,234 @@
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-outbox-v1') {
let databasePromise;
function database() {
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains('issues')) {
request.result.createObjectStore('issues', { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return databasePromise;
}
const requested = request => new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return async work => {
const db = await database();
return new Promise((resolve, reject) => {
const transaction = db.transaction('issues', 'readwrite');
const objectStore = transaction.objectStore('issues');
let result;
let failed = false;
transaction.oncomplete = () => failed ? undefined : resolve(result);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted'));
Promise.resolve(work({
getAll: () => requested(objectStore.getAll()),
put: value => requested(objectStore.put(value)),
delete: id => requested(objectStore.delete(id)),
})).then(value => { result = value; }).catch(error => {
failed = true;
try { transaction.abort(); } catch (_abortError) { reject(error); }
reject(error);
});
});
};
}
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
const transact = transaction || createIndexedDbTransaction(indexedDB);
async function reconcile(items, outboxLane = 'issue') {
return transact(async records => {
const existing = await records.getAll();
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
for (const current of existing) {
const currentLane = current.outboxLane || 'issue';
if (currentLane !== outboxLane) continue;
const replacement = incoming.get(current.id);
if (!replacement) {
if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) {
await records.delete(current.id);
}
continue;
}
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(current.status === 'attention' && replacement.status === 'attention') ||
current.status === 'sent') {
incoming.set(current.id, current);
}
}
for (const item of incoming.values()) await records.put(item);
});
}
async function claimNext(ownerLogin) {
return transact(async records => {
const timestamp = Number(now());
const items = await records.getAll();
const item = items.find(candidate => candidate.ownerLogin === ownerLogin &&
(candidate.status === 'queued' || candidate.status === 'sending') &&
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
if (!item) return null;
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
await records.put(claimed);
return claimed;
});
}
async function claim(id, ownerLogin) {
return transact(async records => {
const timestamp = Number(now());
const item = (await records.getAll()).find(candidate => candidate.id === id);
if (!item || item.ownerLogin !== ownerLogin ||
!['queued', 'sending'].includes(item.status) ||
(item.status === 'sending' && Number(item.claimUntil) > timestamp)) return null;
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
await records.put(claimed);
return claimed;
});
}
async function upsert(item) {
return transact(async records => {
const current = (await records.getAll()).find(candidate => candidate.id === item.id);
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(current.status === 'attention' && item.status === 'attention') ||
current.status === 'sent')) return current;
await records.put({ ...item });
return item;
});
}
async function update(id, transform) {
return transact(async records => {
const item = (await records.getAll()).find(candidate => candidate.id === id);
if (item) await records.put(transform(item));
});
}
return {
reconcile,
upsert,
claim,
claimNext,
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
snapshot: () => transact(records => records.getAll()),
countBlocked: ownerLogin => transact(async records =>
(await records.getAll()).filter(item => item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
};
}
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
function deliveryRequest(item) {
if (item.kind === 'update-reply') {
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return authoredRequest(
'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
item,
);
}
return {
url: base + 'api/v1/repos/' + repository + '/issues',
options: {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title,
body: item.body,
label_ids: item.labelIds,
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
},
};
}
function authoredRequest(url, item) {
return {
url: base + url,
options: {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
},
};
}
async function deliver(item) {
const request = deliveryRequest(item);
try {
const delivered = await fetchJson(request.url, request.options);
await store.complete(item.id);
return item.kind ? { message: delivered } : { issue: delivered };
} catch (error) {
const status = Number(error?.status || 0);
if (status >= 400 && status < 500) {
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
return { attention: true, error };
}
await store.release(item.id);
throw error;
}
}
async function send(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
await store.upsert(item);
const claimed = await store.claim(item.id, currentLogin);
if (!claimed) return { busy: true };
return deliver(claimed);
}
async function flush() {
const identity = await fetchJson(base + 'api/v1/background-identity', {
headers: { Accept: 'application/json' }, cache: 'no-store',
});
const login = String(identity?.login || '').trim();
const confirmed = [];
let attention = 0;
if (!login) return { confirmed, blocked: 0, attention };
while (true) {
const item = await store.claimNext(login);
if (!item) break;
const result = await deliver(item);
if (result.issue) confirmed.push(result.issue);
if (result.message) confirmed.push(result.message);
if (result.attention) attention += 1;
}
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
return { confirmed, blocked, attention };
}
return {
flush, send,
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
};
}
createBackgroundIssueSync.createIssueSyncStore = createIssueSyncStore;
if (typeof module !== 'undefined' && module.exports) module.exports = createBackgroundIssueSync;
if (typeof globalThis !== 'undefined') {
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
globalThis.createIssueSyncStore = createIssueSyncStore;
}
+77 -3
View File
@@ -8,25 +8,44 @@ function newIssueOperationId() {
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
}
function normalizeSharedContent(value = {}) {
const clean = input => String(input || '').replace(/\s+/g, ' ').trim();
const text = String(value.text || '').trim().slice(0, 9500);
const textSummary = clean(text);
const sentence = (textSummary.match(/^.{1,80}?[.!?](?:\s|$)/)?.[0] || textSummary.slice(0, 80)).trim();
const title = (clean(value.title) || sentence).slice(0, 240);
const url = clean(value.url).slice(0, 2000);
const body = text && url && text.includes(url) ? text : [text, url].filter(Boolean).join('\n\n');
return { title, body };
}
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
const storageKey = 'stackchain.issue-capture.v1';
const sharedStorageKey = 'stackchain.issue-share.v1';
let pending = null;
const safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
function loadStored() {
try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''};
return {
const draft = {
repository: String(parsed.repository || ''),
title: String(parsed.title || ''),
body: String(parsed.body || ''),
labelIds: safeLabelIds(parsed.labelIds),
operationId: String(parsed.operationId || '').slice(0, 128),
};
const milestoneId = safeMilestoneId(parsed.milestoneId);
const dueDate = safeDueDate(parsed.dueDate);
if (milestoneId !== null) draft.milestoneId = milestoneId;
if (dueDate) draft.dueDate = dueDate;
return draft;
} catch (_error) {
return {...emptyDraft(), operationId: ''};
}
@@ -45,7 +64,12 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
body: String(draft?.body || ''),
labelIds: safeLabelIds(draft?.labelIds),
};
const unchanged = ['repository', 'title', 'body'].every(key => previous[key] === safe[key]) &&
const milestoneId = safeMilestoneId(draft?.milestoneId);
const dueDate = safeDueDate(draft?.dueDate);
if (milestoneId !== null) safe.milestoneId = milestoneId;
if (dueDate) safe.dueDate = dueDate;
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
.every(key => (previous[key] || '') === (safe[key] || '')) &&
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
return safe;
@@ -61,6 +85,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
catch (_error) { /* Confirmed creation remains authoritative. */ }
}
function pendingSharedContent() {
try {
const parsed = JSON.parse(storage.getItem(sharedStorageKey) || 'null');
if (!parsed || typeof parsed !== 'object') return null;
const shared = normalizeSharedContent({title: parsed.title, text: parsed.body});
return shared.title || shared.body ? shared : null;
} catch (_error) {
return null;
}
}
function acceptSharedContent() {
const shared = pendingSharedContent();
if (!shared) return loadDraft();
const accepted = saveDraft({...loadDraft(), ...shared});
try { storage.removeItem(sharedStorageKey); }
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
return accepted;
}
function discardSharedContent() {
try { storage.removeItem(sharedStorageKey); }
catch (_error) { /* The existing issue draft remains authoritative. */ }
}
function stageSharedContent(value) {
const shared = normalizeSharedContent(value);
if (!shared.title && !shared.body) return {status: 'empty'};
try { storage.setItem(sharedStorageKey, JSON.stringify(shared)); }
catch (_error) { /* The caller can still use the in-page share payload. */ }
const existing = loadDraft();
if (existing.title || existing.body) return {status: 'conflict'};
acceptSharedContent();
return {status: 'ready'};
}
function loadLabels(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
const priorities = new Set(['p0', 'priority-high', 'critical']);
@@ -73,6 +133,13 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
);
}
function loadMilestones(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
return fetchJson('api/v1/repos/' + encoded + '/milestones').then(milestones =>
Array.isArray(milestones) ? milestones : []
);
}
function submit(draft) {
if (pending) return pending;
const saved = saveDraft(draft);
@@ -88,6 +155,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
},
body: JSON.stringify({
title: saved.title, body: saved.body, label_ids: saved.labelIds,
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
}),
}).then(issue => {
clearDraft();
@@ -96,7 +165,12 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
return pending;
}
return { saveDraft, loadDraft, loadLabels, submit };
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};
}
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
+25
View File
@@ -0,0 +1,25 @@
function createDetailDefer({
laterWork,
session,
close,
refresh,
focus,
announce,
formatTime = value => new Date(value).toLocaleString(),
}) {
return {
defer(item, preset) {
if (!item) return false;
const until = laterWork.presetUntil(preset);
if (!laterWork.defer(item, until)) return false;
const inSession = session.active();
if (!inSession) close();
refresh();
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
if (!inSession) focus();
return true;
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDetailDefer;
+73 -1
View File
@@ -1,4 +1,4 @@
function createDraftInbox({ storage, now = () => Date.now() }) {
function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Date.now() }) {
const indexKey = 'stackchain.draft-index.v1';
function readIndex() {
@@ -98,6 +98,68 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
return null;
}
function parseOutbox(raw) {
try {
const record = JSON.parse(raw);
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
const currentLogin = String(getCurrentLogin() || '').trim();
return record.items.filter(item =>
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
).map(item => {
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
return {
id: 'stackchain.issue-outbox.v1:' + item.id,
outbox_id: item.id,
kind: 'issue-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
label: item.status === 'attention' ? 'Needs attention' : 'Queued issue',
repository: item.repository,
title: textPreview(item.title) || 'Untitled queued issue',
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
copy_text: [item.title, item.body].filter(Boolean).join('\n\n'),
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
updated_at: Number(item.queuedAt || 0),
};
});
} catch (_error) { return []; }
}
function parseAuthoredOutbox(raw) {
try {
const record = JSON.parse(raw);
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
const currentLogin = String(getCurrentLogin() || '').trim();
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
.map(item => {
const isUpdate = item.kind === 'update-reply';
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
return {
id: 'stackchain.authored-outbox.v1:' + item.id,
outbox_id: item.id,
kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
label: item.status === 'attention' ? 'Needs attention' : 'Queued message',
repository: isUpdate ? '' : item.repository,
title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
copy_text: item.body,
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
updated_at: Number(item.queuedAt || 0),
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
{ kind:routeKind, repository:item.repository, number:item.number },
};
});
} catch (_error) { return []; }
}
function list() {
const index = readIndex();
const seen = new Set();
@@ -107,6 +169,16 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
let raw;
try { raw = storage.getItem(key); }
catch (_error) { return; }
if (key === 'stackchain.issue-outbox.v1') {
parseOutbox(raw).forEach(item => drafts.push(item));
seen.add(key);
return;
}
if (key === 'stackchain.authored-outbox.v1') {
parseAuthoredOutbox(raw).forEach(item => drafts.push(item));
seen.add(key);
return;
}
const parsed = parse(key, raw);
if (!parsed) return;
seen.add(key);
Binary file not shown.

After

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+620 -54
View File
File diff suppressed because it is too large. Load diff
+176
View File
@@ -0,0 +1,176 @@
function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) {
const storageKey = 'stackchain.issue-outbox.v1';
const operationId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && typeof item === 'object');
} catch (_error) { return []; }
}
function write(items) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
coordinator?.notify('issue');
if (backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items))
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
}
}
function enqueue(draft) {
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
const items = read();
if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.');
const item = {
id: String(operationId()).slice(0, 128),
operationId: '',
repository: String(draft?.repository || ''),
title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
};
item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId);
}
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
items.push(item);
write(items);
return item;
}
function update(id, draft) {
let updated = null;
write(read().map(item => {
if (item.id !== id) return item;
updated = {
...item,
repository: String(draft?.repository || ''), title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
status: 'queued',
};
delete updated.error;
return updated;
}));
return updated;
}
function discard(id) {
const items = read();
if (!items.some(item => item.id === id)) return false;
write(items.filter(item => item.id !== id));
return true;
}
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
const request = (async () => {
try {
let issue;
if (backgroundSync?.send) {
const delivery = await backgroundSync.send(item, currentLogin);
if (delivery.attention) {
const error = delivery.error || new Error('Issue needs attention');
error.status = Number(error.status || 422);
throw error;
}
issue = delivery.issue;
} else {
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
}
if (!issue) return { blocked: true };
discard(item.id);
return { issue };
} catch (error) {
const status = Number(error?.status || 0);
if (status >= 400 && status < 500) {
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
} : candidate));
}
return { error, transient: !(status >= 400 && status < 500) };
}
})();
pending.set(item.id, request);
try { return await request; }
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
}
async function flushQueue(currentLogin) {
const confirmed = [];
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
if (item.status === 'attention') continue;
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
const result = await sendItem(item, currentLogin);
if (result.issue) confirmed.push(result.issue);
if (result.transient) break;
}
return { confirmed, remaining: read(), blocked };
}
async function flush(currentLogin) {
if (coordinator) return coordinator.runExclusive('issue', () => flushQueue(currentLogin));
return flushQueue(currentLogin);
}
async function retryItem(id, currentLogin) {
const item = read().find(candidate => candidate.id === id);
if (!item) return { confirmed: [], remaining: read() };
currentLogin = String(currentLogin || '').trim();
if (!currentLogin || item.ownerLogin !== currentLogin) {
return { confirmed: [], remaining: read(), blocked: 1 };
}
update(id, item);
const result = await sendItem({ ...item, status: 'queued' }, currentLogin);
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
}
async function retry(id, currentLogin) {
if (coordinator) return coordinator.runExclusive('issue', () => retryItem(id, currentLogin));
return retryItem(id, currentLogin);
}
function reconcileBackground(records) {
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
if (background?.status === 'sent') return [];
if (background?.status === 'attention') return [{
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
}];
return [item];
});
write(items);
return items;
}
return { enqueue, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
+113
View File
@@ -0,0 +1,113 @@
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {} }) {
const prefix = 'stackchain.later-work.v1.';
let timer = null;
function identity(item) {
if (!item) return '';
const kind = item.is_review ? 'review' : (item.kind || 'work');
const number = Number.isInteger(item.number) ? item.number : '';
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
return [kind, item.repository || '', number, notification].join(':');
}
function storageKey() {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function read() {
const key = storageKey();
if (!key || !storage) return {};
try {
const value = JSON.parse(storage.getItem(key) || '{}');
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch (_error) {
return {};
}
}
function write(records) {
const key = storageKey();
if (!key || !storage) return false;
try {
if (Object.keys(records).length) storage.setItem(key, JSON.stringify(records));
else storage.removeItem(key);
return true;
} catch (_error) {
return false;
}
}
function defer(item, until) {
const key = storageKey();
const id = identity(item);
const wake = new Date(until);
if (!key || !id || Number.isNaN(wake.getTime()) || wake <= now()) return false;
const records = read();
records[id] = wake.toISOString();
write(records);
return true;
}
function presetUntil(preset) {
const current = new Date(now());
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
if (preset === 'tomorrow') {
const wake = new Date(current);
wake.setDate(wake.getDate() + 1);
wake.setHours(9, 0, 0, 0);
return wake;
}
return new Date(NaN);
}
function restore(item) {
const id = identity(item);
const records = read();
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
delete records[id];
write(records);
return true;
}
function schedule(wakeTimes, current) {
if (timer !== null) clearTimer(timer);
timer = null;
if (!wakeTimes.length) return;
const delay = Math.max(0, Math.min(...wakeTimes) - current);
timer = setTimer(() => {
timer = null;
onWake();
}, Math.min(delay, 2147483647));
}
function partition(items, { pruneMissing = true } = {}) {
const current = now().getTime();
const records = read();
const available = new Map((items || []).map(item => [identity(item), item]));
const retained = {};
const wakeTimes = [];
Object.entries(records).forEach(([id, wake]) => {
const wakeTime = new Date(wake).getTime();
if (!Number.isFinite(wakeTime) || wakeTime <= current) return;
if (pruneMissing && !available.has(id)) return;
retained[id] = new Date(wakeTime).toISOString();
wakeTimes.push(wakeTime);
});
if (JSON.stringify(retained) !== JSON.stringify(records)) write(retained);
schedule(wakeTimes, current);
const deferredIds = new Set(Object.keys(retained));
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
const later = (items || []).flatMap(item => {
const wake = retained[identity(item)];
return wake ? [{ ...item, deferred_until: wake }] : [];
});
return { active, later };
}
return { identity, defer, restore, presetUntil, partition };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
+21
View File
@@ -0,0 +1,21 @@
{
"name": "Stackchain Dashboard",
"short_name": "Stackchain",
"description": "Capture, plan, and complete Gitea work from mobile.",
"id": "./",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#07111f",
"theme_color": "#0b1220",
"icons": [
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
],
"share_target": {
"action": "./",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {"title": "title", "text": "text", "url": "url"}
}
}
+53
View File
@@ -0,0 +1,53 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileTaskDock = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileTaskDock(options) {
const nav = options.nav;
const buttons = options.buttons;
const overlays = options.overlays || [];
let launcher = null;
let wasHidden = false;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (key === name) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
});
}
function refreshVisibility() {
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
nav.hidden = hidden;
if (wasHidden && !hidden && launcher) launcher.focus();
wasHidden = hidden;
}
function start() {
Object.entries(buttons).forEach(([name, button]) => {
button.addEventListener('click', event => {
launcher = event.currentTarget;
select(name);
options.actions[name]();
});
});
options.observe(refreshVisibility, overlays);
refreshVisibility();
}
function updateAttention(count) {
const total = Math.max(0, Number(count) || 0);
const badge = options.attentionBadge;
if (badge) {
badge.textContent = String(total);
badge.hidden = total === 0;
}
if (buttons.work) {
buttons.work.setAttribute(
'aria-label',
total ? 'Work, ' + total + ' items need attention' : 'Work'
);
}
}
return {start, refreshVisibility, updateAttention};
});
+51 -4
View File
@@ -249,10 +249,16 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
};
}
function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose }) {
function createNotificationReader({
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
loadConversation = null,
onConversation = () => {},
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
}) {
let selected = null;
let loadVersion = 0;
let marking = false;
let conversationPager = null;
async function open(item) {
selected = item;
@@ -263,6 +269,14 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
const detail = await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false;
onDetail(detail);
if (createPager && loadConversation && detail.conversation) {
conversationPager = createPager({
loadPage: page => loadConversation(item.notification_id, page),
});
onConversation(conversationPager.reset(detail.conversation));
} else {
conversationPager = null;
}
onStatus('Update ready.');
return true;
} catch (_error) {
@@ -275,6 +289,29 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
return {
open,
appendReply(comment) {
if (!conversationPager) return false;
onConversation(conversationPager.append(comment));
return true;
},
async loadOlder() {
if (!conversationPager || !selected) return false;
const pager = conversationPager;
const version = loadVersion;
onStatus('Loading older messages…');
try {
const state = await pager.loadOlder();
if (pager !== conversationPager || version !== loadVersion) return false;
onConversation(state);
onStatus(state.comments.length + ' of ' + state.total + ' messages loaded.');
return true;
} catch (_error) {
if (pager === conversationPager && version === loadVersion) {
onStatus('Could not load older messages. Retry.');
}
return false;
}
},
async markReadAndNext(items) {
if (!selected || marking) return false;
const current = selected;
@@ -307,7 +344,7 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
}
function createNotificationReplier({
post, storage, onStatus,
post, storage, onStatus, authoredOutbox,
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
}) {
let pending = false;
@@ -343,7 +380,15 @@ function createNotificationReplier({
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
onStatus('Reply posted. You can mark this update read when ready.');
return result;
} catch (_error) {
} catch (error) {
const status = Number(error?.status || 0);
if (authoredOutbox && (!status || status >= 500)) {
authoredOutbox.enqueue({
kind: 'update-reply', notificationId: item.notification_id, body, operationId,
});
onStatus('Queued for sync when the connection returns.');
return { queued: true };
}
onStatus('Could not send reply. Your draft is safe; retry.');
return false;
} finally {
@@ -355,7 +400,8 @@ function createNotificationReplier({
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
let filtered = items;
if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
if (selectedFilter === 'attention') filtered = items.filter((item) => item.has_update || item.is_review);
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
if (selectedMilestone === 'all') return filtered;
@@ -514,6 +560,7 @@ function summarizeMyWork(items) {
function countMyWork(items) {
return {
all: items.length,
attention: items.filter((item) => item.has_update || item.is_review).length,
issue: items.filter((item) => item.kind === 'issue').length,
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
review: items.filter((item) => item.is_review).length,
+88
View File
@@ -0,0 +1,88 @@
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.createOfflineWorkStore = api;
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const ENABLED_KEY = 'stackchain.offline-work.enabled.v1';
const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1';
const VERSION = 1;
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const ITEM_FIELDS = [
'id', 'number', 'title', 'state', 'repository', 'labels', 'assignees',
'updated_at', 'due_date', 'milestone', 'url', 'work_reasons',
];
const NOTIFICATION_FIELDS = [
'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url',
];
function pick(source, fields) {
const output = {};
fields.forEach(field => {
if (source && source[field] !== undefined) output[field] = source[field];
});
return output;
}
function createOfflineWorkStore({ storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS }) {
function enabled() {
try { return storage.getItem(ENABLED_KEY) === 'true'; }
catch (_) { return false; }
}
function clear() {
try { storage.removeItem(SNAPSHOT_KEY); }
catch (_) { return false; }
return true;
}
function setEnabled(value) {
try {
storage.setItem(ENABLED_KEY, value ? 'true' : 'false');
if (!value) clear();
return true;
} catch (_) { return false; }
}
function save(snapshot) {
if (!enabled() || !snapshot?.user?.login) return false;
const record = {
version: VERSION,
user_login: String(snapshot.user.login),
saved_at: now().toISOString(),
data: {
user: pick(snapshot.user, ['login', 'full_name']),
issues: (snapshot.issues || []).map(item => pick(item, ITEM_FIELDS)),
pull_requests: (snapshot.pull_requests || []).map(item => pick(item, ITEM_FIELDS)),
notifications: (snapshot.notifications || []).map(item => pick(item, NOTIFICATION_FIELDS)),
work_pagination: snapshot.work_pagination || {},
notification_pagination: snapshot.notification_pagination || {},
},
};
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
catch (_) { return false; }
return true;
}
function load(expectedLogin) {
let record;
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
catch (_) { clear(); return null; }
const savedAt = Date.parse(record?.saved_at || '');
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
!Number.isFinite(savedAt);
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
if (invalid || expired) {
clear();
return null;
}
if (expectedLogin && record.user_login !== expectedLogin) return null;
return { ...record.data, saved_at: record.saved_at };
}
return { enabled, setEnabled, save, load, clear };
}
return createOfflineWorkStore;
});
+98
View File
@@ -0,0 +1,98 @@
function createOutboxCoordinator({
storage,
locks = globalThis.navigator?.locks,
channelFactory = typeof globalThis.BroadcastChannel === 'function' ?
() => new globalThis.BroadcastChannel('stackchain-outbox-v1') : null,
addStorageListener = globalThis.addEventListener?.bind(globalThis),
removeStorageListener = globalThis.removeEventListener?.bind(globalThis),
tabId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
now = () => Date.now(),
leaseMs = 15000,
} = {}) {
const changeKey = 'stackchain.outbox-change.v1';
const subscribers = new Set();
const channel = channelFactory ? channelFactory() : null;
let sequence = 0;
function validChange(value) {
return value && (value.queue === 'issue' || value.queue === 'authored') ? value : null;
}
function publish(change) {
subscribers.forEach(listener => listener(change));
}
if (channel) {
channel.onmessage = event => {
const change = validChange(event?.data);
if (change && change.tabId !== tabId) publish(change);
};
}
const onStorage = event => {
if (event?.key !== changeKey || !event.newValue) return;
try {
const change = validChange(JSON.parse(event.newValue));
if (change && change.tabId !== tabId) publish(change);
} catch (_error) { /* Ignore malformed cross-tab signals. */ }
};
if (addStorageListener) addStorageListener('storage', onStorage);
function notify(queue) {
const change = { queue, tabId, changedAt: Number(now()), sequence: ++sequence };
if (!validChange(change)) return;
try { storage?.setItem(changeKey, JSON.stringify(change)); } catch (_error) { /* Broadcast may still work. */ }
try { channel?.postMessage(change); } catch (_error) { /* Storage events remain available. */ }
}
function skipped() {
return { confirmed: [], remaining: [], blocked: 0, lease_skipped: true };
}
async function withFallbackLease(queue, work) {
const key = 'stackchain.outbox-lease.' + queue + '.v1';
const token = tabId + ':' + (++sequence);
const timestamp = Number(now());
try {
const current = JSON.parse(storage?.getItem(key) || 'null');
if (current?.token && Number(current.expiresAt) > timestamp) return skipped();
storage?.setItem(key, JSON.stringify({ token, expiresAt: timestamp + leaseMs }));
const claimed = JSON.parse(storage?.getItem(key) || 'null');
if (claimed?.token !== token) return skipped();
} catch (_error) {
return work();
}
try {
return await work();
} finally {
try {
const current = JSON.parse(storage?.getItem(key) || 'null');
if (current?.token === token) storage?.removeItem(key);
} catch (_error) { /* An expired lease will be reclaimed. */ }
}
}
async function runExclusive(queue, work) {
if (locks?.request) {
return locks.request('stackchain-outbox-' + queue + '-v1', { mode: 'exclusive', ifAvailable: true },
lock => lock ? work() : skipped());
}
return withFallbackLease(queue, work);
}
function subscribe(listener) {
if (typeof listener !== 'function') return () => {};
subscribers.add(listener);
return () => subscribers.delete(listener);
}
function close() {
subscribers.clear();
try { channel?.close(); } catch (_error) { /* No-op. */ }
if (removeStorageListener) removeStorageListener('storage', onStorage);
}
return { runExclusive, notify, subscribe, close };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOutboxCoordinator;
+13
View File
@@ -309,6 +309,18 @@ async function copyAndContinue({ text, url, copy, open, fallback }) {
return { copied: true, opened: true };
}
function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }) {
const reasons = Array.isArray(item?.work_reasons) ? item.work_reasons : [];
if (decision !== 'approve' || !reasons.includes('assigned_to_me') || !headSha) return false;
const filenames = Array.isArray(reviewed)
? reviewed.filter(filename => typeof filename === 'string' && filename)
: [];
const key = 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + headSha;
try { storage?.setItem(key, JSON.stringify(filenames)); }
catch (_error) { return false; }
return true;
}
createReviewController.renderDiffFile = renderDiffFile;
createReviewController.parseDiffLines = parseDiffLines;
createReviewController.toggleDiff = toggleDiff;
@@ -316,6 +328,7 @@ createReviewController.createProgress = createProgress;
createReviewController.createDraft = createDraft;
createReviewController.formatFeedback = formatFeedback;
createReviewController.copyAndContinue = copyAndContinue;
createReviewController.prepareMergeContinuation = prepareMergeContinuation;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createReviewController;
+108
View File
@@ -0,0 +1,108 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v16';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/search-preview.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/my-work.js',
BASE + 'static/later-work.js',
BASE + 'static/detail-defer.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.js',
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/pull-sheet.js',
BASE + 'static/review-sheet.js',
BASE + 'static/work-route.js',
BASE + 'static/context-poller.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/background-issue-sync.js',
];
async function sessionCsrf() {
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
headers: { Accept: 'application/json' },
});
if (!response.ok) return '';
const payload = await response.json().catch(() => ({}));
return typeof payload.csrf_token === 'string' ? payload.csrf_token : '';
}
async function fetchJson(url, options = {}) {
const requestOptions = { ...options };
const method = String(options.method || 'GET').toUpperCase();
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const headers = new Headers(options.headers || {});
const csrf = await sessionCsrf();
if (csrf) headers.set('X-CSRF-Token', csrf);
requestOptions.headers = headers;
}
const response = await fetch(new URL(url, self.location.origin), requestOptions);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error || payload.detail || 'Background issue delivery failed.');
error.status = response.status;
throw error;
}
return payload;
}
const issueSync = self.__issueSync || createBackgroundIssueSync({
store: createIssueSyncStore(), fetchJson, base: BASE,
});
self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', event => {
event.waitUntil(caches.keys().then(keys => Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
.map(key => caches.delete(key))
)).then(() => self.clients.claim()));
});
self.addEventListener('sync', event => {
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(issueSync.flush());
});
self.addEventListener('fetch', event => {
const request = event.request;
if (request.method !== 'GET' || request.url.includes('/api/')) return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).then(async response => {
const cache = await caches.open(CACHE);
if (response.ok) {
await cache.put(BASE, response.clone());
} else if (OUTAGE_STATUSES.has(response.status)) {
const cached = await cache.match(BASE);
if (cached) return cached;
}
return response;
}).catch(async () => {
const cache = await caches.open(CACHE);
return cache.match(BASE);
})
);
return;
}
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
}
});
+92
View File
@@ -0,0 +1,92 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else {
const base = new URL('./', root.location.href).pathname;
const originalFetch = root.fetch.bind(root);
const boundary = factory({
cookie: () => root.document.cookie,
origin: root.location.origin,
base,
fetchImpl: originalFetch,
localStorage: root.localStorage,
sessionStorage: root.sessionStorage,
indexedDB: root.indexedDB,
caches: root.caches,
location: root.location,
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
});
root.fetch = boundary.fetch;
const attach = () => {
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
};
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
else attach();
root.stackchainSession = boundary;
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, location,
onExpired = () => {},
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
function csrfToken() {
const entry = String(cookie?.() || '').split(';')
.map(value => value.trim())
.find(value => value.startsWith('stackchain_csrf='));
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
}
function isSameOrigin(input) {
try { return new URL(String(input?.url || input), origin).origin === origin; }
catch (_error) { return false; }
}
async function sessionFetch(input, options = {}) {
const method = String(options.method || input?.method || 'GET').toUpperCase();
const requestOptions = { ...options };
if (!SAFE_METHODS.has(method) && isSameOrigin(input)) {
const headers = new Headers(options.headers || input?.headers || {});
const csrf = csrfToken();
if (csrf) headers.set('X-CSRF-Token', csrf);
requestOptions.headers = headers;
}
const response = await fetchImpl(input, requestOptions);
if (response.status === 401) onExpired();
return response;
}
function removeDashboardStorage(storage) {
if (!storage) return;
const keys = [];
try {
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key?.startsWith('stackchain.')) keys.push(key);
}
keys.forEach(key => storage.removeItem(key));
} catch (_error) { /* Cookie invalidation still protects server data. */ }
}
async function clearPrivateDeviceData() {
removeDashboardStorage(localStorage);
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); }
catch (_error) { /* Continue clearing other dashboard state. */ }
try {
const keys = await caches?.keys?.() || [];
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));
} catch (_error) { /* A later service-worker activation can clear stale caches. */ }
}
async function signOut() {
try {
await sessionFetch(base + 'api/v1/session', { method: 'DELETE' });
} finally {
await clearPrivateDeviceData();
location.assign(base + 'login');
}
}
return { fetch: sessionFetch, signOut, clearPrivateDeviceData };
});
+102
View File
@@ -0,0 +1,102 @@
"""Signed, short-lived single-operator sessions for the dashboard boundary."""
import base64
import hashlib
import hmac
import json
import os
import secrets
import time
from dataclasses import dataclass
from fastapi import Request
SESSION_COOKIE = "stackchain_session"
CSRF_COOKIE = "stackchain_csrf"
DEFAULT_TTL_SECONDS = 8 * 60 * 60
@dataclass(frozen=True)
class Session:
csrf: str
expires_at: int
def access_token() -> str:
return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")
def enabled() -> bool:
return bool(access_token())
def _secret() -> bytes:
configured = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
if configured:
return configured.encode()
return hmac.new(
access_token().encode(), b"stackchain-dashboard-session-signing", hashlib.sha256
).digest()
def _encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def _decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def issue_session(now: int | None = None) -> tuple[str, Session]:
issued_at = int(time.time() if now is None else now)
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
session = Session(csrf=secrets.token_urlsafe(24), expires_at=issued_at + max(1, ttl))
payload = json.dumps(
{"csrf": session.csrf, "exp": session.expires_at},
separators=(",", ":"),
sort_keys=True,
).encode()
encoded = _encode(payload)
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
return f"{encoded}.{signature}", session
def verify_session(value: str | None, now: int | None = None) -> Session | None:
if not value or "." not in value or not enabled():
return None
encoded, supplied_signature = value.rsplit(".", 1)
expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
if not hmac.compare_digest(supplied_signature, expected):
return None
try:
payload = json.loads(_decode(encoded))
session = Session(csrf=payload["csrf"], expires_at=int(payload["exp"]))
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
return None
current = int(time.time() if now is None else now)
if session.expires_at <= current or not isinstance(session.csrf, str) or not session.csrf:
return None
return session
def request_session(request: Request) -> Session | None:
return verify_session(request.cookies.get(SESSION_COOKIE))
def cookie_path(request: Request) -> str:
root_path = request.scope.get("root_path", "").rstrip("/")
return root_path or "/"
def application_path(request: Request) -> str:
path = request.url.path
root_path = request.scope.get("root_path", "").rstrip("/")
if root_path and (path == root_path or path.startswith(root_path + "/")):
return path[len(root_path):] or "/"
return path
def same_origin(request: Request) -> bool:
origin = request.headers.get("origin", "")
expected = f"{request.url.scheme}://{request.url.netloc}"
return bool(origin) and hmac.compare_digest(origin.rstrip("/"), expected)
+192 -10
View File
@@ -2,6 +2,7 @@ import asyncio
import os
import re
import shlex
from contextlib import asynccontextmanager
from typing import Any
from urllib.parse import urlsplit
@@ -12,7 +13,116 @@ GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
REVIEW_DIFF_MAX_BYTES = 64 * 1024
REVIEW_DIFF_MAX_LINES = 400
AVAILABLE_ISSUE_PAGE_CONCURRENCY = 3
_client: httpx.AsyncClient | None = None
_client: "GiteaTransport | None" = None
class GiteaOverloadedError(RuntimeError):
"""Raised when bounded transport admission expires before capacity is available."""
class GiteaTransport:
"""Application-lifetime HTTP transport with single-flight concurrent reads."""
def __init__(
self,
*,
max_concurrency: int = 8,
admission_timeout: float = 0.25,
**kwargs,
) -> None:
max_concurrency = max(2, max_concurrency)
admission_timeout = max(0.001, admission_timeout)
self._http = httpx.AsyncClient(base_url=GITEA_URL, timeout=10, **kwargs)
self.max_concurrency = max_concurrency
self.admission_timeout = admission_timeout
self._request_slots = asyncio.Semaphore(max(2, max_concurrency))
self._read_slots = asyncio.Semaphore(max(1, max_concurrency - 1))
self._reads: dict[tuple, asyncio.Task[httpx.Response]] = {}
@property
def is_closed(self) -> bool:
return self._http.is_closed
def _read_key(self, url: str, kwargs: dict) -> tuple:
params = tuple(httpx.QueryParams(kwargs.get("params", {})).multi_items())
headers = tuple(sorted(httpx.Headers(kwargs.get("headers", {})).multi_items()))
return url, params, headers
async def _acquire(self, semaphore: asyncio.Semaphore) -> None:
try:
await asyncio.wait_for(semaphore.acquire(), timeout=self.admission_timeout)
except TimeoutError as exc:
raise GiteaOverloadedError("Gitea transport is saturated") from exc
async def _perform_get(self, url: str, kwargs: dict) -> httpx.Response:
await self._acquire(self._read_slots)
try:
await self._acquire(self._request_slots)
try:
return await self._http.get(url, **kwargs)
finally:
self._request_slots.release()
finally:
self._read_slots.release()
async def get(self, url: str, **kwargs) -> httpx.Response:
key = self._read_key(url, kwargs)
task = self._reads.get(key)
if task is None:
task = asyncio.create_task(self._perform_get(url, kwargs))
self._reads[key] = task
task.add_done_callback(
lambda completed, request_key=key: (
self._reads.pop(request_key, None)
if self._reads.get(request_key) is completed else None
)
)
return await asyncio.shield(task)
async def _mutate(self, method: str, url: str, kwargs: dict) -> httpx.Response:
await self._acquire(self._request_slots)
try:
return await self._http.request(method, url, **kwargs)
finally:
self._request_slots.release()
async def post(self, url: str, **kwargs) -> httpx.Response:
return await self._mutate("POST", url, kwargs)
async def patch(self, url: str, **kwargs) -> httpx.Response:
return await self._mutate("PATCH", url, kwargs)
async def put(self, url: str, **kwargs) -> httpx.Response:
return await self._mutate("PUT", url, kwargs)
async def delete(self, url: str, **kwargs) -> httpx.Response:
return await self._mutate("DELETE", url, kwargs)
@asynccontextmanager
async def stream(self, method: str, url: str, **kwargs):
await self._acquire(self._read_slots)
try:
await self._acquire(self._request_slots)
try:
async with self._http.stream(method, url, **kwargs) as response:
yield response
finally:
self._request_slots.release()
finally:
self._read_slots.release()
def __getattr__(self, name: str):
return getattr(self._http, name)
async def aclose(self) -> None:
tasks = list(self._reads.values())
for task in tasks:
if not task.done():
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._reads.clear()
await self._http.aclose()
class WorkItems(list[dict]):
@@ -58,14 +168,19 @@ def _auth() -> dict[str, str]:
return headers
def start_client(**kwargs) -> httpx.AsyncClient:
def start_client(**kwargs) -> GiteaTransport:
"""Create the application-lifetime Gitea transport."""
global _client
_client = httpx.AsyncClient(base_url=GITEA_URL, timeout=10, **kwargs)
kwargs.setdefault("max_concurrency", int(os.getenv("GITEA_MAX_CONCURRENCY", "8")))
kwargs.setdefault(
"admission_timeout",
float(os.getenv("GITEA_ADMISSION_TIMEOUT_SECONDS", "0.25")),
)
_client = GiteaTransport(**kwargs)
return _client
def _get_client() -> httpx.AsyncClient:
def _get_client() -> GiteaTransport:
if _client is None or _client.is_closed:
return start_client()
return _client
@@ -533,8 +648,30 @@ async def notification_detail(thread_id: int) -> dict:
subject = subject_value if isinstance(subject_value, dict) else {}
subject_path = _gitea_api_path(subject.get("url"))
comment_path = _gitea_api_path(subject.get("latest_comment_url"))
subject_detail = await fetch(subject_path) if subject_path else {}
comment = await fetch(comment_path) if comment_path else {}
conversation_match = re.fullmatch(
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
)
repository_name = repository.get("full_name")
supported_conversation = (
conversation_match
and conversation_match.group(1) == repository_name
and subject.get("type") in {"Issue", "Pull"}
)
if supported_conversation:
assert conversation_match is not None
subject_detail, comment, conversation = await asyncio.gather(
fetch(subject_path),
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
issue_conversation_page(
conversation_match.group(1), int(conversation_match.group(3))
),
)
else:
subject_detail, comment, conversation = await asyncio.gather(
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
)
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
comment = comment if isinstance(comment, dict) else {}
user_value = comment.get("user")
@@ -573,9 +710,33 @@ async def notification_detail(thread_id: int) -> dict:
else "",
"url": latest_url,
},
"conversation": conversation,
}
async def notification_conversation_page(
thread_id: int, page: int, limit: int = 20
) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
raise ValueError("Gitea notification thread response was not an object")
repository = thread.get("repository")
subject = thread.get("subject")
if not isinstance(repository, dict) or not isinstance(subject, dict):
raise ValueError("Notification does not identify a conversation")
subject_path = _gitea_api_path(subject.get("url"))
match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path)
if (
not match
or match.group(1) != repository.get("full_name")
or subject.get("type") not in {"Issue", "Pull"}
):
raise ValueError("Notification subject is not a supported conversation")
return await issue_conversation_page(
match.group(1), int(match.group(3)), page=page, limit=limit
)
async def reply_to_notification(thread_id: int, body: str) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
@@ -604,10 +765,7 @@ async def reply_to_notification(thread_id: int, body: str) -> dict:
comment = response.json()
if not isinstance(comment, dict):
raise ValueError("Gitea comment response was not an object")
return {
"id": comment.get("id"),
"url": _safe_web_url(comment.get("html_url")),
}
return _normalize_issue_comment(comment)
async def close_issue(repository: str, number: int) -> dict:
@@ -708,10 +866,16 @@ async def create_issue(
body: str,
assignee: str,
label_ids: list[int] | None = None,
milestone_id: int | None = None,
due_date: str | None = None,
) -> dict:
payload: dict = {"title": title, "body": body, "assignee": assignee}
if label_ids:
payload["labels"] = label_ids
if milestone_id is not None:
payload["milestone"] = milestone_id
if due_date is not None:
payload["due_date"] = due_date
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues",
headers=_auth(),
@@ -732,6 +896,22 @@ async def create_issue(
raise ValueError("Gitea did not confirm issue self-assignment")
labels_value = issue.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
milestone_value = issue.get("milestone")
milestone = (
{"id": milestone_value["id"], "title": milestone_value["title"]}
if isinstance(milestone_value, dict)
and isinstance(milestone_value.get("id"), int)
and isinstance(milestone_value.get("title"), str)
else None
)
confirmed_due_date = (
issue.get("due_date") if isinstance(issue.get("due_date"), str) else None
)
if (
(milestone_id is not None and (milestone or {}).get("id") != milestone_id)
or (due_date is not None and confirmed_due_date != due_date)
):
raise ValueError("Gitea did not confirm issue release plan")
return {
"id": issue.get("id"),
"number": issue["number"],
@@ -748,6 +928,8 @@ async def create_issue(
if isinstance(item, dict) and isinstance(item.get("name"), str)
],
"assignees": confirmed_assignees,
"milestone": milestone,
"due_date": confirmed_due_date,
"updated_at": issue.get("updated_at", "")
if isinstance(issue.get("updated_at"), str)
else "",
+61 -43
View File
@@ -12,6 +12,10 @@ class Reservation:
response: Any = None
class IdempotencyLedgerBusy(RuntimeError):
"""Raised when a durable completion cannot acquire the SQLite write lock."""
class IdempotencyLedger:
"""Small durable ledger for replaying successful Gitea mutations."""
@@ -21,18 +25,22 @@ class IdempotencyLedger:
*,
ttl_seconds: float,
max_entries: int,
lock_timeout_seconds: float = 0.1,
clock: Callable[[], float] = time.time,
) -> None:
self.path = Path(path)
self.ttl_seconds = ttl_seconds
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=5)
connection.execute("PRAGMA busy_timeout = 5000")
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
connection.execute(
f"PRAGMA busy_timeout = {max(1, int(self.lock_timeout_seconds * 1000))}"
)
return connection
def _initialize(self) -> None:
@@ -61,43 +69,48 @@ class IdempotencyLedger:
def reserve(self, key: str, fingerprint: tuple[Any, ...]) -> Reservation:
encoded = self._fingerprint(fingerprint)
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"DELETE FROM idempotency_operations "
"WHERE status = 'completed' AND completed_at <= ?",
(now - self.ttl_seconds,),
)
row = connection.execute(
"SELECT fingerprint, status, response_json FROM idempotency_operations "
"WHERE key = ?",
(key,),
).fetchone()
if row is not None:
if row[0] != encoded:
return Reservation("conflict")
if row[1] == "completed":
return Reservation("completed", json.loads(row[2]))
return Reservation("pending")
count = connection.execute(
"SELECT COUNT(*) FROM idempotency_operations"
).fetchone()[0]
if count >= self.max_entries:
completed = connection.execute(
"SELECT key FROM idempotency_operations "
"WHERE status = 'completed' ORDER BY completed_at, rowid LIMIT 1"
).fetchone()
if completed is None:
return Reservation("busy")
try:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"DELETE FROM idempotency_operations WHERE key = ?",
(completed[0],),
"DELETE FROM idempotency_operations "
"WHERE status = 'completed' AND completed_at <= ?",
(now - self.ttl_seconds,),
)
connection.execute(
"INSERT INTO idempotency_operations "
"(key, fingerprint, status, created_at) VALUES (?, ?, 'pending', ?)",
(key, encoded, now),
)
row = connection.execute(
"SELECT fingerprint, status, response_json FROM idempotency_operations "
"WHERE key = ?",
(key,),
).fetchone()
if row is not None:
if row[0] != encoded:
return Reservation("conflict")
if row[1] == "completed":
return Reservation("completed", json.loads(row[2]))
return Reservation("pending")
count = connection.execute(
"SELECT COUNT(*) FROM idempotency_operations"
).fetchone()[0]
if count >= self.max_entries:
completed = connection.execute(
"SELECT key FROM idempotency_operations "
"WHERE status = 'completed' ORDER BY completed_at, rowid LIMIT 1"
).fetchone()
if completed is None:
return Reservation("busy")
connection.execute(
"DELETE FROM idempotency_operations WHERE key = ?",
(completed[0],),
)
connection.execute(
"INSERT INTO idempotency_operations "
"(key, fingerprint, status, created_at) VALUES (?, ?, 'pending', ?)",
(key, encoded, now),
)
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
return Reservation("busy")
raise
return Reservation("reserved")
def clear(self) -> None:
@@ -106,9 +119,14 @@ class IdempotencyLedger:
def complete(self, key: str, response: Any) -> None:
encoded = json.dumps(response, separators=(",", ":"), sort_keys=True)
with self._connect() as connection:
connection.execute(
"UPDATE idempotency_operations SET status = 'completed', "
"response_json = ?, completed_at = ? WHERE key = ?",
(encoded, self.clock(), key),
)
try:
with self._connect() as connection:
connection.execute(
"UPDATE idempotency_operations SET status = 'completed', "
"response_json = ?, completed_at = ? WHERE key = ?",
(encoded, self.clock(), key),
)
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
raise IdempotencyLedgerBusy from exc
raise
+223 -19
View File
@@ -1,18 +1,20 @@
import asyncio
import hmac
import math
import os
import time
from collections.abc import Awaitable, Coroutine
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query
from fastapi.responses import JSONResponse
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
from src import gitea_proxy
from src import dashboard_auth, gitea_proxy
from src.gitea_proxy import (
activity_events,
current_user,
@@ -25,7 +27,7 @@ from src.gitea_proxy import (
pull_review_detail,
repos,
)
from src.idempotency import IdempotencyLedger
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
from src.models import Issue, Milestone, PullRequest, Repo, User
from src.suggestion_engine import compute
from src.views import router as frontend_router
@@ -65,6 +67,9 @@ ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS = 600.0
ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES = 256
AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS = 600.0
AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES = 256
AUTHORED_ACTION_LEDGER_LOCK_TIMEOUT_SECONDS = float(
os.getenv("STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS", "0.1")
)
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
WORK_PAGE_TIMEOUT_SECONDS = 5.0
@@ -104,6 +109,7 @@ _idempotency_ledger = IdempotencyLedger(
AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES
+ ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES
),
lock_timeout_seconds=AUTHORED_ACTION_LEDGER_LOCK_TIMEOUT_SECONDS,
)
_available_issue_snapshot_task: asyncio.Task | None = None
_available_issue_snapshot_value: list[dict] | None = None
@@ -119,6 +125,10 @@ class ReadinessPayloadError(ValueError):
"""Raised when Gitea returns a structurally invalid readiness payload."""
class DashboardSignIn(BaseModel):
access_token: str = Field(min_length=1, max_length=1_024)
class NotificationReadBatch(BaseModel):
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
@@ -151,6 +161,12 @@ class IssueCreation(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
label_ids: list[int] = Field(default_factory=list, max_length=20)
milestone_id: PositiveInt | None = None
due_date: str | None = Field(
default=None,
pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$",
max_length=20,
)
@field_validator("title")
@classmethod
@@ -165,6 +181,13 @@ class IssueCreation(BaseModel):
def strip_issue_body(cls, value: str) -> str:
return value.strip()
@field_validator("due_date")
@classmethod
def validate_due_date(cls, value: str | None) -> str | None:
if value is not None:
datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
return value
class IssueContentUpdate(BaseModel):
title: str = Field(min_length=1, max_length=255)
@@ -250,7 +273,9 @@ async def _run_idempotent_authored_action(
if not idempotency_key:
return await asyncio.wait_for(operation, timeout=timeout)
reservation = _idempotency_ledger.reserve(idempotency_key, fingerprint)
reservation = await asyncio.to_thread(
_idempotency_ledger.reserve, idempotency_key, fingerprint
)
if reservation.state == "conflict":
operation.close()
raise HTTPException(status_code=409, detail="Idempotency key already used")
@@ -280,7 +305,7 @@ async def _run_idempotent_authored_action(
else:
async def persist_result():
result = await operation
_idempotency_ledger.complete(idempotency_key, result)
await asyncio.to_thread(_idempotency_ledger.complete, idempotency_key, result)
return result
task = asyncio.create_task(persist_result())
@@ -296,7 +321,14 @@ async def _run_idempotent_authored_action(
_authored_action_operations.pop(idempotency_key, None)
task.add_done_callback(discard_completed)
return await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
try:
return await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
except IdempotencyLedgerBusy:
raise HTTPException(
status_code=503,
detail="This action may have completed; verify its result before retrying",
headers={"Retry-After": "5"},
)
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
@@ -396,21 +428,63 @@ app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
app.include_router(frontend_router)
@app.middleware("http")
async def require_operator_session(request: Request, call_next):
if not dashboard_auth.enabled():
return await call_next(request)
path = dashboard_auth.application_path(request)
public = (
path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"}
or path.startswith("/static/")
or (path == "/api/v1/session" and request.method == "POST")
)
session = dashboard_auth.request_session(request)
if not public and session is None:
if path.startswith("/api/"):
return JSONResponse(
{"detail": "Authentication required"},
status_code=401,
headers={"Cache-Control": "no-store"},
)
return RedirectResponse("login", status_code=303, headers={"Cache-Control": "no-store"})
if not public and request.method not in {"GET", "HEAD", "OPTIONS"}:
supplied_csrf = request.headers.get("x-csrf-token", "")
if (
session is None
or not dashboard_auth.same_origin(request)
or not supplied_csrf
or not hmac.compare_digest(supplied_csrf, session.csrf)
):
return JSONResponse(
{"detail": "Valid same-origin CSRF proof required"},
status_code=403,
headers={"Cache-Control": "no-store"},
)
request.state.dashboard_session = session
response = await call_next(request)
if path in {"/login", "/api/v1/session"}:
response.headers["Cache-Control"] = "no-store"
return response
@app.middleware("http")
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route"} or request.url.path.startswith("/api/v1/work/") or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review")
) or request.url.path.startswith("/api/v1/notifications") or (
request.url.path.startswith("/api/v1/repos/")
and ("/issues/" in request.url.path or "/pulls/" in request.url.path)
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"} or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
path.startswith("/api/v1/repos/")
and ("/issues/" in path or "/pulls/" in path)
) or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/issues")
path.startswith("/api/v1/repos/")
and path.endswith("/issues")
) or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith(("/labels", "/milestones"))
path.startswith("/api/v1/repos/")
and path.endswith(("/labels", "/milestones"))
):
response.headers["Cache-Control"] = "no-store"
return response
@@ -422,6 +496,68 @@ def health() -> dict[str, str]:
return {"status": "ok", "service": "stackchain-dashboard"}
@app.post("/api/v1/session")
async def sign_in(payload: DashboardSignIn, request: Request, response: Response):
configured_token = dashboard_auth.access_token()
if not configured_token or not hmac.compare_digest(
payload.access_token, configured_token
):
raise HTTPException(status_code=401, detail="Invalid access token")
signed, session = dashboard_auth.issue_session()
path = dashboard_auth.cookie_path(request)
max_age = max(1, session.expires_at - int(time.time()))
response.set_cookie(
dashboard_auth.SESSION_COOKIE,
signed,
max_age=max_age,
path=path,
secure=True,
httponly=True,
samesite="strict",
)
response.set_cookie(
dashboard_auth.CSRF_COOKIE,
session.csrf,
max_age=max_age,
path=path,
secure=True,
httponly=False,
samesite="strict",
)
response.headers["Cache-Control"] = "no-store"
return {"authenticated": True}
@app.get("/api/v1/session")
async def session_status(request: Request):
session = dashboard_auth.request_session(request)
return {
"authenticated": session is not None,
"csrf_token": session.csrf if session is not None else "",
}
@app.delete("/api/v1/session")
async def sign_out(request: Request, response: Response):
path = dashboard_auth.cookie_path(request)
response.delete_cookie(
dashboard_auth.SESSION_COOKIE,
path=path,
secure=True,
httponly=True,
samesite="strict",
)
response.delete_cookie(
dashboard_auth.CSRF_COOKIE,
path=path,
secure=True,
httponly=False,
samesite="strict",
)
response.headers["Cache-Control"] = "no-store"
return {"authenticated": False, "clear_private_device_data": True}
@app.get("/readyz")
async def readiness():
"""Return readiness after verifying the configured Gitea connection."""
@@ -455,11 +591,13 @@ async def readiness():
"Retry-After": str(max(1, math.ceil(READINESS_TIMEOUT_SECONDS)))
} if timed_out else None,
)
return {
payload = {
"status": "ready",
"service": "stackchain-dashboard",
"gitea_user": user["login"],
}
if not dashboard_auth.enabled():
payload["gitea_user"] = user["login"]
return payload
@app.get("/api/v1/context")
@@ -506,6 +644,23 @@ async def context() -> JSONResponse:
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
@app.get("/api/v1/background-identity")
async def background_identity() -> JSONResponse:
"""Return only the account key required to safely drain a browser outbox."""
try:
user = await current_user()
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login.strip():
raise ValueError("Gitea user response did not include a login")
except Exception:
return JSONResponse(
{"error": "Gitea identity is temporarily unavailable"},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse({"login": login})
@app.get("/api/v1/search")
async def global_search(
q: str = Query(min_length=2, max_length=100),
@@ -1095,6 +1250,32 @@ async def notification_thread_detail(
return JSONResponse(result)
@app.get("/api/v1/notifications/{thread_id}/conversation")
async def notification_thread_conversation(
thread_id: int = PathParam(gt=0),
page: int = Query(ge=1),
limit: int = Query(default=20, ge=1, le=50),
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.notification_conversation_page(thread_id, page, limit),
timeout=NOTIFICATION_DETAIL_TIMEOUT_SECONDS,
)
except TimeoutError:
return JSONResponse(
{"error": "Loading older messages timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "Older messages are temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/notifications/{thread_id}/read")
async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try:
@@ -1413,6 +1594,27 @@ async def create_assigned_issue(
}
if any(label_id not in valid_label_ids for label_id in creation.label_ids):
raise HTTPException(status_code=422, detail="Unknown repository label")
if creation.milestone_id is not None:
available_milestones = await gitea_proxy.repo_milestones(repository)
valid_milestone_ids = {
item.get("id")
for item in available_milestones
if isinstance(item, dict) and isinstance(item.get("id"), int)
}
if creation.milestone_id not in valid_milestone_ids:
raise HTTPException(
status_code=422, detail="Unknown open repository milestone"
)
if creation.milestone_id is not None or creation.due_date is not None:
return await gitea_proxy.create_issue(
repository,
creation.title,
creation.body,
login,
creation.label_ids,
creation.milestone_id,
creation.due_date,
)
return await gitea_proxy.create_issue(
repository, creation.title, creation.body, login, creation.label_ids
)
@@ -1427,6 +1629,8 @@ async def create_assigned_issue(
creation.title,
creation.body,
tuple(creation.label_ids),
creation.milestone_id,
creation.due_date,
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
+38 -2
View File
@@ -1,12 +1,48 @@
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse
router = APIRouter()
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
LOGIN_HTML = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign in · Stackchain Dashboard</title>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{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>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button>Sign in</button><p id="status" role="status"></p></form></main>
<script>document.getElementById('sign-in').addEventListener('submit',async(event)=>{event.preventDefault();const status=document.getElementById('status');status.textContent='Signing in…';const access_token=new FormData(event.currentTarget).get('access_token');const response=await fetch('api/v1/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({access_token})});if(response.ok){location.replace('./');return}status.textContent='Sign-in failed. Check the token and try again.';event.currentTarget.reset()});</script></body></html>"""
@router.get("/", response_class=HTMLResponse)
class RevalidatingHTMLResponse(HTMLResponse):
def __init__(self, content, status_code=200, headers=None, media_type=None, background=None):
response_headers = dict(headers or {})
response_headers["Cache-Control"] = "no-cache"
super().__init__(content, status_code, response_headers, media_type, background)
@router.get("/", response_class=RevalidatingHTMLResponse)
async def dashboard() -> str:
return DASHBOARD_FILE.read_text()
@router.get("/login", response_class=RevalidatingHTMLResponse)
async def login() -> str:
return LOGIN_HTML
@router.get("/manifest.webmanifest", response_class=FileResponse)
async def web_app_manifest() -> FileResponse:
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
@router.get("/service-worker.js", response_class=FileResponse)
async def service_worker() -> FileResponse:
return FileResponse(
SERVICE_WORKER_FILE,
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
)
+170
View File
@@ -1,4 +1,7 @@
import asyncio
import sqlite3
import threading
import time
import httpx
import pytest
@@ -228,3 +231,170 @@ async def test_orphaned_pending_comment_fails_closed_without_upstream_retry(
"This action may still be completing; verify its result before retrying"
)
assert calls == 0
@pytest.mark.anyio
async def test_slow_ledger_reservation_does_not_block_health_requests(monkeypatch):
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
return {"id": 248, "body": body}
started = threading.Event()
original_reserve = main._idempotency_ledger.reserve
def slow_reserve(key, fingerprint):
started.set()
time.sleep(0.2)
return original_reserve(key, fingerprint)
monkeypatch.setattr(main._idempotency_ledger, "reserve", slow_reserve)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
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:
started_at = asyncio.get_running_loop().time()
authored = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues/248/comments",
json={"body": "Keep the dashboard responsive"},
headers={"Idempotency-Key": "contention-248"},
))
assert await asyncio.to_thread(started.wait, 1)
health = await client.get("/healthz")
health_elapsed = asyncio.get_running_loop().time() - started_at
authored_response = await authored
assert health.status_code == 200
assert health_elapsed < 0.15
assert authored_response.status_code == 201
@pytest.mark.anyio
async def test_locked_ledger_returns_bounded_retryable_response(monkeypatch, tmp_path):
upstream_calls = 0
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
nonlocal upstream_calls
upstream_calls += 1
return {"id": 248, "body": body}
database = tmp_path / "contended.sqlite3"
ledger = IdempotencyLedger(
database,
ttl_seconds=600,
max_entries=256,
lock_timeout_seconds=0.05,
)
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
blocker = sqlite3.connect(database)
blocker.execute("BEGIN IMMEDIATE")
transport = httpx.ASGITransport(app=main.app)
try:
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
started_at = asyncio.get_running_loop().time()
response = await client.post(
"/api/v1/repos/stackchain/api/issues/248/comments",
json={"body": "Retry without freezing"},
headers={"Idempotency-Key": "locked-ledger-248"},
)
elapsed = asyncio.get_running_loop().time() - started_at
finally:
blocker.rollback()
blocker.close()
assert response.status_code == 503
assert response.headers["Retry-After"] == "1"
assert response.json()["detail"] == "Authored action queue is busy; please retry"
assert elapsed < 0.5
assert upstream_calls == 0
@pytest.mark.anyio
async def test_slow_ledger_completion_does_not_block_health_requests(monkeypatch):
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
return {"id": 249, "body": body}
started = threading.Event()
original_complete = main._idempotency_ledger.complete
def slow_complete(key, response):
started.set()
time.sleep(0.2)
return original_complete(key, response)
monkeypatch.setattr(main._idempotency_ledger, "complete", slow_complete)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
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:
started_at = asyncio.get_running_loop().time()
authored = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues/248/comments",
json={"body": "Persist without freezing"},
headers={"Idempotency-Key": "slow-completion-248"},
))
assert await asyncio.to_thread(started.wait, 1)
health = await client.get("/healthz")
health_elapsed = asyncio.get_running_loop().time() - started_at
authored_response = await authored
assert health.status_code == 200
assert health_elapsed < 0.15
assert authored_response.status_code == 201
@pytest.mark.anyio
async def test_locked_completion_fails_closed_with_retry_guidance(monkeypatch, tmp_path):
database = tmp_path / "completion-contention.sqlite3"
ledger = IdempotencyLedger(
database, ttl_seconds=600, max_entries=256, lock_timeout_seconds=0.05
)
blocker = None
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
nonlocal blocker
blocker = sqlite3.connect(database)
blocker.execute("BEGIN IMMEDIATE")
return {"id": 250, "body": body}
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
try:
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/248/comments",
json={"body": "The upstream mutation may already exist"},
headers={"Idempotency-Key": "locked-completion-248"},
)
finally:
if blocker is not None:
blocker.rollback()
blocker.close()
assert response.status_code == 503
assert response.headers["Retry-After"] == "5"
assert response.json()["detail"] == (
"This action may have completed; verify its result before retrying"
)
assert ledger.reserve(
"locked-completion-248",
("issue-comment", "stackchain/api", 248, "The upstream mutation may already exist"),
).state == "pending"
+225
View File
@@ -0,0 +1,225 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
OUTBOX = Path(__file__).parents[1] / "frontend" / "authored-outbox.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_authored_outbox_quarantines_legacy_and_cross_account_messages():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify({{version:1,items:[
{{id:'legacy',operationId:'legacy',kind:'issue-comment',repository:'o/r',number:1,body:'Old',status:'queued'}}
]}})]]);
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const calls = [];
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=>'timmy',
fetchJson:async url => {{calls.push(url); return {{id:1}};}},
}});
const bound = outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:2,body:'Mine',operationId:'mine'}});
(async()=>{{
const wrong = await outbox.flush('alexander');
const right = await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{bound,wrong,right,calls,remaining:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output["bound"]["ownerLogin"] == "timmy"
assert output["wrong"]["blocked"] == 2
assert len(output["right"]["confirmed"]) == 1
assert [item["id"] for item in output["remaining"]] == ["legacy"]
assert len(output["calls"]) == 1
def test_authored_outbox_persists_each_message_kind_and_flushes_sequentially():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const calls = [];
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=>'timmy', now:() => 1234,
fetchJson:async (url, options) => {{
calls.push({{url,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
return {{id:calls.length}};
}},
}});
outbox.enqueue({{kind:'issue-comment',repository:'stackchain/api',number:7,body:'Issue note',operationId:'issue-op'}});
outbox.enqueue({{kind:'pull-comment',repository:'stackchain/web',number:8,body:'PR note',operationId:'pull-op'}});
outbox.enqueue({{kind:'update-reply',notificationId:9,body:'Update note',operationId:'update-op'}});
const persisted = createAuthoredOutbox({{storage,fetchJson:outbox.fetchJson}}).list();
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persisted,calls,result,remaining:outbox.list()}})));
"""
output = run_node(script)
assert [item["kind"] for item in output["persisted"]] == [
"issue-comment", "pull-comment", "update-reply"
]
assert [item["operationId"] for item in output["persisted"]] == ["issue-op", "pull-op", "update-op"]
assert output["calls"] == [
{"url": "api/v1/repos/stackchain/api/issues/7/comments", "key": "issue-op", "body": {"body": "Issue note"}},
{"url": "api/v1/repos/stackchain/web/pulls/8/comments", "key": "pull-op", "body": {"body": "PR note"}},
{"url": "api/v1/notifications/9/reply", "key": "update-op", "body": {"body": "Update note"}},
]
assert len(output["result"]["confirmed"]) == 3
assert output["remaining"] == []
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let phase = 'permanent';
const calls = [];
const outbox = createAuthoredOutbox({{
storage,
getOwnerLogin:()=>'timmy',
fetchJson:async (_url, options) => {{
const body = JSON.parse(options.body).body; calls.push(body);
if (body === 'Bad' && phase === 'permanent') {{ const e = new Error('Reply rejected'); e.status=422; throw e; }}
if (body === 'Later' && phase === 'transient') {{ const e = new Error('Offline'); e.status=503; throw e; }}
return {{id:calls.length}};
}},
}});
const bad=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Bad',operationId:'a'}});
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Good',operationId:'b'}});
outbox.flush('timmy').then(async first => {{
phase='transient';
outbox.enqueue({{kind:'update-reply',notificationId:3,body:'Later',operationId:'c'}});
const second=await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{first,second,calls,remaining:outbox.list(),badId:bad.id}}));
}});
"""
output = run_node(script)
assert output["calls"] == ["Bad", "Good", "Later"]
assert len(output["first"]["confirmed"]) == 1
assert output["remaining"][0]["id"] == output["badId"]
assert output["remaining"][0]["status"] == "attention"
assert output["remaining"][0]["error"] == "Reply rejected"
assert output["remaining"][1]["status"] == "queued"
def test_authored_outbox_retry_is_single_flight_and_edit_rotates_identity():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let release; const gate=new Promise(resolve=>release=resolve); let calls=0; let sequence=0;
const outbox=createAuthoredOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'new-' + (++sequence),
fetchJson:async()=>{{calls++; await gate; return {{id:1}};}},
}});
const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Old',operationId:'old'}});
const edited=outbox.update(item.id,{{body:'New'}});
const flush=outbox.flush('timmy'); const retry=outbox.retry(item.id, 'timmy'); release();
Promise.all([flush,retry]).then(results=>process.stdout.write(JSON.stringify({{edited,calls,results,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["edited"]["body"] == "New"
assert output["edited"]["operationId"] == "new-1"
assert output["calls"] == 1
assert output["remaining"] == []
def test_authored_outbox_deduplicates_repeated_queue_attempts_by_operation_id():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy'}});
const first=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
const second=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
"""
output = run_node(script)
assert output["first"] == output["second"]
assert len(output["items"]) == 1
def test_authored_outbox_mirrors_to_background_sync_and_reconciles_worker_results():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const mirrors=[]; let requested=0;
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const backgroundSync = {{
reconcile: async (items, lane) => mirrors.push({{items:items.map(item=>({{...item}})),lane}}),
requestSync: async () => {{requested += 1;}},
}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync}});
const sent=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Sent',operationId:'sent'}});
const failed=outbox.enqueue({{kind:'update-reply',notificationId:2,body:'Fix me',operationId:'failed'}});
setTimeout(() => {{
const reconciled=outbox.reconcileBackground([
{{...sent,status:'sent'}},
{{...failed,status:'attention',error:'Reply rejected'}},
]);
setTimeout(() => process.stdout.write(JSON.stringify({{mirrors,requested,reconciled}})), 0);
}}, 0);
"""
output = run_node(script)
assert output["mirrors"][0]["lane"] == "authored"
assert [item["operationId"] for item in output["mirrors"][-1]["items"]] == ["failed"]
assert output["requested"] >= 1
assert [(item["operationId"], item["status"]) for item in output["reconciled"]] == [
("failed", "attention")
]
def test_authored_outbox_foreground_send_uses_atomic_background_delivery():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); let backgroundCalls=0; let directCalls=0;
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const backgroundSync = {{
reconcile: async()=>{{}}, requestSync:async()=>{{}},
send: async (item, owner) => {{backgroundCalls += 1; return {{message:{{id:3,owner}}}};}},
}};
const outbox=createAuthoredOutbox({{
storage,getOwnerLogin:()=>'timmy',backgroundSync,
fetchJson:async()=>{{directCalls += 1; return {{id:4}};}},
}});
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Review',operationId:'once'}});
(async()=>{{
const result=await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{backgroundCalls,directCalls,result,remaining:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output["backgroundCalls"] == 1
assert output["directCalls"] == 0
assert output["result"]["confirmed"] == [{"id": 3, "owner": "timmy"}]
assert output["remaining"] == []
@pytest.mark.anyio
async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
html = await dashboard()
assert '<script src="static/authored-outbox.js"></script>' in html
assert "const authoredOutbox = createAuthoredOutbox({" in html
assert "activeFlushLogin = contextIdentityFresh ?" in html
assert "flushAuthoredOutbox();" in html
assert "authoredOutbox.enqueue" in html
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
assert "authoredOutbox.discard(item.outbox_id)" in html
assert "if (result?.queued)" in html
assert "backgroundSync: backgroundIssueSync" in html
assert "authoredOutbox.reconcileBackground(records)" in html
+394
View File
@@ -0,0 +1,394 @@
import json
import subprocess
from pathlib import Path
import httpx
import pytest
from src import main
from src.views import dashboard
SYNC = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
def run_node(script: str) -> dict:
completed = subprocess.run(
["node", "-e", script], capture_output=True, check=True, text=True
)
return json.loads(completed.stdout)
def test_closed_app_sync_delivers_matching_issue_once_with_original_idempotency_key():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const item = {{
id:'capture-1', operationId:'capture-1', ownerLogin:'timmy', status:'queued',
repository:'stackchain/api', title:'Offline report', body:'Full context',
labelIds:[3], milestoneId:4, dueDate:'2026-08-09', queuedAt:100,
}};
const state = {{item, completed:[], released:[], failed:[], calls:[]}};
const store = {{
claimNext: async owner => state.item && state.item.ownerLogin === owner ? {{...state.item}} : null,
complete: async id => {{ state.completed.push(id); state.item = null; }},
release: async id => state.released.push(id),
fail: async (id, message) => state.failed.push({{id,message}}),
}};
const fetchJson = async (url, options = {{}}) => {{
state.calls.push({{url,options}});
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
return {{repository:'stackchain/api',number:251,title:'Offline report'}};
}};
(async () => {{
const sync = createBackgroundIssueSync({{store,fetchJson}});
const result = await sync.flush();
process.stdout.write(JSON.stringify({{state,result}}));
}})();
"""
output = run_node(script)
assert output["result"]["confirmed"][0]["number"] == 251
assert output["state"]["completed"] == ["capture-1"]
assert output["state"]["released"] == []
assert output["state"]["failed"] == []
assert output["state"]["calls"][0]["url"] == "api/v1/background-identity"
mutation = output["state"]["calls"][1]
assert mutation["url"] == "api/v1/repos/stackchain/api/issues"
assert mutation["options"]["headers"]["Idempotency-Key"] == "capture-1"
assert json.loads(mutation["options"]["body"]) == {
"title": "Offline report",
"body": "Full context",
"label_ids": [3],
"milestone_id": 4,
"due_date": "2026-08-09T23:59:59Z",
}
@pytest.mark.parametrize(
("item", "expected_url"),
[
(
{"kind": "issue-comment", "repository": "stackchain/api", "number": 7},
"api/v1/repos/stackchain/api/issues/7/comments",
),
(
{"kind": "pull-comment", "repository": "stackchain/web", "number": 8},
"api/v1/repos/stackchain/web/pulls/8/comments",
),
(
{"kind": "update-reply", "notificationId": 9},
"api/v1/notifications/9/reply",
),
],
)
def test_closed_app_sync_delivers_each_authored_message_kind(item, expected_url):
authored = {
"id": "message-op",
"operationId": "message-op",
"ownerLogin": "timmy",
"status": "queued",
"body": "Ship this reply",
**item,
}
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let queued = {json.dumps(authored)};
const calls = [];
const store = {{
claimNext: async owner => queued?.ownerLogin === owner ? (queued = null, {json.dumps(authored)}) : null,
complete: async () => {{}}, release: async () => {{}}, fail: async () => {{}},
countBlocked: async () => 0,
}};
const fetchJson = async (url, options = {{}}) => {{
calls.push({{url, options}});
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
return {{id:42}};
}};
(async () => {{
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify({{calls,result}}));
}})();
"""
output = run_node(script)
mutation = output["calls"][1]
assert mutation["url"] == expected_url
assert mutation["options"]["headers"]["Idempotency-Key"] == "message-op"
assert json.loads(mutation["options"]["body"]) == {"body": "Ship this reply"}
assert output["result"]["confirmed"] == [{"id": 42}]
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map();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}});
await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
await store.reconcile([{{id:'message',kind:'issue-comment',ownerLogin:'timmy',status:'queued'}}], 'authored');
await store.reconcile([], 'issue');
process.stdout.write(JSON.stringify(await store.snapshot()));
}})();
"""
output = run_node(script)
assert output == [
{"id": "message", "kind": "issue-comment", "ownerLogin": "timmy", "status": "queued", "outboxLane": "authored"}
]
def test_closed_app_sync_leaves_another_accounts_issue_queued():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const state = {{owners:[],mutations:0}};
const store = {{
claimNext: async owner => {{ state.owners.push(owner); return null; }},
countBlocked: async owner => owner === 'alexander' ? 1 : 0,
complete: async () => {{}},
}};
const fetchJson = async url => {{
if (url === 'api/v1/background-identity') return {{login:'alexander'}};
state.mutations += 1;
}};
(async () => {{
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify({{state,result}}));
}})();
"""
output = run_node(script)
assert output["state"] == {"owners": ["alexander"], "mutations": 0}
assert output["result"]["confirmed"] == []
assert output["result"]["blocked"] == 1
def test_transient_delivery_failure_releases_claim_and_requests_another_sync():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let item = {{id:'capture-2',operationId:'capture-2',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}};
const state = {{released:[],completed:[]}};
const store = {{
claimNext: async () => item ? (item = null, {{id:'capture-2',operationId:'capture-2',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}}) : null,
release: async id => state.released.push(id),
complete: async id => state.completed.push(id),
}};
const fetchJson = async url => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
const error = new Error('Gitea unavailable'); error.status = 503; throw error;
}};
(async () => {{
let error = null;
try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }}
catch (caught) {{ error = caught.message; }}
process.stdout.write(JSON.stringify({{state,error}}));
}})();
"""
output = run_node(script)
assert output["state"]["released"] == ["capture-2"]
assert output["state"]["completed"] == []
assert output["error"] == "Gitea unavailable"
def test_permanent_delivery_failure_marks_issue_for_foreground_attention():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let claimed = false;
const state = {{failed:[],released:[]}};
const store = {{
claimNext: async () => claimed ? null : (claimed = true, {{id:'capture-3',operationId:'capture-3',ownerLogin:'timmy',repository:'o/r',title:'Invalid',body:'',labelIds:[]}}),
fail: async (id, message) => state.failed.push({{id,message}}),
release: async id => state.released.push(id),
countBlocked: async () => 0,
}};
const fetchJson = async url => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
const error = new Error('Title is invalid'); error.status = 422; throw error;
}};
(async () => {{
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify({{state,result}}));
}})();
"""
output = run_node(script)
assert output["state"]["failed"] == [
{"id": "capture-3", "message": "Title is invalid"}
]
assert output["state"]["released"] == []
assert output["result"]["attention"] == 1
def test_issue_store_atomically_grants_one_delivery_claim():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records = new Map();
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:()=>1000,claimMs:5000}});
await store.reconcile([{{id:'same',operationId:'same',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Once',labelIds:[]}}]);
const claims = await Promise.all([store.claimNext('timmy'),store.claimNext('timmy')]);
process.stdout.write(JSON.stringify({{claims,records:[...records.values()]}}));
}})();
"""
output = run_node(script)
assert sum(claim is not None for claim in output["claims"]) == 1
assert output["records"][0]["status"] == "sending"
assert output["records"][0]["claimUntil"] == 6000
def test_foreground_and_worker_race_still_posts_one_issue():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records = new Map(); 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;
}};
const item={{id:'race',operationId:'race',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Exactly once',body:'',labelIds:[]}};
let mutations=0;
const fetchJson=async url=>{{
if(url==='api/v1/background-identity') return {{login:'timmy'}};
mutations+=1; return {{number:77}};
}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
await store.reconcile([item]);
const sync=createBackgroundIssueSync({{store,fetchJson}});
const [foreground,worker]=await Promise.all([sync.send(item,'timmy'),sync.flush()]);
process.stdout.write(JSON.stringify({{foreground,worker,mutations,remaining:[...records.values()]}}));
}})();
"""
output = run_node(script)
assert output["mutations"] == 1
assert len(output["remaining"]) == 1
assert output["remaining"][0]["status"] == "sent"
delivered = int(bool(output["foreground"].get("issue"))) + len(
output["worker"]["confirmed"]
)
assert delivered == 1
def test_foreground_send_upserts_before_claim_when_mirror_is_still_pending():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let record = null; const state={{upserts:0,mutations:0}};
const store={{
upsert:async item=>{{state.upserts+=1;record={{...item}};}},
claim:async(id,owner)=>record?.id===id&&record?.ownerLogin===owner?{{...record}}:null,
complete:async()=>{{record=null;}}, release:async()=>{{}}, fail:async()=>{{}},
}};
const fetchJson=async()=>{{state.mutations+=1;return {{number:88}};}};
(async()=>{{
const sync=createBackgroundIssueSync({{store,fetchJson}});
const result=await sync.send({{id:'early',operationId:'early',ownerLogin:'timmy',repository:'o/r',title:'Fast',body:'',labelIds:[]}},'timmy');
process.stdout.write(JSON.stringify({{state,result,record}}));
}})();
"""
output = run_node(script)
assert output["state"] == {"upserts": 1, "mutations": 1}
assert output["result"]["issue"]["number"] == 88
assert output["record"] is None
def test_completed_delivery_leaves_non_replayable_tombstone_for_next_page():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map();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}});
await store.reconcile([{{id:'done',ownerLogin:'timmy',status:'queued'}}]);
await store.claimNext('timmy');
await store.complete('done');
const replay=await store.claimNext('timmy');
const snapshot=await store.snapshot();
process.stdout.write(JSON.stringify({{replay,snapshot}}));
}})();
"""
output = run_node(script)
assert output["replay"] is None
assert output["snapshot"] == [
{"id": "done", "ownerLogin": "timmy", "status": "sent", "outboxLane": "issue", "claimUntil": 0}
]
def test_user_edit_resets_worker_attention_item_for_retry():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map([['edit',{{id:'edit',ownerLogin:'timmy',status:'attention',title:'Bad',error:'Invalid'}}]]);
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}});
await store.reconcile([{{id:'edit',ownerLogin:'timmy',status:'queued',title:'Fixed'}}]);
const claimed=await store.claimNext('timmy');
process.stdout.write(JSON.stringify(claimed));
}})();
"""
output = run_node(script)
assert output["title"] == "Fixed"
assert output["status"] == "sending"
assert "error" not in output
@pytest.mark.anyio
async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch):
calls = 0
async def user():
nonlocal calls
calls += 1
return {"id": 7, "login": "timmy", "email": "private@example.com"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/background-identity")
assert response.status_code == 200
assert response.json() == {"login": "timmy"}
assert response.headers["cache-control"] == "no-store"
assert calls == 1
@pytest.mark.anyio
async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback():
html = await dashboard()
assert '<script src="static/background-issue-sync.js"></script>' in html
assert "const backgroundIssueStore = createIssueSyncStore();" in html
assert "createBackgroundIssueSync({" in html
assert "backgroundSync: backgroundIssueSync" in html
assert "registration.sync.register('stackchain-issue-outbox-v1')" in html
assert "backgroundIssueSync.snapshot().then(records => {" in html
assert "issueOutbox.reconcileBackground(records);" in html
assert "authoredOutbox.reconcileBackground(records);" in html
assert "if ('indexedDB' in window)" in html
+186
View File
@@ -0,0 +1,186 @@
import httpx
import pytest
from src import main
@pytest.fixture
def access_control(monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
@pytest.mark.anyio
async def test_anonymous_private_request_is_rejected_before_gitea(access_control, monkeypatch):
called = False
async def user():
nonlocal called
called = True
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:
response = await client.get("/api/v1/context")
assert response.status_code == 401
assert response.json() == {"detail": "Authentication required"}
assert response.headers["cache-control"] == "no-store"
assert called is False
@pytest.mark.anyio
async def test_sign_in_creates_secure_session_without_echoing_access_token(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
assert response.status_code == 200
assert response.json() == {"authenticated": True}
cookies = response.headers.get_list("set-cookie")
assert any("stackchain_session=" in value and "HttpOnly" in value and "Secure" in value and "SameSite=strict" in value for value in cookies)
assert any("stackchain_csrf=" in value and "Secure" in value and "SameSite=strict" in value and "HttpOnly" not in value for value in cookies)
assert "correct horse battery staple" not in response.text
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_authenticated_get_reaches_private_api(access_control, monkeypatch):
async def user():
return {"id": 1, "login": "timmy", "full_name": "", "email": ""}
async def empty():
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
response = await client.get("/api/v1/context")
assert signed_in.status_code == 200
assert response.status_code == 200
assert response.json()["user"]["login"] == "timmy"
@pytest.mark.anyio
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
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"}
)
response = await client.get("/api/v1/session")
assert response.status_code == 200
assert response.json() == {
"authenticated": True,
"csrf_token": client.cookies["stackchain_csrf"],
}
assert "correct horse battery staple" not in response.text
@pytest.mark.anyio
async def test_mutation_requires_same_origin_and_session_csrf(access_control, monkeypatch):
calls = 0
async def mark_read(notification_id):
nonlocal calls
calls += 1
return {"id": notification_id, "read": True}
monkeypatch.setattr(main, "mark_notification_read", mark_read)
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"}
)
missing = await client.patch("/api/v1/notifications/7/read")
foreign = await client.patch(
"/api/v1/notifications/7/read",
headers={
"Origin": "https://evil.example",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
valid = await client.patch(
"/api/v1/notifications/7/read",
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
assert missing.status_code == 403
assert foreign.status_code == 403
assert valid.status_code == 200
assert calls == 1
@pytest.mark.anyio
async def test_logout_clears_session_and_blocks_private_routes(access_control):
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"}
)
csrf = client.cookies["stackchain_csrf"]
response = await client.delete(
"/api/v1/session",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
private = await client.get("/api/v1/background-identity")
assert response.status_code == 200
assert response.json() == {"authenticated": False, "clear_private_device_data": True}
assert private.status_code == 401
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
@pytest.mark.anyio
async def test_public_routes_remain_available_and_readiness_hides_identity(access_control, monkeypatch):
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:
health = await client.get("/healthz")
login = await client.get("/login")
ready = await client.get("/readyz")
assert health.status_code == 200
assert login.status_code == 200
assert 'name="access_token"' in login.text
assert ready.status_code == 200
assert ready.json() == {"status": "ready", "service": "stackchain-dashboard"}
@pytest.mark.anyio
async def test_subpath_deployment_scopes_routes_and_session_cookies(access_control):
transport = httpx.ASGITransport(app=main.app, root_path="/dashboard")
async with httpx.AsyncClient(
transport=transport, base_url="https://test/dashboard/"
) as client:
login = await client.get("login")
signed_in = await client.post(
"api/v1/session",
json={"access_token": "correct horse battery staple"},
)
status = await client.get("api/v1/session")
assert login.status_code == 200
assert signed_in.status_code == 200
assert status.status_code == 200
assert any(
"stackchain_session=" in value and "Path=/dashboard" in value
for value in signed_in.headers.get_list("set-cookie")
)
+81
View File
@@ -0,0 +1,81 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
ROOT = Path(__file__).resolve().parents[1]
SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict:
harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '' }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
key(index) {{ return Array.from(this.values.keys())[index] || null; }},
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
}};
const boundary = createSessionBoundary({{
cookie: () => 'other=x; stackchain_csrf=csrf-proof; theme=dark',
origin: 'https://forge.example',
base: '/dashboard/',
fetchImpl: async (url, options = {{}}) => {{
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }});
return new Response('{{}}', {{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }});
}},
localStorage: storage,
sessionStorage: storage,
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
location: {{ assign: value => {{ state.assigned = value; }} }},
}});
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
return json.loads(result.stdout)
def test_mutating_same_origin_fetch_receives_csrf_proof():
result = run_session_scenario(
"""
await boundary.fetch('/dashboard/api/v1/notifications/7/read', { method: 'PATCH' });
process.stdout.write(JSON.stringify(state));
"""
)
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_sign_out_clears_only_dashboard_private_device_state():
result = run_session_scenario(
"""
await boundary.signOut();
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
request = result["requests"][0]
assert request["url"] == "/dashboard/api/v1/session"
assert request["method"] == "DELETE"
assert request["headers"]["x-csrf-token"] == "csrf-proof"
assert "stackchain.private" in result["removed"]
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
@pytest.mark.anyio
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
html = await dashboard()
assert '<script src="static/session.js"></script>' in html
assert html.index('static/session.js') < html.index('static/markdown.js')
assert '<button id="sign-out" type="button">Sign out &amp; clear this device</button>' in html
+64
View File
@@ -102,6 +102,51 @@ process.stdout.write(JSON.stringify(drafts));
assert output[0]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 18}
def test_draft_inbox_expands_issue_outbox_items_with_actionable_states():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:1,items:[
{{id:'queued-1',repository:'stackchain/api',title:'Offline capture',body:'Context',status:'queued',queuedAt:100}},
{{id:'attention-2',repository:'stackchain/web',title:'Fix labels',body:'Details',status:'attention',error:'Unknown label',queuedAt:200}},
]}})]]);
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const drafts = createDraftInbox({{storage,now:()=>300}}).list();
process.stdout.write(JSON.stringify(drafts));
"""
output = run_node(script)
assert [item["outbox_id"] for item in output] == ["attention-2", "queued-1"]
assert [item["status"] for item in output] == ["attention", "queued"]
assert output[0]["label"] == "Needs attention"
assert output[1]["label"] == "Queued issue"
def test_draft_inbox_marks_mismatched_and_legacy_outbox_content_copy_only():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-outbox.v1', JSON.stringify({{version:2,items:[
{{id:'other',repository:'o/r',title:'Other issue',body:'Private context',ownerLogin:'alexander',status:'queued'}},
{{id:'legacy',repository:'o/r',title:'Legacy issue',body:'Old context',status:'queued'}}
]}})],
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
{{id:'mine',kind:'issue-comment',repository:'o/r',number:2,body:'My comment',ownerLogin:'timmy',status:'queued'}}
]}})],
]);
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const drafts = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list();
process.stdout.write(JSON.stringify(drafts));
"""
output = run_node(script)
by_id = {item["outbox_id"]: item for item in output}
assert by_id["other"]["quarantined"] is True
assert by_id["other"]["ownership"] == "Queued by alexander — current account is timmy"
assert by_id["other"]["copy_text"] == "Other issue\n\nPrivate context"
assert by_id["legacy"]["ownership"] == "Queued by an unknown account — current account is timmy"
assert by_id["mine"]["quarantined"] is False
@pytest.mark.anyio
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
html = await dashboard()
@@ -115,3 +160,22 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
assert '.draft-actions button { min-height:44px;' in html
assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
@pytest.mark.anyio
async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot():
html = await dashboard()
assert "let confirmedOwnerLogin = '';" in html
assert "let activeFlushLogin = '';" in html
assert "getOwnerLogin: () => confirmedOwnerLogin" in html
assert "getCurrentLogin: () => activeFlushLogin" in html
assert "const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&" in html
assert "!contextFreshness?.degraded && !contextFreshness?.revalidating;" in html
assert "activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';" in html
assert "issueOutbox.flush(activeFlushLogin)" in html
assert "authoredOutbox.flush(activeFlushLogin)" in html
assert "activeFlushLogin = '';" in html
assert 'class="draft-copy"' in html
assert "navigator.clipboard.writeText(item.copy_text)" in html
assert "item.quarantined" in html
+28
View File
@@ -136,11 +136,20 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
return httpx.Response(200, json={"body": "Deploy fails after **three** retries."})
if request.url.path.endswith("/issues/comments/9"):
return httpx.Response(200, json={
"id": 9,
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"user": {"login": "alexander"},
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
})
if request.url.path.endswith("/issues/7/comments"):
return httpx.Response(200, json=[{
"id": 9,
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"user": {"login": "alexander"},
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
}], headers={"X-Total-Count": "1"})
return httpx.Response(404)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
@@ -153,6 +162,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
]
assert result == {
"id": 42,
@@ -168,6 +178,18 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"created_at": "2026-08-06T12:30:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
},
"conversation": {
"comments": [{
"id": 9,
"author": "alexander",
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
}],
"page": 1,
"older_page": None,
"total": 1,
},
}
@@ -223,6 +245,9 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
})
return httpx.Response(201, json={
"id": 91,
"user": {"login": "timmy"},
"body": "Please retry the worker.",
"created_at": "2026-08-07T19:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
})
@@ -246,5 +271,8 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
]
assert result == {
"id": 91,
"author": "timmy",
"body": "Please retry the worker.",
"created_at": "2026-08-07T19:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
}
+192
View File
@@ -28,6 +28,198 @@ async def test_gitea_transport_is_reused_across_requests_and_closed():
assert client.is_closed
@pytest.mark.anyio
async def test_gitea_transport_coalesces_identical_concurrent_gets():
calls = 0
release = asyncio.Event()
async def handler(request):
nonlocal calls
calls += 1
await release.wait()
return httpx.Response(200, json={"path": request.url.path})
gitea_proxy.start_client(
transport=httpx.MockTransport(handler),
max_concurrency=4,
admission_timeout=0.1,
)
try:
requests = [asyncio.create_task(gitea_proxy.fetch("user")) for _ in range(20)]
await asyncio.sleep(0)
release.set()
results = await asyncio.gather(*requests)
finally:
await gitea_proxy.stop_client()
assert calls == 1
assert results == [{"path": "/api/v1/user"}] * 20
@pytest.mark.anyio
async def test_cancelling_one_coalesced_waiter_keeps_the_shared_get_running():
started = asyncio.Event()
release = asyncio.Event()
async def handler(request):
started.set()
await release.wait()
return httpx.Response(200, json={"path": request.url.path})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
owner = asyncio.create_task(gitea_proxy.fetch("user"))
try:
await started.wait()
waiter = asyncio.create_task(gitea_proxy.fetch("user"))
await asyncio.sleep(0)
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
release.set()
assert await owner == {"path": "/api/v1/user"}
finally:
release.set()
await asyncio.gather(owner, return_exceptions=True)
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_gitea_transport_rejects_reads_beyond_bounded_capacity():
active = 0
peak = 0
saturated = asyncio.Event()
release = asyncio.Event()
async def handler(request):
nonlocal active, peak
active += 1
peak = max(peak, active)
if active == 2:
saturated.set()
try:
await release.wait()
return httpx.Response(200, json={"path": request.url.path})
finally:
active -= 1
gitea_proxy.start_client(
transport=httpx.MockTransport(handler),
max_concurrency=3,
admission_timeout=0.02,
)
first = asyncio.create_task(gitea_proxy.fetch("first"))
second = asyncio.create_task(gitea_proxy.fetch("second"))
try:
await asyncio.wait_for(saturated.wait(), timeout=0.2)
with pytest.raises(gitea_proxy.GiteaOverloadedError):
await gitea_proxy.fetch("third")
finally:
release.set()
await asyncio.gather(first, second)
await gitea_proxy.stop_client()
assert peak == 2
@pytest.mark.anyio
async def test_gitea_transport_reserves_one_slot_for_mutations_and_bounds_them():
active = 0
peak = 0
reads_started = asyncio.Event()
mutation_started = asyncio.Event()
release = asyncio.Event()
async def handler(request):
nonlocal active, peak
active += 1
peak = max(peak, active)
if active == 2:
reads_started.set()
if request.method == "POST":
mutation_started.set()
try:
await release.wait()
return httpx.Response(200, json={"method": request.method})
finally:
active -= 1
client = gitea_proxy.start_client(
transport=httpx.MockTransport(handler),
max_concurrency=3,
admission_timeout=0.02,
)
reads = [
asyncio.create_task(gitea_proxy.fetch("first")),
asyncio.create_task(gitea_proxy.fetch("second")),
]
mutation = None
try:
await asyncio.wait_for(reads_started.wait(), timeout=0.2)
mutation = asyncio.create_task(client.post("/api/v1/mutate", json={"value": 1}))
await asyncio.wait_for(mutation_started.wait(), timeout=0.2)
with pytest.raises(gitea_proxy.GiteaOverloadedError):
await asyncio.wait_for(
client.post("/api/v1/mutate", json={"value": 1}), timeout=0.1
)
finally:
release.set()
await asyncio.gather(*reads, *(task for task in [mutation] if task is not None))
await gitea_proxy.stop_client()
assert peak == 3
@pytest.mark.anyio
async def test_gitea_transport_reads_bulkhead_configuration_from_environment(monkeypatch):
monkeypatch.setenv("GITEA_MAX_CONCURRENCY", "12")
monkeypatch.setenv("GITEA_ADMISSION_TIMEOUT_SECONDS", "0.75")
client = gitea_proxy.start_client(
transport=httpx.MockTransport(lambda request: httpx.Response(200, json={}))
)
try:
assert client.max_concurrency == 12
assert client.admission_timeout == 0.75
finally:
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_streaming_reads_share_the_same_bounded_read_capacity():
active = 0
saturated = asyncio.Event()
release = asyncio.Event()
async def handler(request):
nonlocal active
active += 1
if active == 2:
saturated.set()
try:
await release.wait()
return httpx.Response(200, content=b"diff")
finally:
active -= 1
gitea_proxy.start_client(
transport=httpx.MockTransport(handler),
max_concurrency=3,
admission_timeout=0.02,
)
streams = [
asyncio.create_task(gitea_proxy.fetch_text("first", 100)),
asyncio.create_task(gitea_proxy.fetch_text("second", 100)),
]
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)
finally:
release.set()
await asyncio.gather(*streams)
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_application_lifespan_opens_and_closes_gitea_transport(monkeypatch):
calls = []
+90
View File
@@ -373,6 +373,55 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
@pytest.mark.anyio
async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch):
calls = []
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def milestones(repository):
assert repository == "stackchain/api"
return [{"id": 9, "title": "August RC"}]
async def create(repository, title, body, assignee, label_ids, milestone_id, due_date):
calls.append((repository, title, body, assignee, label_ids, milestone_id, due_date))
return {
"id": 221, "number": 221, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"milestone": {"id": 9, "title": "August RC"},
"due_date": "2026-08-31T23:59:59Z",
"updated_at": "2026-08-07T20:00:00Z",
"url": "https://forge.example/stackchain/api/issues/221",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={
"title": "Ship mobile plan",
"milestone_id": 9,
"due_date": "2026-08-31T23:59:59Z",
},
)
assert response.status_code == 201
assert response.json()["milestone"] == {"id": 9, "title": "August RC"}
assert response.json()["due_date"] == "2026-08-31T23:59:59Z"
assert calls == [(
"stackchain/api", "Ship mobile plan", "", "timmy", [], 9,
"2026-08-31T23:59:59Z",
)]
@pytest.mark.anyio
async def test_create_issue_replays_one_upstream_result_for_concurrent_idempotent_requests(monkeypatch):
calls = 0
@@ -630,6 +679,26 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
assert result["labels"] == ["P0"]
@pytest.mark.anyio
async def test_gitea_create_issue_requires_confirmed_release_plan():
async def handler(_request):
return httpx.Response(201, json={
"id": 221, "number": 221, "title": "Planned work", "state": "open",
"assignees": [{"login": "timmy"}], "labels": [],
"milestone": None, "due_date": "2026-09-01T23:59:59Z",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(ValueError, match="release plan"):
await gitea_proxy.create_issue(
"stackchain/api", "Planned work", "", "timmy", [], 9,
"2026-08-31T23:59:59Z",
)
finally:
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_gitea_repo_labels_returns_safe_touch_picker_options():
requests = []
@@ -702,6 +771,27 @@ async def test_create_issue_rejects_blank_title_before_upstream(monkeypatch):
assert called is False
@pytest.mark.anyio
async def test_create_issue_rejects_nonexistent_due_date_before_upstream(monkeypatch):
called = False
async def user():
nonlocal called
called = True
return {"login": "timmy"}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={"title": "Impossible plan", "due_date": "2026-02-31T23:59:59Z"},
)
assert response.status_code == 422
assert called is False
@pytest.mark.anyio
async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
created = False
+276
View File
@@ -0,0 +1,276 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_issue_outbox_binds_items_and_only_flushes_for_the_matching_account():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const calls = [];
let confirmedLogin = 'timmy';
const outbox = createIssueOutbox({{
storage, getOwnerLogin:() => confirmedLogin, createOperationId:() => 'bound-op',
fetchJson:async url => {{ calls.push(url); return {{number:1}}; }},
}});
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bound',body:'Context'}});
confirmedLogin = 'alexander';
(async () => {{
const mismatch = await outbox.flush('alexander');
const unknown = await outbox.retry(queued.id, '');
const matched = await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{queued,mismatch,unknown,matched,calls}}));
}})();
"""
output = run_node(script)
assert output["queued"]["ownerLogin"] == "timmy"
assert output["mismatch"]["blocked"] == 1
assert output["unknown"]["blocked"] == 1
assert len(output["matched"]["confirmed"]) == 1
assert len(output["calls"]) == 1
def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let sequence = 0;
const outbox = createIssueOutbox({{storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}});
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One',labelIds:[3]}});
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two',milestoneId:4,dueDate:'2026-08-09'}});
const reloaded = createIssueOutbox({{storage}}).list();
process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.get('stackchain.issue-outbox.v1'))}}));
"""
output = run_node(script)
assert [(item["repository"], item["title"]) for item in output["items"]] == [
("stackchain/api", "First"),
("stackchain/web", "Second"),
]
assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
assert all(item["status"] == "queued" for item in output["items"])
assert output["stored"]["version"] == 2
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let sequence = 0;
const calls = [];
const outbox = createIssueOutbox({{
storage,
getOwnerLogin:()=>'timmy',
createOperationId:() => 'stable-' + (++sequence),
fetchJson:async (url, options) => {{
calls.push({{url, key:options.headers['Idempotency-Key']}});
if (calls.length === 2) {{ const error = new Error('upstream unavailable'); error.status = 503; throw error; }}
return {{repository:'stackchain/api',number:41,title:'First'}};
}},
}});
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One'}});
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two'}});
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["result"]["confirmed"][0]["number"] == 41
assert output["calls"] == [
{"url": "api/v1/repos/stackchain/api/issues", "key": "stable-1"},
{"url": "api/v1/repos/stackchain/web/issues", "key": "stable-2"},
]
assert len(output["remaining"]) == 1
assert output["remaining"][0]["title"] == "Second"
assert output["remaining"][0]["status"] == "queued"
def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let invalid = true;
const calls = [];
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'stable-edit',
fetchJson:async (_url, options) => {{
calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }}
return {{repository:'stackchain/api',number:42,title:'Fixed title'}};
}},
}});
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bad',body:'Context'}});
outbox.flush('timmy').then(async () => {{
const attention = outbox.list()[0];
outbox.update(queued.id, {{...attention,title:'Fixed title'}});
invalid = false;
const result = await outbox.retry(queued.id, 'timmy');
process.stdout.write(JSON.stringify({{attention,result,calls,remaining:outbox.list()}}));
}});
"""
output = run_node(script)
assert output["attention"]["status"] == "attention"
assert output["attention"]["error"] == "Title is invalid"
assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"]
assert output["calls"][1]["body"]["title"] == "Fixed title"
assert output["result"]["confirmed"][0]["number"] == 42
assert output["remaining"] == []
def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let calls = 0;
let release;
const gate = new Promise(resolve => {{ release = resolve; }});
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'single-flight',
fetchJson:async () => {{ calls += 1; await gate; return {{repository:'stackchain/api',number:43,title:'Once'}}; }},
}});
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Once',body:'Context'}});
const reconnect = outbox.flush('timmy');
const sendNow = outbox.retry(queued.id, 'timmy');
release();
Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.stringify({{calls,results,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["calls"] == 1
assert output["remaining"] == []
assert sum(len(result["confirmed"]) for result in output["results"]) >= 1
def test_issue_outbox_mirrors_queue_and_registers_background_sync():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const state = {{snapshots:[],syncs:0}};
const backgroundSync = {{
reconcile: async items => state.snapshots.push(items.map(item => ({{...item}}))),
requestSync: async () => {{ state.syncs += 1; }},
}};
const outbox = createIssueOutbox({{
storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'background-1',
}});
outbox.enqueue({{repository:'stackchain/api',title:'Close the app',body:'Still deliver'}});
setTimeout(() => process.stdout.write(JSON.stringify(state)), 0);
"""
output = run_node(script)
assert output["snapshots"][0][0]["operationId"] == "background-1"
assert output["snapshots"][0][0]["ownerLogin"] == "timmy"
assert output["syncs"] == 1
def test_foreground_delivery_uses_same_atomic_background_claim():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const state = {{backgroundCalls:0,directCalls:0}};
const backgroundSync = {{
reconcile: async () => {{}}, requestSync: async () => {{}},
send: async (item, login) => {{ state.backgroundCalls += 1; return {{issue:{{number:9}}, claimed:item.id + ':' + login}}; }},
}};
const outbox = createIssueOutbox({{
storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'shared-claim',
fetchJson:async () => {{ state.directCalls += 1; return {{number:10}}; }},
}});
outbox.enqueue({{repository:'o/r',title:'One owner'}});
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{state,result,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["state"] == {"backgroundCalls": 1, "directCalls": 0}
assert output["result"]["confirmed"][0]["number"] == 9
assert output["remaining"] == []
def test_foreground_background_delivery_surfaces_permanent_failure_immediately():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const error=new Error('Title is invalid');error.status=422;
const backgroundSync={{reconcile:async()=>{{}},requestSync:async()=>{{}},send:async()=>({{attention:true,error}})}};
const outbox=createIssueOutbox({{storage,backgroundSync,getOwnerLogin:()=>'timmy',createOperationId:()=>'invalid'}});
outbox.enqueue({{repository:'o/r',title:'Bad'}});
outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result,items:outbox.list()}})));
"""
output = run_node(script)
assert output["items"][0]["status"] == "attention"
assert output["items"][0]["error"] == "Title is invalid"
assert output["result"]["confirmed"] == []
def test_page_reconciles_worker_success_and_attention_into_visible_outbox():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:2,items:[
{{id:'sent',operationId:'sent',ownerLogin:'timmy',status:'queued',title:'Done'}},
{{id:'bad',operationId:'bad',ownerLogin:'timmy',status:'queued',title:'Fix me'}},
]}}));
const outbox=createIssueOutbox({{storage}});
outbox.reconcileBackground([
{{id:'sent',status:'sent'}},
{{id:'bad',status:'attention',error:'Title is invalid'}},
]);
process.stdout.write(JSON.stringify(outbox.list()));
"""
output = run_node(script)
assert output == [
{
"id": "bad",
"operationId": "bad",
"ownerLogin": "timmy",
"status": "attention",
"title": "Fix me",
"error": "Title is invalid",
}
]
@pytest.mark.anyio
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
html = await dashboard()
assert '<script src="static/issue-outbox.js"></script>' in html
assert "const issueOutbox = createIssueOutbox({" in html
assert "issueOutbox.enqueue(captureDraft)" in html
assert "issueOutbox.retry(queued.id, activeFlushLogin)" in html
assert "navigator.onLine" in html
assert "activeFlushLogin = contextIdentityFresh ?" in html
assert "flushIssueOutbox();" in html
assert 'class="draft-send"' in html
assert 'class="draft-edit"' in html
assert 'Queued for sync' in html
assert 'Needs attention' in html
assert "issueOutbox.discard(item.outbox_id)" in html
assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html
+123
View File
@@ -0,0 +1,123 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
class FakeElement {{
constructor() {{
this.listeners = {{}};
this.attributes = {{}};
this.classList = {{ values:new Set(), contains:value => this.classList.values.has(value) }};
this.hidden = false;
this.focuses = 0;
}}
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
click() {{ this.listeners.click({{currentTarget:this}}); }}
setAttribute(name, value) {{ this.attributes[name] = value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
focus() {{ this.focuses += 1; }}
}}
const nav = new FakeElement();
const buttons = Object.fromEntries(['work','find','new','search','drafts'].map(name => [name, new FakeElement()]));
const overlay = new FakeElement();
const calls = [];
let observerCallback;
const dock = createDock({{
nav, buttons, overlays:[overlay],
actions: Object.fromEntries(Object.keys(buttons).map(name => [name, () => calls.push(name)])),
observe(callback) {{ observerCallback = callback; return {{disconnect() {{}}}}; }},
}});
dock.start();
buttons.work.click();
buttons.find.click();
overlay.classList.values.add('open'); observerCallback();
const hiddenWhileOpen = nav.hidden;
overlay.classList.values.delete('open'); observerCallback();
buttons.drafts.click();
process.stdout.write(JSON.stringify({{
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden,
findFocuses:buttons.find.focuses,
current:Object.fromEntries(Object.entries(buttons).map(([name, button]) => [name, button.attributes['aria-current'] || null])),
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": ["work", "find", "drafts"],
"hiddenWhileOpen": True,
"hiddenAfterClose": False,
"findFocuses": 1,
"current": {
"work": None,
"find": None,
"new": None,
"search": None,
"drafts": "page",
},
}
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
const badge = {{ textContent:'', hidden:true }};
const dock = createDock({{ nav:{{}}, buttons:{{work}}, attentionBadge:badge }});
dock.updateAttention(3);
const pending = {{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
dock.updateAttention(0);
process.stdout.write(JSON.stringify({{
pending,
empty:{{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }},
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"pending": {"count": "3", "hidden": False, "label": "Work, 3 items need attention"},
"empty": {"count": "0", "hidden": True, "label": "Work"},
}
@pytest.mark.anyio
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
html = await dashboard()
assert '<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">' in html
assert html.count('class="mobile-task-action" data-mobile-task=') == 5
for task in ("work", "find", "new", "search", "drafts"):
assert f'data-mobile-task="{task}"' in html
assert 'id="mobile-draft-count"' in html
assert 'data-work-filter="attention"' in html
assert 'id="mobile-attention-count"' in html
assert '.mobile-task-dock { display:none;' in html
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
assert '<script src="static/mobile-task-dock.js"></script>' in html
assert "createMobileTaskDock({" in html
assert "work: openMobileWork" in html
assert "counts.attention ? 'attention' : 'all'" in html
assert "qs('[data-work-filter=\"' + filter + '\"]').click()" in html
assert "find: () => qs('#find-work').click()" in html
assert "new: () => qs('#new-issue').click()" in html
assert "search: () => qs('#open-palette').click()" in html
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
assert "draftCount.textContent = sourceDraftCount.textContent" in html
assert "mobileTaskDock.updateAttention(counts.attention)" in html
+587 -4
View File
@@ -1,4 +1,5 @@
import json
import os
import subprocess
from pathlib import Path
@@ -8,6 +9,8 @@ from src.views import dashboard
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
@@ -521,6 +524,33 @@ async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_b
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
@pytest.mark.anyio
async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
html = await dashboard()
assert 'id="create-issue-milestone"' in html
assert 'id="create-issue-due-date" type="date"' in html
assert 'id="create-issue-milestone-status" aria-live="polite"' in html
assert '.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px;' in html
assert 'issueCapture.loadMilestones(repository)' in html
assert "milestoneId: Number(qs('#create-issue-milestone').value) || null" in html
assert "dueDate: qs('#create-issue-due-date').value" in html
@pytest.mark.anyio
async def test_dashboard_launches_share_capture_with_draft_conflict_choices():
html = await dashboard()
assert '<link rel="manifest" href="manifest.webmanifest"' in html
assert "issueCapture.stageSharedContent(sharedLaunch)" in html
assert 'id="use-shared-content"' in html
assert 'id="resume-issue-draft"' in html
assert 'id="shared-content-conflict"' in html
assert '.shared-content-actions button { min-height:44px;' in html
assert "history.replaceState({}, '', cleanUrl)" in html
assert "navigator.serviceWorker.register('service-worker.js')" in html
@pytest.mark.anyio
async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor():
html = await dashboard()
@@ -624,6 +654,270 @@ process.stdout.write(JSON.stringify({{
assert output["summary"] == "1 review · 2 assigned"
def test_attention_filter_unions_updates_and_reviews_without_double_counting():
items = [
{"title": "Unread issue", "kind": "issue", "has_update": True, "is_review": False},
{"title": "Requested review", "kind": "pull", "has_update": False, "is_review": True},
{"title": "Updated review", "kind": "pull", "has_update": True, "is_review": True},
{"title": "Ordinary assignment", "kind": "issue", "has_update": False, "is_review": False},
]
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = {json.dumps(items)};
process.stdout.write(JSON.stringify({{
attention: buildMyWork.filterMyWork(items, 'attention').map(item => item.title),
count: buildMyWork.countMyWork(items).attention,
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"attention": ["Unread issue", "Requested review", "Updated review"],
"count": 3,
}
def test_later_queue_defers_work_locally_and_scopes_it_to_confirmed_login():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let login = 'timmy';
const item = {{kind:'pull',is_review:true,repository:'stackchain/api',number:17,title:'Review me'}};
const store = createLaterWork({{storage,getLogin:() => login,now:() => new Date('2026-08-08T12:00:00Z')}});
const deferred = store.defer(item, new Date('2026-08-08T16:00:00Z'));
const timmy = store.partition([item]);
login = 'alexander';
const alexander = store.partition([item]);
process.stdout.write(JSON.stringify({{
deferred,
timmy:{{active:timmy.active.length,later:timmy.later}},
alexander:{{active:alexander.active.length,later:alexander.later.length}},
keys:Array.from(values.keys()),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"deferred": True,
"timmy": {
"active": 0,
"later": [{
"kind": "pull", "is_review": True, "repository": "stackchain/api",
"number": 17, "title": "Review me", "deferred_until": "2026-08-08T16:00:00.000Z",
}],
},
"alexander": {"active": 1, "later": 0},
"keys": ["stackchain.later-work.v1.timmy"],
}
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let clock = new Date('2026-08-08T12:00:00Z');
let scheduled = null;
let wakes = 0;
const store = createLaterWork({{
storage, getLogin:() => 'timmy', now:() => clock,
setTimer:(callback, delay) => {{ scheduled = {{callback,delay}}; return 7; }},
clearTimer:() => {{}}, onWake:() => {{ wakes += 1; }},
}});
const kept = {{kind:'issue',repository:'stackchain/api',number:17,title:'Kept'}};
const gone = {{kind:'issue',repository:'stackchain/api',number:18,title:'Gone'}};
store.defer(kept, new Date('2026-08-08T13:00:00Z'));
store.defer(gone, new Date('2026-08-09T13:00:00Z'));
const before = store.partition([kept]);
const persisted = JSON.parse(values.get('stackchain.later-work.v1.timmy'));
clock = new Date('2026-08-08T13:00:01Z');
scheduled.callback();
const after = store.partition([kept]);
process.stdout.write(JSON.stringify({{
before:{{active:before.active.length,later:before.later.length}},
persisted:Object.keys(persisted), delay:scheduled.delay, wakes,
after:{{active:after.active.map(item => item.title),later:after.later.length}},
storageEmpty:!values.has('stackchain.later-work.v1.timmy'),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"before": {"active": 0, "later": 1},
"persisted": ["issue:stackchain/api:17:"],
"delay": 3600000,
"wakes": 1,
"after": {"active": ["Kept"], "later": 0},
"storageEmpty": True,
}
def test_later_queue_preserves_rank_and_retains_unloaded_paginated_work():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const store = createLaterWork({{
storage,getLogin:() => 'timmy',now:() => new Date('2026-08-08T12:00:00Z'),
setTimer:() => 1, clearTimer:() => {{}},
}});
const first = {{kind:'issue',repository:'stackchain/api',number:1,title:'Higher priority'}};
const second = {{kind:'pull',repository:'stackchain/api',number:2,title:'Lower priority'}};
store.defer(second, new Date('2026-08-09T12:00:00Z'));
store.defer(first, new Date('2026-08-09T12:00:00Z'));
store.partition([first], {{pruneMissing:false}});
const visible = store.partition([first, second], {{pruneMissing:false}});
process.stdout.write(JSON.stringify({{
titles:visible.later.map(item => item.title),
records:Object.keys(JSON.parse(values.get('stackchain.later-work.v1.timmy'))).length,
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"titles": ["Higher priority", "Lower priority"],
"records": 2,
}
def test_later_queue_presets_and_bring_back_now_preserve_work_identity():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const now = new Date('2026-08-08T12:00:00Z');
const store = createLaterWork({{storage,getLogin:() => 'timmy',now:() => now}});
const item = {{kind:'update',repository:'stackchain/api',number:17,notification_id:91}};
const today = store.presetUntil('today');
const tomorrow = store.presetUntil('tomorrow');
store.defer(item, tomorrow);
const removed = store.restore(item);
process.stdout.write(JSON.stringify({{
today:today.toISOString(), tomorrow:tomorrow.toISOString(), removed,
partition:store.partition([item]),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True,
env={**os.environ, "TZ": "UTC"},
)
assert json.loads(result.stdout) == {
"today": "2026-08-08T16:00:00.000Z",
"tomorrow": "2026-08-09T09:00:00.000Z",
"removed": True,
"partition": {"active": [{
"kind": "update", "repository": "stackchain/api", "number": 17,
"notification_id": 91,
}], "later": []},
}
def test_detail_defer_closes_normal_triage_but_keeps_session_open_for_reconcile():
script = f"""
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
const calls = [];
let sessionActive = false;
const controller = createDetailDefer({{
laterWork: {{
presetUntil:preset => new Date(preset === 'today' ? '2026-08-08T16:00:00Z' : '2026-08-09T09:00:00Z'),
defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return true; }},
}},
session: {{ active:() => sessionActive }},
close:() => calls.push(['close']),
refresh:() => calls.push(['refresh']),
focus:() => calls.push(['focus']),
announce:message => calls.push(['announce', message]),
formatTime:value => value.toISOString(),
}});
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Read first'}};
const outside = controller.defer(item, 'today');
sessionActive = true;
const session = controller.defer(item, 'tomorrow');
process.stdout.write(JSON.stringify({{outside,session,calls}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"outside": True,
"session": True,
"calls": [
["defer", "Read first", "2026-08-08T16:00:00.000Z"],
["close"],
["refresh"],
["announce", "Deferred until 2026-08-08T16:00:00.000Z. It stays unread and unchanged in Gitea."],
["focus"],
["defer", "Read first", "2026-08-09T09:00:00.000Z"],
["refresh"],
["announce", "Deferred until 2026-08-09T09:00:00.000Z. It stays unread and unchanged in Gitea."],
],
}
@pytest.mark.anyio
async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation():
html = await dashboard()
assert '<script src="static/detail-defer.js"></script>' in html
assert html.count('class="detail-defer"') == 4
assert html.count('data-detail-defer-preset="today"') == 4
assert html.count('data-detail-defer-preset="tomorrow"') == 4
assert 'const detailDefer = createDetailDefer({' in html
assert 'selectedUpdate || selectedReview || selectedIssue || selectedPull' in html
assert "detailDefer.defer(item, button.dataset.detailDeferPreset)" in html
assert ".detail-defer summary, .detail-defer button { min-height:44px;" in html
assert "fetch(" not in DETAIL_DEFER.read_text()
@pytest.mark.anyio
async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
html = await dashboard()
assert '<script src="static/later-work.js"></script>' in html
assert 'data-work-filter="later"' in html
assert 'data-work-count="later"' in html
assert 'const laterWork = createLaterWork({' in html
assert 'getLogin: () => confirmedOwnerLogin' in html
assert 'laterWork.partition(lastMyWork,' in html
assert 'data-later-preset="today"' in html
assert 'data-later-preset="tomorrow"' in html
assert 'data-later-restore' in html
assert "Deferred until ' + escapeHtml(fmt(item.deferred_until))" in html
assert "laterWork.defer(item, laterWork.presetUntil(button.dataset.laterPreset))" in html
assert 'laterWork.restore(item)' in html
assert '.later-actions button { min-height:44px;' in html
assert 'Deferred work stays unread and unchanged in Gitea.' in html
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
payload = {
"issues": [
@@ -815,7 +1109,9 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1, "update": 0}
assert json.loads(result.stdout) == {
"all": 3, "attention": 1, "issue": 1, "pull": 1, "review": 1, "update": 0
}
def test_work_pager_is_single_flight_and_unions_pull_responsibilities():
@@ -1219,6 +1515,105 @@ first.submit(draft).catch(() => {{
assert output["number"] == 17
def test_issue_capture_normalizes_shared_mobile_content_without_repeating_source_url():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const normalized = createIssueCapture.normalizeSharedContent({{
title: ' Production alert ',
text: 'Latency crossed the threshold https://status.example/incidents/42',
url: 'https://status.example/incidents/42',
}});
process.stdout.write(JSON.stringify(normalized));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"title": "Production alert",
"body": "Latency crossed the threshold https://status.example/incidents/42",
}
def test_issue_capture_suggests_a_title_when_mobile_share_only_contains_text():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
process.stdout.write(JSON.stringify(createIssueCapture.normalizeSharedContent({{
text: 'Investigate checkout latency before the release window opens. More diagnostic context follows.'
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"title": "Investigate checkout latency before the release window opens.",
"body": "Investigate checkout latency before the release window opens. More diagnostic context follows.",
}
def test_issue_capture_keeps_existing_draft_until_shared_content_is_accepted():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
capture.saveDraft({{
repository:'stackchain/api', title:'Existing draft', body:'Keep me', labelIds:[3], milestoneId:9,
}});
const staged = capture.stageSharedContent({{title:'Shared alert', text:'Investigate', url:'https://status.example/42'}});
const before = capture.loadDraft();
capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
const pendingAfterReload = capture.pendingSharedContent();
const accepted = capture.acceptSharedContent();
process.stdout.write(JSON.stringify({{staged, before, pendingAfterReload, accepted, pending:capture.pendingSharedContent()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"staged": {"status": "conflict"},
"before": {
"repository": "stackchain/api", "title": "Existing draft", "body": "Keep me",
"labelIds": [3], "milestoneId": 9,
},
"pendingAfterReload": {
"title": "Shared alert", "body": "Investigate\n\nhttps://status.example/42",
},
"accepted": {
"repository": "stackchain/api", "title": "Shared alert",
"body": "Investigate\n\nhttps://status.example/42", "labelIds": [3], "milestoneId": 9,
},
"pending": None,
}
def test_issue_capture_can_resume_existing_draft_and_discard_staged_share():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{getItem:key=>values.get(key)||null, setItem:(key,value)=>values.set(key,value), removeItem:key=>values.delete(key)}};
const capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
capture.saveDraft({{repository:'stackchain/api', title:'Existing', body:'Keep', labelIds:[]}});
capture.stageSharedContent({{title:'Incoming', text:'Replace'}});
capture.discardSharedContent();
process.stdout.write(JSON.stringify({{draft:capture.loadDraft(), pending:capture.pendingSharedContent()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"draft": {"repository": "stackchain/api", "title": "Existing", "body": "Keep", "labelIds": []},
"pending": None,
}
def test_issue_capture_loads_repository_labels_with_priorities_first():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
@@ -1245,6 +1640,50 @@ capture.loadLabels('stackchain/api').then(labels => process.stdout.write(JSON.st
assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"]
def test_issue_capture_persists_and_submits_milestone_and_due_date():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const calls = [];
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const capture = createIssueCapture({{
storage,
createOperationId: () => 'planned-operation',
fetchJson: (url, options) => {{
calls.push({{url, body:options?.body ? JSON.parse(options.body) : null}});
if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}}]);
return Promise.resolve({{number:221, milestone:{{id:9,title:'August RC'}}, due_date:'2026-08-31T23:59:59Z'}});
}},
}});
const draft = {{repository:'stackchain/api', title:'Ship plan', body:'', labelIds:[], milestoneId:9, dueDate:'2026-08-31'}};
capture.saveDraft(draft);
Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.loadDraft())]).then(results =>
process.stdout.write(JSON.stringify({{calls, stored:results[1], milestones:results[0]}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == [
{"url": "api/v1/repos/stackchain/api/milestones", "body": None},
{
"url": "api/v1/repos/stackchain/api/issues",
"body": {
"title": "Ship plan", "body": "", "label_ids": [],
"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z",
},
},
]
assert output["milestones"] == [{"id": 9, "title": "August RC"}]
assert output["stored"]["number"] == 221
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
payload = {
"user": {"login": "timmy"},
@@ -1288,7 +1727,9 @@ process.stdout.write(JSON.stringify({{
assert output["updates"][0]["kind"] == "update"
assert output["updates"][1]["kind"] == "issue"
assert output["updates"][1]["url"].endswith("#issuecomment-9")
assert output["counts"] == {"all": 2, "issue": 1, "pull": 0, "review": 0, "update": 2}
assert output["counts"] == {
"all": 2, "attention": 2, "issue": 1, "pull": 0, "review": 0, "update": 2
}
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
@@ -1619,6 +2060,72 @@ reader.open(item, [item]).then(() =>
}
def test_notification_reader_pages_conversation_single_flight_and_ignores_stale_update():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const details = {{
42: {{id:42, conversation:{{comments:[{{id:41,created_at:'2026-08-07T12:41:00Z'}}],page:3,older_page:2,total:47}}}},
43: {{id:43, conversation:{{comments:[{{id:90,created_at:'2026-08-07T13:00:00Z'}}],page:1,older_page:null,total:1}}}},
}};
let release;
const requested = [];
const states = [];
const reader = build.createNotificationReader({{
load: async id => details[id], createPager,
loadConversation: (id, page) => {{
requested.push([id, page]);
return new Promise(resolve => {{ release = () => resolve({{comments:[{{id:21,created_at:'2026-08-07T12:21:00Z'}}],page:2,older_page:1,total:47}}); }});
}},
markRead: async () => {{}}, onOpen: () => {{}}, onDetail: () => {{}},
onConversation: state => states.push(state.comments.map(item => item.id)),
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
}});
const firstItem = {{notification_id:42}};
const secondItem = {{notification_id:43}};
reader.open(firstItem).then(async () => {{
const first = reader.loadOlder();
const duplicate = reader.loadOlder();
await reader.open(secondItem);
release();
await Promise.all([first, duplicate]);
process.stdout.write(JSON.stringify({{requested, states}}));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["requested"] == [[42, 2]]
assert output["states"] == [[41], [90]]
def test_notification_reader_appends_a_confirmed_reply_exactly_once():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const states = [];
const reader = build.createNotificationReader({{
load: async () => ({{conversation:{{comments:[{{id:41}}],page:1,older_page:null,total:1}}}}),
loadConversation: async () => ({{}}), createPager,
markRead: async () => {{}}, onOpen: () => {{}}, onDetail: () => {{}},
onConversation: state => states.push(state.comments.map(item => item.id)),
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
}});
reader.open({{notification_id:42}}).then(() => {{
reader.appendReply({{id:91, author:'timmy', body:'Ship it'}});
reader.appendReply({{id:91, author:'timmy', body:'Ship it'}});
process.stdout.write(JSON.stringify(states));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == [[41], [41, 91], [41, 91]]
def test_notification_reader_wraps_to_an_earlier_visible_unread_update():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -2283,7 +2790,7 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
assert 'id="keep-update-unread"' in html
assert 'id="mark-update-read-next"' in html
assert 'id="retry-update-load"' in html
assert 'id="update-comment-body"' in html
assert 'id="update-comments"' in html
assert 'id="update-subject-body"' in html
assert 'id="open-update-gitea"' in html
assert '.update-sheet-panel { width:min(560px,100%);' in html
@@ -2405,11 +2912,13 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
assert 'data-work-count="all"' in html
assert 'data-work-count="attention"' in html
assert 'data-work-count="issue"' in html
assert 'data-work-count="pull"' in html
assert 'data-work-count="review"' in html
assert 'data-work-count="update"' in html
assert "['all', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)" in html
assert 'data-work-count="later"' in html
assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert 'data-work-count="draft"' in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
@@ -2870,6 +3379,63 @@ process.stdout.write(JSON.stringify({{removed, draft:draft.snapshot(), progress:
assert payload["progress"]["reviewedCount"] == 0
def test_approved_assigned_review_carries_exact_head_progress_into_merge():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{
getItem: key => values.get(key) || null,
setItem: (key, value) => values.set(key, value),
}};
const assigned = {{
repository:'stackchain/api', number:7,
work_reasons:['assigned_to_me', 'review_requested']
}};
const carried = reviewSheet.prepareMergeContinuation({{
storage, item:assigned, headSha:'abc123',
reviewed:['a.py', 'b.py'], decision:'approve'
}});
const unassigned = reviewSheet.prepareMergeContinuation({{
storage, item:{{...assigned, number:8, work_reasons:['review_requested']}},
headSha:'def456', reviewed:['c.py'], decision:'approve'
}});
const changesRequested = reviewSheet.prepareMergeContinuation({{
storage, item:{{...assigned, number:9}}, headSha:'ghi789',
reviewed:['d.py'], decision:'request_changes'
}});
process.stdout.write(JSON.stringify({{carried, unassigned, changesRequested, entries:[...values.entries()]}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"carried": True,
"unassigned": False,
"changesRequested": False,
"entries": [[
"stackchain.pull-review.v1:stackchain/api#7:abc123",
'["a.py","b.py"]',
]],
}
@pytest.mark.anyio
async def test_successful_assigned_approval_continues_to_merge_in_place():
html = await dashboard()
assert 'id="continue-review-to-merge"' in html
assert '#continue-review-to-merge[hidden]' in html and 'display:none' in html
assert '.review-merge-continuation' in html and 'position:sticky' in html
assert '#continue-review-to-merge { min-height:44px;' in html
assert "createReviewController.prepareMergeContinuation" in html
assert "reviewed: progressSnapshot.reviewed" in html
assert "decision: snapshot.decision" in html
assert "workRoute.open({ ...item, kind:'pull', is_review:false }, { replace:true })" in html
assert "qs('#continue-review-to-merge').hidden = false" in html
assert "qs('#continue-review-to-merge').focus()" in html
@pytest.mark.anyio
async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item():
html = await dashboard()
@@ -2882,6 +3448,23 @@ async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item(
assert "qs('#retry-review-load').focus()" in html
@pytest.mark.anyio
async def test_mobile_update_sheet_renders_and_pages_the_complete_conversation():
html = await dashboard()
update_sheet = html[html.index('id="update-sheet"'):html.index('id="pull-sheet"')]
assert '<h2>Full conversation</h2>' in update_sheet
assert 'id="update-comments"' in update_sheet
assert 'id="load-older-update-comments"' in update_sheet
assert 'id="update-conversation-status"' in update_sheet
assert '.conversation-more' in html and 'min-height:44px' in html
assert "'/conversation?page='" in html
assert "loadConversation: fetchNotificationConversation" in html
assert "onConversation: renderUpdateConversation" in html
assert "notificationReader.loadOlder()" in html
assert "notificationReader.appendReply(result)" in html
def test_issue_comment_reuses_operation_key_after_reload_until_success():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
+79 -1
View File
@@ -3,7 +3,59 @@ import asyncio
import httpx
import pytest
from src import main
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_notification_detail_opens_the_newest_conversation_page_in_chronological_order(monkeypatch):
requested_pages = []
def comment(comment_id):
return {
"id": comment_id,
"user": {"login": f"user-{comment_id}"},
"body": f"message {comment_id}",
"created_at": f"2026-08-06T12:{comment_id:02d}:00Z",
"html_url": f"https://forge.example/stackchain/api/issues/7#issuecomment-{comment_id}",
}
def handler(request):
if request.url.path.endswith("/notifications/threads/42"):
return httpx.Response(200, json={
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Issue", "title": "Retry failed deploy", "state": "open",
"url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
"latest_comment_url": "https://forge.example/api/v1/repos/stackchain/api/issues/comments/47",
"html_url": "https://forge.example/stackchain/api/issues/7",
},
})
if request.url.path.endswith("/repos/stackchain/api/issues/7/comments"):
page = int(request.url.params["page"])
requested_pages.append(page)
comments = [comment(i) for i in (range(1, 21) if page == 1 else range(41, 48))]
return httpx.Response(200, json=comments, headers={"X-Total-Count": "47"})
if request.url.path.endswith("/repos/stackchain/api/issues/7"):
return httpx.Response(200, json={"body": "Deploy fails after retries."})
if request.url.path.endswith("/repos/stackchain/api/issues/comments/47"):
return httpx.Response(200, json=comment(47))
raise AssertionError(f"unexpected request: {request.url}")
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.notification_detail(42)
finally:
await gitea_proxy.stop_client()
assert requested_pages == [1, 3]
assert [item["id"] for item in result["conversation"]["comments"]] == list(range(41, 48))
assert result["conversation"] == {
"comments": result["conversation"]["comments"],
"page": 3,
"older_page": 2,
"total": 47,
}
@pytest.mark.anyio
@@ -41,6 +93,32 @@ async def test_notification_detail_api_is_bounded_and_never_cacheable(monkeypatc
assert calls == [42]
@pytest.mark.anyio
async def test_notification_conversation_api_loads_one_bounded_older_page(monkeypatch):
calls = []
async def conversation(thread_id, page, limit):
calls.append((thread_id, page, limit))
return {
"comments": [{"id": 21, "author": "timmy", "body": "Earlier context"}],
"page": page,
"older_page": 1,
"total": 47,
}
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_page", conversation, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/42/conversation?page=2&limit=20")
invalid = await client.get("/api/v1/notifications/42/conversation?page=0&limit=51")
assert response.status_code == 200
assert response.json()["comments"][0]["id"] == 21
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert calls == [(42, 2, 20)]
@pytest.mark.anyio
async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch):
async def detail(_thread_id):
+36 -1
View File
@@ -3,7 +3,42 @@ import asyncio
import httpx
import pytest
from src import main
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_notification_reply_returns_the_confirmed_comment_for_exact_once_append(monkeypatch):
def handler(request):
if request.method == "GET":
return httpx.Response(200, json={
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Issue",
"url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
},
})
return httpx.Response(201, json={
"id": 91,
"user": {"login": "timmy"},
"body": "Please retry the worker.",
"created_at": "2026-08-07T19:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
})
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.reply_to_notification(42, "Please retry the worker.")
finally:
await gitea_proxy.stop_client()
assert result == {
"id": 91,
"author": "timmy",
"body": "Please retry the worker.",
"created_at": "2026-08-07T19:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
}
@pytest.mark.anyio
+115
View File
@@ -0,0 +1,115 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
OFFLINE_WORK = Path(__file__).parents[1] / "frontend" / "offline-work.js"
def run_scenario(scenario: str) -> dict:
script = f"""
const createOfflineWorkStore = require({json.dumps(str(OFFLINE_WORK))});
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
{scenario}
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(result.stdout)
def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
store.setEnabled(true);
store.save({
user:{login:'timmy', full_name:'Timmy', token:'secret'},
issues:[{id:1, number:8, title:'Ship offline work', body:'private body', state:'open',
repository:'stackchain/dashboard', labels:['P0'], assignees:['timmy'],
updated_at:'2026-08-07T11:00:00Z', url:'https://forge.example/issues/8'}],
pull_requests:[],
notifications:[{id:9, number:8, title:'Updated', unread:true, subject_body:'private comment',
repository:'stackchain/dashboard', subject_type:'Issue', updated_at:'2026-08-07T11:30:00Z',
url:'https://forge.example/issues/8'}],
work_pagination:{issue:{page:1,total:1,has_more:false}},
repos:[{private:true}], events:[{body:'private event'}],
});
const restarted = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:01:00Z')});
process.stdout.write(JSON.stringify({loaded:restarted.load('timmy'), raw:[...values.values()].join(' ')}));
""")
assert result["loaded"]["user"] == {"login": "timmy", "full_name": "Timmy"}
assert result["loaded"]["issues"][0]["title"] == "Ship offline work"
assert result["loaded"]["notifications"][0]["unread"] is True
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
assert "private body" not in result["raw"]
assert "private comment" not in result["raw"]
assert "private event" not in result["raw"]
assert "secret" not in result["raw"]
def test_expired_snapshot_is_deleted_and_wrong_user_cannot_load_it():
result = run_scenario("""
const writer = createOfflineWorkStore({storage, now:() => new Date('2026-08-01T12:00:00Z'), maxAgeMs:1000});
writer.setEnabled(true);
writer.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
const wrongUser = writer.load('alexander');
const expired = createOfflineWorkStore({storage, now:() => new Date('2026-08-01T12:00:02Z'), maxAgeMs:1000});
const expiredValue = expired.load('timmy');
process.stdout.write(JSON.stringify({wrongUser, expiredValue, snapshotStillStored:values.has('stackchain.offline-work.snapshot.v1')}));
""")
assert result == {
"wrongUser": None,
"expiredValue": None,
"snapshotStillStored": False,
}
def test_opting_out_deletes_the_persisted_snapshot():
result = run_scenario("""
const store = createOfflineWorkStore({storage});
store.setEnabled(true);
store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
store.setEnabled(false);
process.stdout.write(JSON.stringify({enabled:store.enabled(), loaded:store.load()}));
""")
assert result == {"enabled": False, "loaded": None}
@pytest.mark.anyio
async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
html = await dashboard()
assert '<script src="static/offline-work.js"></script>' in html
assert 'id="keep-work-offline"' in html
assert 'Keep My Work available offline' in html
assert 'id="clear-offline-work"' in html
assert 'id="offline-work-status"' in html
assert 'offlineWorkStore.save({' in html
assert 'offlineWorkStore.load()' in html
assert 'Offline · saved ' in html
assert "setOfflineWorkMode(true)" in html
assert "if (offlineWorkMode)" in html
assert '.offline-work-controls button { min-height:44px;' in html
@pytest.mark.anyio
async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snapshot():
html = await dashboard()
assert "if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;" in html
assert "Outage · saved " in html
assert "Server unavailable · showing private My Work saved " in html
assert "Live details and actions will return automatically." in html
assert "function renderLiveSnapshot(snapshot) {\n setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
+134
View File
@@ -0,0 +1,134 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
ROOT = Path(__file__).parents[1]
COORDINATOR = ROOT / "frontend" / "outbox-coordinator.js"
ISSUE_OUTBOX = ROOT / "frontend" / "issue-outbox.js"
AUTHORED_OUTBOX = ROOT / "frontend" / "authored-outbox.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_two_tabs_share_one_flush_lease_for_each_outbox():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const createIssueOutbox = require({json.dumps(str(ISSUE_OUTBOX))});
const createAuthoredOutbox = require({json.dumps(str(AUTHORED_OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const held = new Set();
const locks = {{request: async (name, options, work) => {{
if (held.has(name)) return work(null);
held.add(name);
try {{ return await work({{name}}); }} finally {{ held.delete(name); }}
}}}};
let releaseIssue, releaseMessage;
const issueGate = new Promise(resolve => releaseIssue = resolve);
const messageGate = new Promise(resolve => releaseMessage = resolve);
let issueCalls = 0, messageCalls = 0;
const issueA = createIssueOutbox({{storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'issue-1',
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'a'}}),fetchJson:async()=>{{issueCalls++;await issueGate;return {{number:1}};}}}});
const issueB = createIssueOutbox({{storage,getOwnerLogin:()=> 'timmy',
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'b'}}),fetchJson:async()=>{{issueCalls++;return {{number:1}};}}}});
issueA.enqueue({{repository:'o/r',title:'One'}});
const messageA = createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy',
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'a'}}),fetchJson:async()=>{{messageCalls++;await messageGate;return {{id:1}};}}}});
const messageB = createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy',
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'b'}}),fetchJson:async()=>{{messageCalls++;return {{id:1}};}}}});
messageA.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Reply',operationId:'message-1'}});
(async()=>{{
const issueFirst=issueA.flush('timmy'); const issueSecond=issueB.retry('issue-1', 'timmy');
const messageFirst=messageA.flush('timmy'); const messageSecond=messageB.retry('message-1', 'timmy');
await Promise.resolve();
releaseIssue(); releaseMessage();
const results=await Promise.all([issueFirst,issueSecond,messageFirst,messageSecond]);
process.stdout.write(JSON.stringify({{issueCalls,messageCalls,results,issueRemaining:issueA.list(),messageRemaining:messageA.list()}}));
}})();
"""
output = run_node(script)
assert output["issueCalls"] == 1
assert output["messageCalls"] == 1
assert output["issueRemaining"] == []
assert output["messageRemaining"] == []
assert sum(result.get("lease_skipped", False) for result in output["results"]) == 2
def test_fallback_lease_expires_and_stale_owner_cannot_release_successor():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let clock=100;
let releaseFirst;
const firstGate=new Promise(resolve=>releaseFirst=resolve);
const first=createCoordinator({{storage,locks:null,channelFactory:null,tabId:'first',now:()=>clock,leaseMs:50}});
const second=createCoordinator({{storage,locks:null,channelFactory:null,tabId:'second',now:()=>clock,leaseMs:50}});
(async()=>{{
const running=first.runExclusive('issue', async()=>{{await firstGate;return 'first-done';}});
await Promise.resolve();
const blocked=await second.runExclusive('issue', async()=> 'too-early');
clock=151;
const takeover=await second.runExclusive('issue', async()=> 'taken-over');
releaseFirst();
const original=await running;
const after=await second.runExclusive('issue', async()=> 'after-release');
process.stdout.write(JSON.stringify({{blocked,takeover,original,after,lease:storage.getItem('stackchain.outbox-lease.issue.v1')}}));
}})();
"""
output = run_node(script)
assert output["blocked"]["lease_skipped"] is True
assert output["takeover"] == "taken-over"
assert output["original"] == "first-done"
assert output["after"] == "after-release"
assert output["lease"] is None
def test_queue_change_notifications_cross_tabs_and_can_unsubscribe():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const channels=[];
function channelFactory() {{
const channel={{onmessage:null,postMessage(message){{channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data:message}}));}},close(){{}}}};
channels.push(channel); return channel;
}}
const first=createCoordinator({{storage,channelFactory,tabId:'first'}});
const second=createCoordinator({{storage,channelFactory,tabId:'second'}});
const seen=[];
const unsubscribe=second.subscribe(change=>seen.push(change));
first.notify('issue');
first.notify('authored');
unsubscribe();
first.notify('issue');
process.stdout.write(JSON.stringify({{seen,signal:JSON.parse(storage.getItem('stackchain.outbox-change.v1'))}}));
"""
output = run_node(script)
assert [change["queue"] for change in output["seen"]] == ["issue", "authored"]
assert output["signal"]["queue"] == "issue"
assert output["signal"]["tabId"] == "first"
@pytest.mark.anyio
async def test_dashboard_wires_cross_tab_draft_refresh_and_precaches_coordinator():
html = await dashboard()
worker = (ROOT / "frontend" / "service-worker.js").read_text()
assert '<script src="static/outbox-coordinator.js"></script>' in html
assert "const outboxCoordinator = createOutboxCoordinator" in html
assert "coordinator: outboxCoordinator" in html
assert "outboxCoordinator.subscribe" in html
assert "refreshMyWorkView()" in html
assert "BASE + 'static/outbox-coordinator.js'" in worker
+234
View File
@@ -0,0 +1,234 @@
import json
import subprocess
from pathlib import Path
import pytest
WORKER = Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js"
def run_worker_scenario(scenario: str) -> dict:
harness = f"""
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, failFetch: false, fetchStatus: 200, cachedBody: null }};
const cache = {{
addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
}};
const context = {{
URL, Request, Response,
console,
self: {{
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{ claim: async () => {{ state.claimed = true; }} }},
__issueSync: {{ flush: async () => {{ state.backgroundFlushes += 1; }} }},
}},
importScripts: () => {{}},
caches: {{
open: async () => cache,
keys: async () => ['stackchain-dashboard-old', 'another-app-cache'],
delete: async key => {{ state.deleted.push(key); return true; }},
match: async request => cache.match(request),
}},
fetch: async request => {{
state.fetches.push(String(request.url || request));
if (state.failFetch) throw new Error('offline');
return new Response('network', {{ status: state.fetchStatus }});
}},
}};
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8'), context);
async function dispatch(name, request) {{
let pending;
let response;
listeners[name]({{
request,
waitUntil: promise => {{ pending = promise; }},
respondWith: promise => {{ response = promise; }},
}});
if (pending) await pending;
return response ? await response : null;
}}
async function dispatchSync(tag) {{
let pending;
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
(async () => {{
{scenario}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
completed = subprocess.run(
["node", "-e", harness], capture_output=True, check=True, text=True
)
return json.loads(completed.stdout)
def test_operator_session_boundary_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v16" in source
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
result = run_worker_scenario(
"""
await dispatchSync('stackchain-issue-outbox-v1');
await dispatchSync('another-app-sync');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["backgroundFlushes"] == 1
def test_background_mutations_obtain_session_bound_csrf_proof():
source = WORKER.read_text()
assert "async function sessionCsrf()" in source
assert "BASE + 'api/v1/session'" in source
assert "headers.set('X-CSRF-Token', csrf)" in source
def test_install_precaches_complete_subpath_scoped_app_shell():
result = run_worker_scenario(
"""
await dispatch('install');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["skipped"] is True
assert result["added"][0] == "/dashboard/"
assert set(result["added"]) == {
"/dashboard/",
"/dashboard/manifest.webmanifest",
"/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js",
"/dashboard/static/markdown.js",
"/dashboard/static/commands.js",
"/dashboard/static/search-preview.js",
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/my-work.js",
"/dashboard/static/later-work.js",
"/dashboard/static/detail-defer.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/conversation.js",
"/dashboard/static/issue-sheet.js",
"/dashboard/static/create-issue-sheet.js",
"/dashboard/static/pull-sheet.js",
"/dashboard/static/review-sheet.js",
"/dashboard/static/work-route.js",
"/dashboard/static/context-poller.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/background-issue-sync.js",
}
def test_activate_deletes_only_stale_stackchain_caches():
result = run_worker_scenario(
"""
await dispatch('activate');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["claimed"] is True
assert result["deleted"] == ["stackchain-dashboard-old"]
def test_offline_navigation_returns_cached_shell_for_share_target_url():
result = run_worker_scenario(
"""
state.failFetch = true;
state.cachedBody = 'cached dashboard';
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate',
url: 'https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com',
});
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
"""
)
assert result["body"] == "cached dashboard"
assert result["state"]["fetches"] == [
"https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com"
]
def test_cross_origin_navigation_is_not_intercepted_or_cached():
result = run_worker_scenario(
"""
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://outside.example/page',
});
process.stdout.write(JSON.stringify({ intercepted: response !== null, state }));
"""
)
assert result["intercepted"] is False
assert result["state"]["fetches"] == []
assert result["state"]["puts"] == []
@pytest.mark.parametrize("status", [500, 502, 503, 504])
def test_server_outage_navigation_returns_working_cached_shell_without_replacing_it(status):
result = run_worker_scenario(
f"""
state.fetchStatus = {status};
state.cachedBody = 'cached dashboard';
const response = await dispatch('fetch', {{
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
}});
process.stdout.write(JSON.stringify({{ status: response.status, body: await response.text(), state }}));
"""
)
assert result["status"] == 200
assert result["body"] == "cached dashboard"
assert result["state"]["puts"] == []
def test_server_outage_without_cached_shell_preserves_network_error():
result = run_worker_scenario(
"""
state.fetchStatus = 503;
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
});
process.stdout.write(JSON.stringify({ status: response.status, body: await response.text(), state }));
"""
)
assert result["status"] == 503
assert result["body"] == "network"
assert result["state"]["puts"] == []
def test_client_error_navigation_is_not_hidden_by_cached_shell():
result = run_worker_scenario(
"""
state.fetchStatus = 401;
state.cachedBody = 'cached dashboard';
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
});
process.stdout.write(JSON.stringify({ status: response.status, body: await response.text(), state }));
"""
)
assert result["status"] == 401
assert result["body"] == "network"
assert result["state"]["puts"] == []
+50 -1
View File
@@ -61,4 +61,53 @@ async def test_work_page_endpoint_rejects_unknown_stream_before_upstream_io(monk
response = await client.get("/api/v1/work/unknown?page=2")
assert response.status_code == 422
assert called is False
assert called is False
@pytest.mark.anyio
async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_data():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
manifest = await client.get("/manifest.webmanifest")
worker = await client.get("/service-worker.js")
assert manifest.status_code == 200
assert manifest.headers["content-type"].startswith("application/manifest+json")
assert manifest.json()["id"] == "./"
assert manifest.json()["start_url"] == "./"
assert manifest.json()["scope"] == "./"
assert manifest.json()["share_target"] == {
"action": "./", "method": "GET", "enctype": "application/x-www-form-urlencoded",
"params": {"title": "title", "text": "text", "url": "url"},
}
assert worker.status_code == 200
assert worker.headers["content-type"].startswith("application/javascript")
assert worker.headers["service-worker-allowed"] == "/"
assert "request.url.includes('/api/')" in worker.text
assert "request.method !== 'GET'" in worker.text
assert "new URL('./', self.location.href).pathname" in worker.text
@pytest.mark.anyio
async def test_dashboard_announces_offline_mode_and_refreshes_after_reconnect():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/")
assert response.status_code == 200
assert 'id="offline-status"' in response.text
assert 'role="status"' in response.text
assert 'aria-live="polite"' in response.text
assert "window.addEventListener('offline'" in response.text
assert "window.addEventListener('online'" in response.text
assert "contextPoller.refresh()" in response.text
@pytest.mark.anyio
async def test_dashboard_shell_revalidates_instead_of_being_stored_as_fresh():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-cache"