Compare commits

...
Author SHA1 Message Date
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
timmy 174dac05b9 feat: make assigned pull sheets read-first (#217)
CI / lint (pull_request) Successful in 20s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 18:00:00 +00:00
rockachopa ea1f4ad837 Make mobile issue sheets read-first with on-demand planning (#216)
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Closes #215
2026-08-07 17:39:18 +00:00
timmy d09ceb8650 feat: make mobile issue sheets read-first (#215)
CI / lint (pull_request) Successful in 18s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 17:38:01 +00:00
timmy c0cc6ec46e Merge pull request 'Keep Find Work instant while its catalog refreshes' (#214) from timmy/213-instant-find-work into main
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 17:16:30 +00:00
timmy aaff109ede feat: keep Find Work instant during refresh (#213)
CI / lint (pull_request) Successful in 18s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 17:15:31 +00:00
timmy 2fe9b6cc3d Merge pull request 'Recover unfinished mobile drafts from My Work' (#212) from timmy/211-mobile-draft-recovery 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 16:56:08 +00:00
timmy a08ce31858 feat: recover mobile drafts from My Work (#211)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 16:55:00 +00:00
timmy 092234af38 Merge pull request 'Load complete mobile conversations without leaving My Work' (#210) from timmy/209-mobile-conversation-history into main
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 16:23:32 +00:00
timmy c63b23865c feat: load complete mobile conversations (#209)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 16:22:30 +00:00
rockachopa e04d6753ac Merge pull request 'Resolve shared mobile My Work routes beyond page one' (#208) from timmy/207-resolve-mobile-work-routes 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 16:00:02 +00:00
timmy b5545e380e feat: resolve shared My Work routes
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 15:58:56 +00:00
timmy 2fecccfbde Make mobile My Work sheets URL-addressable and back-button safe (#206)
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Closes #205
2026-08-07 15:18:23 +00:00
timmy 9e697dc63f feat: route mobile My Work sheets (#205)
CI / lint (pull_request) Successful in 16s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 15:17:21 +00:00
timmy 5e826bcc7e Merge pull request 'Plan and focus mobile My Work by milestone' (#204) from timmy/203-mobile-milestone-planning into main
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 15:01:56 +00:00
timmy 2be29b684a feat: plan mobile work by milestone (#203)
CI / lint (pull_request) Successful in 16s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 15:01:02 +00:00
timmy 09b2898d1c Merge pull request 'Persist authored action idempotency across restarts and workers' (#202) from timmy/201-durable-idempotency into main
CI / lint (push) Successful in 16s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-07 14:38:07 +00:00
timmy a5f63a6fa6 feat: persist authored action idempotency (#201)
CI / lint (pull_request) Successful in 16s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 14:37:10 +00:00
timmy 4cc00d2e48 Merge pull request 'Keep global search useful during partial failures and rapid typing' (#200) from timmy/199-partial-cancellable-search into main
CI / lint (push) Successful in 18s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
Keep global search useful during partial failures and rapid typing (#200)

Closes #199
2026-08-07 14:19:53 +00:00
timmy f1448e6b58 feat: keep global search useful during partial failures (#199)
CI / lint (pull_request) Successful in 15s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 14:18:38 +00:00
timmy 2243b9453c Merge pull request 'Preview and claim global search results without leaving the dashboard' (#198)
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 13:45:47 +00:00
timmy fe834a37a4 feat: preview global search results in dashboard (#197)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 13:34:26 +00:00
rockachopa 46070d03f6 Search global work from the command palette (#196)
CI / lint (push) Successful in 14s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
Closes #195
2026-08-07 12:29:10 +00:00
timmy 7651e5b090 feat: add global work search (#195)
CI / lint (pull_request) Successful in 15s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 12:27:53 +00:00
rockachopa de20406a45 Merge pull request 'Work through My Work sequentially on mobile' (#194) from timmy/193-mobile-work-session into main
CI / lint (push) Successful in 14s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 11:55:25 +00:00
47 changed files with 6563 additions and 320 deletions

No files matched your search

+1
View File
@@ -2,3 +2,4 @@ __pycache__/
*.py[cod]
.pytest_cache/
.release-engine/
.stackchain-state/
+55 -6
View File
@@ -16,15 +16,21 @@ 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 or reload replays a confirmed result instead
of posting duplicate content. Direct API callers should preserve the `Idempotency-Key`
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,
Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. Native Comment, Approve, and Request changes reviews support
@@ -45,14 +51,57 @@ 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
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
`user`, `repos`, `issues`, and `pull_requests`. Never commit the token or place
it in a tracked configuration file.
`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
the configured Gitea token. Remote search starts after two characters, is debounced,
and keeps local commands usable if Gitea search is unavailable. Selecting a remote
result opens a mobile-safe, read-only preview without discarding the search query;
open unassigned issues can be claimed in place and handed into My Work after Gitea
confirms the assignment. Closed work and pull requests remain read-only with a safe
canonical Gitea link. The bounded APIs are available at
`GET /api/v1/search?q=<query>&limit=<1-25>` and
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`. Never commit the token
or place it in a tracked configuration file.
For service monitoring, GET `/healthz` is a liveness check that confirms the
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.
## 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.
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. Drafts
shows queued and needs-attention messages with explicit send/discard controls; reconnect
flushes messages sequentially with their original idempotency keys. State-sensitive
actions such as reviews, merges, closures, labels, milestones, and assignments are never
queued. Service-worker upgrades are atomic and remove only older
`stackchain-dashboard-*` caches, preserving unrelated caches on the same origin.
Run the test suite with:
```bash
+133
View File
@@ -0,0 +1,133 @@
function createAuthoredOutbox({ storage, fetchJson, 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 (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && supportedKinds.has(item.kind));
} catch (_error) { return []; }
}
function write(items) {
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
}
function enqueue(message) {
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
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 || ''),
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) {
if (pending.has(item.id)) return pending.get(item.id);
const request = (async () => {
try {
const 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 }),
});
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 flush() {
const confirmed = [];
for (const item of read()) {
if (item.status === 'attention') continue;
const outcome = await sendItem(item);
if (outcome.result) confirmed.push(outcome.result);
if (outcome.transient) break;
}
return { confirmed, remaining: read() };
}
async function retry(id) {
const item = read().find(candidate => candidate.id === id);
if (!item) return { confirmed: [], remaining: read() };
const queued = item.status === 'attention' ? update(id, item) : item;
const outcome = await sendItem(queued);
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read() };
}
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
+50 -1
View File
@@ -3,8 +3,57 @@
if (typeof module === 'object' && module.exports) module.exports = filterCommands;
if (root) root.filterCommands = filterCommands;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function filterCommands(commands, filter) {
function filterCommands(commands, filter) {
const term = String(filter || '').toLowerCase();
return commands.filter(command => command.name.toLowerCase().includes(term));
}
filterCommands.nextSelection = function nextSelection(current, key, count) {
if (count < 1) return -1;
if (key === 'ArrowDown') return (current + 1 + count) % count;
if (key === 'ArrowUp') return (current - 1 + count) % count;
return current;
};
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
const search = options.search;
const onState = options.onState;
const delay = options.delay === undefined ? 250 : options.delay;
let timer = null;
let generation = 0;
let activeController = null;
return {
setQuery(value) {
const query = String(value || '').trim();
generation += 1;
const current = generation;
if (timer !== null) clearTimeout(timer);
if (activeController !== null) activeController.abort();
activeController = null;
if (query.length < 2) {
onState({ status: 'idle', query, items: [] });
return;
}
onState({ status: 'loading', query, items: [] });
timer = setTimeout(async () => {
const requestController = new AbortController();
activeController = requestController;
try {
const result = await search(query, requestController.signal);
const items = Array.isArray(result) ? result : result.items;
const partial = !Array.isArray(result) && result.partial === true;
if (current === generation) onState({ status: 'ready', query, items, partial });
} catch (error) {
if (error && error.name === 'AbortError') return;
if (current === generation) onState({ status: 'error', query, items: [], error });
} finally {
if (activeController === requestController) activeController = null;
}
}, delay);
},
};
};
return filterCommands;
});
+69
View File
@@ -0,0 +1,69 @@
function createConversationPager({ loadPage }) {
let state = { comments: [], page: 1, older_page: null, total: 0 };
let olderRequest = null;
const validComments = comments => Array.isArray(comments)
? comments.filter(comment => comment && Number.isInteger(comment.id))
: [];
const unique = comments => {
const seen = new Set();
return comments.filter(comment => {
if (seen.has(comment.id)) return false;
seen.add(comment.id);
return true;
}).sort((left, right) => {
const leftTime = Date.parse(left.created_at || '');
const rightTime = Date.parse(right.created_at || '');
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return left.id - right.id;
});
};
const snapshot = () => ({ ...state, comments: state.comments.map(comment => ({ ...comment })) });
return {
reset(page) {
const comments = unique(validComments(page?.comments));
state = {
comments,
page: Number.isInteger(page?.page) ? page.page : 1,
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
total: Number.isInteger(page?.total) ? Math.max(page.total, comments.length) : comments.length,
};
olderRequest = null;
return snapshot();
},
snapshot,
loadOlder() {
if (olderRequest) return olderRequest;
if (!Number.isInteger(state.older_page)) return Promise.resolve(snapshot());
const requestedPage = state.older_page;
olderRequest = Promise.resolve(loadPage(requestedPage)).then(page => {
state = {
comments: unique(validComments(page?.comments).concat(state.comments)),
page: Number.isInteger(page?.page) ? page.page : requestedPage,
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
total: Number.isInteger(page?.total) ? Math.max(page.total, state.comments.length) : state.total,
};
return snapshot();
}).finally(() => { olderRequest = null; });
return olderRequest;
},
append(comment) {
if (!comment || !Number.isInteger(comment.id)) return snapshot();
if (!state.comments.some(existing => existing.id === comment.id)) {
state = {
...state,
comments: state.comments.concat(comment),
total: Math.max(state.total + 1, state.comments.length + 1),
};
}
return snapshot();
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationPager;
+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;
+200
View File
@@ -0,0 +1,200 @@
function createDraftInbox({ storage, now = () => Date.now() }) {
const indexKey = 'stackchain.draft-index.v1';
function readIndex() {
try {
const value = JSON.parse(storage?.getItem(indexKey) || '{}');
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch (_error) { return {}; }
}
function writeIndex(index) {
try { storage?.setItem(indexKey, JSON.stringify(index)); }
catch (_error) { /* Draft discovery remains available without metadata. */ }
}
function keys() {
try {
return Array.from({ length: Number(storage?.length || 0) }, (_, index) => storage.key(index))
.filter(Boolean);
} catch (_error) { return []; }
}
function textPreview(value) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 180);
}
function parseTarget(value) {
const match = String(value || '').match(/^(.+)#(\d+)$/);
return match ? { repository: match[1], number: Number(match[2]) } : null;
}
function parse(key, raw) {
let match;
if (key === 'stackchain.issue-capture.v1') {
try {
const value = JSON.parse(raw);
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
if (!preview && !value?.repository) return null;
return {
kind: 'new-issue', label: 'New issue', repository: String(value?.repository || ''),
title: textPreview(value?.title) || 'Untitled new issue', preview,
};
} catch (_error) { return null; }
}
match = key.match(/^stackchain\.(issue|pull)-comment\.v1:(.+#\d+)$/);
if (match) {
const target = parseTarget(match[2]);
const preview = textPreview(raw);
if (!target || !preview) return null;
const routeKind = match[1] === 'issue' ? 'issue' : 'pull';
return {
kind: routeKind + '-comment', label: routeKind === 'issue' ? 'Issue comment' : 'PR comment',
...target, title: target.repository + '#' + target.number, preview,
route: { kind: routeKind, ...target },
};
}
match = key.match(/^stackchain\.issue-content\.v1:(.+#\d+)$/);
if (match) {
const target = parseTarget(match[1]);
try {
const value = JSON.parse(raw);
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
if (!target || !preview || typeof value?.expectedUpdatedAt !== 'string') return null;
return {
kind: 'issue-edit', label: 'Issue edit', ...target,
title: target.repository + '#' + target.number, preview,
route: { kind: 'issue', ...target },
};
} catch (_error) { return null; }
}
match = key.match(/^stackchain\.update-reply\.v1\.(\d+)$/);
if (match) {
const preview = textPreview(raw);
if (!preview) return null;
const notificationId = Number(match[1]);
return {
kind: 'update-reply', label: 'Update reply', notification_id: notificationId,
title: 'Update #' + notificationId, preview,
route: { kind: 'update', notification_id: notificationId },
};
}
match = key.match(/^stackchain\.review-draft\.v1:(.+#\d+)@([^:]+)$/);
if (match) {
const target = parseTarget(match[1]);
try {
const value = JSON.parse(raw);
const notes = Object.values(value?.notes || {});
const comments = Array.isArray(value?.comments) ? value.comments.map(item => item?.body) : [];
const preview = textPreview([value?.summary, ...notes, ...comments].filter(Boolean).join(' — '));
if (!target || !preview) return null;
return {
kind: 'review', label: 'PR review', ...target, head_sha: match[2],
title: target.repository + '#' + target.number, preview,
route: { kind: 'review', ...target },
};
} catch (_error) { return null; }
}
return null;
}
function parseOutbox(raw) {
try {
const record = JSON.parse(raw);
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item =>
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
).map(item => ({
id: 'stackchain.issue-outbox.v1:' + item.id,
outbox_id: item.id,
kind: 'issue-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
label: item.status === 'attention' ? 'Needs attention' : 'Queued issue',
repository: item.repository,
title: textPreview(item.title) || 'Untitled queued issue',
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
updated_at: Number(item.queuedAt || 0),
}));
} catch (_error) { return []; }
}
function parseAuthoredOutbox(raw) {
try {
const record = JSON.parse(raw);
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.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;
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(' — ')),
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();
const drafts = [];
keys().forEach(key => {
if (key === indexKey || key.endsWith(':operation')) return;
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);
const fingerprint = String(raw);
const previous = index[key];
if (!previous || previous.fingerprint !== fingerprint) {
index[key] = { fingerprint, updated_at: Number(now()) };
}
drafts.push({ id: key, ...parsed, updated_at: index[key].updated_at });
});
Object.keys(index).forEach(key => { if (!seen.has(key)) delete index[key]; });
writeIndex(index);
return drafts.sort((left, right) =>
Number(right.updated_at || 0) - Number(left.updated_at || 0) || left.id.localeCompare(right.id)
);
}
function discard(id) {
if (typeof id !== 'string' || !parse(id, (() => {
try { return storage?.getItem(id); } catch (_error) { return null; }
})())) return false;
try {
storage?.removeItem(id);
storage?.removeItem(id + ':operation');
const index = readIndex();
delete index[id];
writeIndex(index);
return true;
} catch (_error) { return false; }
}
return { list, discard };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
Binary file not shown.

After

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+1155 -160
View File
File diff suppressed because it is too large. Load diff
+123
View File
@@ -0,0 +1,123 @@
function createIssueOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 20 }) {
const storageKey = 'stackchain.issue-outbox.v1';
const operationId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && typeof item === 'object');
} catch (_error) { return []; }
}
function write(items) {
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
}
function enqueue(draft) {
const items = read();
if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.');
const item = {
id: String(operationId()).slice(0, 128),
operationId: '',
repository: String(draft?.repository || ''),
title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
status: 'queued',
queuedAt: Number(now()),
};
item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId);
}
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
items.push(item);
write(items);
return item;
}
function update(id, draft) {
let updated = null;
write(read().map(item => {
if (item.id !== id) return item;
updated = {
...item,
repository: String(draft?.repository || ''), title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
status: 'queued',
};
delete updated.error;
return updated;
}));
return updated;
}
function discard(id) {
const items = read();
if (!items.some(item => item.id === id)) return false;
write(items.filter(item => item.id !== id));
return true;
}
async function sendItem(item) {
if (pending.has(item.id)) return pending.get(item.id);
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
const request = (async () => {
try {
const issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
discard(item.id);
return { issue };
} catch (error) {
const status = Number(error?.status || 0);
if (status >= 400 && status < 500) {
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
} : candidate));
}
return { error, transient: !(status >= 400 && status < 500) };
}
})();
pending.set(item.id, request);
try { return await request; }
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
}
async function flush() {
const confirmed = [];
for (const item of read()) {
if (item.status === 'attention') continue;
const result = await sendItem(item);
if (result.issue) confirmed.push(result.issue);
if (result.transient) break;
}
return { confirmed, remaining: read() };
}
async function retry(id) {
const item = read().find(candidate => candidate.id === id);
if (!item) return { confirmed: [], remaining: read() };
update(id, item);
const result = await sendItem({ ...item, status: 'queued' });
return { confirmed: result.issue ? [result.issue] : [], remaining: read() };
}
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
+67 -1
View File
@@ -1,10 +1,31 @@
function createIssueSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function createPlanningLoader({ loadLabels, loadMilestones }) {
const requests = new Map();
const keyFor = item => item.repository + '#' + item.number;
return {
open(item) {
const key = keyFor(item);
if (requests.has(key)) return requests.get(key);
const request = Promise.all([loadLabels(item), loadMilestones(item)])
.then(([labels, milestones]) => ({ labels, milestones }))
.catch(error => {
if (requests.get(key) === request) requests.delete(key);
throw error;
});
requests.set(key, request);
return request;
},
};
}
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
let labelRequest = null;
let editRequest = null;
let dueDateRequest = null;
let milestoneRequest = null;
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
@@ -12,6 +33,7 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number;
const dueDateDraftKey = item => 'stackchain.issue-due-date.v1:' + item.repository + '#' + item.number;
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
return {
load(item) {
@@ -19,11 +41,26 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
headers: { Accept: 'application/json' },
});
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
}),
});
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
return pager;
},
loadLabels(item) {
return fetchJson(issuePath(item) + '/labels', {
headers: { Accept: 'application/json' },
});
},
loadMilestones(item) {
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
return fetchJson('api/v1/repos/' + repository + '/milestones', {
headers: { Accept: 'application/json' },
});
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
@@ -93,6 +130,33 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
}).finally(() => { dueDateRequest = null; });
return dueDateRequest;
},
loadMilestoneDraft(item) {
try {
const raw = storage?.getItem(milestoneDraftKey(item));
if (raw === null || raw === undefined) return null;
const value = JSON.parse(raw);
return value === null || Number.isInteger(value) ? value : null;
} catch (_error) { return null; }
},
updateMilestone(item, milestoneId) {
if (milestoneRequest) return milestoneRequest;
try { storage?.setItem(milestoneDraftKey(item), JSON.stringify(milestoneId)); }
catch (_error) { /* The select remains the fallback. */ }
milestoneRequest = fetchJson(issuePath(item) + '/milestone', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ milestone_id: milestoneId }),
}).then(result => {
if (result?.number !== item.number ||
(milestoneId === null ? result?.milestone !== null : result?.milestone?.id !== milestoneId)) {
throw new Error('Issue milestone was not confirmed.');
}
try { storage?.removeItem(milestoneDraftKey(item)); }
catch (_error) { /* Confirmed upstream milestone is authoritative. */ }
return result;
}).finally(() => { milestoneRequest = null; });
return milestoneRequest;
},
loadLabelDraft(item) {
try {
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
@@ -169,4 +233,6 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
};
}
createIssueSheet.createPlanningLoader = createPlanningLoader;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
+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"}
}
}
+38
View File
@@ -0,0 +1,38 @@
(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();
}
return {start, refreshVisibility};
});
+84 -10
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 {
@@ -353,11 +398,29 @@ function createNotificationReplier({
};
}
function filterMyWork(items, selectedFilter) {
if (selectedFilter === 'all') return items;
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
if (selectedFilter === 'update') return items.filter((item) => item.has_update);
return items.filter((item) => item.kind === selectedFilter);
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
let filtered = items;
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;
if (selectedMilestone === 'unplanned') {
return filtered.filter(item => item.kind === 'issue' && !item.milestone);
}
return filtered.filter(item =>
item.kind === 'issue' && String(item.milestone?.id || '') === String(selectedMilestone)
);
}
function milestoneLanes(items) {
const lanes = new Map();
(items || []).forEach(item => {
if (item?.kind === 'issue' && Number.isInteger(item.milestone?.id) &&
typeof item.milestone?.title === 'string') {
lanes.set(item.milestone.id, { id: item.milestone.id, title: item.milestone.title });
}
});
return Array.from(lanes.values()).sort((left, right) => left.title.localeCompare(right.title));
}
function workIdentity(item) {
@@ -369,12 +432,12 @@ function workIdentity(item) {
return [kind, repository, number, notification].join(':');
}
function createWorkSession({ getItems, getFilter, onOpen, onProgress, onFinish }) {
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
let currentIdentity = '';
let currentIndex = -1;
let running = false;
const queue = () => filterMyWork(getItems() || [], getFilter());
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
const report = (items, index) => onProgress({
index: index + 1,
total: items.length,
@@ -465,6 +528,15 @@ function replaceIssueDueDate(data, repository, number, dueDate) {
};
}
function replaceIssueMilestone(data, repository, number, milestone) {
return {
...data,
issues: (data.issues || []).map(item =>
item.repository === repository && item.number === number ? { ...item, milestone } : item
),
};
}
function removeIssue(data, repository, number) {
return {
...data,
@@ -496,11 +568,13 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.milestoneLanes = milestoneLanes;
buildMyWork.createWorkSession = createWorkSession;
buildMyWork.workIdentity = workIdentity;
buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.replaceIssueContent = replaceIssueContent;
buildMyWork.replaceIssueDueDate = replaceIssueDueDate;
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
buildMyWork.removeIssue = removeIssue;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.countMyWork = countMyWork;
+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;
});
+5
View File
@@ -39,6 +39,11 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
headers: { Accept: 'application/json' },
}).then(result => {
apply(result, append);
if (result?.refresh_failed === true) {
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
} else if (result?.revalidating === true) {
onStatus('Showing saved available work while the catalog refreshes…');
}
return result;
}).finally(() => { loadRequest = null; });
return loadRequest;
+26 -1
View File
@@ -37,9 +37,11 @@ function renderFile(file, index, reviewed, escapeHtml) {
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
}
function createPullSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
const reviewRequests = new Map();
const reviewCache = new Map();
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
@@ -62,6 +64,29 @@ function createPullSheet({ fetchJson, storage, createOperationId = () => globalT
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
loadReview(item, headSha) {
const key = item.repository + '#' + item.number + ':' + headSha;
if (reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
if (reviewRequests.has(key)) return reviewRequests.get(key);
const request = fetchJson(pathFor(item) + '/review-data', {
headers: { Accept: 'application/json' },
}).then(detail => {
const confirmedKey = item.repository + '#' + item.number + ':' + detail.head_sha;
reviewCache.set(confirmedKey, detail);
return detail;
}).finally(() => reviewRequests.delete(key));
reviewRequests.set(key, request);
return request;
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
}),
});
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
return pager;
},
reviewState,
toggleReviewed(item, detail, filename) {
const state = reviewState(item, detail);
+48
View File
@@ -0,0 +1,48 @@
(function (root, factory) {
const createSearchPreview = factory();
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
if (root) root.createSearchPreview = createSearchPreview;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, claim, onState }) {
let generation = 0;
let current = null;
let claimRequest = null;
return {
open(item) {
generation += 1;
const requestGeneration = generation;
current = { ...item };
onState({ status: 'loading', item: current });
return fetchJson(current).then(detail => {
if (requestGeneration === generation) {
onState({ status: 'ready', item: current, detail });
}
return detail;
}).catch(error => {
if (requestGeneration === generation) {
onState({ status: 'error', item: current, error });
}
throw error;
});
},
close() {
generation += 1;
current = null;
onState({ status: 'closed' });
},
claim(detail) {
if (claimRequest) return claimRequest;
onState({ status: 'claiming', item: current, detail });
claimRequest = claim(detail).then(result => {
onState({ status: 'claimed', item: current, detail, result });
return result;
}).catch(error => {
onState({ status: 'ready', item: current, detail, error });
throw error;
}).finally(() => { claimRequest = null; });
return claimRequest;
},
};
};
});
+66
View File
@@ -0,0 +1,66 @@
const CACHE = 'stackchain-dashboard-shell-v7';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const BASE = new URL('./', self.location.href).pathname;
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/search-preview.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/my-work.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',
];
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('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)));
}
});
+161
View File
@@ -0,0 +1,161 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createWorkRoute = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
function positiveInteger(value) {
const number = Number(value);
return Number.isSafeInteger(number) && number > 0 ? number : null;
}
function parse(fragment) {
const parts = String(fragment || '').split('/');
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
if (parts[2] === 'update' && parts.length === 4) {
const notificationId = positiveInteger(parts[3]);
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
}
if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
const number = positiveInteger(parts[5]);
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
}
function serialize(item) {
if (item?.kind === 'update') {
const notificationId = positiveInteger(item.notification_id);
return notificationId ? '#/my-work/update/' + notificationId : '';
}
if (!['issue', 'pull', 'review'].includes(item?.kind)) return '';
const repository = String(item.repository || '').split('/');
const number = positiveInteger(item.number);
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
return '#/my-work/' + item.kind + '/' + repository.join('/') + '/' + number;
}
function sameRoute(item, route) {
if (route.kind === 'update') {
return Number(item.notification_id) === route.notification_id;
}
const itemKind = item.is_review ? 'review' : item.kind;
return itemKind === route.kind && item.repository === route.repository &&
Number(item.number) === route.number;
}
function createController({
location, history, eventTarget, onOpen, onClose, onInvalid,
resolve, onResolving = function () {}, onError = function () {},
}) {
let items = [];
let started = false;
let active = '';
let ready = false;
let resolving = '';
let resolution = 0;
function resolveMissing(fragment, route) {
if (typeof resolve !== 'function') {
onInvalid();
return;
}
if (resolving === fragment) return;
resolving = fragment;
const request = ++resolution;
onResolving(route);
Promise.resolve(resolve(route)).then(item => {
if (request !== resolution || String(location.hash || '') !== fragment) return;
resolving = '';
if (!item || !sameRoute(item, route)) {
onInvalid();
return;
}
active = fragment;
onOpen({ ...item, kind: route.kind });
}).catch(error => {
if (request !== resolution || String(location.hash || '') !== fragment) return;
resolving = '';
if (error?.unavailable) onInvalid();
else onError(error, route);
});
}
function sync() {
const fragment = String(location.hash || '');
if (!fragment) {
resolution += 1;
resolving = '';
if (active) onClose();
active = '';
return;
}
const route = parse(fragment);
if (!route) {
resolution += 1;
resolving = '';
if (fragment.startsWith('#/my-work/')) onInvalid();
active = '';
return;
}
const item = items.find(candidate => sameRoute(candidate, route));
if (!item) {
if (ready) resolveMissing(fragment, route);
return;
}
if (active === fragment) return;
resolution += 1;
resolving = '';
active = fragment;
onOpen({ ...item, kind: route.kind });
}
return {
start() {
if (started) return;
started = true;
eventTarget.addEventListener('popstate', sync);
eventTarget.addEventListener('hashchange', sync);
sync();
},
setItems(nextItems) {
items = Array.isArray(nextItems) ? nextItems.slice() : [];
ready = true;
sync();
},
open(item, options = {}) {
const fragment = serialize(item);
if (!fragment) return false;
const method = options.replace ? 'replaceState' : 'pushState';
history[method]({ workRoute: fragment }, '', fragment);
active = fragment;
onOpen(item);
return true;
},
close() {
if (parse(location.hash)) history.back();
else {
active = '';
onClose();
}
},
sync,
};
}
async function share(url, navigatorObject, clipboard) {
if (typeof navigatorObject?.share === 'function') {
await navigatorObject.share({ url });
return 'shared';
}
if (typeof clipboard?.writeText === 'function') {
await clipboard.writeText(url);
return 'copied';
}
throw new Error('Sharing is unavailable.');
}
return { parse, serialize, createController, share };
});
+421 -41
View File
@@ -11,6 +11,7 @@ GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000").rstrip("/")
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
@@ -22,6 +23,10 @@ class WorkItems(list[dict]):
self.pagination = pagination
class WorkRouteUnavailableError(ValueError):
"""Raised when a shared route no longer belongs in the current user's queue."""
class StaleReviewError(ValueError):
"""Raised before mutation when a pull request head changed during review."""
@@ -183,6 +188,112 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
}
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
if not isinstance(item, dict):
return None
repository = item.get("repository")
repository = repository if isinstance(repository, dict) else {}
url = _safe_web_url(item.get("html_url"))
if not (
isinstance(item.get("number"), int)
and isinstance(item.get("title"), str)
and isinstance(item.get("state"), str)
and isinstance(repository.get("full_name"), str)
and repository.get("full_name")
and url
):
return None
return {
"kind": kind,
"repository": repository["full_name"],
"number": item["number"],
"title": item["title"],
"state": item["state"],
"url": url,
}
async def global_search(query: str, limit: int = 10) -> dict:
"""Search accessible issues and pulls concurrently with a bounded result set."""
async def load(item_type: str) -> Any:
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
params={"q": query, "type": item_type, "limit": limit, "page": 1},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea global search response was not a list")
return payload
outcomes = await asyncio.gather(
load("issues"), load("pulls"), return_exceptions=True
)
for outcome in outcomes:
if isinstance(outcome, asyncio.CancelledError):
raise outcome
if all(isinstance(outcome, BaseException) for outcome in outcomes):
raise outcomes[0]
results = []
seen: set[tuple[str, str, int]] = set()
for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True):
if isinstance(outcome, BaseException):
continue
for item in outcome:
normalized = _normalize_global_search_item(item, kind)
if normalized is not None:
identity = (kind, normalized["repository"], normalized["number"])
if identity in seen:
continue
seen.add(identity)
results.append(normalized)
return {
"items": results[:limit],
"partial": any(isinstance(outcome, BaseException) for outcome in outcomes),
}
async def work_preview(repository: str, kind: str, number: int) -> dict:
"""Load bounded, read-only context for a global search result."""
issue, user = await asyncio.gather(
fetch(f"repos/{repository}/issues/{number}"), current_user()
)
if not isinstance(issue, dict):
raise ValueError("Gitea work preview response was not an object")
labels_value = issue.get("labels")
assignees_value = issue.get("assignees")
labels = labels_value if isinstance(labels_value, list) else []
assignees = assignees_value if isinstance(assignees_value, list) else []
assignee_names = [
assignee["login"] for assignee in assignees
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
]
author = issue.get("user")
author = author if isinstance(author, dict) else {}
login = user.get("login") if isinstance(user, dict) else ""
state = issue.get("state") if isinstance(issue.get("state"), str) else ""
actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue"
return {
"kind": actual_kind,
"repository": repository,
"number": number,
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
"state": state,
"author": author.get("login", "") if isinstance(author.get("login"), str) else "",
"labels": [
label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
"assignees": assignee_names,
"url": _safe_web_url(issue.get("html_url")),
"claimable": actual_kind == "issue" and state == "open" and not assignee_names,
"assigned_to_me": bool(login and login in assignee_names),
}
def _normalize_available_issue(item: Any) -> dict | None:
if (
not isinstance(item, dict)
@@ -218,8 +329,8 @@ async def available_issue_snapshot(max_pages: int = 10, upstream_limit: int = 50
"""Load, filter, and globally rank a bounded snapshot of available issues."""
items: list[dict] = []
seen_ids: set[Any] = set()
upstream_total: int | None = None
for upstream_page in range(1, max_pages + 1):
async def load_page(upstream_page: int) -> tuple[list[Any], int | None]:
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
@@ -232,21 +343,41 @@ async def available_issue_snapshot(max_pages: int = 10, upstream_limit: int = 50
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea available issue search response was not a list")
if upstream_total is None:
try:
upstream_total = max(0, int(response.headers["X-Total-Count"]))
except (KeyError, TypeError, ValueError):
upstream_total = None
try:
total = max(0, int(response.headers["X-Total-Count"]))
except (KeyError, TypeError, ValueError):
total = None
return payload, total
first_payload, upstream_total = await load_page(1)
pages: list[list[Any]] = [first_payload]
if upstream_total is not None:
page_count = min(max_pages, max(1, (upstream_total + upstream_limit - 1) // upstream_limit))
semaphore = asyncio.Semaphore(AVAILABLE_ISSUE_PAGE_CONCURRENCY)
async def load_bounded(page: int) -> list[Any]:
async with semaphore:
payload, _ = await load_page(page)
return payload
if page_count > 1:
pages.extend(await asyncio.gather(*(
load_bounded(page) for page in range(2, page_count + 1)
)))
else:
previous = first_payload
for page in range(2, max_pages + 1):
if not previous or len(previous) < upstream_limit:
break
previous, _ = await load_page(page)
pages.append(previous)
for payload in pages:
for raw_item in payload:
item = _normalize_available_issue(raw_item)
if item is not None and item["id"] not in seen_ids:
seen_ids.add(item["id"])
items.append(item)
loaded = upstream_page * upstream_limit
if not payload or len(payload) < upstream_limit or (
upstream_total is not None and loaded >= upstream_total
):
break
priority = {"p0", "priority-high", "critical"}
items.sort(key=lambda item: (item["repository"], item["number"] or 0))
@@ -402,8 +533,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")
@@ -442,9 +595,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):
@@ -473,10 +650,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:
@@ -551,16 +725,42 @@ async def repo_labels(repository: str) -> list[dict]:
]
async def repo_milestones(repository: str) -> list[dict]:
response = await _get_client().get(
f"/api/v1/repos/{repository}/milestones",
headers=_auth(),
params={"state": "open", "limit": 50, "page": 1},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea milestones response was not a list")
return [
{"id": item["id"], "title": item["title"]}
for item in payload
if isinstance(item, dict)
and isinstance(item.get("id"), int)
and isinstance(item.get("title"), str)
and item.get("state") == "open"
]
async def create_issue(
repository: str,
title: str,
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(),
@@ -581,6 +781,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"],
@@ -597,6 +813,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 "",
@@ -765,10 +983,105 @@ async def update_assigned_issue_due_date(
}
async def update_assigned_issue_milestone(
repository: str, number: int, milestone_id: int | None
) -> dict:
path = f"repos/{repository}/issues/{number}"
login, issue = await _current_login_and_target(path)
if (
issue.get("state") != "open"
or isinstance(issue.get("pull_request"), dict)
or not _login_in_users(login, issue.get("assignees"))
):
raise IssueNotAvailableError("assigned issue not found")
selected = None
if milestone_id is not None:
selected = next(
(item for item in await repo_milestones(repository) if item["id"] == milestone_id),
None,
)
if selected is None:
raise ValueError("Unknown open repository milestone")
response = await _get_client().patch(
f"/api/v1/{path}", headers=_auth(), json={"milestone": milestone_id or 0}
)
response.raise_for_status()
confirmed = response.json()
milestone_value = confirmed.get("milestone") if isinstance(confirmed, dict) else None
normalized = (
{"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
)
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or normalized != selected
):
raise ValueError("Gitea did not confirm the issue milestone update")
return {
"repository": repository,
"number": number,
"state": confirmed.get("state", "open"),
"milestone": normalized,
}
async def issue_conversation_page(
repository: str,
number: int,
page: int | None = None,
limit: int = 20,
) -> dict:
"""Return one bounded comment page, opening on the newest page by default."""
bounded_limit = min(50, max(1, limit))
requested_page = max(1, page or 1)
path = f"/api/v1/repos/{repository}/issues/{number}/comments"
async def load(selected_page: int) -> tuple[list, int]:
response = await _get_client().get(
path,
headers=_auth(),
params={"limit": bounded_limit, "page": selected_page},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea issue comments response was not a list")
comments = [item for item in payload if isinstance(item, dict)]
try:
total = max(len(comments), int(response.headers.get("X-Total-Count", len(comments))))
except (TypeError, ValueError):
total = len(comments)
return comments, total
comments, total = await load(requested_page)
newest_page = max(1, (total + bounded_limit - 1) // bounded_limit)
if len(comments) >= total:
selected_page = newest_page if page is None else min(requested_page, newest_page)
start = (selected_page - 1) * bounded_limit
comments = comments[start:start + bounded_limit]
else:
selected_page = requested_page
if page is None and newest_page != requested_page:
selected_page = newest_page
comments, confirmed_total = await load(selected_page)
total = max(total, confirmed_total)
return {
"comments": [_normalize_issue_comment(item) for item in comments],
"page": selected_page,
"older_page": selected_page - 1 if selected_page > 1 else None,
"total": total,
}
async def issue_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/issues/{number}"
issue, comments = await asyncio.gather(
fetch(base), fetch(f"{base}/comments?limit=20&page=1")
issue, conversation = await asyncio.gather(
fetch(base), issue_conversation_page(repository, number)
)
if not isinstance(issue, dict):
raise ValueError("Gitea issue response was not an object")
@@ -776,12 +1089,7 @@ async def issue_detail(repository: str, number: int) -> dict:
labels: list = labels_value if isinstance(labels_value, list) else []
assignees_value = issue.get("assignees")
assignees: list = assignees_value if isinstance(assignees_value, list) else []
comments_value: list = comments if isinstance(comments, list) else []
normalized_comments = [
_normalize_issue_comment(comment)
for comment in comments_value
if isinstance(comment, dict)
]
normalized_comments = conversation["comments"]
return {
"repository": repository,
"number": number,
@@ -790,6 +1098,13 @@ async def issue_detail(repository: str, number: int) -> dict:
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
"updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "",
"due_date": issue.get("due_date") if isinstance(issue.get("due_date"), str) else None,
"milestone": (
{"id": issue["milestone"]["id"], "title": issue["milestone"]["title"]}
if isinstance(issue.get("milestone"), dict)
and isinstance(issue["milestone"].get("id"), int)
and isinstance(issue["milestone"].get("title"), str)
else None
),
"url": _safe_web_url(issue.get("html_url")),
"labels": [
label["name"] for label in labels
@@ -800,6 +1115,7 @@ async def issue_detail(repository: str, number: int) -> dict:
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
],
"comments": normalized_comments,
"conversation": conversation,
}
@@ -860,6 +1176,57 @@ def _login_in_users(login: str, value: object) -> bool:
)
async def resolve_work_route(
kind: str,
repository: str | None,
number: int | None,
notification_id: int | None,
) -> dict:
if kind == "update":
if notification_id is None:
raise WorkRouteUnavailableError("Notification identity is missing")
thread = await fetch(f"notifications/threads/{notification_id}")
if not isinstance(thread, dict) or thread.get("unread") is not True:
raise WorkRouteUnavailableError("Notification is no longer unread")
detail = await notification_detail(notification_id)
return {
"kind": "update",
"notification_id": notification_id,
"has_update": True,
"repository": detail.get("repository", ""),
"title": detail.get("title", ""),
"url": detail.get("url", ""),
}
if repository is None or number is None or kind not in {"issue", "pull", "review"}:
raise WorkRouteUnavailableError("Work identity is missing")
target_path = "issues" if kind == "issue" else "pulls"
login, target = await _current_login_and_target(
f"repos/{repository}/{target_path}/{number}"
)
assigned = _login_in_users(login, target.get("assignees"))
requested = _login_in_users(login, target.get("requested_reviewers"))
eligible = (
target.get("state") == "open"
and (
(kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned)
or (kind == "pull" and assigned)
or (kind == "review" and requested)
)
)
if not eligible:
raise WorkRouteUnavailableError("Work item is no longer in My Work")
return {
"kind": kind,
"repository": repository,
"number": number,
"title": target.get("title", "") if isinstance(target.get("title"), str) else "",
"state": "open",
"url": _safe_web_url(target.get("html_url")),
**({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}),
}
async def is_assigned_issue(repository: str, number: int) -> bool:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
@@ -922,18 +1289,9 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
conversation = await issue_conversation_page(repository, number)
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
sha = head.get("sha") if isinstance(head.get("sha"), str) else ""
files, status, comments, diff_result = await asyncio.gather(
fetch(f"{base}/files"),
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch(f"repos/{repository}/issues/{number}/comments?limit=20&page=1"),
fetch_text(
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
),
)
diff, diff_truncated = diff_result
previews = _diff_previews(diff, diff_truncated)
user = pull.get("user") if isinstance(pull.get("user"), dict) else {}
return {
"repository": repository,
@@ -944,6 +1302,33 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
"author": user.get("login") if isinstance(user.get("login"), str) else "",
"head_sha": sha,
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
"conversation": conversation,
}
async def pull_completion_review(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
head_value = pull.get("head")
head: dict = head_value if isinstance(head_value, dict) else {}
sha_value = head.get("sha")
sha = sha_value if isinstance(sha_value, str) else ""
files, status, diff_result = await asyncio.gather(
fetch(f"{base}/files"),
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch_text(
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
),
)
diff, diff_truncated = diff_result
previews = _diff_previews(diff, diff_truncated)
return {
"repository": repository,
"number": number,
"head_sha": sha,
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
"draft": pull.get("draft") is True,
"mergeable": pull.get("mergeable") is True,
"merged": pull.get("merged") is True,
@@ -967,11 +1352,6 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
for item in (files if isinstance(files, list) else [])[:100]
if isinstance(item, dict) and isinstance(item.get("filename"), str)
],
"comments": [
_normalize_issue_comment(item)
for item in (comments if isinstance(comments, list) else [])[:20]
if isinstance(item, dict)
],
}
+114
View File
@@ -0,0 +1,114 @@
import json
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
@dataclass(frozen=True)
class Reservation:
state: str
response: Any = None
class IdempotencyLedger:
"""Small durable ledger for replaying successful Gitea mutations."""
def __init__(
self,
path: str | Path,
*,
ttl_seconds: float,
max_entries: int,
clock: Callable[[], float] = time.time,
) -> None:
self.path = Path(path)
self.ttl_seconds = ttl_seconds
self.max_entries = max_entries
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")
return connection
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS idempotency_operations (
key TEXT PRIMARY KEY,
fingerprint TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending', 'completed')),
response_json TEXT,
created_at REAL NOT NULL,
completed_at REAL
)
"""
)
connection.execute(
"CREATE INDEX IF NOT EXISTS idempotency_completed_at_idx "
"ON idempotency_operations(completed_at)"
)
@staticmethod
def _fingerprint(value: tuple[Any, ...]) -> str:
return json.dumps(value, separators=(",", ":"), sort_keys=True)
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")
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),
)
return Reservation("reserved")
def clear(self) -> None:
with self._connect() as connection:
connection.execute("DELETE FROM idempotency_operations")
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),
)
+395 -80
View File
@@ -1,8 +1,10 @@
import asyncio
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
@@ -24,7 +26,8 @@ from src.gitea_proxy import (
pull_review_detail,
repos,
)
from src.models import Issue, PullRequest, Repo, User
from src.idempotency import IdempotencyLedger
from src.models import Issue, Milestone, PullRequest, Repo, User
from src.suggestion_engine import compute
from src.views import router as frontend_router
@@ -66,6 +69,7 @@ AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES = 256
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
WORK_PAGE_TIMEOUT_SECONDS = 5.0
GLOBAL_SEARCH_TIMEOUT_SECONDS = 3.0
NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0
BULK_NOTIFICATION_CONCURRENCY = 5
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
@@ -73,6 +77,7 @@ LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
LIVE_SNAPSHOT_RETRY_BASE_SECONDS = 5.0
LIVE_SNAPSHOT_RETRY_MAX_SECONDS = 60.0
AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS = 15.0
AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS = 5.0
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
_live_snapshot_task: asyncio.Task | None = None
_live_snapshot_value: dict | None = None
@@ -89,15 +94,22 @@ _live_section_retry_at: dict[str, float | None] = {
}
_live_snapshot_refreshing_sections: set[str] = set()
_read_notification_ids: set[int] = set()
_issue_creation_operations: dict[
str, tuple[tuple[Any, ...], asyncio.Task, float]
] = {}
_authored_action_operations: dict[
str, tuple[tuple[Any, ...], asyncio.Task, float]
] = {}
_state_dir = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"))
_idempotency_ledger = IdempotencyLedger(
os.getenv("STACKCHAIN_IDEMPOTENCY_DB", str(_state_dir / "idempotency.sqlite3")),
ttl_seconds=AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS,
max_entries=(
AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES
+ ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES
),
)
_available_issue_snapshot_task: asyncio.Task | None = None
_available_issue_snapshot_value: list[dict] | None = None
_available_issue_snapshot_created_at: float | None = None
_available_issue_snapshot_retry_at: float | None = None
class ContextPayloadError(ValueError):
@@ -140,6 +152,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
@@ -154,6 +172,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)
@@ -186,6 +211,10 @@ class IssueDueDateUpdate(BaseModel):
)
class IssueMilestoneUpdate(BaseModel):
milestone_id: PositiveInt | None = None
class PullReviewComment(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
body: str = Field(min_length=1, max_length=10_000)
@@ -235,39 +264,52 @@ async def _run_idempotent_authored_action(
if not idempotency_key:
return await asyncio.wait_for(operation, timeout=timeout)
now = time.monotonic()
expired = [
key for key, (_, task, created_at) in _authored_action_operations.items()
if task.done() and now - created_at >= AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS
]
for key in expired:
_authored_action_operations.pop(key, None)
reservation = _idempotency_ledger.reserve(idempotency_key, fingerprint)
if reservation.state == "conflict":
operation.close()
raise HTTPException(status_code=409, detail="Idempotency key already used")
if reservation.state == "completed":
operation.close()
return reservation.response
if reservation.state == "busy":
operation.close()
raise HTTPException(
status_code=503,
detail="Authored action queue is busy; please retry",
headers={"Retry-After": "1"},
)
existing = _authored_action_operations.get(idempotency_key)
if existing is not None:
if reservation.state == "pending":
operation.close()
if existing[0] != fingerprint:
raise HTTPException(status_code=409, detail="Idempotency key already used")
if existing is None or existing[0] != fingerprint:
raise HTTPException(
status_code=503,
detail=(
"This action may still be completing; verify its result before retrying"
),
headers={"Retry-After": "5"},
)
task = existing[1]
else:
while len(_authored_action_operations) >= AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES:
completed = [
key for key, (_, task, _) in _authored_action_operations.items()
if task.done()
]
if not completed:
operation.close()
raise HTTPException(
status_code=503,
detail="Authored action queue is busy; please retry",
headers={"Retry-After": "1"},
)
oldest = min(
completed, key=lambda key: _authored_action_operations[key][2]
)
_authored_action_operations.pop(oldest)
task = asyncio.create_task(operation)
_authored_action_operations[idempotency_key] = (fingerprint, task, now)
async def persist_result():
result = await operation
_idempotency_ledger.complete(idempotency_key, result)
return result
task = asyncio.create_task(persist_result())
_authored_action_operations[idempotency_key] = (
fingerprint,
task,
time.monotonic(),
)
def discard_completed(completed: asyncio.Task) -> None:
existing_operation = _authored_action_operations.get(idempotency_key)
if existing_operation is not None and existing_operation[1] is completed:
_authored_action_operations.pop(idempotency_key, None)
task.add_done_callback(discard_completed)
return await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
@@ -296,6 +338,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
updated_at=i.get("updated_at") or "",
due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None,
milestone=Milestone(**i["milestone"]) if isinstance(i.get("milestone"), dict)
and isinstance(i["milestone"].get("id"), int)
and isinstance(i["milestone"].get("title"), str) else None,
url=i["html_url"],
)
for i in (issues_data or [])[:50]
@@ -336,6 +381,10 @@ def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
updated_at=item.get("updated_at") or "",
due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None,
milestone=Milestone(**item["milestone"])
if isinstance(item.get("milestone"), dict)
and isinstance(item["milestone"].get("id"), int)
and isinstance(item["milestone"].get("title"), str) else None,
url=item["html_url"],
).model_dump()
for item in items
@@ -364,7 +413,7 @@ app.include_router(frontend_router)
@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"} or request.url.path.startswith("/api/v1/work/") or (
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 (
@@ -375,7 +424,7 @@ async def prevent_live_api_caching(request, call_next):
and request.url.path.endswith("/issues")
) or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/labels")
and request.url.path.endswith(("/labels", "/milestones"))
):
response.headers["Cache-Control"] = "no-store"
return response
@@ -471,6 +520,76 @@ async def context() -> JSONResponse:
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
@app.get("/api/v1/search")
async def global_search(
q: str = Query(min_length=2, max_length=100),
limit: int = Query(default=10, ge=1, le=25),
) -> JSONResponse:
query = q.strip()
if len(query) < 2:
raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters")
try:
result = await asyncio.wait_for(
gitea_proxy.global_search(query, limit),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{"error": "Search is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse({"query": query, **result})
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview")
async def global_search_preview(
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
) -> JSONResponse:
try:
preview = await asyncio.wait_for(
gitea_proxy.work_preview(f"{owner}/{repo}", kind, number),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{"error": "This work item is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(preview)
@app.get("/api/v1/work-route")
async def resolve_work_route(
kind: Literal["issue", "pull", "review", "update"] = Query(),
repository: str | None = Query(default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"),
number: int | None = Query(default=None, gt=0),
notification_id: int | None = Query(default=None, gt=0),
) -> JSONResponse:
repository_route = repository is not None and number is not None and notification_id is None
update_route = kind == "update" and notification_id is not None and repository is None and number is None
if (kind == "update" and not update_route) or (kind != "update" and not repository_route):
raise HTTPException(status_code=422, detail="Invalid work route identity")
try:
result = await asyncio.wait_for(
gitea_proxy.resolve_work_route(kind, repository, number, notification_id),
timeout=WORK_PAGE_TIMEOUT_SECONDS,
)
except gitea_proxy.WorkRouteUnavailableError:
raise HTTPException(status_code=404, detail="Work item is no longer in My Work")
except Exception:
return JSONResponse(
{"error": "The shared work item is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/work/{stream}")
async def paged_work(
stream: Literal["issue", "pull", "review"],
@@ -497,13 +616,24 @@ async def paged_work(
async def _refresh_available_issue_snapshot() -> list[dict]:
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
global _available_issue_snapshot_retry_at
result = await gitea_proxy.available_issue_snapshot()
_available_issue_snapshot_value = result
_available_issue_snapshot_created_at = time.monotonic()
_available_issue_snapshot_retry_at = None
return result
async def _available_issue_snapshot() -> tuple[list[dict], bool]:
def _observe_available_issue_refresh(task: asyncio.Task) -> None:
global _available_issue_snapshot_retry_at
if not task.cancelled():
if task.exception() is not None:
_available_issue_snapshot_retry_at = (
time.monotonic() + AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS
)
async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
global _available_issue_snapshot_task
now = time.monotonic()
if (
@@ -512,16 +642,25 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool]:
and now - _available_issue_snapshot_created_at
< AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS
):
return _available_issue_snapshot_value, False
return _available_issue_snapshot_value, False, False, False
if (
_available_issue_snapshot_value is not None
and _available_issue_snapshot_retry_at is not None
and now < _available_issue_snapshot_retry_at
):
return _available_issue_snapshot_value, True, False, True
if _available_issue_snapshot_task is None or _available_issue_snapshot_task.done():
_available_issue_snapshot_task = asyncio.create_task(
_refresh_available_issue_snapshot()
)
_available_issue_snapshot_task.add_done_callback(_observe_available_issue_refresh)
if _available_issue_snapshot_value is not None:
return _available_issue_snapshot_value, True, True, False
try:
return await asyncio.shield(_available_issue_snapshot_task), False
return await asyncio.shield(_available_issue_snapshot_task), False, False, False
except Exception:
if _available_issue_snapshot_value is not None:
return _available_issue_snapshot_value, True
return _available_issue_snapshot_value, True, False, True
raise
@@ -539,7 +678,7 @@ def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict
@app.get("/api/v1/available-issues")
async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse:
try:
items, stale = await asyncio.wait_for(
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
)
except Exception:
@@ -551,6 +690,10 @@ async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONRe
result = _available_issue_page(items, page)
if stale:
result["stale"] = True
if revalidating:
result["revalidating"] = True
if refresh_failed:
result["refresh_failed"] = True
return JSONResponse(result)
@@ -966,6 +1109,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:
@@ -1164,6 +1333,69 @@ async def update_assigned_issue_due_date(
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/milestone")
async def update_assigned_issue_milestone(
update: IssueMilestoneUpdate,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.update_assigned_issue_milestone(
repository, number, update.milestone_id
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
raise HTTPException(status_code=404, detail="Assigned issue not found")
except ValueError as exc:
if str(exc) == "Unknown open repository milestone":
raise HTTPException(status_code=422, detail=str(exc))
return JSONResponse(
{"error": "The milestone could not be updated. Your selection is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The milestone could not be updated. Your selection is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/milestones")
async def repository_milestones(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_milestones():
available = await gitea_proxy.repos()
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if repository not in accessible:
raise HTTPException(status_code=404, detail="Repository not found")
return await gitea_proxy.repo_milestones(repository)
try:
return await asyncio.wait_for(
load_milestones(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Milestones could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/repos/{owner}/{repo}/labels")
async def repository_labels(owner: str, repo: str):
repository = f"{owner}/{repo}"
@@ -1221,52 +1453,46 @@ 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
)
operation = create_issue()
if idempotency_key:
now = time.monotonic()
expired = [
key for key, (_, task, created_at) in _issue_creation_operations.items()
if task.done()
and now - created_at >= ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS
]
for key in expired:
_issue_creation_operations.pop(key, None)
fingerprint = (
repository, creation.title, creation.body, tuple(creation.label_ids)
)
existing = _issue_creation_operations.get(idempotency_key)
if existing is not None:
operation.close()
if existing[0] != fingerprint:
raise HTTPException(status_code=409, detail="Idempotency key already used")
task = existing[1]
else:
while len(_issue_creation_operations) >= ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES:
completed = [
key for key, (_, task, _) in _issue_creation_operations.items()
if task.done()
]
if not completed:
operation.close()
raise HTTPException(
status_code=503,
detail="Issue creation is busy; please retry",
headers={"Retry-After": "1"},
)
oldest = min(
completed, key=lambda key: _issue_creation_operations[key][2]
)
_issue_creation_operations.pop(oldest)
task = asyncio.create_task(operation)
_issue_creation_operations[idempotency_key] = (fingerprint, task, now)
operation = asyncio.shield(task)
try:
result = await asyncio.wait_for(operation, timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
result = await _run_idempotent_authored_action(
create_issue(),
idempotency_key=idempotency_key,
fingerprint=(
"issue-create",
repository,
creation.title,
creation.body,
tuple(creation.label_ids),
creation.milestone_id,
creation.due_date,
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
@@ -1278,6 +1504,35 @@ async def create_assigned_issue(
return JSONResponse(result, status_code=201)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/comments")
async def assigned_issue_conversation(
owner: str,
repo: str,
number: int = PathParam(gt=0),
page: int | None = Query(default=None, ge=1, le=100),
limit: int = Query(default=20, ge=1, le=50),
):
repository = f"{owner}/{repo}"
async def load_conversation():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.issue_conversation_page(repository, number, page, limit)
try:
return await asyncio.wait_for(
load_conversation(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The conversation could not be loaded. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", status_code=201)
async def comment_on_assigned_issue(
comment: IssueComment,
@@ -1450,6 +1705,66 @@ async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
async def assigned_pull_review_data(
owner: str, repo: str, number: int = PathParam(gt=0)
):
repository = f"{owner}/{repo}"
async def load_assigned_pull_review():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.pull_completion_review(repository, number)
try:
return await asyncio.wait_for(
load_assigned_pull_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except TimeoutError:
return JSONResponse(
{"error": "Loading review and merge data timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "Review and merge data is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments")
async def assigned_pull_conversation(
owner: str,
repo: str,
number: int = PathParam(gt=0),
page: int | None = Query(default=None, ge=1, le=100),
limit: int = Query(default=20, ge=1, le=50),
):
repository = f"{owner}/{repo}"
async def load_conversation():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.issue_conversation_page(repository, number, page, limit)
try:
return await asyncio.wait_for(
load_conversation(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The conversation could not be loaded. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", status_code=201)
async def comment_on_assigned_pull(
comment: IssueComment,
+6
View File
@@ -17,6 +17,11 @@ class Repo(BaseModel):
updated_at: str = ""
class Milestone(BaseModel):
id: int
title: str
class Issue(BaseModel):
id: int
number: int
@@ -27,6 +32,7 @@ class Issue(BaseModel):
repository: str = ""
updated_at: str = ""
due_date: str | None = None
milestone: Milestone | None = None
url: str
+25 -2
View File
@@ -1,12 +1,35 @@
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"
@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("/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"},
)
+1
View File
@@ -19,5 +19,6 @@ def test_api_requests_resolve_inside_dashboard_subpath():
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
}
+88
View File
@@ -4,13 +4,16 @@ import httpx
import pytest
from src import main
from src.idempotency import IdempotencyLedger
@pytest.fixture(autouse=True)
def clear_authored_operations():
main._authored_action_operations.clear()
main._idempotency_ledger.clear()
yield
main._authored_action_operations.clear()
main._idempotency_ledger.clear()
@pytest.mark.anyio
@@ -140,3 +143,88 @@ async def test_review_replays_same_key_and_rejects_changed_head(monkeypatch):
assert [created.status_code, replayed.status_code, conflict.status_code] == [201, 201, 409]
assert calls == [("abc", "approve", "Good")]
@pytest.mark.anyio
async def test_completed_comment_replays_after_ledger_reconstruction(monkeypatch, tmp_path):
calls = 0
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
nonlocal calls
calls += 1
return {"id": 201, "body": body}
database = tmp_path / "actions.sqlite3"
monkeypatch.setattr(
main,
"_idempotency_ledger",
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
raising=False,
)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "restart-comment-201"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post(
"/api/v1/repos/stackchain/api/issues/7/comments",
json={"body": "Persist me"},
headers=headers,
)
main._authored_action_operations.clear()
monkeypatch.setattr(
main,
"_idempotency_ledger",
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
)
replayed = await client.post(
"/api/v1/repos/stackchain/api/issues/7/comments",
json={"body": "Persist me"},
headers=headers,
)
assert [created.status_code, replayed.status_code] == [201, 201]
assert replayed.json() == {"id": 201, "body": "Persist me"}
assert calls == 1
@pytest.mark.anyio
async def test_orphaned_pending_comment_fails_closed_without_upstream_retry(
monkeypatch, tmp_path
):
calls = 0
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
nonlocal calls
calls += 1
return {"id": 202, "body": body}
ledger = IdempotencyLedger(
tmp_path / "orphan.sqlite3", ttl_seconds=600, max_entries=256
)
fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first")
assert ledger.reserve("orphan-comment-201", fingerprint).state == "reserved"
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)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/7/comments",
json={"body": "Verify first"},
headers={"Idempotency-Key": "orphan-comment-201"},
)
assert response.status_code == 503
assert response.json()["detail"] == (
"This action may still be completing; verify its result before retrying"
)
assert calls == 0
+136
View File
@@ -0,0 +1,136 @@
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_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, 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().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,
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().then(async first => {{
phase='transient';
outbox.enqueue({{kind:'update-reply',notificationId:3,body:'Later',operationId:'c'}});
const second=await outbox.flush();
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, createOperationId:()=> 'new-' + (++sequence),
fetchJson:async()=>{{calls++; await gate; return {{id:1}};}},
}});
const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Old',operationId:'old'}});
const edited=outbox.update(item.id,{{body:'New'}});
const flush=outbox.flush(); const retry=outbox.retry(item.id); release();
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}});
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
@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 "createAuthoredOutbox({ storage: localStorage" in html
assert "window.addEventListener('online', flushAuthoredOutbox)" in html
assert "authoredOutbox.enqueue" in html
assert "authoredOutbox.retry(item.outbox_id)" in html
assert "authoredOutbox.discard(item.outbox_id)" in html
assert "if (result?.queued)" in html
+192
View File
@@ -7,6 +7,7 @@ from urllib.parse import urljoin
FRONTEND = Path(__file__).parents[1] / "frontend"
COMMANDS = FRONTEND / "commands.js"
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
class ScriptSourceParser(HTMLParser):
@@ -46,3 +47,194 @@ def test_command_script_resolves_inside_dashboard_subpath():
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/", command_source
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
def test_remote_command_search_ignores_stale_responses():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const pending = new Map();
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: query => new Promise(resolve => pending.set(query, resolve)),
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
controller.setQuery('release');
await new Promise(resolve => setTimeout(resolve, 0));
pending.get('release')([{{ title: 'Current result' }}]);
await new Promise(resolve => setTimeout(resolve, 0));
pending.get('mobile')([{{ title: 'Stale result' }}]);
await new Promise(resolve => setTimeout(resolve, 0));
const ready = states.filter(state => state.status === 'ready');
if (ready.length !== 1) throw new Error(`expected one ready state, got ${{ready.length}}`);
if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result') {{
throw new Error(`stale response replaced current state: ${{JSON.stringify(ready)}}`);
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(
["node", "-e", script],
check=True, capture_output=True, text=True,
)
def test_remote_command_search_aborts_superseded_and_cleared_queries():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const signals = [];
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: (query, signal) => {{
signals.push(signal);
return new Promise((resolve, reject) => signal.addEventListener('abort', () => {{
const error = new Error('aborted');
error.name = 'AbortError';
reject(error);
}}));
}},
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
controller.setQuery('release');
if (!signals[0].aborted) throw new Error('superseded request was not aborted');
await new Promise(resolve => setTimeout(resolve, 0));
controller.setQuery('');
if (!signals[1].aborted) throw new Error('cleared query request was not aborted');
await new Promise(resolve => setTimeout(resolve, 0));
if (states.some(state => state.status === 'error')) throw new Error('abort rendered an error');
if (states.at(-1).status !== 'idle') throw new Error('clear did not restore idle state');
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_command_search_preserves_partial_result_status():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: () => Promise.resolve({{ items:[{{ title:'Useful result' }}], partial:true }}),
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
await new Promise(resolve => setTimeout(resolve, 0));
const ready = states.find(state => state.status === 'ready');
if (!ready || ready.items.length !== 1 || ready.partial !== true) {{
throw new Error('partial result metadata was lost: ' + JSON.stringify(states));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_command_selection_wraps_for_arrow_keys():
script = f"""
const commands = require({json.dumps(str(COMMANDS))});
if (commands.nextSelection(-1, 'ArrowDown', 3) !== 0) throw new Error('should select first');
if (commands.nextSelection(2, 'ArrowDown', 3) !== 0) throw new Error('should wrap down');
if (commands.nextSelection(0, 'ArrowUp', 3) !== 2) throw new Error('should wrap up');
if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys should not move');
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath():
html = (FRONTEND / "index.html").read_text()
assert 'role="combobox"' in html
assert 'aria-controls="cmd-results"' in html
assert 'role="listbox"' in html
assert "fetch('api/v1/search?q='" in html
assert "async function searchGlobalWork(query, signal)" in html
assert "signal," in html
assert "Some results are temporarily unavailable." in html
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/",
"api/v1/search?q=mobile",
) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile"
def test_search_preview_ignores_stale_result_details():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const pending = new Map();
const states = [];
const preview = createSearchPreview({{
fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)),
claim: () => Promise.resolve(),
onState: state => states.push(state),
}});
preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }});
preview.open({{ repository:'stackchain/api', number:2, kind:'issue' }});
pending.get(2)({{ number:2, title:'Current' }});
await new Promise(resolve => setTimeout(resolve, 0));
pending.get(1)({{ number:1, title:'Stale' }});
await new Promise(resolve => setTimeout(resolve, 0));
const ready = states.filter(state => state.status === 'ready');
if (ready.length !== 1 || ready[0].detail.number !== 2) {{
throw new Error('stale detail replaced current preview: ' + JSON.stringify(ready));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_claim_is_single_flight_and_reports_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let claims = 0;
let resolveClaim;
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve(item),
claim: () => {{ claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
onState: state => states.push(state),
}});
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
const detail = {{ repository:'stackchain/api', number:42, claimable:true }};
const first = preview.claim(detail);
const second = preview.claim(detail);
if (claims !== 1 || first !== second) throw new Error('claim was not single-flight');
resolveClaim({{ assignees:['timmy'] }});
await first;
if (!states.some(state => state.status === 'claimed')) throw new Error('claim confirmation missing');
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_search_selection_opens_native_preview_without_navigation():
html = (FRONTEND / "index.html").read_text()
run_item = html.split("function runCommandItem(item)", 1)[1].split(
"function renderCommands", 1
)[0]
assert "searchPreview.open(item.result)" in run_item
assert "window.location.assign" not in run_item
remote_branch = run_item.split("} else {", 1)[1]
assert "qs('#cmd-input').value = ''" not in remote_branch
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
html = (FRONTEND / "index.html").read_text()
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
assert "openPreviewIssueInMyWork" in html
+136
View File
@@ -0,0 +1,136 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.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_draft_inbox_discovers_existing_formats_orders_activity_and_ignores_corruption():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-capture.v1', JSON.stringify({{repository:'stackchain/api',title:'Ship recovery',body:'Detailed plan',labelIds:[]}})],
['stackchain.issue-comment.v1:stackchain/api#17', 'Please add a regression test'],
['stackchain.pull-comment.v1:stackchain/web#9', 'Looks good on mobile'],
['stackchain.update-reply.v1.44', 'I can take this'],
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{'app.js':'Handle offline state'}},comments:[],summary:'Needs one fix',decision:'request_changes'}})],
['stackchain.issue-content.v1:stackchain/api#18', '{{broken-json'],
['stackchain.issue-comment.v1:stackchain/api#20:operation', 'not-a-draft'],
]);
const storage = {{
get length() {{ return values.size; }},
key: index => Array.from(values.keys())[index] || null,
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
let clock = 1000;
const inbox = createDraftInbox({{storage, now:() => ++clock}});
const first = inbox.list();
values.set('stackchain.issue-comment.v1:stackchain/api#17', 'Newest comment text');
const second = inbox.list();
process.stdout.write(JSON.stringify({{
kinds:first.map(item => item.kind).sort(),
newest:second[0],
count:second.length,
}}));
"""
output = run_node(script)
assert output["kinds"] == ["issue-comment", "new-issue", "pull-comment", "review", "update-reply"]
assert output["count"] == 5
assert output["newest"]["kind"] == "issue-comment"
assert output["newest"]["repository"] == "stackchain/api"
assert output["newest"]["number"] == 17
assert output["newest"]["preview"] == "Newest comment text"
assert output["newest"]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 17}
def test_draft_discard_removes_only_selected_content_and_operation_identity():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-comment.v1:stackchain/api#17', 'One'],
['stackchain.issue-comment.v1:stackchain/api#17:operation', 'operation-one'],
['stackchain.pull-comment.v1:stackchain/web#9', 'Two'],
]);
const storage = {{
get length() {{ return values.size; }}, key:i => Array.from(values.keys())[i] || null,
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key),
}};
const inbox = createDraftInbox({{storage, now:() => 1000}});
const selected = inbox.list().find(item => item.kind === 'issue-comment');
inbox.discard(selected.id);
process.stdout.write(JSON.stringify({{remaining:inbox.list(), keys:Array.from(values.keys()).sort()}}));
"""
output = run_node(script)
assert [item["kind"] for item in output["remaining"]] == ["pull-comment"]
assert "stackchain.issue-comment.v1:stackchain/api#17" not in output["keys"]
assert "stackchain.issue-comment.v1:stackchain/api#17:operation" not in output["keys"]
assert "stackchain.pull-comment.v1:stackchain/web#9" in output["keys"]
def test_draft_inbox_recovers_issue_edit_and_review_content_without_exposing_empty_records():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-content.v1:stackchain/api#18', JSON.stringify({{title:'Clarify acceptance',body:'Describe mobile flow',expectedUpdatedAt:'2026-08-07T10:00:00Z'}})],
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{}},comments:[],summary:'',decision:'approve'}})],
['stackchain.pull-comment.v1:stackchain/web#10', ' '],
]);
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:()=>42}}).list();
process.stdout.write(JSON.stringify(drafts));
"""
output = run_node(script)
assert len(output) == 1
assert output[0]["kind"] == "issue-edit"
assert output[0]["preview"] == "Clarify acceptance — Describe mobile flow"
assert output[0]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 18}
def test_draft_inbox_expands_issue_outbox_items_with_actionable_states():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:1,items:[
{{id:'queued-1',repository:'stackchain/api',title:'Offline capture',body:'Context',status:'queued',queuedAt:100}},
{{id:'attention-2',repository:'stackchain/web',title:'Fix labels',body:'Details',status:'attention',error:'Unknown label',queuedAt:200}},
]}})]]);
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const drafts = createDraftInbox({{storage,now:()=>300}}).list();
process.stdout.write(JSON.stringify(drafts));
"""
output = run_node(script)
assert [item["outbox_id"] for item in output] == ["attention-2", "queued-1"]
assert [item["status"] for item in output] == ["attention", "queued"]
assert output[0]["label"] == "Needs attention"
assert output[1]["label"] == "Queued issue"
@pytest.mark.anyio
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
html = await dashboard()
assert '<script src="static/drafts.js"></script>' in html
assert 'data-work-filter="draft"' in html
assert 'data-work-count="draft"' in html
assert 'class="draft-resume"' in html
assert 'class="draft-discard"' in html
assert 'Discard this unfinished draft?' in html
assert '.draft-actions button { min-height:44px;' in html
assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" 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",
}
+110 -1
View File
@@ -13,6 +13,7 @@ def reset_available_issue_snapshot():
main._available_issue_snapshot_task = None
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
main._available_issue_snapshot_retry_at = None
yield
task = main._available_issue_snapshot_task
if task is not None and not task.done():
@@ -20,6 +21,7 @@ def reset_available_issue_snapshot():
main._available_issue_snapshot_task = None
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
main._available_issue_snapshot_retry_at = None
@pytest.mark.anyio
@@ -136,6 +138,53 @@ async def test_available_issue_page_ranks_all_upstream_pages_before_logical_pagi
}
@pytest.mark.anyio
async def test_available_issue_snapshot_loads_known_remaining_pages_concurrently():
active = 0
peak_active = 0
remaining_started = asyncio.Event()
release = asyncio.Event()
async def upstream(request):
nonlocal active, peak_active
page = int(request.url.params["page"])
if page == 1:
return httpx.Response(
200, headers={"X-Total-Count": "200"},
json=[{
"id": number, "number": number, "title": f"Issue {number}",
"state": "open", "assignees": [], "pull_request": None,
"labels": [], "repository": {"full_name": "stackchain/api"},
} for number in range(1, 51)],
)
active += 1
peak_active = max(peak_active, active)
if peak_active == 3:
remaining_started.set()
await release.wait()
active -= 1
start = (page - 1) * 50 + 1
return httpx.Response(200, headers={"X-Total-Count": "200"}, json=[{
"id": number, "number": number, "title": f"Issue {number}",
"state": "open", "assignees": [], "pull_request": None,
"labels": [], "repository": {"full_name": "stackchain/api"},
} for number in range(start, start + 50)])
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
task = asyncio.create_task(gitea_proxy.available_issue_snapshot())
try:
await asyncio.wait_for(remaining_started.wait(), timeout=1)
assert peak_active == 3
release.set()
result = await asyncio.wait_for(task, timeout=1)
finally:
release.set()
await gitea_proxy.stop_client()
assert len(result) == 200
assert peak_active == 3
@pytest.mark.anyio
async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkeypatch):
calls = []
@@ -204,7 +253,67 @@ async def test_available_issue_endpoint_retains_last_snapshot_on_refresh_failure
assert response.status_code == 200
assert response.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True,
"has_more": False, "stale": True, "revalidating": True,
}
@pytest.mark.anyio
async def test_available_issue_endpoint_serves_expired_snapshot_while_one_refresh_runs(monkeypatch):
calls = 0
started = asyncio.Event()
release = asyncio.Event()
async def refresh():
nonlocal calls
calls += 1
started.set()
await release.wait()
return [{"number": 8}]
main._available_issue_snapshot_value = [{"number": 7}]
main._available_issue_snapshot_created_at = 0.0
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", refresh)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first, second = await asyncio.gather(
client.get("/api/v1/available-issues?page=1"),
client.get("/api/v1/available-issues?page=1"),
)
await asyncio.wait_for(started.wait(), timeout=1)
assert calls == 1
assert first.json() == second.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True, "revalidating": True,
}
release.set()
await asyncio.wait_for(main._available_issue_snapshot_task, timeout=1)
assert main._available_issue_snapshot_value == [{"number": 8}]
@pytest.mark.anyio
async def test_available_issue_endpoint_backs_off_after_background_refresh_failure(monkeypatch):
calls = 0
async def unavailable():
nonlocal calls
calls += 1
raise httpx.ConnectError("offline")
main._available_issue_snapshot_value = [{"number": 7}]
main._available_issue_snapshot_created_at = 0.0
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", unavailable)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = await client.get("/api/v1/available-issues?page=1")
await asyncio.gather(main._available_issue_snapshot_task, return_exceptions=True)
second = await client.get("/api/v1/available-issues?page=1")
assert calls == 1
assert first.json()["revalidating"] is True
assert second.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True, "refresh_failed": True,
}
+257
View File
@@ -0,0 +1,257 @@
import asyncio
import httpx
import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
requested = []
async def search(query, limit):
requested.append((query, limit))
return {
"items": [{
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
"title": "Repair mobile queue",
"state": "open",
"url": "https://forge.example/stackchain/api/issues/42",
}],
"partial": False,
}
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/search?q=mobile&limit=7")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("mobile", 7)]
assert response.json() == {"query": "mobile", "items": [{
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
"title": "Repair mobile queue",
"state": "open",
"url": "https://forge.example/stackchain/api/issues/42",
}], "partial": False}
@pytest.mark.anyio
async def test_global_search_preview_returns_normalized_action_context(monkeypatch):
requested = []
async def preview(repository, kind, number):
requested.append((repository, kind, number))
return {
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
"title": "Repair mobile queue",
"body": "Restore the queue before release.",
"state": "open",
"author": "alex",
"labels": ["P1"],
"assignees": [],
"url": "https://forge.example/stackchain/api/issues/42",
"claimable": True,
"assigned_to_me": False,
}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview, 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/repos/stackchain/api/issues/42/preview?kind=issue"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("stackchain/api", "issue", 42)]
assert response.json()["claimable"] is True
@pytest.mark.anyio
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
requests = []
async def handler(request):
requests.append(dict(request.url.params))
kind = request.url.params["type"]
if kind == "issues":
return httpx.Response(200, json=[{
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/42",
}, {
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/42",
}, {
"id": 5, "number": 43, "title": "Unsafe", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "javascript:alert(1)",
}])
return httpx.Response(200, json=[{
"id": 8, "number": 9, "title": "Improve search", "state": "closed",
"repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
results = await gitea_proxy.global_search("mobile queue", 7)
finally:
await gitea_proxy.stop_client()
assert {request["type"] for request in requests} == {"issues", "pulls"}
assert all(request["q"] == "mobile queue" and request["limit"] == "7" for request in requests)
assert results == {
"items": [{
"kind": "issue", "repository": "stackchain/api", "number": 42,
"title": "Repair queue", "state": "open",
"url": "https://forge.example/stackchain/api/issues/42",
}, {
"kind": "pull", "repository": "stackchain/web", "number": 9,
"title": "Improve search", "state": "closed",
"url": "https://forge.example/stackchain/web/pulls/9",
}],
"partial": False,
}
@pytest.mark.anyio
async def test_global_search_returns_healthy_stream_when_other_stream_fails():
async def handler(request):
if request.url.params["type"] == "issues":
return httpx.Response(503, json={"message": "upstream details must stay private"})
return httpx.Response(200, json=[{
"number": 9,
"title": "Improve search",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.global_search("search", 10)
finally:
await gitea_proxy.stop_client()
assert result == {
"items": [{
"kind": "pull",
"repository": "stackchain/web",
"number": 9,
"title": "Improve search",
"state": "open",
"url": "https://forge.example/stackchain/web/pulls/9",
}],
"partial": True,
}
@pytest.mark.anyio
async def test_global_search_caps_combined_results_to_requested_limit():
async def handler(request):
kind = request.url.params["type"]
items = [{
"number": number,
"title": f"{kind} {number}",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": f"https://forge.example/stackchain/web/{kind}/{number}",
} for number in (1, 2)]
return httpx.Response(200, json=items)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.global_search("search", 2)
finally:
await gitea_proxy.stop_client()
assert len(result["items"]) == 2
assert result["partial"] is False
@pytest.mark.anyio
async def test_global_search_propagates_cancellation_to_stop_obsolete_work():
async def handler(request):
if request.url.params["type"] == "issues":
raise asyncio.CancelledError()
return httpx.Response(200, json=[])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(asyncio.CancelledError):
await gitea_proxy.global_search("obsolete", 10)
finally:
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_issues():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"number": 42,
"title": "Repair queue",
"body": "Keep mobile operators moving.",
"state": "open",
"html_url": "https://forge.example/stackchain/api/issues/42",
"user": {"login": "alex"},
"labels": [{"name": "P1"}, None],
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/api", "issue", 42)
finally:
await gitea_proxy.stop_client()
assert preview == {
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
"title": "Repair queue",
"body": "Keep mobile operators moving.",
"state": "open",
"author": "alex",
"labels": ["P1"],
"assignees": [],
"url": "https://forge.example/stackchain/api/issues/42",
"claimable": True,
"assigned_to_me": False,
}
@pytest.mark.anyio
async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"number": 9,
"title": "Improve search",
"state": "open",
"html_url": "https://forge.example/stackchain/web/pulls/9",
"pull_request": {"merged": False},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "issue", 9)
finally:
await gitea_proxy.stop_client()
assert preview["kind"] == "pull"
assert preview["claimable"] is False
+73
View File
@@ -0,0 +1,73 @@
import sqlite3
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from src.idempotency import IdempotencyLedger
def test_completed_result_replays_after_ledger_reconstruction(tmp_path):
database = tmp_path / "idempotency.sqlite3"
first = IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
reservation = first.reserve("create-201", ("issue-create", "stackchain/api", "Ship it"))
assert reservation.state == "reserved"
first.complete("create-201", {"number": 42, "title": "Ship it"})
reconstructed = IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
replay = reconstructed.reserve(
"create-201", ("issue-create", "stackchain/api", "Ship it")
)
assert replay.state == "completed"
assert replay.response == {"number": 42, "title": "Ship it"}
assert sqlite3.connect(database).execute(
"SELECT status FROM idempotency_operations WHERE key = ?", ("create-201",)
).fetchone() == ("completed",)
def test_reservation_expires_completed_rows_and_enforces_capacity(tmp_path):
now = [1_000.0]
ledger = IdempotencyLedger(
tmp_path / "bounded.sqlite3",
ttl_seconds=10,
max_entries=2,
clock=lambda: now[0],
)
assert ledger.reserve("old", ("comment", "old")).state == "reserved"
ledger.complete("old", {"id": 1})
now[0] += 11
assert ledger.reserve("new-1", ("comment", "new-1")).state == "reserved"
ledger.complete("new-1", {"id": 2})
assert ledger.reserve("new-2", ("comment", "new-2")).state == "reserved"
ledger.complete("new-2", {"id": 3})
replacement = ledger.reserve("new-3", ("comment", "new-3"))
assert replacement.state == "reserved"
with sqlite3.connect(ledger.path) as connection:
keys = connection.execute(
"SELECT key FROM idempotency_operations ORDER BY key"
).fetchall()
indexes = connection.execute(
"PRAGMA index_list(idempotency_operations)"
).fetchall()
assert keys == [("new-2",), ("new-3",)]
assert any(index[1] == "idempotency_completed_at_idx" for index in indexes)
def test_independent_connections_atomically_reserve_one_operation(tmp_path):
database = tmp_path / "workers.sqlite3"
ledgers = [
IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
for _ in range(2)
]
barrier = Barrier(2)
def reserve(ledger):
barrier.wait()
return ledger.reserve("worker-key-201", ("review", "stackchain/api", 7)).state
with ThreadPoolExecutor(max_workers=2) as executor:
states = list(executor.map(reserve, ledgers))
assert sorted(states) == ["pending", "reserved"]
+357 -6
View File
@@ -5,6 +5,92 @@ import httpx
import pytest
from src import gitea_proxy, main
from src.idempotency import IdempotencyLedger
@pytest.mark.anyio
async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone():
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.method == "GET" and request.url.path.endswith("/issues/17"):
return httpx.Response(200, json={
"number": 17, "state": "open", "pull_request": None,
"assignees": [{"login": "timmy"}],
})
if request.method == "GET" and request.url.path.endswith("/milestones"):
return httpx.Response(200, json=[
{"id": 9, "title": "August RC", "state": "open"},
{"id": 10, "title": "Old release", "state": "closed"},
{"id": "bad", "title": "Invalid", "state": "open"},
])
return httpx.Response(200, json={
"number": 17, "state": "open",
"milestone": {"id": 9, "title": "August RC"},
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
options = await gitea_proxy.repo_milestones("stackchain/api")
result = await gitea_proxy.update_assigned_issue_milestone(
"stackchain/api", 17, 9
)
finally:
await gitea_proxy.stop_client()
assert options == [{"id": 9, "title": "August RC"}]
patch_request = next(request for request in requests if request.method == "PATCH")
assert patch_request.url.path == "/api/v1/repos/stackchain/api/issues/17"
assert json.loads(patch_request.content) == {"milestone": 9}
assert result == {
"repository": "stackchain/api", "number": 17, "state": "open",
"milestone": {"id": 9, "title": "August RC"},
}
@pytest.mark.anyio
async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch):
calls = []
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def milestones(repository):
calls.append(("list", repository))
return [{"id": 9, "title": "August RC"}]
async def update(repository, number, milestone_id):
calls.append(("update", repository, number, milestone_id))
return {
"repository": repository, "number": number, "state": "open",
"milestone": {"id": milestone_id, "title": "August RC"} if milestone_id else None,
}
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones, raising=False)
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue_milestone", update, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
listed = await client.get("/api/v1/repos/stackchain/api/milestones")
updated = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/milestone", json={"milestone_id": 9}
)
missing = await client.get("/api/v1/repos/other/private/milestones")
assert listed.status_code == 200
assert listed.headers["cache-control"] == "no-store"
assert listed.json() == [{"id": 9, "title": "August RC"}]
assert updated.status_code == 200
assert updated.headers["cache-control"] == "no-store"
assert updated.json()["milestone"] == {"id": 9, "title": "August RC"}
assert missing.status_code == 404
assert calls == [
("list", "stackchain/api"),
("update", "stackchain/api", 17, 9),
]
@pytest.mark.anyio
@@ -226,9 +312,11 @@ async def test_edit_assigned_issue_reports_revision_conflict_without_mutation(mo
@pytest.fixture(autouse=True)
def clear_issue_creation_operations():
main._issue_creation_operations.clear()
main._authored_action_operations.clear()
main._idempotency_ledger.clear()
yield
main._issue_creation_operations.clear()
main._authored_action_operations.clear()
main._idempotency_ledger.clear()
@pytest.mark.anyio
@@ -285,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
@@ -331,6 +468,61 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
assert calls == 1
@pytest.mark.anyio
async def test_completed_issue_creation_replays_after_ledger_reconstruction(monkeypatch, tmp_path):
calls = 0
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
nonlocal calls
calls += 1
return {
"id": 201, "number": 201, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T15:00:00Z",
"url": "https://forge.example/stackchain/api/issues/201",
}
database = tmp_path / "issues.sqlite3"
monkeypatch.setattr(
main,
"_idempotency_ledger",
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "restart-create-201"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={"title": "Durable capture"},
headers=headers,
)
main._authored_action_operations.clear()
monkeypatch.setattr(
main,
"_idempotency_ledger",
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
)
replayed = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={"title": "Durable capture"},
headers=headers,
)
assert [created.status_code, replayed.status_code] == [201, 201]
assert replayed.json()["number"] == 201
assert calls == 1
@pytest.mark.anyio
async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_key(monkeypatch):
calls = []
@@ -412,7 +604,9 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
@pytest.mark.anyio
async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_limit(monkeypatch):
async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
monkeypatch, tmp_path
):
async def user():
return {"login": "timmy"}
@@ -427,7 +621,10 @@ async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_lim
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main, "ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES", 2)
ledger = IdempotencyLedger(
tmp_path / "bounded-issues.sqlite3", ttl_seconds=600, max_entries=2
)
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
@@ -441,8 +638,11 @@ async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_lim
)
assert response.status_code == 201
assert len(main._issue_creation_operations) == 2
assert "capture-177-bounded-0" not in main._issue_creation_operations
replay = ledger.reserve(
"capture-177-bounded-0",
("issue-create", "stackchain/api", "Capture 0", "", ()),
)
assert replay.state == "reserved"
@pytest.mark.anyio
@@ -479,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 = []
@@ -551,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
@@ -761,6 +1002,44 @@ async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkey
}
@pytest.mark.anyio
async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no_store(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return repository == "stackchain/api"
async def conversation(repository, number, page, limit):
calls.append(("conversation", repository, number, page, limit))
return {"comments": [{"id": 21}], "page": 2, "older_page": 1, "total": 21}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/repos/stackchain/api/issues/7/comments?page=2&limit=20"
)
missing = await client.get(
"/api/v1/repos/private/secret/issues/7/comments?page=2&limit=20"
)
invalid = await client.get(
"/api/v1/repos/stackchain/api/issues/7/comments?page=999&limit=999"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["older_page"] == 1
assert missing.status_code == 404
assert invalid.status_code == 422
assert calls == [
("assigned", "stackchain/api", 7),
("conversation", "stackchain/api", 7, 2, 20),
("assigned", "private/secret", 7),
]
@pytest.mark.anyio
async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch):
cancelled = asyncio.Event()
@@ -968,6 +1247,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"state": "open",
"body": "Full issue context",
"updated_at": "2026-08-07T09:59:00Z",
"milestone": {"id": 9, "title": "August RC", "description": "not exposed"},
"html_url": "https://forge.example/stackchain/api/issues/7",
"labels": [{"name": "P1"}],
"assignees": [{"login": "timmy"}],
@@ -992,6 +1272,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"body": "Full issue context",
"updated_at": "2026-08-07T09:59:00Z",
"due_date": None,
"milestone": {"id": 9, "title": "August RC"},
"url": "https://forge.example/stackchain/api/issues/7",
"labels": ["P1"],
"assignees": ["timmy"],
@@ -1004,9 +1285,79 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
}
],
"conversation": {
"comments": [
{
"id": 81,
"author": "sam",
"body": "Latest update",
"created_at": "2026-08-07T10:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
}
],
"page": 1,
"older_page": None,
"total": 1,
},
}
@pytest.mark.anyio
async def test_gitea_conversation_page_opens_newest_page_and_reports_older_history():
requests = []
async def handler(request):
requests.append((request.url.path, request.url.query.decode()))
page = request.url.params.get("page")
comments = {
"1": [{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
for value in range(1, 21)],
"3": [{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
for value in range(41, 48)],
}[page]
return httpx.Response(200, json=comments, headers={"X-Total-Count": "47"})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_conversation_page("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == [
("/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
("/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=3"),
]
assert [comment["id"] for comment in result["comments"]] == list(range(41, 48))
assert result["page"] == 3
assert result["older_page"] == 2
assert result["total"] == 47
@pytest.mark.anyio
async def test_gitea_conversation_page_locally_bounds_unpaginated_gitea_comments():
requests = []
all_comments = [
{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
for value in range(1, 48)
]
async def handler(request):
requests.append(request.url.query.decode())
return httpx.Response(200, json=all_comments, headers={"X-Total-Count": "47"})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_conversation_page("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == ["limit=20&page=1"]
assert [comment["id"] for comment in result["comments"]] == list(range(41, 48))
assert result["page"] == 3
assert result["older_page"] == 2
assert result["total"] == 47
@pytest.mark.anyio
async def test_update_assigned_issue_labels_validates_and_returns_confirmed_labels(monkeypatch):
calls = []
+150
View File
@@ -0,0 +1,150 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let sequence = 0;
const outbox = createIssueOutbox({{storage, createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}});
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One',labelIds:[3]}});
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two',milestoneId:4,dueDate:'2026-08-09'}});
const reloaded = createIssueOutbox({{storage}}).list();
process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.get('stackchain.issue-outbox.v1'))}}));
"""
output = run_node(script)
assert [(item["repository"], item["title"]) for item in output["items"]] == [
("stackchain/api", "First"),
("stackchain/web", "Second"),
]
assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
assert all(item["status"] == "queued" for item in output["items"])
assert output["stored"]["version"] == 1
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let sequence = 0;
const calls = [];
const outbox = createIssueOutbox({{
storage,
createOperationId:() => 'stable-' + (++sequence),
fetchJson:async (url, options) => {{
calls.push({{url, key:options.headers['Idempotency-Key']}});
if (calls.length === 2) {{ const error = new Error('upstream unavailable'); error.status = 503; throw error; }}
return {{repository:'stackchain/api',number:41,title:'First'}};
}},
}});
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One'}});
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two'}});
outbox.flush().then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["result"]["confirmed"][0]["number"] == 41
assert output["calls"] == [
{"url": "api/v1/repos/stackchain/api/issues", "key": "stable-1"},
{"url": "api/v1/repos/stackchain/web/issues", "key": "stable-2"},
]
assert len(output["remaining"]) == 1
assert output["remaining"][0]["title"] == "Second"
assert output["remaining"][0]["status"] == "queued"
def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let invalid = true;
const calls = [];
const outbox = createIssueOutbox({{
storage, createOperationId:() => 'stable-edit',
fetchJson:async (_url, options) => {{
calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }}
return {{repository:'stackchain/api',number:42,title:'Fixed title'}};
}},
}});
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bad',body:'Context'}});
outbox.flush().then(async () => {{
const attention = outbox.list()[0];
outbox.update(queued.id, {{...attention,title:'Fixed title'}});
invalid = false;
const result = await outbox.retry(queued.id);
process.stdout.write(JSON.stringify({{attention,result,calls,remaining:outbox.list()}}));
}});
"""
output = run_node(script)
assert output["attention"]["status"] == "attention"
assert output["attention"]["error"] == "Title is invalid"
assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"]
assert output["calls"][1]["body"]["title"] == "Fixed title"
assert output["result"]["confirmed"][0]["number"] == 42
assert output["remaining"] == []
def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let calls = 0;
let release;
const gate = new Promise(resolve => {{ release = resolve; }});
const outbox = createIssueOutbox({{
storage, createOperationId:() => 'single-flight',
fetchJson:async () => {{ calls += 1; await gate; return {{repository:'stackchain/api',number:43,title:'Once'}}; }},
}});
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Once',body:'Context'}});
const reconnect = outbox.flush();
const sendNow = outbox.retry(queued.id);
release();
Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.stringify({{calls,results,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["calls"] == 1
assert output["remaining"] == []
assert sum(len(result["confirmed"]) for result in output["results"]) >= 1
@pytest.mark.anyio
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
html = await dashboard()
assert '<script src="static/issue-outbox.js"></script>' in html
assert "createIssueOutbox({ storage: localStorage" in html
assert "issueOutbox.enqueue(captureDraft)" in html
assert "navigator.onLine" in html
assert "window.addEventListener('online', flushIssueOutbox)" in html
assert 'class="draft-send"' in html
assert 'class="draft-edit"' in html
assert 'Queued for sync' in html
assert 'Needs attention' in html
assert "issueOutbox.discard(item.outbox_id)" in html
assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html
+93
View File
@@ -0,0 +1,93 @@
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",
},
}
@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 '.mobile-task-dock { display:none;' in html
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
assert '<script src="static/mobile-task-dock.js"></script>' in html
assert "createMobileTaskDock({" in html
assert "work: () => qs('#my-work').scrollIntoView" in html
assert "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
+818 -3
View File
@@ -12,7 +12,207 @@ 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"
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const inputs = [
{{kind:'issue',repository:'stackchain/api',number:17}},
{{kind:'pull',repository:'stackchain/dashboard',number:42}},
{{kind:'review',repository:'stackchain/dashboard',number:42}},
{{kind:'update',notification_id:913}},
];
process.stdout.write(JSON.stringify({{
paths: inputs.map(routes.serialize),
parsed: inputs.map(item => routes.parse(routes.serialize(item))),
invalid: [
'#/my-work/issue/../../etc/1',
'#/my-work/issue/stackchain/api/not-a-number',
'#/my-work/update/-1',
'#/other/issue/stackchain/api/1',
].map(routes.parse),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"paths": [
"#/my-work/issue/stackchain/api/17",
"#/my-work/pull/stackchain/dashboard/42",
"#/my-work/review/stackchain/dashboard/42",
"#/my-work/update/913",
],
"parsed": [
{"kind": "issue", "repository": "stackchain/api", "number": 17},
{"kind": "pull", "repository": "stackchain/dashboard", "number": 42},
{"kind": "review", "repository": "stackchain/dashboard", "number": 42},
{"kind": "update", "notification_id": 913},
],
"invalid": [None, None, None, None],
}
def test_work_route_controller_restores_direct_links_and_uses_history_for_close():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const listeners = {{}};
const location = {{hash:'#/my-work/issue/stackchain/api/17', href:'https://forge.example/dashboard/#/my-work/issue/stackchain/api/17'}};
const calls = [];
const history = {{
state: null,
pushState(state, _, hash) {{ this.state = state; location.hash = hash; calls.push(['push', hash]); }},
replaceState(state, _, hash) {{ this.state = state; location.hash = hash; calls.push(['replace', hash]); }},
back() {{ calls.push(['back']); location.hash = ''; listeners.popstate(); }},
}};
const controller = routes.createController({{
location, history,
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
onOpen(item) {{ calls.push(['open', item.kind, item.repository, item.number || item.notification_id]); }},
onClose() {{ calls.push(['close']); }},
onInvalid() {{ calls.push(['invalid']); }},
}});
controller.start();
controller.setItems([{{kind:'issue',repository:'stackchain/api',number:17}}]);
controller.close();
controller.open({{kind:'pull',repository:'stackchain/dashboard',number:9}});
controller.open({{kind:'review',repository:'stackchain/dashboard',number:10}}, {{replace:true}});
process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == [
["open", "issue", "stackchain/api", 17],
["back"],
["close"],
["push", "#/my-work/pull/stackchain/dashboard/9"],
["open", "pull", "stackchain/dashboard", 9],
["replace", "#/my-work/review/stackchain/dashboard/10"],
["open", "review", "stackchain/dashboard", 10],
]
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
def test_work_route_controller_resolves_cold_routes_without_erasing_the_fragment():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const location = {{hash:'#/my-work/issue/stackchain/api/87'}};
const calls = [];
const controller = routes.createController({{
location,
history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
eventTarget: {{addEventListener() {{}}}},
resolve: async route => {{
calls.push(['resolve', route.kind, route.repository, route.number]);
return {{kind:'issue', repository:'stackchain/api', number:87, title:'Older assigned issue'}};
}},
onResolving: route => calls.push(['resolving', route.number]),
onOpen: item => calls.push(['open', item.number, item.title]),
onClose() {{}},
onInvalid: () => calls.push(['invalid']),
onError: () => calls.push(['error']),
}});
(async () => {{
controller.start();
controller.setItems([]);
await new Promise(resolve => setTimeout(resolve, 0));
process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"calls": [
["resolving", 87],
["resolve", "issue", "stackchain/api", 87],
["open", 87, "Older assigned issue"],
],
"hash": "#/my-work/issue/stackchain/api/87",
}
def test_work_route_controller_dismisses_only_confirmed_unavailable_routes():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const location = {{hash:'#/my-work/update/913'}};
const calls = [];
const unavailable = new Error('gone'); unavailable.unavailable = true;
const controller = routes.createController({{
location,
history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
eventTarget: {{addEventListener() {{}}}},
resolve: async () => {{ throw unavailable; }},
onResolving() {{}}, onOpen() {{}}, onClose() {{}},
onInvalid: () => calls.push('invalid'),
onError: () => calls.push('error'),
}});
(async () => {{
controller.start(); controller.setItems([]);
await new Promise(resolve => setTimeout(resolve, 0));
process.stdout.write(JSON.stringify(calls));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == ["invalid"]
def test_work_route_share_prefers_native_share_and_falls_back_to_clipboard():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const calls = [];
(async () => {{
const native = await routes.share('https://forge.example/dashboard/#/my-work/update/9', {{
share: async payload => calls.push(['native', payload.url]),
}}, null);
const fallback = await routes.share('https://forge.example/dashboard/#/my-work/update/10', {{}}, {{
writeText: async text => calls.push(['clipboard', text]),
}});
process.stdout.write(JSON.stringify({{native, fallback, calls}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"native": "shared",
"fallback": "copied",
"calls": [
["native", "https://forge.example/dashboard/#/my-work/update/9"],
["clipboard", "https://forge.example/dashboard/#/my-work/update/10"],
],
}
@pytest.mark.anyio
async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share():
html = await dashboard()
assert '<script src="static/work-route.js"></script>' in html
assert 'const workRoute = createWorkRoute.createController({' in html
assert 'workRoute.setItems(lastMyWork);' in html
assert "api('api/v1/work-route?' + params.toString())" in html
assert "Loading shared work item…" in html
assert 'id="retry-work-route"' in html
assert 'href="' + "' + escAttr(createWorkRoute.serialize(" in html
assert '.read-update { min-height:44px; width:100%; display:flex;' in html
assert 'workRoute.close();' in html
assert html.count('class="share-work-route"') == 4
assert 'createWorkRoute.share(window.location.href, navigator, navigator.clipboard)' in html
assert 'Route unavailable · this item is no longer in My Work.' in html
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
@@ -321,6 +521,112 @@ 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()
assert 'id="work-milestone-filter"' in html
assert '<option value="unplanned">Unplanned</option>' in html
assert 'id="issue-milestone"' in html
assert 'id="save-issue-milestone"' in html
assert 'class="pill milestone-badge"' in html
assert '.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px;' in html
assert 'getMilestone: () => selectedWorkMilestone' in html
assert 'buildMyWork.replaceIssueMilestone(' in html
@pytest.mark.anyio
async def test_mobile_issue_sheet_puts_reading_before_collapsed_planning_controls():
html = await dashboard()
body = html.index('id="issue-sheet-body"')
conversation = html.index('<h2>Full conversation</h2>', body)
planning = html.index('id="issue-planning"', conversation)
labels = html.index('id="issue-label-editor"', planning)
milestone = html.index('id="issue-milestone"', planning)
assert '<summary>Plan &amp; edit</summary>' in html[planning:labels]
assert '<details class="issue-planning" id="issue-planning">' in html
assert body < conversation < planning < labels < milestone
def test_issue_planning_metadata_is_lazy_cached_and_retryable_after_failure():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const calls = [];
let fail = true;
const planning = createIssueSheet.createPlanningLoader({{
loadLabels: async item => {{ calls.push('labels:' + item.number); return [{{id:1,name:'P0'}}]; }},
loadMilestones: async item => {{
calls.push('milestones:' + item.number);
if (fail) throw new Error('offline');
return [{{id:9,title:'RC'}}];
}},
}});
const item = {{repository:'stackchain/api', number:17}};
(async () => {{
const before = calls.slice();
let failed = false;
try {{ await planning.open(item); }} catch (_error) {{ failed = true; }}
fail = false;
const first = await planning.open(item);
const second = await planning.open(item);
process.stdout.write(JSON.stringify({{before, failed, calls, same:first === second, first}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"before": [],
"failed": True,
"calls": ["labels:17", "milestones:17", "labels:17", "milestones:17"],
"same": True,
"first": {"labels": [{"id": 1, "name": "P0"}], "milestones": [{"id": 9, "title": "RC"}]},
}
@pytest.mark.anyio
async def test_opening_issue_defers_planning_requests_until_disclosure_expands():
html = await dashboard()
open_handler = html[html.index('async function openIssueSheet'):html.index('function closeIssueSheet')]
assert 'loadIssueLabelEditor(' not in open_handler
assert 'loadIssueMilestoneEditor(' not in open_handler
assert "if (qs('#issue-planning').open) loadIssuePlanning();" in open_handler
assert "qs('#issue-planning').addEventListener('toggle'" in html
assert 'planningLoader.open(selectedIssue)' in html
assert 'id="retry-issue-planning"' in html
def test_my_work_reviews_filter_and_summary_are_actionable():
items = [
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
@@ -345,6 +651,88 @@ process.stdout.write(JSON.stringify({{
assert output["summary"] == "1 review · 2 assigned"
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
payload = {
"issues": [
{"number": 1, "repository": "stackchain/api", "title": "RC issue",
"milestone": {"id": 9, "title": "August RC"}},
{"number": 2, "repository": "stackchain/api", "title": "Unplanned",
"milestone": None},
],
"pull_requests": [
{"number": 3, "repository": "stackchain/api", "title": "PR"},
],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = {json.dumps(payload)};
const queue = buildMyWork(original);
const updated = buildMyWork.replaceIssueMilestone(
original, 'stackchain/api', 2, {{id:9,title:'August RC'}}
);
process.stdout.write(JSON.stringify({{
rc: buildMyWork.filterMyWork(queue, 'issue', '9').map(item => item.title),
unplanned: buildMyWork.filterMyWork(queue, 'all', 'unplanned').map(item => item.title),
options: buildMyWork.milestoneLanes(queue),
updated: updated.issues[1].milestone,
original: original.issues[1].milestone,
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"rc": ["RC issue"],
"unplanned": ["Unplanned"],
"options": [{"id": 9, "title": "August RC"}],
"updated": {"id": 9, "title": "August RC"},
"original": None,
}
def test_issue_milestone_editor_is_single_flight_and_keeps_scoped_draft_until_confirmed():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
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 calls = [];
let finish;
const controller = createIssueSheet({{
storage,
fetchJson:(url, options) => {{
calls.push({{url, options}});
return new Promise(resolve => {{ finish = resolve; }});
}},
}});
const item = {{repository:'stackchain/api', number:17}};
const first = controller.updateMilestone(item, 9);
const duplicate = controller.updateMilestone(item, 9);
const during = controller.loadMilestoneDraft(item);
finish({{repository:'stackchain/api',number:17,state:'open',milestone:{{id:9,title:'August RC'}}}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
same:first === duplicate, during, after:controller.loadMilestoneDraft(item), results
}})));
"""
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/issues/17/milestone",
"method": "PATCH", "body": {"milestone_id": 9},
}]
assert output["same"] is True
assert output["during"] == 9
assert output["after"] is None
assert output["results"][0]["milestone"] == {"id": 9, "title": "August RC"}
def test_mobile_work_session_follows_filter_and_reconciles_by_identity():
items = [
{"kind": "issue", "repository": "stackchain/api", "number": 1, "title": "First"},
@@ -624,6 +1012,40 @@ controller.load().then(() => controller.loadMore()).then(() =>
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
def test_find_work_keeps_retained_cards_and_announces_refresh_freshness():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
const statuses = [];
const states = [];
const responses = [
{{items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:1,has_more:false,
stale:true,revalidating:true}},
{{items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:1,has_more:false,
stale:true,refresh_failed:true}},
];
const controller = createFindWork({{
fetchJson: () => Promise.resolve(responses.shift()),
onItems: items => states.push(items),
onPagination: () => {{}},
onStatus: status => statuses.push(status),
}});
controller.load().then(() => controller.load()).then(() =>
process.stdout.write(JSON.stringify({{statuses,states,items:controller.items()}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["statuses"] == [
"Showing saved available work while the catalog refreshes…",
"Showing saved available work. Catalog refresh failed; retrying shortly.",
]
assert [item["number"] for item in output["items"]] == [1]
assert len(output["states"]) == 2
def test_find_work_preview_stays_with_issue_across_pagination_and_clears_when_claimed():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
@@ -704,6 +1126,9 @@ async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe
assert '.find-work-action { min-height:44px;' in html
assert 'padding-bottom:calc(18px + env(safe-area-inset-bottom))' in html
assert '@media(max-width:320px)' in html
assert 'const retainedItems = findWorkController.items();' in html
assert 'if (retainedItems.length) renderAvailableIssues(retainedItems);' in html
assert 'Refreshing available issues…' in html
@pytest.mark.anyio
@@ -821,6 +1246,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))});
@@ -847,6 +1371,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"},
@@ -1221,6 +1789,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))});
@@ -1274,6 +1908,100 @@ reader.open(item, [item]).then(() => {{
]
def test_conversation_pager_prepends_older_pages_deduplicates_and_appends_once():
script = f"""
const createConversationPager = require({json.dumps(str(CONVERSATION))});
let calls = 0;
const pager = createConversationPager({{
loadPage: async page => {{ calls += 1; return {{
comments:[{{id:20,body:'duplicate'}},{{id:1,body:'oldest'}}],
page, older_page:null, total:3
}}; }}
}});
pager.reset({{comments:[{{id:20,body:'middle'}},{{id:21,body:'newest'}}],page:2,older_page:1,total:3}});
const first = pager.loadOlder();
const duplicate = pager.loadOlder();
Promise.all([first, duplicate]).then(() => {{
pager.append({{id:22,body:'posted'}});
pager.append({{id:22,body:'posted'}});
process.stdout.write(JSON.stringify({{calls,same:first===duplicate,state:pager.snapshot()}}));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert output["same"] is True
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 20, 21, 22]
assert output["state"]["older_page"] is None
assert output["state"]["total"] == 4
def test_conversation_pager_keeps_loaded_messages_when_older_page_fails():
script = f"""
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const pager = createConversationPager({{loadPage: async () => {{ throw new Error('offline'); }}}});
pager.reset({{comments:[{{id:21,body:'newest'}}],page:2,older_page:1,total:21}});
pager.loadOlder().catch(error => process.stdout.write(JSON.stringify({{
error:error.message, state:pager.snapshot()
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["error"] == "offline"
assert output["state"]["comments"] == [{"id": 21, "body": "newest"}]
assert output["state"]["older_page"] == 1
def test_issue_sheet_conversation_loads_older_history_through_assigned_boundary():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const calls = [];
const controller = createIssueSheet({{
createConversationPager,
fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
}});
const pager = controller.conversation(
{{repository:'stackchain/api',number:7}},
{{comments:[{{id:21}}],page:2,older_page:1,total:21}}
);
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == ["api/v1/repos/stackchain/api/issues/7/comments?page=1&limit=20"]
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]
def test_pull_sheet_conversation_loads_older_history_through_assigned_boundary():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const calls = [];
const controller = createPullSheet({{
createConversationPager,
fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
}});
const pager = controller.conversation(
{{repository:'stackchain/api',number:7}},
{{comments:[{{id:21}}],page:2,older_page:1,total:21}}
);
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == ["api/v1/repos/stackchain/api/pulls/7/comments?page=1&limit=20"]
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]
def test_issue_sheet_loads_encoded_assigned_issue_detail_path():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -1531,6 +2259,63 @@ process.stdout.write(JSON.stringify({{
assert output["next"] == "frontend/app.js"
def test_pull_sheet_lazy_review_is_single_flight_cached_by_head_and_retryable():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
let rejectFirst;
const fetchJson = url => {{
calls.push(url);
if (calls.length === 1) return new Promise((_resolve, reject) => {{ rejectFirst = reject; }});
return Promise.resolve({{head_sha:'abc123', files:[]}});
}};
const sheet = createPullSheet({{fetchJson, storage:null}});
const item = {{repository:'stackchain/api', number:7}};
const first = sheet.loadReview(item, 'abc123');
const concurrent = sheet.loadReview(item, 'abc123');
rejectFirst(new Error('diff unavailable'));
Promise.allSettled([first, concurrent]).then(async failed => {{
const retried = await sheet.loadReview(item, 'abc123');
const cached = await sheet.loadReview(item, 'abc123');
process.stdout.write(JSON.stringify({{
same:first === concurrent,
failed:failed.map(result => result.status),
retried,
cached,
calls,
}}));
}});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert output["same"] is True
assert output["failed"] == ["rejected", "rejected"]
assert output["retried"]["head_sha"] == "abc123"
assert output["cached"] == output["retried"]
assert output["calls"] == [
"api/v1/repos/stackchain/api/pulls/7/review-data",
"api/v1/repos/stackchain/api/pulls/7/review-data",
]
@pytest.mark.anyio
async def test_mobile_pull_sheet_puts_reading_before_collapsed_review_controls():
html = await dashboard()
body = html.index('id="pull-sheet-body"')
conversation = html.index('<h2>Full conversation</h2>', body)
composer = html.index('id="pull-comment-title"', conversation)
review = html.index('id="pull-review"', composer)
files = html.index('id="pull-files"', review)
assert '<summary><h2>Review &amp; merge</h2></summary>' in html
assert '<details class="pull-review" id="pull-review">' in html
assert 'id="pull-review-retry"' in html
assert 'id="pull-review-status"' in html
assert body < conversation < composer < review < files
def test_pull_sheet_renders_mobile_diff_fallbacks_and_review_controls():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -1565,6 +2350,12 @@ async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
assert 'id="pull-files"' in html and 'id="pull-comments"' in html
assert 'id="pull-review-progress"' in html and 'aria-live="polite"' in html
assert 'id="next-unreviewed-pull-file"' in html
assert 'id="load-older-pull-comments"' in html
assert 'id="pull-conversation-status"' in html and 'aria-live="assertive"' in html
assert '<script src="static/conversation.js"></script>' in html
assert "pullController.conversation(item, detail.conversation)" in html
assert "pullConversation.loadOlder()" in html
assert "pullConversation.append(comment)" in html
assert 'id="pull-comment"' in html and 'maxlength="10000"' in html
assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html
assert '<script src="static/pull-sheet.js"></script>' in html
@@ -1590,6 +2381,12 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut
assert 'id="issue-sheet-body"' in html
assert 'id="issue-labels"' in html and 'id="issue-assignees"' in html
assert 'id="issue-comments"' in html
assert 'id="load-older-issue-comments"' in html
assert 'id="issue-conversation-status"' in html and 'aria-live="assertive"' in html
assert "issueController.conversation(item, detail.conversation)" in html
assert "issueConversation.loadOlder()" in html
assert "issueConversation.append(comment)" in html
assert '.conversation-more { min-height:44px;' in html
assert 'id="issue-comment"' in html and 'maxlength="10000"' in html
assert 'id="send-issue-comment"' in html
assert 'id="close-issue"' in html
@@ -1675,7 +2472,7 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
assert '<script src="static/create-issue-sheet.js"></script>' in html
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
assert 'openIssueSheet(created' in html
assert 'openRoutedWork(created' in html
assert 'issueCapture.loadLabels(repository)' in html
assert "input[name=\"create-issue-label\"]:checked" in html
assert '.create-issue-label-option' in html and 'min-height:44px' in html
@@ -1722,7 +2519,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
@@ -1848,7 +2645,8 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
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'].includes(savedFilter)" in html
assert "['all', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)" in html
assert 'data-work-count="draft"' in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
@@ -2320,6 +3118,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
+104 -2
View File
@@ -54,7 +54,77 @@ async def test_assigned_pull_detail_rejects_unassigned_pull(monkeypatch):
@pytest.mark.anyio
async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
async def test_gitea_assigned_pull_detail_loads_reading_without_review_resources():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7,
"title": "Read this first",
"body": "The discussion should not wait for the diff.",
"state": "open",
"mergeable": True,
"head": {"sha": "abc123"},
"user": {"login": "alex"},
})
if request.url.path.endswith("/issues/7/comments"):
return httpx.Response(200, json=[{
"id": 9, "body": "Question", "user": {"login": "sam"},
"created_at": "2026-08-07T18:00:00Z",
}], headers={"X-Total-Count": "1"})
raise AssertionError(f"review resource requested during read load: {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
detail = await gitea_proxy.pull_completion_detail("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/issues/7/comments"),
]
assert detail["head_sha"] == "abc123"
assert detail["conversation"]["comments"][0]["body"] == "Question"
assert "files" not in detail
assert "ci_state" not in detail
@pytest.mark.anyio
async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def review(repository, number):
calls.append(("review", repository, number))
return {
"repository": repository, "number": number, "head_sha": "abc123",
"state": "open", "draft": False, "mergeable": True, "merged": False,
"ci_state": "success", "files": [{"filename": "src/api.py"}],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", review, 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/repos/stackchain/api/pulls/7/review-data")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["files"] == [{"filename": "src/api.py"}]
assert calls == [
("assigned", "stackchain/api", 7),
("review", "stackchain/api", 7),
]
@pytest.mark.anyio
async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
async def handler(request):
path = request.url.path
if path.endswith("/pulls/7"):
@@ -86,7 +156,7 @@ async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
detail = await gitea_proxy.pull_completion_detail("stackchain/api", 7)
detail = await gitea_proxy.pull_completion_review("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
@@ -94,6 +164,38 @@ async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
assert "+new" in detail["files"][0]["diff_lines"]
assert detail["files"][1]["diff_binary"] is True
assert detail["files"][1]["diff_available"] is False
assert "conversation" not in detail
@pytest.mark.anyio
async def test_assigned_pull_conversation_endpoint_reuses_issue_thread_with_pull_authorization(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def conversation(repository, number, page, limit):
calls.append(("conversation", repository, number, page, limit))
return {"comments": [{"id": 41}], "page": 3, "older_page": 2, "total": 47}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/repos/stackchain/api/pulls/7/comments?page=3&limit=20"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"comments": [{"id": 41}], "page": 3, "older_page": 2, "total": 47
}
assert calls == [
("assigned", "stackchain/api", 7),
("conversation", "stackchain/api", 7, 3, 20),
]
@pytest.mark.anyio
+196
View File
@@ -0,0 +1,196 @@
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: [], 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; }} }},
}},
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 () => {{
{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_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/markdown.js",
"/dashboard/static/commands.js",
"/dashboard/static/search-preview.js",
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/my-work.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",
}
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"] == []
+14
View File
@@ -19,3 +19,17 @@ async def test_dashboard_viewport_allows_mobile_zoom():
assert 'name="viewport"' in html
assert "width=device-width" in html
assert "user-scalable=no" not in html
@pytest.mark.anyio
async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
html = await dashboard()
assert 'id="search-preview" role="dialog" aria-modal="true"' in html
assert 'aria-labelledby="search-preview-title"' in html
assert 'id="claim-search-result"' in html
assert 'id="open-search-result-gitea"' in html
assert 'src="static/search-preview.js"' in html
assert ".search-preview-panel" in html
assert "height:100dvh" in html
assert "env(safe-area-inset-bottom)" in html
+52 -1
View File
@@ -21,6 +21,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
"repository": {"full_name": "stackchain/api"},
"updated_at": "2026-08-07T10:00:00Z",
"due_date": "2026-08-09T23:59:59Z",
"milestone": {"id": 9, "title": "August RC", "state": "open"},
"html_url": "https://forge.example/stackchain/api/issues/51",
}],
}
@@ -40,6 +41,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
"updated_at": "2026-08-07T10:00:00Z",
"due_date": "2026-08-09T23:59:59Z",
"milestone": {"id": 9, "title": "August RC"},
"url": "https://forge.example/stackchain/api/issues/51",
}],
}
@@ -59,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"
+100
View File
@@ -0,0 +1,100 @@
import httpx
import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_work_route_endpoint_resolves_one_authorized_item(monkeypatch):
requested = []
async def resolve(kind, repository, number, notification_id):
requested.append((kind, repository, number, notification_id))
return {
"kind": "review",
"repository": "stackchain/dashboard",
"number": 87,
"title": "Review the mobile resolver",
"is_review": True,
"url": "https://forge.example/stackchain/dashboard/pulls/87",
}
monkeypatch.setattr(main.gitea_proxy, "resolve_work_route", resolve, 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/work-route?kind=review&repository=stackchain/dashboard&number=87"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("review", "stackchain/dashboard", 87, None)]
assert response.json() == {
"kind": "review",
"repository": "stackchain/dashboard",
"number": 87,
"title": "Review the mobile resolver",
"is_review": True,
"url": "https://forge.example/stackchain/dashboard/pulls/87",
}
@pytest.mark.anyio
async def test_resolve_work_route_returns_only_a_requested_review():
requests = []
async def upstream(request):
requests.append(request.url.path)
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 700,
"number": 87,
"title": "Review the resolver",
"state": "open",
"html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/pulls/87",
"repository": {"full_name": "stackchain/dashboard"},
"requested_reviewers": [{"login": "timmy"}],
"assignees": [],
"user": {"login": "alex"},
})
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.resolve_work_route(
"review", "stackchain/dashboard", 87, None
)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 2
assert result == {
"kind": "review",
"repository": "stackchain/dashboard",
"number": 87,
"title": "Review the resolver",
"state": "open",
"url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/pulls/87",
"is_review": True,
"work_reasons": ["review_requested"],
}
@pytest.mark.anyio
async def test_resolve_work_route_rejects_a_notification_that_is_already_read():
async def upstream(request):
if request.url.path.endswith("/notifications/threads/913"):
return httpx.Response(200, json={
"id": 913,
"unread": False,
"repository": {"full_name": "stackchain/dashboard"},
"subject": {"title": "Already handled", "type": "Issue"},
})
return httpx.Response(404)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
with pytest.raises(gitea_proxy.WorkRouteUnavailableError):
await gitea_proxy.resolve_work_route("update", None, None, 913)
finally:
await gitea_proxy.stop_client()