Compare commits

...
Author SHA1 Message Date
timmy 0ac1277479 perf: keep session validation off event loop (#275)
CI / lint (pull_request) Successful in 26s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 07:15:37 +00:00
rockachopa 6185f6a7ff Merge pull request 'Surface deadline-critical work in mobile Attention' (#274) from timmy/273-mobile-deadline-attention into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 26s
CI / build-frontend (push) Successful in 5s
2026-08-08 06:55:34 +00:00
timmy 98d6a29fa0 feat: surface deadline-critical work in Attention (#273)
CI / lint (pull_request) Successful in 27s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 06:54:18 +00:00
timmy 3decdb4cac Merge pull request 'Keep sign-in retry guidance within the mobile viewport' (#272)
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 29s
CI / build-frontend (push) Successful in 5s
Closes #270
2026-08-08 06:27:17 +00:00
timmy 0fa3e675dc fix: keep sign-in retry state within mobile viewport (#270)
CI / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 6s
2026-08-08 06:26:13 +00:00
timmy 3210b8be6d Merge pull request 'Throttle repeated operator sign-in failures' (#271)
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 26s
Closes #270
2026-08-08 06:20:25 +00:00
timmy 87becb08ad feat: throttle repeated operator sign-in failures (#270)
CI / lint (pull_request) Successful in 26s
CI / build-frontend (pull_request) Successful in 7s
2026-08-08 06:19:08 +00:00
timmy 334fe77e84 Revoke captured operator sessions on logout (#269)
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 25s
Closes #268
2026-08-08 05:57:06 +00:00
timmy c8de28ca0c feat: revoke operator sessions on logout (#268)
CI / lint (pull_request) Successful in 24s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 05:55:51 +00:00
timmy 741d768fc3 Merge pull request 'Resume queued delivery after operator sign-in' (#267) from timmy/266-resume-queued-delivery into main
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 24s
CI / build-frontend (push) Successful in 5s
2026-08-08 05:34:02 +00:00
timmy acd2d82d10 fix: resume queued delivery after sign-in (#266)
CI / lint (pull_request) Successful in 24s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 05:32:29 +00:00
timmy 65d1f9f194 Confirm offline work only after durable background admission (#265)
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 25s
CI / build-frontend (push) Successful in 4s
Closes #264
2026-08-08 05:11:20 +00:00
timmy c74c63d29c feat: make background queue admission durable (#264)
CI / lint (pull_request) Successful in 25s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 05:10:11 +00:00
timmy 94aad3e76e Merge pull request 'Fail closed when dashboard authentication is not explicitly configured' (#263) from timmy/262-fail-closed-dashboard-auth into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 25s
CI / build-frontend (push) Successful in 4s
Closes #262
2026-08-08 04:33:25 +00:00
timmy 00a956b1fb feat: fail closed on invalid dashboard auth (#262)
CI / lint (pull_request) Successful in 25s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 04:32:15 +00:00
rockachopa 7bdfe62495 Merge pull request 'Notify operators when background queued work finishes' (#261) from timmy/260-background-delivery-receipts into main
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 30s
CI / build-frontend (push) Successful in 4s
2026-08-08 04:19:47 +00:00
timmy 1b10a5a8a8 feat: notify on background delivery completion (#260)
CI / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 04:18:32 +00:00
timmy a7afd85634 Merge pull request 'Require operator sessions for privileged dashboard access' (#259) from timmy/258-operator-session into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 24s
CI / build-frontend (push) Successful in 4s
2026-08-08 03:42:53 +00:00
timmy 39357263d0 feat: require operator sessions for privileged access (#258)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 03:41:30 +00:00
rockachopa e40c75b360 Merge pull request 'Defer from mobile detail sheets and continue triage' (#257) from timmy/256-mobile-detail-defer into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 24s
CI / build-frontend (push) Successful in 4s
2026-08-08 03:17:03 +00:00
timmy fdb39e43ad feat: defer from mobile detail sheets (#256)
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 03:16:01 +00:00
rockachopa 61168dbfd4 Defer mobile Attention items into a Later queue (#255)
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 24s
CI / build-frontend (push) Successful in 4s
Closes #254
2026-08-08 02:42:22 +00:00
timmy 65a09b5c66 feat: add mobile Later queue (#254)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 02:41:09 +00:00
rockachopa 8e0389153c Merge pull request 'Deliver queued authored messages after the app closes' (#253) from timmy/252-background-authored-message-sync into main
Release / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 24s
CI / build-frontend (push) Successful in 4s
2026-08-08 02:12:33 +00:00
timmy c3e8ead47b feat: deliver authored messages after app close (#252)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 02:11:26 +00:00
timmy 5f6dd265a5 Merge pull request 'Deliver queued issue captures after the app closes' (#251) from timmy/250-background-issue-sync into main
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 24s
CI / build-frontend (push) Successful in 6s
2026-08-08 01:45:29 +00:00
timmy a4beca2d9e feat: deliver offline issue captures after app close (#250)
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 4s
Closes #250
2026-08-08 01:44:20 +00:00
timmy 5f3f0176f1 Keep authored actions responsive during ledger contention (#249)
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 21s
CI / build-frontend (push) Successful in 5s
Closes #248
2026-08-08 01:02:39 +00:00
timmy b3c76dcd69 fix: keep authored actions responsive under ledger contention (#248)
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 01:01:31 +00:00
rockachopa 540584a417 Merge pull request 'Bound aggregate Gitea traffic and coalesce concurrent reads' (#247) from timmy/246-gitea-transport-bulkhead into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 21s
CI / build-frontend (push) Successful in 4s
2026-08-08 00:35:28 +00:00
timmy 4f6ba0302d feat: bound aggregate Gitea transport traffic (#246)
CI / lint (pull_request) Successful in 21s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 00:34:10 +00:00
rockachopa d7d6170c3e Merge pull request 'Add a one-tap mobile Attention queue' (#245) from timmy/244-mobile-attention-queue into main
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 21s
CI / build-frontend (push) Successful in 5s
2026-08-08 00:01:32 +00:00
timmy 51ac802d77 feat: add mobile attention queue (#244)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 00:00:21 +00:00
rockachopa e0bc7271fa Merge pull request 'Coordinate offline outboxes safely across dashboard tabs' (#243) from timmy/242-cross-tab-outbox-coordination into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 22s
CI / build-frontend (push) Successful in 5s
2026-08-07 23:35:24 +00:00
timmy ddd87e9051 feat: coordinate offline outboxes across tabs (#242)
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 23:34:21 +00:00
timmy 8b71de70bd Continue assigned reviews directly into merge (#241)
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 20s
CI / build-frontend (push) Successful in 4s
Closes #240
2026-08-07 23:14:10 +00:00
timmy 2bd8426db8 feat: continue assigned approvals into merge (#240)
CI / lint (pull_request) Successful in 19s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 23:12:55 +00:00
rockachopa 9289434ad2 Merge pull request 'Bind offline outboxes to their originating Gitea account' (#239) from timmy/238-account-bound-outboxes into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 20s
CI / build-frontend (push) Successful in 5s
2026-08-07 22:59:05 +00:00
timmy a73b3bc773 feat: bind offline outboxes to Gitea identity (#238)
CI / lint (pull_request) Successful in 19s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 22:57:49 +00:00
rockachopa d155f0081f Merge pull request 'Add a thumb-reachable mobile task dock' (#237)
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 21s
CI / build-frontend (push) Successful in 5s
Closes #236
2026-08-07 22:33:50 +00:00
timmy bad7d524f0 feat: add mobile task dock (#236)
CI / lint (pull_request) Successful in 21s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 22:32:41 +00:00
rockachopa ea7f3cf098 Merge pull request 'Queue authored conversation messages during connectivity failures' (#235) from timmy/234-authored-message-outbox into main
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 21s
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
Release / release-candidate (push) Successful in 3s
CI / lint (push) Successful in 19s
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
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 19s
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
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 19s
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
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 18s
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)
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 18s
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)
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 18s
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
Release / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 18s
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
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 20s
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
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 18s
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)
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 17s
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
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 17s
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
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 18s
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
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 17s
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
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 21s
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)
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 17s
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
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 17s
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
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 16s
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
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 18s
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)
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 17s
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)
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 14s
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
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 14s
CI / build-frontend (push) Successful in 4s
2026-08-07 11:55:25 +00:00
timmy 2072de337e feat: add mobile My Work sessions (#193)
CI / lint (pull_request) Successful in 14s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 11:54:31 +00:00
rockachopa ecd4b55a26 Merge pull request 'Schedule assigned issues from mobile My Work' (#192) from timmy/191-mobile-issue-due-dates into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 16s
CI / build-frontend (push) Successful in 5s
2026-08-07 11:30:32 +00:00
timmy c639b09aaf feat: schedule assigned issues from mobile (#191)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 11:29:35 +00:00
timmy 5406ee2297 Merge pull request 'Edit assigned issue content safely from mobile' (#190) from timmy/189-edit-assigned-issue-content into main
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 14s
CI / build-frontend (push) Successful in 5s
2026-08-07 11:08:46 +00:00
timmy 937df5d20b feat: edit assigned issue content from mobile (#189)
CI / lint (pull_request) Successful in 15s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 11:07:56 +00:00
rockachopa c0eabbf865 Merge pull request 'Review assigned pull diffs before mobile merge' (#188) from timmy/187-review-assigned-pull-diffs into main
Release / release-candidate (push) Successful in 3s
CI / lint (push) Successful in 13s
CI / build-frontend (push) Successful in 4s
2026-08-07 10:24:08 +00:00
timmy 2d8a659ebe feat: review assigned pull diffs before merge (#187)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 10:23:15 +00:00
rockachopa 3ff173ca89 Merge pull request 'Make mobile authored actions idempotent across retries' (#186) from timmy/185-idempotent-authored-actions into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 13s
CI / build-frontend (push) Successful in 4s
2026-08-07 09:56:14 +00:00
timmy 0bc6e01eb5 feat: make authored actions idempotent (#185)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 09:55:21 +00:00
timmy 4da3b198d0 Release mobile issue assignments from My Work (#184)
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 14s
CI / build-frontend (push) Successful in 5s
Closes #183
2026-08-07 09:25:06 +00:00
timmy 87058d72e6 feat: release mobile issue assignments (#183)
CI / lint (pull_request) Successful in 14s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 09:24:21 +00:00
rockachopa e96ff42dff Merge pull request 'Make Find Work production-schema-safe and truthfully paginated' (#182) from timmy/181-find-work-schema-pagination into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 12s
CI / build-frontend (push) Successful in 4s
2026-08-07 08:59:02 +00:00
timmy b6c7ec83e4 feat: make Find Work production-schema-safe (#181)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 08:58:07 +00:00
rockachopa b4f984e331 Preview issue details before claiming mobile work (#180)
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 13s
CI / build-frontend (push) Successful in 4s
Closes #179
2026-08-07 08:23:29 +00:00
timmy b4b13a7db1 feat: preview issue details before claiming (#179)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 08:22:43 +00:00
rockachopa 1947f878e9 Make mobile issue capture idempotent across retries (#178)
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 13s
Closes #177
2026-08-07 07:55:30 +00:00
timmy 6f54d5764b feat: make mobile issue capture idempotent (#177)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 07:54:42 +00:00
rockachopa 9293dde1af Merge pull request 'Discover and claim unassigned issues from mobile My Work' (#176) from timmy/175-mobile-find-work into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 13s
CI / build-frontend (push) Successful in 4s
2026-08-07 07:31:45 +00:00
timmy 1f997cc504 feat: discover and claim mobile work (#175)
CI / lint (pull_request) Successful in 14s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 07:30:43 +00:00
rockachopa f9e11fca6a Merge pull request 'Reprioritize assigned issues from the mobile issue sheet' (#174) from timmy/173-mobile-issue-labels into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 13s
CI / build-frontend (push) Successful in 5s
2026-08-07 06:53:49 +00:00
timmy 390ad0d4d2 feat: reprioritize assigned issues from mobile (#173)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 06:52:53 +00:00
timmy b673911e36 Merge pull request 'Make My Work authorization constant-request and deadline-safe' (#172) from timmy/171-constant-request-work-auth into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 13s
CI / build-frontend (push) Successful in 5s
2026-08-07 05:53:22 +00:00
timmy 484c0d4aeb perf: bound My Work authorization requests (#171)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 05:52:28 +00:00
rockachopa 5ce29fbe7d Merge pull request 'Paginate mobile My Work beyond the first 50 items' (#170) from timmy/169-paginate-mobile-my-work into main
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 13s
CI / build-frontend (push) Successful in 4s
2026-08-07 05:36:55 +00:00
timmy 625e65e7e4 feat: paginate mobile My Work queues (#169)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 05:35:45 +00:00
rockachopa 29a786cea3 Merge pull request 'Keep healthy live sections refreshing during partial outages' (#168) from timmy/167-section-isolated-live-refresh into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 13s
2026-08-07 04:54:28 +00:00
timmy 4870b58c5b feat: isolate live section refresh backoff (#167)
CI / lint (pull_request) Successful in 15s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 04:53:42 +00:00
timmy 5fe0b28747 Merge pull request 'Add draft-safe inline comments to mobile pull request reviews' (#166) from timmy/165-mobile-inline-review-comments into main
Release / release-candidate (push) Successful in 3s
CI / lint (push) Successful in 12s
CI / build-frontend (push) Successful in 6s
2026-08-07 04:29:40 +00:00
timmy 58376f6d45 feat: add inline mobile review comments (#165)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 04:28:44 +00:00
timmy 4b7405aa65 Merge pull request 'Select priority labels while creating mobile issues' (#164) from timmy/163-mobile-issue-labels into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 13s
CI / build-frontend (push) Successful in 4s
2026-08-07 03:56:56 +00:00
timmy b6390d3298 feat: triage mobile issue capture with labels (#163)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 03:55:51 +00:00
timmy e5f001f2b0 Merge pull request 'Complete assigned pull requests from mobile My Work' (#162) from timmy/161-mobile-assigned-pr-completion into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 13s
CI / build-frontend (push) Successful in 4s
2026-08-07 03:31:10 +00:00
timmy 745c3779c2 feat: complete assigned pull requests from mobile (#161)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 03:30:21 +00:00
timmy 35e46f81f1 Merge pull request 'Create and self-assign issues from mobile My Work' (#160) from timmy/159-mobile-issue-capture into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 15s
CI / build-frontend (push) Successful in 4s
2026-08-07 02:59:05 +00:00
timmy bb394be8b2 feat: capture issues from mobile My Work (#159)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 02:58:14 +00:00
timmy 8c889e3ffc Merge pull request 'Complete assigned issues from mobile My Work' (#158) from timmy/157-mobile-issue-actions into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 12s
CI / build-frontend (push) Successful in 4s
2026-08-07 02:41:57 +00:00
timmy 1c0cacb2b8 feat: complete assigned issues from mobile My Work (#157)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 02:40:57 +00:00
timmy 30a014b619 Merge pull request 'Submit pull request reviews from mobile My Work' (#156) from timmy/155-mobile-review-submit into main
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 11s
2026-08-07 02:02:03 +00:00
timmy 9b81dce8aa feat: submit pull reviews from mobile My Work (#155)
CI / lint (pull_request) Successful in 11s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 02:01:18 +00:00
timmy 7f5e671f8d Merge pull request 'Reply to unread updates from mobile My Work' (#154) from timmy/153-mobile-update-replies into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 11s
CI / build-frontend (push) Successful in 5s
2026-08-07 01:02:56 +00:00
timmy 83de2a7499 feat: reply to unread updates in My Work (#153)
CI / lint (pull_request) Successful in 11s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 01:01:59 +00:00
timmy 62ebabb132 Merge pull request 'Read and triage unread updates inside mobile My Work' (#152) from timmy/151-mobile-update-reader into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 11s
CI / build-frontend (push) Successful in 5s
2026-08-07 00:36:31 +00:00
70 changed files with 19058 additions and 281 deletions

No files matched your search

+1
View File
@@ -2,3 +2,4 @@ __pycache__/
*.py[cod]
.pytest_cache/
.release-engine/
.stackchain-state/
+173 -8
View File
@@ -14,26 +14,191 @@ 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 and update the authenticated user's
notification threads, then start the API and bundled frontend:
token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, discover, claim, and release issue assignments,
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. The New issue sheet can optionally select an open
repository milestone and due date; the dashboard validates both and sends them with
self-assignment in the single create request, so planned work appears in its release
lane immediately. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. Results
are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
writes run outside the request event loop, and lock admission is bounded to 100 ms by
default. Tune it with `STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS`; keep the value below
route deadlines. Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
If contention occurs after the upstream mutation, the dashboard fails closed with
`Retry-After: 5` and asks the caller to verify the result before retrying. Direct API callers
should preserve the `Idempotency-Key` header with the unchanged route and payload until a
`201` response is confirmed. Closing an assigned issue, native Comment,
Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. Native Comment, Approve, and Request changes reviews support
head-scoped draft comments anchored to changed lines; the dashboard validates each
comment path and submits the summary, decision, and inline comments in one review
request. The dashboard rechecks the current pull-request head, CI success, draft
state, and mergeability immediately before every merge.
Serve the dashboard only to trusted users on its own origin; cross-origin API
access is intentionally disabled. Authentication defaults to fail-closed
`operator` mode. It requires two independent secrets of at least 24 characters;
missing, short, reused, or invalid configuration leaves `/healthz` available but
returns HTTP 503 before any Gitea access. The access token is entered at `/login`;
the browser receives only a short-lived signed session and CSRF proof. Generate
both values in your secret manager (for example, `openssl rand -hex 32`) and inject
them at runtime—never commit them:
```bash
export GITEA_URL='https://forge.example.com'
export GITEA_TOKEN='<read-scoped-token>'
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
# Optional; defaults to eight hours.
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
export STACKCHAIN_LOGIN_MAX_FAILURES=5
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
# Trust forwarding headers only from these immediate reverse-proxy networks.
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
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.
Sign-in failures are scoped to a hashed canonical client address and persisted across
workers and restarts. Once the budget is exhausted, the server returns `429` with
`Retry-After`; the mobile login form disables retries for that interval. Expired
source records are pruned and the ledger is size-bounded. Keep its SQLite file on
shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
that setting, a reverse proxy is safely treated as one shared source.
Each signed cookie includes an opaque session identifier whose hash and expiry are
kept in the SQLite session registry. Keep that registry on persistent, writable
storage shared by all dashboard workers. Sign-out revokes only the current session
before clearing browser state, so a copied cookie cannot be replayed afterward;
other signed-in devices remain active. Deploying this version invalidates older
cookies that do not contain a registered identifier, so operators must sign in once
again. Registry read or write failures return a sanitized HTTP 503 before Gitea is
contacted.
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath.
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
removing unrelated forge preferences. Rotate either dashboard secret by replacing
the injected value and restarting the service; changing the signing secret expires
all sessions immediately. The signing key is never derived from the access token.
For direct, unproxied local development only, set
`STACKCHAIN_DASHBOARD_AUTH_MODE=insecure-local` and open
`http://127.0.0.1:8000/`. This mode checks the network peer and rejects non-loopback
clients; do not use it behind a reverse proxy, whose loopback connection would hide
the browser's peer address. To verify the backend and its Gitea connection in
operator mode, sign in and request `http://127.0.0.1:8000/api/v1/context`; a
successful response is JSON containing
`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard
to search commands plus issues and pull requests across every repository visible to
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.
The application-lifetime Gitea transport bounds aggregate upstream traffic and
coalesces identical concurrent GETs without caching completed responses. By
default, at most eight requests run at once, with one slot reserved for authored
mutations so polling and detail-read bursts cannot starve comments, reviews, or
other writes. Requests that cannot enter within 250 ms fail as retryable HTTP
503 responses. Tune these limits with `GITEA_MAX_CONCURRENCY` (minimum `2`) and
`GITEA_ADMISSION_TIMEOUT_SECONDS` (minimum `0.001`); keep the admission timeout
below the route deadlines. Streaming diff reads share the same read capacity,
while POST, PATCH, PUT, and DELETE requests are never coalesced.
## Offline mobile shell
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,
**Search**, and **Drafts** thumb-reachable. It reuses the existing sheets and My Work
filter, preserves the selected release lane, shows the current draft count, respects
the device safe area, and moves out of the way while a full-screen task is open.
Desktop layout is unchanged.
My Work also has a local **Later** queue. **Later today** defers an item for four
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone.
Deferred items leave normal and Attention queues without marking notifications read
or changing any Gitea issue or pull request. They automatically return to their
existing priority position at the wake time, and **Bring back now** restores them
early. Later state is stored only in this browser, scoped to the confirmed Gitea
login, and removed when fully loaded work confirms that an item no longer exists.
After one successful online load, the installed dashboard precaches a versioned,
subpath-scoped application shell. During a network outage or a dashboard HTTP
`500`, `502`, `503`, or `504` response, navigation falls back to that shell when
it is available so local issue and review drafts remain reachable. Authentication
and other client-error responses are never hidden. A visible, accessible offline
or server-outage notice distinguishes cached mode from live Gitea data, and the
dashboard keeps polling until it can restore its live snapshot automatically.
Users can explicitly enable **Keep My Work available offline**. Each healthy live
refresh then stores a seven-day, versioned snapshot containing only the signed-in
user identity and queue-card metadata for issues, pull requests, unread updates,
and pagination totals. Bodies, comments, diffs, credentials, repository catalogs,
events, and complete API responses are excluded. A cold offline launch labels the
saved time and renders this snapshot read-only; opening live details, pagination,
and server mutations remain disabled until reconnection. **Clear offline work
data** deletes the snapshot, and opting out deletes it automatically.
API responses and mutations are never cached by the service worker. New issue captures,
issue comments, pull-request comments, and unread-update replies use bounded local
outboxes when connectivity or a retryable server failure prevents delivery. Issue
captures and authored messages are also mirrored into account-bound IndexedDB lanes
and registered with Background Sync, so a supporting installed browser can deliver
new issues, issue comments, pull-request comments, and unread-update replies after
every dashboard client has closed. The worker verifies the current Gitea login, shares
an atomic delivery claim with the foreground path, and preserves the original
idempotency key. Installed browsers can explicitly enable **Notify me when queued
work finishes**; permission is requested only from that user gesture and the choice
is stored for the confirmed account in the private background outbox database.
Successful deliveries produce privacy-safe receipts that open the created issue or
source conversation, while permanent validation failures open Drafts for recovery.
Authentication expiry during a worker mutation is recoverable: the worker releases the
claim without creating an Attention receipt, stops that drain, and keeps the original
idempotency key. Loading the authenticated dashboard after signing in asks the worker
to resume queued delivery automatically.
Notification text never includes issue titles, comment bodies, or validation details.
The preference is off by default, unsupported or denied browsers retain foreground
reconciliation, and **Sign out & clear this device** removes the account-bound choice.
Browsers without
IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows
queued and needs-attention messages with explicit send/discard controls; reopening the
dashboard reconciles worker completions and permanent failures into the visible outbox.
Foreground delivery flushes messages sequentially with their original idempotency keys.
Tabs coordinate each queue through the browser lock manager (with an expiring local
lease fallback), so an installed PWA and a browser tab cannot submit the same operation
concurrently. Queue changes are broadcast to other tabs and refresh Drafts without
polling; an abandoned lease can be reclaimed after expiry. State-sensitive actions such
as reviews, merges, closures, labels, milestones, and assignments are never queued.
Service-worker upgrades are atomic and remove only older
`stackchain-dashboard-*` caches, preserving unrelated caches on the same origin.
Run the test suite with:
```bash
+9
View File
@@ -0,0 +1,9 @@
import pytest
@pytest.fixture(autouse=True)
def explicit_local_dashboard_auth_mode(monkeypatch):
"""Keep tests intentional now that deployed authentication fails closed."""
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
+202
View File
@@ -0,0 +1,202 @@
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
const storageKey = 'stackchain.authored-outbox.v1';
const makeId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']);
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && supportedKinds.has(item.kind));
} catch (_error) { return []; }
}
function write(items, mirror = true) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
coordinator?.notify('authored');
if (mirror && backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items, 'authored'))
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
}
}
function enqueue(message, mirror = true) {
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
const items = read();
const requestedOperationId = String(message.operationId || '').slice(0, 128);
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
if (existing) return { ...existing };
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
const id = String(requestedOperationId || makeId()).slice(0, 128);
const item = {
id,
operationId: requestedOperationId || id,
kind: message.kind,
repository: String(message.repository || ''),
number: Number(message.number || 0),
notificationId: Number(message.notificationId || 0),
body: String(message.body || ''),
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
};
items.push(item);
write(items, mirror);
return item;
}
async function enqueueDurably(message) {
const item = enqueue(message, false);
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
try {
await backgroundSync.reconcile(read(), 'authored');
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
}
}
function update(id, changes) {
let updated = null;
write(read().map(item => {
if (item.id !== id) return item;
const body = String(changes?.body ?? item.body);
updated = {
...item,
body,
operationId: body === item.body ? item.operationId : String(makeId()).slice(0, 128),
status: 'queued',
};
delete updated.error;
return updated;
}));
return updated;
}
function discard(id) {
const items = read();
if (!items.some(item => item.id === id)) return false;
write(items.filter(item => item.id !== id));
return true;
}
function endpoint(item) {
if (item.kind === 'update-reply') {
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
}
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
}
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
const request = (async () => {
try {
let result;
if (backgroundSync?.send) {
const delivery = await backgroundSync.send(item, currentLogin);
if (delivery.attention) {
const error = delivery.error || new Error('Message needs attention');
error.status = Number(error.status || 422);
throw error;
}
result = delivery.message;
} else {
result = await fetchJson(endpoint(item), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
});
}
if (!result) return { blocked: true };
discard(item.id);
return { result };
} catch (error) {
const status = Number(error?.status || 0);
const permanent = status >= 400 && status < 500;
if (permanent) {
write(read().map(candidate => candidate.id === item.id ? {
...candidate,
status: 'attention',
error: String(error.message || 'Message needs attention').slice(0, 240),
} : candidate));
}
return { error, transient: !permanent };
}
})();
pending.set(item.id, request);
try { return await request; }
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
}
async function flushQueue(currentLogin) {
const confirmed = [];
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
if (item.status === 'attention') continue;
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
const outcome = await sendItem(item, currentLogin);
if (outcome.result) confirmed.push(outcome.result);
if (outcome.transient) break;
}
return { confirmed, remaining: read(), blocked };
}
async function flush(currentLogin) {
if (coordinator) return coordinator.runExclusive('authored', () => flushQueue(currentLogin));
return flushQueue(currentLogin);
}
async function retryItem(id, currentLogin) {
const item = read().find(candidate => candidate.id === id);
if (!item) return { confirmed: [], remaining: read() };
currentLogin = String(currentLogin || '').trim();
if (!currentLogin || item.ownerLogin !== currentLogin) {
return { confirmed: [], remaining: read(), blocked: 1 };
}
const queued = item.status === 'attention' ? update(id, item) : item;
const outcome = await sendItem(queued, currentLogin);
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 };
}
async function retry(id, currentLogin) {
if (coordinator) return coordinator.runExclusive('authored', () => retryItem(id, currentLogin));
return retryItem(id, currentLogin);
}
function reconcileBackground(records) {
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
if (background?.status === 'sent') return [];
if (background?.status === 'attention') return [{
...item,
status: 'attention',
error: String(background.error || 'Message needs attention').slice(0, 240),
}];
return [item];
});
write(items);
return items;
}
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
+284
View File
@@ -0,0 +1,284 @@
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-outbox-v1') {
let databasePromise;
function database() {
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains('issues')) {
request.result.createObjectStore('issues', { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return databasePromise;
}
const requested = request => new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return async work => {
const db = await database();
return new Promise((resolve, reject) => {
const transaction = db.transaction('issues', 'readwrite');
const objectStore = transaction.objectStore('issues');
let result;
let failed = false;
transaction.oncomplete = () => failed ? undefined : resolve(result);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted'));
Promise.resolve(work({
getAll: () => requested(objectStore.getAll()),
put: value => requested(objectStore.put(value)),
delete: id => requested(objectStore.delete(id)),
})).then(value => { result = value; }).catch(error => {
failed = true;
try { transaction.abort(); } catch (_abortError) { reject(error); }
reject(error);
});
});
};
}
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
const transact = transaction || createIndexedDbTransaction(indexedDB);
async function reconcile(items, outboxLane = 'issue') {
return transact(async records => {
const existing = await records.getAll();
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
for (const current of existing) {
if (current.recordType === 'receipt-preference') continue;
const currentLane = current.outboxLane || 'issue';
if (currentLane !== outboxLane) continue;
const replacement = incoming.get(current.id);
if (!replacement) {
if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) {
await records.delete(current.id);
}
continue;
}
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(current.status === 'attention' && replacement.status === 'attention') ||
current.status === 'sent') {
incoming.set(current.id, current);
}
}
for (const item of incoming.values()) await records.put(item);
});
}
async function claimNext(ownerLogin) {
return transact(async records => {
const timestamp = Number(now());
const items = await records.getAll();
const item = items.find(candidate => candidate.ownerLogin === ownerLogin &&
(candidate.status === 'queued' || candidate.status === 'sending') &&
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
if (!item) return null;
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
await records.put(claimed);
return claimed;
});
}
async function claim(id, ownerLogin) {
return transact(async records => {
const timestamp = Number(now());
const item = (await records.getAll()).find(candidate => candidate.id === id);
if (!item || item.ownerLogin !== ownerLogin ||
!['queued', 'sending'].includes(item.status) ||
(item.status === 'sending' && Number(item.claimUntil) > timestamp)) return null;
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
await records.put(claimed);
return claimed;
});
}
async function upsert(item) {
return transact(async records => {
const current = (await records.getAll()).find(candidate => candidate.id === item.id);
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(current.status === 'attention' && item.status === 'attention') ||
current.status === 'sent')) return current;
await records.put({ ...item });
return item;
});
}
async function update(id, transform) {
return transact(async records => {
const item = (await records.getAll()).find(candidate => candidate.id === id);
if (item) await records.put(transform(item));
});
}
function preferenceId(ownerLogin) {
return 'receipt-preference:' + String(ownerLogin || '').trim();
}
async function setReceiptPreference(ownerLogin, enabled) {
const login = String(ownerLogin || '').trim();
if (!login) return;
return transact(async records => {
const id = preferenceId(login);
if (!enabled) return records.delete(id);
return records.put({ id, recordType: 'receipt-preference', ownerLogin: login, enabled: true });
});
}
async function getReceiptPreference(ownerLogin) {
const id = preferenceId(ownerLogin);
return transact(async records => Boolean(
(await records.getAll()).find(item => item.id === id)?.enabled
));
}
return {
reconcile,
upsert,
claim,
claimNext,
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
snapshot: () => transact(async records =>
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
countBlocked: ownerLogin => transact(async records =>
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference' &&
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
setReceiptPreference,
getReceiptPreference,
};
}
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
function receiptFor(item, status, delivered = {}) {
if (status === 'attention') {
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
}
if (item.kind === 'update-reply') {
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
}
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
}
function deliveryRequest(item) {
if (item.kind === 'update-reply') {
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return authoredRequest(
'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
item,
);
}
return {
url: base + 'api/v1/repos/' + repository + '/issues',
options: {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title,
body: item.body,
label_ids: item.labelIds,
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
},
};
}
function authoredRequest(url, item) {
return {
url: base + url,
options: {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
},
};
}
async function deliver(item) {
const request = deliveryRequest(item);
try {
const delivered = await fetchJson(request.url, request.options);
await store.complete(item.id);
const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
} catch (error) {
const status = Number(error?.status || 0);
if (status === 401) {
await store.release(item.id);
throw error;
}
if (status >= 400 && status < 500) {
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
return { attention: true, error, receipt: receiptFor(item, 'attention') };
}
await store.release(item.id);
throw error;
}
}
async function send(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
await store.upsert(item);
const claimed = await store.claim(item.id, currentLogin);
if (!claimed) return { busy: true };
return deliver(claimed);
}
async function flush() {
const identity = await fetchJson(base + 'api/v1/background-identity', {
headers: { Accept: 'application/json' }, cache: 'no-store',
});
const login = String(identity?.login || '').trim();
const confirmed = [];
const receipts = [];
let attention = 0;
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
while (true) {
const item = await store.claimNext(login);
if (!item) break;
const result = await deliver(item);
if (result.issue) confirmed.push(result.issue);
if (result.message) confirmed.push(result.message);
if (result.attention) attention += 1;
if (result.receipt) receipts.push(result.receipt);
}
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
return { confirmed, blocked, attention, login, receipts };
}
return {
flush, send,
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
getReceiptPreference: ownerLogin => store.getReceiptPreference(ownerLogin),
};
}
createBackgroundIssueSync.createIssueSyncStore = createIssueSyncStore;
if (typeof module !== 'undefined' && module.exports) module.exports = createBackgroundIssueSync;
if (typeof globalThis !== 'undefined') {
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
globalThis.createIssueSyncStore = createIssueSyncStore;
}
+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;
+176
View File
@@ -0,0 +1,176 @@
function newIssueOperationId() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
if (globalThis.crypto?.getRandomValues) {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
}
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: ''};
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: ''};
}
}
function writeStored(record) {
try { storage.setItem(storageKey, JSON.stringify(record)); }
catch (_error) { /* Keep the form as the in-memory fallback. */ }
}
function saveDraft(draft) {
const previous = loadStored();
const safe = {
repository: String(draft?.repository || ''),
title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: safeLabelIds(draft?.labelIds),
};
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;
}
function loadDraft() {
const {operationId: _operationId, ...draft} = loadStored();
return draft;
}
function clearDraft() {
try { storage.removeItem(storageKey); }
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']);
return fetchJson('api/v1/repos/' + encoded + '/labels').then(labels =>
(Array.isArray(labels) ? labels : []).slice().sort((left, right) => {
const leftPriority = priorities.has(String(left?.name || '').toLowerCase());
const rightPriority = priorities.has(String(right?.name || '').toLowerCase());
return Number(rightPriority) - Number(leftPriority);
})
);
}
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);
const stored = loadStored();
const operationId = stored.operationId || String(createOperationId()).slice(0, 128);
writeStored({...saved, operationId});
const repository = saved.repository.split('/').map(encodeURIComponent).join('/');
pending = fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': operationId,
},
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();
return issue;
}).finally(() => { pending = null; });
return pending;
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};
}
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
+25
View File
@@ -0,0 +1,25 @@
function createDetailDefer({
laterWork,
session,
close,
refresh,
focus,
announce,
formatTime = value => new Date(value).toLocaleString(),
}) {
return {
defer(item, preset) {
if (!item) return false;
const until = laterWork.presetUntil(preset);
if (!laterWork.defer(item, until)) return false;
const inSession = session.active();
if (!inSession) close();
refresh();
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
if (!inSession) focus();
return true;
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDetailDefer;
+216
View File
@@ -0,0 +1,216 @@
function createDraftInbox({ storage, getCurrentLogin = () => '', 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 (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
const currentLogin = String(getCurrentLogin() || '').trim();
return record.items.filter(item =>
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
).map(item => {
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
return {
id: 'stackchain.issue-outbox.v1:' + item.id,
outbox_id: item.id,
kind: 'issue-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
label: item.status === 'attention' ? 'Needs attention' : 'Queued issue',
repository: item.repository,
title: textPreview(item.title) || 'Untitled queued issue',
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
copy_text: [item.title, item.body].filter(Boolean).join('\n\n'),
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
updated_at: Number(item.queuedAt || 0),
};
});
} catch (_error) { return []; }
}
function parseAuthoredOutbox(raw) {
try {
const record = JSON.parse(raw);
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
const currentLogin = String(getCurrentLogin() || '').trim();
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
.map(item => {
const isUpdate = item.kind === 'update-reply';
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
return {
id: 'stackchain.authored-outbox.v1:' + item.id,
outbox_id: item.id,
kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
label: item.status === 'attention' ? 'Needs attention' : 'Queued message',
repository: isUpdate ? '' : item.repository,
title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
copy_text: item.body,
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
updated_at: Number(item.queuedAt || 0),
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
{ kind:routeKind, repository:item.repository, number:item.number },
};
});
} catch (_error) { return []; }
}
function list() {
const index = readIndex();
const seen = new Set();
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

+2599 -46
View File
File diff suppressed because it is too large. Load diff
+204
View File
@@ -0,0 +1,204 @@
function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) {
const storageKey = 'stackchain.issue-outbox.v1';
const operationId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && typeof item === 'object');
} catch (_error) { return []; }
}
function write(items, mirror = true) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
coordinator?.notify('issue');
if (mirror && backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items))
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
}
}
function enqueue(draft, mirror = true) {
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
const items = read();
if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.');
const item = {
id: String(operationId()).slice(0, 128),
operationId: '',
repository: String(draft?.repository || ''),
title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
};
item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId);
}
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
items.push(item);
write(items, mirror);
return item;
}
async function enqueueDurably(draft) {
const item = enqueue(draft, false);
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
try {
await backgroundSync.reconcile(read());
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
}
}
function update(id, draft, mirror = true) {
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;
}), mirror);
return updated;
}
async function updateDurably(id, draft) {
const item = update(id, draft, false);
if (!item || !backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
try {
await backgroundSync.reconcile(read());
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
}
}
function discard(id) {
const items = read();
if (!items.some(item => item.id === id)) return false;
write(items.filter(item => item.id !== id));
return true;
}
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
const request = (async () => {
try {
let issue;
if (backgroundSync?.send) {
const delivery = await backgroundSync.send(item, currentLogin);
if (delivery.attention) {
const error = delivery.error || new Error('Issue needs attention');
error.status = Number(error.status || 422);
throw error;
}
issue = delivery.issue;
} else {
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
}
if (!issue) return { blocked: true };
discard(item.id);
return { issue };
} catch (error) {
const status = Number(error?.status || 0);
if (status >= 400 && status < 500) {
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
} : candidate));
}
return { error, transient: !(status >= 400 && status < 500) };
}
})();
pending.set(item.id, request);
try { return await request; }
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
}
async function flushQueue(currentLogin) {
const confirmed = [];
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
if (item.status === 'attention') continue;
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
const result = await sendItem(item, currentLogin);
if (result.issue) confirmed.push(result.issue);
if (result.transient) break;
}
return { confirmed, remaining: read(), blocked };
}
async function flush(currentLogin) {
if (coordinator) return coordinator.runExclusive('issue', () => flushQueue(currentLogin));
return flushQueue(currentLogin);
}
async function retryItem(id, currentLogin) {
const item = read().find(candidate => candidate.id === id);
if (!item) return { confirmed: [], remaining: read() };
currentLogin = String(currentLogin || '').trim();
if (!currentLogin || item.ownerLogin !== currentLogin) {
return { confirmed: [], remaining: read(), blocked: 1 };
}
update(id, item);
const result = await sendItem({ ...item, status: 'queued' }, currentLogin);
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
}
async function retry(id, currentLogin) {
if (coordinator) return coordinator.runExclusive('issue', () => retryItem(id, currentLogin));
return retryItem(id, currentLogin);
}
function reconcileBackground(records) {
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
if (background?.status === 'sent') return [];
if (background?.status === 'attention') return [{
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
}];
return [item];
});
write(items);
return items;
}
return { enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
+238
View File
@@ -0,0 +1,238 @@
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;
const operationKey = item => draftKey(item) + ':operation';
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) {
return fetchJson(issuePath(item) + '/detail', {
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 ''; }
},
saveDraft(item, body) {
try {
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
storage?.setItem(draftKey(item), body);
}
catch (_error) { /* The textarea remains the fallback. */ }
},
loadEditDraft(item) {
try {
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
return value && typeof value.title === 'string' && typeof value.body === 'string' &&
typeof value.expectedUpdatedAt === 'string' ? value : null;
} catch (_error) { return null; }
},
saveEditDraft(item, draft) {
try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); }
catch (_error) { /* The edit fields remain the fallback. */ }
},
updateContent(item, draft) {
if (editRequest) return editRequest;
this.saveEditDraft(item, draft);
editRequest = fetchJson(issuePath(item) + '/content', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
title: draft.title,
body: draft.body,
expected_updated_at: draft.expectedUpdatedAt,
}),
}).then(result => {
if (result?.number !== item.number || result?.title !== draft.title || result?.body !== draft.body) {
throw new Error('Issue update was not confirmed.');
}
try { storage?.removeItem(editDraftKey(item)); }
catch (_error) { /* Confirmed upstream content is authoritative. */ }
return result;
}).finally(() => { editRequest = null; });
return editRequest;
},
loadDueDateDraft(item) {
try {
const raw = storage?.getItem(dueDateDraftKey(item));
if (raw === null || raw === undefined) return null;
const value = JSON.parse(raw);
return typeof value === 'string' ? value : null;
} catch (_error) { return null; }
},
updateDueDate(item, dueDate) {
if (dueDateRequest) return dueDateRequest;
try { storage?.setItem(dueDateDraftKey(item), JSON.stringify(dueDate)); }
catch (_error) { /* The date input remains the fallback. */ }
dueDateRequest = fetchJson(issuePath(item) + '/due-date', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ due_date: dueDate }),
}).then(result => {
if (result?.number !== item.number || result?.due_date !== dueDate) {
throw new Error('Issue due date was not confirmed.');
}
try { storage?.removeItem(dueDateDraftKey(item)); }
catch (_error) { /* Confirmed upstream deadline is authoritative. */ }
return result;
}).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)) || '[]');
return Array.isArray(value) ? value.filter(Number.isInteger) : [];
} catch (_error) { return []; }
},
updateLabels(item, labelIds) {
if (labelRequest) return labelRequest;
try { storage?.setItem(labelDraftKey(item), JSON.stringify(labelIds)); }
catch (_error) { /* The checked controls remain the fallback. */ }
labelRequest = fetchJson(issuePath(item) + '/labels', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ label_ids: labelIds }),
}).then(result => {
if (result?.number !== item.number || !Array.isArray(result.labels)) {
throw new Error('Issue labels were not confirmed.');
}
try { storage?.removeItem(labelDraftKey(item)); }
catch (_error) { /* The upstream label set is authoritative. */ }
return result;
}).finally(() => { labelRequest = null; });
return labelRequest;
},
close(item) {
if (closeRequest) return closeRequest;
closeRequest = fetchJson(issuePath(item) + '/close', {
method: 'PATCH',
headers: { Accept: 'application/json' },
}).then(result => {
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
return result;
}).finally(() => { closeRequest = null; });
return closeRequest;
},
release(item, currentLogin) {
if (releaseRequest) return releaseRequest;
releaseRequest = fetchJson(issuePath(item) + '/release', {
method: 'PATCH',
headers: { Accept: 'application/json' },
}).then(result => {
if (
result?.number !== item.number ||
!Array.isArray(result.assignees) ||
result.assignees.includes(currentLogin)
) {
throw new Error('Issue release was not confirmed.');
}
return result;
}).finally(() => { releaseRequest = null; });
return releaseRequest;
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
let operationId;
try {
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
storage?.setItem(operationKey(item), operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
commentRequest = fetchJson(issuePath(item) + '/comments', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
body: JSON.stringify({ body }),
}).then(result => {
try { storage?.removeItem(draftKey(item)); }
catch (_error) { /* The upstream comment is authoritative. */ }
try { storage?.removeItem(operationKey(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
return result;
}).finally(() => { commentRequest = null; });
return commentRequest;
},
};
}
createIssueSheet.createPlanningLoader = createPlanningLoader;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
+113
View File
@@ -0,0 +1,113 @@
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {} }) {
const prefix = 'stackchain.later-work.v1.';
let timer = null;
function identity(item) {
if (!item) return '';
const kind = item.is_review ? 'review' : (item.kind || 'work');
const number = Number.isInteger(item.number) ? item.number : '';
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
return [kind, item.repository || '', number, notification].join(':');
}
function storageKey() {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function read() {
const key = storageKey();
if (!key || !storage) return {};
try {
const value = JSON.parse(storage.getItem(key) || '{}');
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch (_error) {
return {};
}
}
function write(records) {
const key = storageKey();
if (!key || !storage) return false;
try {
if (Object.keys(records).length) storage.setItem(key, JSON.stringify(records));
else storage.removeItem(key);
return true;
} catch (_error) {
return false;
}
}
function defer(item, until) {
const key = storageKey();
const id = identity(item);
const wake = new Date(until);
if (!key || !id || Number.isNaN(wake.getTime()) || wake <= now()) return false;
const records = read();
records[id] = wake.toISOString();
write(records);
return true;
}
function presetUntil(preset) {
const current = new Date(now());
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
if (preset === 'tomorrow') {
const wake = new Date(current);
wake.setDate(wake.getDate() + 1);
wake.setHours(9, 0, 0, 0);
return wake;
}
return new Date(NaN);
}
function restore(item) {
const id = identity(item);
const records = read();
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
delete records[id];
write(records);
return true;
}
function schedule(wakeTimes, current) {
if (timer !== null) clearTimer(timer);
timer = null;
if (!wakeTimes.length) return;
const delay = Math.max(0, Math.min(...wakeTimes) - current);
timer = setTimer(() => {
timer = null;
onWake();
}, Math.min(delay, 2147483647));
}
function partition(items, { pruneMissing = true } = {}) {
const current = now().getTime();
const records = read();
const available = new Map((items || []).map(item => [identity(item), item]));
const retained = {};
const wakeTimes = [];
Object.entries(records).forEach(([id, wake]) => {
const wakeTime = new Date(wake).getTime();
if (!Number.isFinite(wakeTime) || wakeTime <= current) return;
if (pruneMissing && !available.has(id)) return;
retained[id] = new Date(wakeTime).toISOString();
wakeTimes.push(wakeTime);
});
if (JSON.stringify(retained) !== JSON.stringify(records)) write(retained);
schedule(wakeTimes, current);
const deferredIds = new Set(Object.keys(retained));
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
const later = (items || []).flatMap(item => {
const wake = retained[identity(item)];
return wake ? [{ ...item, deferred_until: wake }] : [];
});
return { active, later };
}
return { identity, defer, restore, presetUntil, partition };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
+76
View File
@@ -0,0 +1,76 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createLoginController = factory;
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
const form = options.form;
const status = options.status;
const button = options.button;
const fetchImpl = options.fetchImpl;
const location = options.location;
const setIntervalImpl = options.setIntervalImpl || setInterval;
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
let timer = null;
function showRetryCountdown(seconds) {
let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1);
button.disabled = true;
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
timer = setIntervalImpl(() => {
remaining -= 1;
if (remaining <= 0) {
clearIntervalImpl(timer);
timer = null;
button.disabled = false;
status.textContent = 'You can try signing in again.';
return;
}
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
}, 1000);
}
async function submit(accessToken) {
status.textContent = 'Signing in…';
let response;
try {
response = await fetchImpl('api/v1/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token: accessToken }),
});
} catch (_error) {
form.reset();
status.textContent = 'Sign-in failed. Check your connection and try again.';
return;
}
form.reset();
if (response.ok) {
location.replace('./');
return;
}
if (response.status === 429) {
showRetryCountdown(response.headers.get('Retry-After'));
return;
}
status.textContent = 'Sign-in failed. Check the token and try again.';
}
return { submit };
}));
if (typeof document !== 'undefined') {
const form = document.getElementById('sign-in');
const status = document.getElementById('status');
const button = document.getElementById('submit-sign-in');
const controller = createLoginController({
form,
status,
button,
fetchImpl: fetch.bind(window),
location: window.location,
});
form.addEventListener('submit', event => {
event.preventDefault();
const accessToken = new FormData(form).get('access_token');
controller.submit(accessToken);
});
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "Stackchain Dashboard",
"short_name": "Stackchain",
"description": "Capture, plan, and complete Gitea work from mobile.",
"id": "./",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#07111f",
"theme_color": "#0b1220",
"icons": [
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
],
"share_target": {
"action": "./",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {"title": "title", "text": "text", "url": "url"}
}
}
+53
View File
@@ -0,0 +1,53 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileTaskDock = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileTaskDock(options) {
const nav = options.nav;
const buttons = options.buttons;
const overlays = options.overlays || [];
let launcher = null;
let wasHidden = false;
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
if (key === name) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
});
}
function refreshVisibility() {
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
nav.hidden = hidden;
if (wasHidden && !hidden && launcher) launcher.focus();
wasHidden = hidden;
}
function start() {
Object.entries(buttons).forEach(([name, button]) => {
button.addEventListener('click', event => {
launcher = event.currentTarget;
select(name);
options.actions[name]();
});
});
options.observe(refreshVisibility, overlays);
refreshVisibility();
}
function updateAttention(count) {
const total = Math.max(0, Number(count) || 0);
const badge = options.attentionBadge;
if (badge) {
badge.textContent = String(total);
badge.hidden = total === 0;
}
if (buttons.work) {
buttons.work.setAttribute(
'aria-label',
total ? 'Work, ' + total + ' items need attention' : 'Work'
);
}
}
return {start, refreshVisibility, updateAttention};
});
+370 -11
View File
@@ -1,4 +1,39 @@
function buildMyWork(data) {
function issueDueState(dueDate, now) {
if (!dueDate) return null;
const due = new Date(dueDate);
if (Number.isNaN(due.getTime())) return null;
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
String(value.getDate()).padStart(2, '0');
const dueDay = day(due);
const today = day(now);
if (dueDay < today) return { label: 'Overdue', priority: 2 };
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
return {
label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
priority: 4,
};
}
function urgencyReason(item) {
if (!item?.is_assigned || item.kind !== 'issue') return '';
const priorityLabels = ['p0', 'priority-high', 'critical'];
const priorityLabel = (item.labels || []).find(label =>
priorityLabels.includes(String(label).toLowerCase())
);
if (priorityLabel) return priorityLabel + ' priority';
return ['Overdue', 'Due today'].includes(item.due_label) ? item.due_label : '';
}
function attentionReason(item) {
if (item?.has_update) return 'Unread update';
return urgencyReason(item) || (item?.is_review ? 'Needs your review' : '');
}
function needsAttention(item) {
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
}
function buildMyWork(data, now = new Date()) {
const login = data.user?.login || '';
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
@@ -11,16 +46,23 @@ function buildMyWork(data) {
);
const assigned = (item.assignees || []).includes(login);
const isReview = (item.work_reasons || []).includes('review_requested');
return {
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
const normalized = {
...item,
key: (item.repository || 'unknown') + '#' + item.number,
is_review: isReview,
is_assigned: assigned,
has_update: false,
...(due ? { due_label: due.label } : {}),
reason: priorityLabel ? priorityLabel + ' priority' :
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')),
_priority: priorityLabel ? 0 : (isReview ? 2 : (assigned ? 3 : 4)),
(due && due.priority < 4 ? due.label :
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))),
_priority: priorityLabel ? 0 :
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
};
normalized.needs_attention = needsAttention(normalized);
normalized.attention_reason = attentionReason(normalized);
return normalized;
});
const byKey = new Map(work.map((item) => [item.kind + ':' + item.key, item]));
@@ -30,6 +72,8 @@ function buildMyWork(data) {
const existing = byKey.get(subjectKind + ':' + key);
if (existing) {
existing.has_update = true;
existing.needs_attention = true;
existing.attention_reason = 'Unread update';
existing.notification_id = update.id;
existing.url = update.url || existing.url;
existing.updated_at = update.updated_at || existing.updated_at;
@@ -44,6 +88,8 @@ function buildMyWork(data) {
is_review: false,
is_assigned: false,
has_update: true,
needs_attention: true,
attention_reason: 'Unread update',
notification_id: update.id,
reason: 'Unread update',
_priority: 1,
@@ -64,7 +110,10 @@ function acknowledgeNotification(items, notificationId) {
if (item.notification_id !== notificationId) return [item];
if (item.kind === 'update') return [];
const { notification_id, ...acknowledged } = item;
return [{ ...acknowledged, has_update: false }];
const next = { ...acknowledged, has_update: false };
if ('needs_attention' in item) next.needs_attention = needsAttention(next);
if ('attention_reason' in item) next.attention_reason = attentionReason(next);
return [next];
});
}
@@ -169,10 +218,76 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
};
}
function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose }) {
function createWorkPager({ load, onItems, onPagination, onStatus }) {
let pagination = {};
const pending = new Set();
const labels = {
issue: 'issues',
pull: 'pull requests',
review: 'review requests',
};
return {
reset(next) {
Object.entries(next || {}).forEach(([stream, value]) => {
const current = pagination[stream];
pagination[stream] = current && current.page > 1 ?
{ ...value, page: current.page, has_more: current.page * 50 < value.total } :
{ ...value };
});
onPagination({ ...pagination });
},
async loadMore(stream, existing) {
const page = pagination[stream];
if (pending.has(stream) || !page?.has_more) return false;
pending.add(stream);
const label = labels[stream] || 'work';
onStatus('Loading older ' + label + '…');
try {
const result = await load(stream, page.page + 1);
const merged = new Map((existing || [])
.filter(item => item && Number.isInteger(item.id))
.map(item => [item.id, { ...item }]));
(result.items || []).forEach(item => {
if (!item || !Number.isInteger(item.id)) return;
const current = merged.get(item.id);
if (!current) {
merged.set(item.id, { ...item });
return;
}
const reasons = Array.from(new Set(
(current.work_reasons || []).concat(item.work_reasons || [])
));
merged.set(item.id, {
...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}),
});
});
pagination[stream] = {
page: result.page, total: result.total, has_more: result.has_more === true,
};
onItems(stream, Array.from(merged.values()));
onPagination({ ...pagination });
onStatus(Math.min(result.total, result.page * 50) + ' of ' + result.total + ' ' + label + ' loaded.');
return true;
} catch (_error) {
onStatus('Could not load older ' + label + '. Retry.');
return false;
} finally {
pending.delete(stream);
}
},
};
}
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;
@@ -183,6 +298,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) {
@@ -195,6 +318,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;
@@ -226,11 +372,213 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
};
}
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 createNotificationReplier({
post, storage, onStatus, authoredOutbox,
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
}) {
let pending = false;
const keyFor = item => 'stackchain.update-reply.v1.' + item.notification_id;
const operationKeyFor = item => keyFor(item) + ':operation';
return {
loadDraft(item) {
try { return storage.getItem(keyFor(item)) || ''; }
catch (_error) { return ''; }
},
saveDraft(item, body) {
try {
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
storage.setItem(keyFor(item), body);
}
catch (_error) { /* Keep the editable textarea as the fallback. */ }
},
async submit(item, body) {
if (pending) return false;
pending = true;
this.saveDraft(item, body);
let operationId;
try {
operationId = storage.getItem(operationKeyFor(item)) || String(createOperationId()).slice(0, 128);
storage.setItem(operationKeyFor(item), operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
onStatus('Sending reply…');
try {
const result = await post(item.notification_id, body, operationId);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
try { storage.removeItem(operationKeyFor(item)); }
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) {
const status = Number(error?.status || 0);
if (authoredOutbox && (!status || status >= 500)) {
onStatus('Saving for background delivery…');
const admission = await authoredOutbox.enqueueDurably({
kind: 'update-reply', notificationId: item.notification_id, body, operationId,
});
if (admission.background) {
onStatus('Queued for sync when the connection returns.');
return { queued: true };
}
onStatus('Saved for next launch; background delivery unavailable.');
return { queued: false, degraded: true };
}
onStatus('Could not send reply. Your draft is safe; retry.');
return false;
} finally {
pending = false;
}
},
};
}
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
let filtered = items;
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
if (selectedMilestone === 'all') return filtered;
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) {
if (!item) return '';
const kind = item.is_review ? 'review' : (item.kind || 'work');
const repository = item.repository || '';
const number = Number.isInteger(item.number) ? item.number : '';
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
return [kind, repository, number, notification].join(':');
}
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
let currentIdentity = '';
let currentIndex = -1;
let running = false;
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
const report = (items, index) => onProgress({
index: index + 1,
total: items.length,
can_previous: index > 0,
can_next: index < items.length - 1,
});
const finish = () => {
running = false;
currentIdentity = '';
currentIndex = -1;
onFinish();
return false;
};
const openAt = (items, index) => {
if (!items.length || index < 0 || index >= items.length) return finish();
currentIndex = index;
currentIdentity = workIdentity(items[index]);
report(items, index);
onOpen(items[index]);
return true;
};
return {
active: () => running,
start() {
const items = queue();
if (!items.length) return finish();
running = true;
return openAt(items, 0);
},
reconcile() {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
if (index < 0) return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
currentIndex = index;
report(items, index);
return true;
},
previous() {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
return index > 0 ? openAt(items, index - 1) : false;
},
next() {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
},
complete() {
if (!running) return false;
const items = queue();
const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
if (stillPresent >= 0) {
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
}
return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
},
};
}
function replaceIssueLabels(data, repository, number, labels) {
return {
...data,
issues: (data.issues || []).map(item =>
item.repository === repository && item.number === number ? { ...item, labels: [...labels] } : item
),
};
}
function replaceIssueContent(data, repository, number, content) {
return {
...data,
issues: (data.issues || []).map(item =>
item.repository === repository && item.number === number ? { ...item, ...content } : item
),
};
}
function replaceIssueDueDate(data, repository, number, dueDate) {
return {
...data,
issues: (data.issues || []).map(item =>
item.repository === repository && item.number === number ? { ...item, due_date: dueDate } : item
),
};
}
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,
issues: (data.issues || []).filter(item =>
item.repository !== repository || item.number !== number
),
};
}
function summarizeMyWork(items) {
@@ -246,6 +594,7 @@ function summarizeMyWork(items) {
function countMyWork(items) {
return {
all: items.length,
attention: items.filter(needsAttention).length,
issue: items.filter((item) => item.kind === 'issue').length,
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
review: items.filter((item) => item.is_review).length,
@@ -255,6 +604,14 @@ 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;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
@@ -262,6 +619,8 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.notificationIds = notificationIds;
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
buildMyWork.createNotificationPager = createNotificationPager;
buildMyWork.createWorkPager = createWorkPager;
buildMyWork.createNotificationReader = createNotificationReader;
buildMyWork.createNotificationReplier = createNotificationReplier;
module.exports = buildMyWork;
}
+88
View File
@@ -0,0 +1,88 @@
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.createOfflineWorkStore = api;
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const ENABLED_KEY = 'stackchain.offline-work.enabled.v1';
const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1';
const VERSION = 1;
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const ITEM_FIELDS = [
'id', 'number', 'title', 'state', 'repository', 'labels', 'assignees',
'updated_at', 'due_date', 'milestone', 'url', 'work_reasons',
];
const NOTIFICATION_FIELDS = [
'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url',
];
function pick(source, fields) {
const output = {};
fields.forEach(field => {
if (source && source[field] !== undefined) output[field] = source[field];
});
return output;
}
function createOfflineWorkStore({ storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS }) {
function enabled() {
try { return storage.getItem(ENABLED_KEY) === 'true'; }
catch (_) { return false; }
}
function clear() {
try { storage.removeItem(SNAPSHOT_KEY); }
catch (_) { return false; }
return true;
}
function setEnabled(value) {
try {
storage.setItem(ENABLED_KEY, value ? 'true' : 'false');
if (!value) clear();
return true;
} catch (_) { return false; }
}
function save(snapshot) {
if (!enabled() || !snapshot?.user?.login) return false;
const record = {
version: VERSION,
user_login: String(snapshot.user.login),
saved_at: now().toISOString(),
data: {
user: pick(snapshot.user, ['login', 'full_name']),
issues: (snapshot.issues || []).map(item => pick(item, ITEM_FIELDS)),
pull_requests: (snapshot.pull_requests || []).map(item => pick(item, ITEM_FIELDS)),
notifications: (snapshot.notifications || []).map(item => pick(item, NOTIFICATION_FIELDS)),
work_pagination: snapshot.work_pagination || {},
notification_pagination: snapshot.notification_pagination || {},
},
};
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
catch (_) { return false; }
return true;
}
function load(expectedLogin) {
let record;
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
catch (_) { clear(); return null; }
const savedAt = Date.parse(record?.saved_at || '');
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
!Number.isFinite(savedAt);
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
if (invalid || expired) {
clear();
return null;
}
if (expectedLogin && record.user_login !== expectedLogin) return null;
return { ...record.data, saved_at: record.saved_at };
}
return { enabled, setEnabled, save, load, clear };
}
return createOfflineWorkStore;
});
+98
View File
@@ -0,0 +1,98 @@
function createOutboxCoordinator({
storage,
locks = globalThis.navigator?.locks,
channelFactory = typeof globalThis.BroadcastChannel === 'function' ?
() => new globalThis.BroadcastChannel('stackchain-outbox-v1') : null,
addStorageListener = globalThis.addEventListener?.bind(globalThis),
removeStorageListener = globalThis.removeEventListener?.bind(globalThis),
tabId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
now = () => Date.now(),
leaseMs = 15000,
} = {}) {
const changeKey = 'stackchain.outbox-change.v1';
const subscribers = new Set();
const channel = channelFactory ? channelFactory() : null;
let sequence = 0;
function validChange(value) {
return value && (value.queue === 'issue' || value.queue === 'authored') ? value : null;
}
function publish(change) {
subscribers.forEach(listener => listener(change));
}
if (channel) {
channel.onmessage = event => {
const change = validChange(event?.data);
if (change && change.tabId !== tabId) publish(change);
};
}
const onStorage = event => {
if (event?.key !== changeKey || !event.newValue) return;
try {
const change = validChange(JSON.parse(event.newValue));
if (change && change.tabId !== tabId) publish(change);
} catch (_error) { /* Ignore malformed cross-tab signals. */ }
};
if (addStorageListener) addStorageListener('storage', onStorage);
function notify(queue) {
const change = { queue, tabId, changedAt: Number(now()), sequence: ++sequence };
if (!validChange(change)) return;
try { storage?.setItem(changeKey, JSON.stringify(change)); } catch (_error) { /* Broadcast may still work. */ }
try { channel?.postMessage(change); } catch (_error) { /* Storage events remain available. */ }
}
function skipped() {
return { confirmed: [], remaining: [], blocked: 0, lease_skipped: true };
}
async function withFallbackLease(queue, work) {
const key = 'stackchain.outbox-lease.' + queue + '.v1';
const token = tabId + ':' + (++sequence);
const timestamp = Number(now());
try {
const current = JSON.parse(storage?.getItem(key) || 'null');
if (current?.token && Number(current.expiresAt) > timestamp) return skipped();
storage?.setItem(key, JSON.stringify({ token, expiresAt: timestamp + leaseMs }));
const claimed = JSON.parse(storage?.getItem(key) || 'null');
if (claimed?.token !== token) return skipped();
} catch (_error) {
return work();
}
try {
return await work();
} finally {
try {
const current = JSON.parse(storage?.getItem(key) || 'null');
if (current?.token === token) storage?.removeItem(key);
} catch (_error) { /* An expired lease will be reclaimed. */ }
}
}
async function runExclusive(queue, work) {
if (locks?.request) {
return locks.request('stackchain-outbox-' + queue + '-v1', { mode: 'exclusive', ifAvailable: true },
lock => lock ? work() : skipped());
}
return withFallbackLease(queue, work);
}
function subscribe(listener) {
if (typeof listener !== 'function') return () => {};
subscribers.add(listener);
return () => subscribers.delete(listener);
}
function close() {
subscribers.clear();
try { channel?.close(); } catch (_error) { /* No-op. */ }
if (removeStorageListener) removeStorageListener('storage', onStorage);
}
return { runExclusive, notify, subscribe, close };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOutboxCoordinator;
+104
View File
@@ -0,0 +1,104 @@
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
let available = [];
let pagination = { page: 1, total: 0, has_more: false };
let loadRequest = null;
let claimRequest = null;
const previewed = new Set();
function itemKey(item) {
return String(item?.repository || '') + '#' + String(item?.number || '');
}
function apply(result, append) {
const incoming = Array.isArray(result?.items) ? result.items : [];
if (append) {
const byId = new Map(available.map(item => [item.id, item]));
incoming.forEach(item => {
if (item && !byId.has(item.id)) byId.set(item.id, { ...item });
});
available = Array.from(byId.values());
} else {
available = incoming.map(item => ({ ...item }));
}
const availableKeys = new Set(available.map(itemKey));
previewed.forEach(key => {
if (!availableKeys.has(key)) previewed.delete(key);
});
pagination = {
page: Number(result?.page) || 1,
total: Number(result?.total) || available.length,
has_more: result?.has_more === true,
};
onItems(available.slice());
onPagination({ ...pagination });
}
function loadPage(page, append) {
if (loadRequest) return loadRequest;
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
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;
}
return {
reset(result) {
apply(result, false);
},
load() {
return loadPage(1, false);
},
loadMore() {
if (!pagination.has_more) return Promise.resolve(false);
return loadPage(pagination.page + 1, true);
},
items() {
return available.slice();
},
togglePreview(item) {
const key = itemKey(item);
if (previewed.has(key)) previewed.delete(key);
else previewed.add(key);
return previewed.has(key);
},
isPreviewed(item) {
return previewed.has(itemKey(item));
},
claim(item) {
if (claimRequest) return claimRequest;
const key = item.repository + '#' + item.number;
onStatus('Assigning ' + key + '…');
const path = 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number) + '/claim';
claimRequest = fetchJson(path, {
method: 'PATCH',
headers: { Accept: 'application/json' },
}).then(confirmed => {
if (!Array.isArray(confirmed?.assignees) || !confirmed.assignees.length) {
throw new Error('Issue assignment was not confirmed.');
}
available = available.filter(candidate =>
candidate.repository !== item.repository || candidate.number !== item.number
);
pagination.total = Math.max(available.length, pagination.total - 1);
pagination.has_more = available.length < pagination.total;
previewed.delete(itemKey(item));
onItems(available.slice());
onPagination({ ...pagination });
onStatus('Assigned ' + key + ' to you.');
return confirmed;
}).finally(() => { claimRequest = null; });
return claimRequest;
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
+152
View File
@@ -0,0 +1,152 @@
function mergeEligibility(detail, reviewState) {
if (!detail || detail.state !== 'open' || detail.merged) {
return { allowed: false, reason: 'Pull request is not open' };
}
if (detail.draft) return { allowed: false, reason: 'Draft pull requests cannot be merged' };
if (!detail.mergeable) return { allowed: false, reason: 'Resolve conflicts before merging' };
if (detail.ci_state !== 'success') {
return { allowed: false, reason: 'CI must succeed before merging' };
}
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
if (reviewState && !reviewState.complete) {
return { allowed: false, reason: 'Review every changed file before merging' };
}
return { allowed: true, reason: 'Ready to merge' };
}
function renderFile(file, index, reviewed, escapeHtml) {
const filename = escapeHtml(file.filename || 'Unknown file');
const panelId = 'pull-diff-' + index;
const lines = (file.diff_lines || []).map(line => {
const text = String(line);
const kind = text.startsWith('@@') ? 'hunk' : text.startsWith('+') ? 'added' :
text.startsWith('-') ? 'removed' : 'context';
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(text) + '</span>';
}).join('');
const preview = file.diff_available
? '<pre class="pull-diff" id="' + panelId + '" hidden>' + lines +
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
: '<div class="pull-diff-empty" id="' + panelId + '" hidden>' +
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') +
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
return '<article class="pull-file" data-pull-filename="' + filename + '">' +
'<button type="button" class="pull-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
'<strong>' + filename + '</strong><span class="small">' + escapeHtml(file.status || 'changed') +
' · +' + Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span></button>' + preview +
'<button type="button" class="pull-review-file" data-pull-review-file="' + filename +
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
}
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;
const operationKey = item => draftKey(item) + ':operation';
const reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha;
const fileNames = detail => (detail?.files || []).map(file => file.filename).filter(Boolean);
function reviewState(item, detail) {
const files = fileNames(detail);
let saved = [];
try {
const value = JSON.parse(storage?.getItem(reviewKey(item, detail)) || '[]');
if (Array.isArray(value)) saved = value;
} catch (_error) { /* Corrupt progress safely starts over. */ }
const reviewed = files.filter(filename => saved.includes(filename));
return { reviewed, total: files.length, complete: reviewed.length === files.length };
}
return {
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);
const reviewed = new Set(state.reviewed);
if (reviewed.has(filename)) reviewed.delete(filename);
else if (fileNames(detail).includes(filename)) reviewed.add(filename);
try { storage?.setItem(reviewKey(item, detail), JSON.stringify(Array.from(reviewed))); }
catch (_error) { /* In-memory controls still work for this render. */ }
return reviewState(item, detail);
},
nextUnreviewed(detail, state) {
return fileNames(detail).find(filename => !state.reviewed.includes(filename)) || null;
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
},
saveDraft(item, body) {
try {
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
storage?.setItem(draftKey(item), body);
}
catch (_error) { /* The textarea remains the fallback. */ }
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
let operationId;
try {
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
storage?.setItem(operationKey(item), operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
commentRequest = fetchJson(pathFor(item) + '/comments', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
body: JSON.stringify({ body }),
}).then(result => {
try { storage?.removeItem(draftKey(item)); }
catch (_error) { /* The upstream comment is authoritative. */ }
try { storage?.removeItem(operationKey(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
return result;
}).finally(() => { commentRequest = null; });
return commentRequest;
},
merge(item, expectedHeadSha) {
if (mergeRequest) return mergeRequest;
mergeRequest = fetchJson(pathFor(item) + '/merge', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
}).then(result => {
if (!result?.merged) throw new Error('Pull request merge was not confirmed.');
return result;
}).finally(() => { mergeRequest = null; });
return mergeRequest;
},
};
}
createPullSheet.mergeEligibility = mergeEligibility;
createPullSheet.renderFile = renderFile;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
+154 -13
View File
@@ -1,4 +1,10 @@
function createReviewController({ fetchJson }) {
function createReviewController({
fetchJson,
storage,
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
}) {
let pendingSubmission = null;
function endpoint(item) {
const [owner, repo] = String(item.repository || '').split('/');
if (!owner || !repo || !Number.isInteger(Number(item.number))) {
@@ -12,7 +18,33 @@ function createReviewController({ fetchJson }) {
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
}
return { load };
function submit(item, payload) {
if (pendingSubmission) return pendingSubmission;
const operationKey = 'stackchain.review-submit.v1:' + item.repository + '#' + item.number;
const fingerprint = JSON.stringify(payload);
let operationId;
try {
const saved = JSON.parse(storage?.getItem(operationKey) || 'null');
operationId = saved?.fingerprint === fingerprint && saved?.operationId
? saved.operationId : String(createOperationId()).slice(0, 128);
storage?.setItem(operationKey, JSON.stringify({ fingerprint, operationId }));
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
pendingSubmission = fetchJson(endpoint(item), {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': operationId,
},
body: JSON.stringify(payload),
}).then(result => {
try { storage?.removeItem(operationKey); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
return result;
}).finally(() => { pendingSubmission = null; });
return pendingSubmission;
}
return { load, submit };
}
function diffLineClass(line) {
@@ -22,14 +54,54 @@ function diffLineClass(line) {
return 'context';
}
function parseDiffLines(lines) {
let oldLine = null;
let newLine = null;
return (lines || []).map(value => {
const text = String(value);
const hunk = text.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (hunk) {
oldLine = Number(hunk[1]);
newLine = Number(hunk[2]);
return { text, kind: 'hunk', commentable: false };
}
if (text.startsWith('\\')) return { text, kind: 'note', commentable: false };
if (oldLine === null || newLine === null) {
return { text, kind: diffLineClass(text), commentable: false };
}
if (text.startsWith('-')) {
const row = { text, kind: 'removed', commentable: true, old_position: oldLine };
oldLine += 1;
return row;
}
if (text.startsWith('+')) {
const row = { text, kind: 'added', commentable: true, new_position: newLine };
newLine += 1;
return row;
}
const row = { text, kind: 'context', commentable: true, new_position: newLine };
oldLine += 1;
newLine += 1;
return row;
});
}
function renderDiffFile(file, index, escapeHtml) {
const panelId = 'review-diff-' + index;
let preview;
if (file.diff_available) {
const lines = (file.diff_lines || []).map(line =>
'<span class="review-diff-line ' + diffLineClass(String(line)) + '">' +
escapeHtml(String(line)) + '</span>'
).join('');
const lines = parseDiffLines(file.diff_lines).map(row => {
if (!row.commentable) {
return '<span class="review-diff-line ' + row.kind + '">' + escapeHtml(row.text) + '</span>';
}
const position = row.old_position
? ' data-old-position="' + row.old_position + '"'
: ' data-new-position="' + row.new_position + '"';
return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
'" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
(row.old_position || row.new_position) + '">' + escapeHtml(row.text) + '</button>';
}).join('');
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
'</pre>';
@@ -90,23 +162,40 @@ function createProgress({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, markReviewed };
function clear() {
reviewed = [];
newHead = false;
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
return snapshot();
}
return { snapshot, markReviewed, clear };
}
function createDraft({ storage, repository, number, headSha, files }) {
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
const key = 'stackchain.review-draft.v1:' + repository + '#' + number + '@' + headSha;
let draft = { notes: {}, summary: '', decision: 'comment' };
let draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
try {
const saved = JSON.parse(storage.getItem(key) || '{}');
if (saved && typeof saved === 'object') {
draft.notes = Object.fromEntries(filenames
.filter(filename => typeof saved.notes?.[filename] === 'string' && saved.notes[filename])
.map(filename => [filename, saved.notes[filename]]));
if (typeof saved.summary === 'string') draft.summary = saved.summary;
if (['comment', 'approve', 'request_changes'].includes(saved.decision)) {
draft.decision = saved.decision;
if (Array.isArray(saved.comments)) {
draft.comments = saved.comments.filter(comment =>
comment && filenames.includes(comment.path) && typeof comment.body === 'string' &&
Boolean(comment.body.trim()) &&
((Number.isInteger(comment.new_position) && !comment.old_position) ||
(Number.isInteger(comment.old_position) && !comment.new_position))
).map(comment => ({
path: comment.path, body: comment.body,
...(comment.new_position ? { new_position: comment.new_position } : {}),
...(comment.old_position ? { old_position: comment.old_position } : {}),
}));
}
if (typeof saved.summary === 'string') draft.summary = saved.summary;
if (['comment', 'approve', 'request_changes'].includes(saved.decision)) draft.decision = saved.decision;
}
} catch (error) { /* start with an empty in-memory draft */ }
@@ -115,7 +204,10 @@ function createDraft({ storage, repository, number, headSha, files }) {
}
function snapshot() {
return { notes: { ...draft.notes }, summary: draft.summary, decision: draft.decision };
return {
notes: { ...draft.notes }, comments: draft.comments.map(comment => ({ ...comment })),
summary: draft.summary, decision: draft.decision,
};
}
function setNote(filename, note) {
@@ -126,6 +218,35 @@ function createDraft({ storage, repository, number, headSha, files }) {
return snapshot();
}
function inlineKey(comment) {
return comment.path + ':' + (comment.new_position ? 'new:' + comment.new_position : 'old:' + comment.old_position);
}
function setInlineComment(anchor, body) {
if (!anchor || !filenames.includes(anchor.path)) return snapshot();
const position = Number.isInteger(anchor.new_position) && anchor.new_position > 0
? { new_position: anchor.new_position }
: Number.isInteger(anchor.old_position) && anchor.old_position > 0
? { old_position: anchor.old_position } : null;
const text = String(body || '').trim();
if (!position || !text) return snapshot();
const comment = { path: anchor.path, body: text, ...position };
const key = inlineKey(comment);
const index = draft.comments.findIndex(item => inlineKey(item) === key);
if (index >= 0) draft.comments[index] = comment;
else draft.comments.push(comment);
persist();
return snapshot();
}
function removeInlineComment(anchor) {
if (!anchor) return snapshot();
const key = inlineKey(anchor);
draft.comments = draft.comments.filter(comment => inlineKey(comment) !== key);
persist();
return snapshot();
}
function setSummary(summary) {
draft.summary = String(summary || '');
persist();
@@ -140,7 +261,13 @@ function createDraft({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, setNote, setSummary, setDecision };
function clear() {
draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
return snapshot();
}
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear };
}
function formatFeedback(draft, files) {
@@ -182,12 +309,26 @@ async function copyAndContinue({ text, url, copy, open, fallback }) {
return { copied: true, opened: true };
}
function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }) {
const reasons = Array.isArray(item?.work_reasons) ? item.work_reasons : [];
if (decision !== 'approve' || !reasons.includes('assigned_to_me') || !headSha) return false;
const filenames = Array.isArray(reviewed)
? reviewed.filter(filename => typeof filename === 'string' && filename)
: [];
const key = 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + headSha;
try { storage?.setItem(key, JSON.stringify(filenames)); }
catch (_error) { return false; }
return true;
}
createReviewController.renderDiffFile = renderDiffFile;
createReviewController.parseDiffLines = parseDiffLines;
createReviewController.toggleDiff = toggleDiff;
createReviewController.createProgress = createProgress;
createReviewController.createDraft = createDraft;
createReviewController.formatFeedback = formatFeedback;
createReviewController.copyAndContinue = copyAndContinue;
createReviewController.prepareMergeContinuation = prepareMergeContinuation;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createReviewController;
+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;
},
};
};
});
+143
View File
@@ -0,0 +1,143 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v19';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/search-preview.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/my-work.js',
BASE + 'static/later-work.js',
BASE + 'static/detail-defer.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.js',
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/pull-sheet.js',
BASE + 'static/review-sheet.js',
BASE + 'static/work-route.js',
BASE + 'static/context-poller.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/background-issue-sync.js',
];
async function sessionCsrf() {
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
headers: { Accept: 'application/json' },
});
if (!response.ok) return '';
const payload = await response.json().catch(() => ({}));
return typeof payload.csrf_token === 'string' ? payload.csrf_token : '';
}
async function fetchJson(url, options = {}) {
const requestOptions = { ...options };
const method = String(options.method || 'GET').toUpperCase();
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const headers = new Headers(options.headers || {});
const csrf = await sessionCsrf();
if (csrf) headers.set('X-CSRF-Token', csrf);
requestOptions.headers = headers;
}
const response = await fetch(new URL(url, self.location.origin), requestOptions);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error || payload.detail || 'Background issue delivery failed.');
error.status = response.status;
throw error;
}
return payload;
}
const issueSync = self.__issueSync || createBackgroundIssueSync({
store: createIssueSyncStore(), fetchJson, base: BASE,
});
async function flushAndNotify() {
const result = await issueSync.flush();
if (!result?.login || !result.receipts?.length ||
!await issueSync.getReceiptPreference?.(result.login)) return;
for (const receipt of result.receipts) {
const needsAttention = receipt.status === 'attention';
const title = needsAttention
? 'Queued work needs attention'
: receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent';
await self.registration.showNotification(title, {
body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
tag: 'stackchain-delivery-' + receipt.id,
data: { route: receipt.route },
});
}
}
self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', event => {
event.waitUntil(caches.keys().then(keys => Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
.map(key => caches.delete(key))
)).then(() => self.clients.claim()));
});
self.addEventListener('sync', event => {
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
});
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
});
self.addEventListener('notificationclick', event => {
event.notification.close();
const route = String(event.notification.data?.route || '');
if (!route.startsWith('#/my-work/')) return;
const target = new URL(BASE + route, self.location.origin).href;
event.waitUntil((async () => {
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
if (!client) return self.clients.openWindow(target);
if (client.navigate) await client.navigate(target);
return client.focus();
})());
});
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)));
}
});
+101
View File
@@ -0,0 +1,101 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else {
const base = new URL('./', root.location.href).pathname;
const originalFetch = root.fetch.bind(root);
const boundary = factory({
cookie: () => root.document.cookie,
origin: root.location.origin,
base,
fetchImpl: originalFetch,
localStorage: root.localStorage,
sessionStorage: root.sessionStorage,
indexedDB: root.indexedDB,
caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
location: root.location,
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
});
root.fetch = boundary.fetch;
const attach = () => {
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
boundary.resumeQueuedWork();
};
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
else attach();
root.stackchainSession = boundary;
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location,
onExpired = () => {},
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
function csrfToken() {
const entry = String(cookie?.() || '').split(';')
.map(value => value.trim())
.find(value => value.startsWith('stackchain_csrf='));
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
}
function isSameOrigin(input) {
try { return new URL(String(input?.url || input), origin).origin === origin; }
catch (_error) { return false; }
}
async function sessionFetch(input, options = {}) {
const method = String(options.method || input?.method || 'GET').toUpperCase();
const requestOptions = { ...options };
if (!SAFE_METHODS.has(method) && isSameOrigin(input)) {
const headers = new Headers(options.headers || input?.headers || {});
const csrf = csrfToken();
if (csrf) headers.set('X-CSRF-Token', csrf);
requestOptions.headers = headers;
}
const response = await fetchImpl(input, requestOptions);
if (response.status === 401) onExpired();
return response;
}
function removeDashboardStorage(storage) {
if (!storage) return;
const keys = [];
try {
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key?.startsWith('stackchain.')) keys.push(key);
}
keys.forEach(key => storage.removeItem(key));
} catch (_error) { /* Cookie invalidation still protects server data. */ }
}
async function clearPrivateDeviceData() {
removeDashboardStorage(localStorage);
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); }
catch (_error) { /* Continue clearing other dashboard state. */ }
try {
const keys = await caches?.keys?.() || [];
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));
} catch (_error) { /* A later service-worker activation can clear stale caches. */ }
}
async function signOut() {
try {
await sessionFetch(base + 'api/v1/session', { method: 'DELETE' });
} finally {
await clearPrivateDeviceData();
location.assign(base + 'login');
}
}
async function resumeQueuedWork() {
try {
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({ type: 'stackchain-resume-outbox' });
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
}
return { fetch: sessionFetch, signOut, clearPrivateDeviceData, resumeQueuedWork };
});
+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 };
});
+163
View File
@@ -0,0 +1,163 @@
"""Signed, short-lived single-operator sessions for the dashboard boundary."""
import asyncio
import base64
import hashlib
import hmac
import ipaddress
import json
import os
import secrets
import time
from dataclasses import dataclass
from fastapi import Request
from src.session_store import SessionStore, SessionStoreError
SESSION_COOKIE = "stackchain_session"
CSRF_COOKIE = "stackchain_csrf"
DEFAULT_TTL_SECONDS = 8 * 60 * 60
MIN_SECRET_LENGTH = 24
OPERATOR_MODE = "operator"
INSECURE_LOCAL_MODE = "insecure-local"
@dataclass(frozen=True)
class Session:
session_id: str
csrf: str
expires_at: int
def access_token() -> str:
return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")
def mode() -> str:
return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower()
def configuration_error() -> str | None:
configured_mode = mode()
if configured_mode == INSECURE_LOCAL_MODE:
return None
if configured_mode != OPERATOR_MODE:
return "unsupported authentication mode"
operator_secret = access_token()
signing_secret = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
if len(operator_secret) < MIN_SECRET_LENGTH or len(signing_secret) < MIN_SECRET_LENGTH:
return "operator mode requires two sufficiently long secrets"
if hmac.compare_digest(operator_secret, signing_secret):
return "operator and session secrets must be independent"
return None
def enabled() -> bool:
return mode() == OPERATOR_MODE and configuration_error() is None
def is_loopback_request(request: Request) -> bool:
host = request.client.host if request.client else ""
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return host == "localhost"
def _secret() -> bytes:
configured = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
if configuration_error() is not None or mode() != OPERATOR_MODE:
raise RuntimeError("Dashboard authentication is not configured")
return configured.encode()
def _encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def _decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def _session_store(now: int | None = None) -> SessionStore:
database = os.getenv(
"STACKCHAIN_SESSION_DB",
os.path.join(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"), "sessions.sqlite3"),
)
current = time.time if now is None else lambda: now
return SessionStore(database, clock=current)
def issue_session(now: int | None = None) -> tuple[str, Session]:
issued_at = int(time.time() if now is None else now)
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
session = Session(
session_id=secrets.token_urlsafe(32),
csrf=secrets.token_urlsafe(24),
expires_at=issued_at + max(1, ttl),
)
payload = json.dumps(
{"csrf": session.csrf, "exp": session.expires_at, "sid": session.session_id},
separators=(",", ":"),
sort_keys=True,
).encode()
encoded = _encode(payload)
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
_session_store(now).activate(session.session_id, session.expires_at)
return f"{encoded}.{signature}", session
def verify_session(value: str | None, now: int | None = None) -> Session | None:
if not value or "." not in value or not enabled():
return None
encoded, supplied_signature = value.rsplit(".", 1)
expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
if not hmac.compare_digest(supplied_signature, expected):
return None
try:
payload = json.loads(_decode(encoded))
session = Session(
session_id=payload["sid"],
csrf=payload["csrf"],
expires_at=int(payload["exp"]),
)
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
return None
current = int(time.time() if now is None else now)
if (
session.expires_at <= current
or not isinstance(session.session_id, str)
or not session.session_id
or not isinstance(session.csrf, str)
or not session.csrf
):
return None
return session if _session_store(now).is_active(session.session_id, session.expires_at) else None
def revoke_session(session: Session) -> None:
_session_store().revoke(session.session_id)
async def request_session(request: Request) -> Session | None:
return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))
def cookie_path(request: Request) -> str:
root_path = request.scope.get("root_path", "").rstrip("/")
return root_path or "/"
def application_path(request: Request) -> str:
path = request.url.path
root_path = request.scope.get("root_path", "").rstrip("/")
if root_path and (path == root_path or path.startswith(root_path + "/")):
return path[len(root_path):] or "/"
return path
def same_origin(request: Request) -> bool:
origin = request.headers.get("origin", "")
expected = f"{request.url.scheme}://{request.url.netloc}"
return bool(origin) and hmac.compare_digest(origin.rstrip("/"), expected)
+1265 -28
View File
File diff suppressed because it is too large. Load diff
+132
View File
@@ -0,0 +1,132 @@
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 IdempotencyLedgerBusy(RuntimeError):
"""Raised when a durable completion cannot acquire the SQLite write lock."""
class IdempotencyLedger:
"""Small durable ledger for replaying successful Gitea mutations."""
def __init__(
self,
path: str | Path,
*,
ttl_seconds: float,
max_entries: int,
lock_timeout_seconds: float = 0.1,
clock: Callable[[], float] = time.time,
) -> None:
self.path = Path(path)
self.ttl_seconds = ttl_seconds
self.max_entries = max_entries
self.lock_timeout_seconds = lock_timeout_seconds
self.clock = clock
self.path.parent.mkdir(parents=True, exist_ok=True)
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
connection.execute(
f"PRAGMA busy_timeout = {max(1, int(self.lock_timeout_seconds * 1000))}"
)
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()
try:
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),
)
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
return Reservation("busy")
raise
return Reservation("reserved")
def clear(self) -> None:
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)
try:
with self._connect() as connection:
connection.execute(
"UPDATE idempotency_operations SET status = 'completed', "
"response_json = ?, completed_at = ? WHERE key = ?",
(encoded, self.clock(), key),
)
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
raise IdempotencyLedgerBusy from exc
raise
+149
View File
@@ -0,0 +1,149 @@
"""Durable throttling state for operator sign-in attempts."""
import hashlib
import ipaddress
import math
import sqlite3
from pathlib import Path
from typing import Callable
class LoginAttemptStoreError(RuntimeError):
"""Raised when sign-in throttling state cannot be accessed safely."""
def client_source(peer_host: str, forwarded_for: str, trusted_proxy_cidrs: str) -> str:
"""Resolve a canonical client IP without trusting arbitrary forwarding headers."""
try:
peer = ipaddress.ip_address(peer_host)
trusted = [
ipaddress.ip_network(value.strip())
for value in trusted_proxy_cidrs.split(",")
if value.strip()
]
except ValueError:
return peer_host
if not any(peer in network for network in trusted):
return str(peer)
try:
forwarded = [
ipaddress.ip_address(value.strip())
for value in forwarded_for.split(",")
if value.strip()
]
except ValueError:
return str(peer)
for address in reversed(forwarded):
if not any(address in network for network in trusted):
return str(address)
return str(forwarded[0]) if forwarded else str(peer)
class LoginAttemptStore:
def __init__(
self,
path: str | Path,
*,
clock: Callable[[], float],
max_failures: int,
window_seconds: int,
max_entries: int = 10_000,
lock_timeout_seconds: float = 0.1,
) -> None:
self.path = Path(path)
self.clock = clock
self.max_failures = max(1, max_failures)
self.window_seconds = max(1, window_seconds)
self.max_entries = max(1, max_entries)
self.lock_timeout_seconds = lock_timeout_seconds
@staticmethod
def _digest(source: str) -> str:
return hashlib.sha256(source.encode()).hexdigest()
def _connect(self) -> sqlite3.Connection:
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS login_attempts (
source_hash TEXT PRIMARY KEY,
failures INTEGER NOT NULL,
window_started_at REAL NOT NULL
)
"""
)
return connection
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def retry_after(self, source: str) -> int:
now = self.clock()
try:
with self._connect() as connection:
row = connection.execute(
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
(self._digest(source),),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
if row is None or row[0] < self.max_failures:
return 0
remaining = row[1] + self.window_seconds - now
return max(0, math.ceil(remaining))
def record_failure(self, source: str) -> None:
now = self.clock()
source_hash = self._digest(source)
try:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"DELETE FROM login_attempts WHERE window_started_at + ? <= ?",
(self.window_seconds, now),
)
row = connection.execute(
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
(source_hash,),
).fetchone()
if row is None or row[1] + self.window_seconds <= now:
connection.execute(
"INSERT OR REPLACE INTO login_attempts VALUES (?, 1, ?)",
(source_hash, now),
)
else:
connection.execute(
"UPDATE login_attempts SET failures = failures + 1 WHERE source_hash = ?",
(source_hash,),
)
connection.execute(
"""
DELETE FROM login_attempts
WHERE source_hash NOT IN (
SELECT source_hash FROM login_attempts
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
)
""",
(self.max_entries,),
)
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def clear(self, source: str) -> None:
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM login_attempts WHERE source_hash = ?",
(self._digest(source),),
)
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
+1591 -111
View File
File diff suppressed because it is too large. Load diff
+7
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
@@ -26,6 +31,8 @@ class Issue(BaseModel):
assignees: list[str] = []
repository: str = ""
updated_at: str = ""
due_date: str | None = None
milestone: Milestone | None = None
url: str
+88
View File
@@ -0,0 +1,88 @@
"""Durable active-session registry used to revoke signed operator sessions."""
import hashlib
import sqlite3
from pathlib import Path
from typing import Callable
class SessionStoreError(RuntimeError):
"""Raised when session state cannot be read or changed safely."""
class SessionStore:
def __init__(
self,
path: str | Path,
*,
clock: Callable[[], float],
lock_timeout_seconds: float = 0.1,
) -> None:
self.path = Path(path)
self.clock = clock
self.lock_timeout_seconds = lock_timeout_seconds
@staticmethod
def _digest(session_id: str) -> str:
return hashlib.sha256(session_id.encode()).hexdigest()
def _connect(self, *, initialize: bool = False) -> sqlite3.Connection:
try:
if initialize:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(
self.path, timeout=self.lock_timeout_seconds
)
else:
connection = sqlite3.connect(
f"{self.path.resolve().as_uri()}?mode=rw",
timeout=self.lock_timeout_seconds,
uri=True,
)
if initialize:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS active_sessions (
session_hash TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
)
"""
)
return connection
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def activate(self, session_id: str, expires_at: int) -> None:
try:
with self._connect(initialize=True) as connection:
connection.execute(
"DELETE FROM active_sessions WHERE expires_at <= ?", (int(self.clock()),)
)
connection.execute(
"INSERT INTO active_sessions(session_hash, expires_at) VALUES (?, ?)",
(self._digest(session_id), expires_at),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def is_active(self, session_id: str, expires_at: int) -> bool:
now = int(self.clock())
try:
with self._connect() as connection:
row = connection.execute(
"SELECT expires_at FROM active_sessions WHERE session_hash = ?",
(self._digest(session_id),),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
return row is not None and row[0] == expires_at and expires_at > now
def revoke(self, session_id: str) -> None:
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM active_sessions WHERE session_hash = ?",
(self._digest(session_id),),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
+38 -2
View File
@@ -1,12 +1,48 @@
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse
router = APIRouter()
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
LOGIN_HTML = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign in · Stackchain Dashboard</title>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status"></p></form></main>
<script src="static/login.js"></script></body></html>"""
@router.get("/", response_class=HTMLResponse)
class RevalidatingHTMLResponse(HTMLResponse):
def __init__(self, content, status_code=200, headers=None, media_type=None, background=None):
response_headers = dict(headers or {})
response_headers["Cache-Control"] = "no-cache"
super().__init__(content, status_code, response_headers, media_type, background)
@router.get("/", response_class=RevalidatingHTMLResponse)
async def dashboard() -> str:
return DASHBOARD_FILE.read_text()
@router.get("/login", response_class=RevalidatingHTMLResponse)
async def login() -> str:
return LOGIN_HTML
@router.get("/manifest.webmanifest", response_class=FileResponse)
async def web_app_manifest() -> FileResponse:
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
@router.get("/service-worker.js", response_class=FileResponse)
async def service_worker() -> FileResponse:
return FileResponse(
SERVICE_WORKER_FILE,
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
)
+2
View File
@@ -19,4 +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/",
}
+400
View File
@@ -0,0 +1,400 @@
import asyncio
import sqlite3
import threading
import time
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
async def test_issue_comment_replays_one_upstream_result_for_concurrent_requests(monkeypatch):
calls = 0
started = asyncio.Event()
release = asyncio.Event()
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
nonlocal calls
calls += 1
started.set()
await release.wait()
return {"id": 82, "body": body}
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": "issue-comment-185"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues/7/comments",
json={"body": "Ship it"}, headers=headers,
))
await started.wait()
second = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues/7/comments",
json={"body": "Ship it"}, headers=headers,
))
await asyncio.sleep(0)
release.set()
responses = await asyncio.gather(first, second)
assert [response.status_code for response in responses] == [201, 201]
assert calls == 1
@pytest.mark.anyio
async def test_pull_comment_rejects_changed_payload_for_same_key(monkeypatch):
calls = []
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
calls.append(body)
return {"id": len(calls), "body": body}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "pull-comment-185"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/comments",
json={"body": "Ship it"}, headers=headers,
)
conflict = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/comments",
json={"body": "Changed"}, headers=headers,
)
assert created.status_code == 201
assert conflict.status_code == 409
assert calls == ["Ship it"]
@pytest.mark.anyio
async def test_notification_reply_retry_recovers_after_caller_timeout(monkeypatch):
calls = 0
async def reply(_thread_id, body):
nonlocal calls
calls += 1
await asyncio.sleep(0.03)
return {"id": 91, "body": body}
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "notification-reply-185"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
timed_out = await client.post(
"/api/v1/notifications/42/reply", json={"body": "Retry"}, headers=headers,
)
await asyncio.sleep(0.03)
recovered = await client.post(
"/api/v1/notifications/42/reply", json={"body": "Retry"}, headers=headers,
)
assert timed_out.status_code == 503
assert recovered.status_code == 201
assert recovered.json()["id"] == 91
assert calls == 1
@pytest.mark.anyio
async def test_review_replays_same_key_and_rejects_changed_head(monkeypatch):
calls = []
async def requested(_repository, _number):
return True
async def submit(_repository, _number, head, decision, body):
calls.append((head, decision, body))
return {"id": 93, "state": "APPROVED"}
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "review-185"}
payload = {"expected_head_sha": "abc", "decision": "approve", "body": "Good"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/review", json=payload, headers=headers,
)
replayed = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/review", json=payload, headers=headers,
)
conflict = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/review",
json={**payload, "expected_head_sha": "def"}, headers=headers,
)
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
@pytest.mark.anyio
async def test_slow_ledger_reservation_does_not_block_health_requests(monkeypatch):
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
return {"id": 248, "body": body}
started = threading.Event()
original_reserve = main._idempotency_ledger.reserve
def slow_reserve(key, fingerprint):
started.set()
time.sleep(0.2)
return original_reserve(key, fingerprint)
monkeypatch.setattr(main._idempotency_ledger, "reserve", slow_reserve)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
started_at = asyncio.get_running_loop().time()
authored = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues/248/comments",
json={"body": "Keep the dashboard responsive"},
headers={"Idempotency-Key": "contention-248"},
))
assert await asyncio.to_thread(started.wait, 1)
health = await client.get("/healthz")
health_elapsed = asyncio.get_running_loop().time() - started_at
authored_response = await authored
assert health.status_code == 200
assert health_elapsed < 0.15
assert authored_response.status_code == 201
@pytest.mark.anyio
async def test_locked_ledger_returns_bounded_retryable_response(monkeypatch, tmp_path):
upstream_calls = 0
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
nonlocal upstream_calls
upstream_calls += 1
return {"id": 248, "body": body}
database = tmp_path / "contended.sqlite3"
ledger = IdempotencyLedger(
database,
ttl_seconds=600,
max_entries=256,
lock_timeout_seconds=0.05,
)
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
blocker = sqlite3.connect(database)
blocker.execute("BEGIN IMMEDIATE")
transport = httpx.ASGITransport(app=main.app)
try:
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
started_at = asyncio.get_running_loop().time()
response = await client.post(
"/api/v1/repos/stackchain/api/issues/248/comments",
json={"body": "Retry without freezing"},
headers={"Idempotency-Key": "locked-ledger-248"},
)
elapsed = asyncio.get_running_loop().time() - started_at
finally:
blocker.rollback()
blocker.close()
assert response.status_code == 503
assert response.headers["Retry-After"] == "1"
assert response.json()["detail"] == "Authored action queue is busy; please retry"
assert elapsed < 0.5
assert upstream_calls == 0
@pytest.mark.anyio
async def test_slow_ledger_completion_does_not_block_health_requests(monkeypatch):
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
return {"id": 249, "body": body}
started = threading.Event()
original_complete = main._idempotency_ledger.complete
def slow_complete(key, response):
started.set()
time.sleep(0.2)
return original_complete(key, response)
monkeypatch.setattr(main._idempotency_ledger, "complete", slow_complete)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
started_at = asyncio.get_running_loop().time()
authored = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues/248/comments",
json={"body": "Persist without freezing"},
headers={"Idempotency-Key": "slow-completion-248"},
))
assert await asyncio.to_thread(started.wait, 1)
health = await client.get("/healthz")
health_elapsed = asyncio.get_running_loop().time() - started_at
authored_response = await authored
assert health.status_code == 200
assert health_elapsed < 0.15
assert authored_response.status_code == 201
@pytest.mark.anyio
async def test_locked_completion_fails_closed_with_retry_guidance(monkeypatch, tmp_path):
database = tmp_path / "completion-contention.sqlite3"
ledger = IdempotencyLedger(
database, ttl_seconds=600, max_entries=256, lock_timeout_seconds=0.05
)
blocker = None
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
nonlocal blocker
blocker = sqlite3.connect(database)
blocker.execute("BEGIN IMMEDIATE")
return {"id": 250, "body": body}
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
try:
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/248/comments",
json={"body": "The upstream mutation may already exist"},
headers={"Idempotency-Key": "locked-completion-248"},
)
finally:
if blocker is not None:
blocker.rollback()
blocker.close()
assert response.status_code == 503
assert response.headers["Retry-After"] == "5"
assert response.json()["detail"] == (
"This action may have completed; verify its result before retrying"
)
assert ledger.reserve(
"locked-completion-248",
("issue-comment", "stackchain/api", 248, "The upstream mutation may already exist"),
).state == "pending"
+256
View File
@@ -0,0 +1,256 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
OUTBOX = Path(__file__).parents[1] / "frontend" / "authored-outbox.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_authored_outbox_quarantines_legacy_and_cross_account_messages():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify({{version:1,items:[
{{id:'legacy',operationId:'legacy',kind:'issue-comment',repository:'o/r',number:1,body:'Old',status:'queued'}}
]}})]]);
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const calls = [];
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=>'timmy',
fetchJson:async url => {{calls.push(url); return {{id:1}};}},
}});
const bound = outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:2,body:'Mine',operationId:'mine'}});
(async()=>{{
const wrong = await outbox.flush('alexander');
const right = await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{bound,wrong,right,calls,remaining:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output["bound"]["ownerLogin"] == "timmy"
assert output["wrong"]["blocked"] == 2
assert len(output["right"]["confirmed"]) == 1
assert [item["id"] for item in output["remaining"]] == ["legacy"]
assert len(output["calls"]) == 1
def test_authored_outbox_persists_each_message_kind_and_flushes_sequentially():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const calls = [];
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=>'timmy', now:() => 1234,
fetchJson:async (url, options) => {{
calls.push({{url,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
return {{id:calls.length}};
}},
}});
outbox.enqueue({{kind:'issue-comment',repository:'stackchain/api',number:7,body:'Issue note',operationId:'issue-op'}});
outbox.enqueue({{kind:'pull-comment',repository:'stackchain/web',number:8,body:'PR note',operationId:'pull-op'}});
outbox.enqueue({{kind:'update-reply',notificationId:9,body:'Update note',operationId:'update-op'}});
const persisted = createAuthoredOutbox({{storage,fetchJson:outbox.fetchJson}}).list();
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persisted,calls,result,remaining:outbox.list()}})));
"""
output = run_node(script)
assert [item["kind"] for item in output["persisted"]] == [
"issue-comment", "pull-comment", "update-reply"
]
assert [item["operationId"] for item in output["persisted"]] == ["issue-op", "pull-op", "update-op"]
assert output["calls"] == [
{"url": "api/v1/repos/stackchain/api/issues/7/comments", "key": "issue-op", "body": {"body": "Issue note"}},
{"url": "api/v1/repos/stackchain/web/pulls/8/comments", "key": "pull-op", "body": {"body": "PR note"}},
{"url": "api/v1/notifications/9/reply", "key": "update-op", "body": {"body": "Update note"}},
]
assert len(output["result"]["confirmed"]) == 3
assert output["remaining"] == []
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let phase = 'permanent';
const calls = [];
const outbox = createAuthoredOutbox({{
storage,
getOwnerLogin:()=>'timmy',
fetchJson:async (_url, options) => {{
const body = JSON.parse(options.body).body; calls.push(body);
if (body === 'Bad' && phase === 'permanent') {{ const e = new Error('Reply rejected'); e.status=422; throw e; }}
if (body === 'Later' && phase === 'transient') {{ const e = new Error('Offline'); e.status=503; throw e; }}
return {{id:calls.length}};
}},
}});
const bad=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Bad',operationId:'a'}});
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Good',operationId:'b'}});
outbox.flush('timmy').then(async first => {{
phase='transient';
outbox.enqueue({{kind:'update-reply',notificationId:3,body:'Later',operationId:'c'}});
const second=await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{first,second,calls,remaining:outbox.list(),badId:bad.id}}));
}});
"""
output = run_node(script)
assert output["calls"] == ["Bad", "Good", "Later"]
assert len(output["first"]["confirmed"]) == 1
assert output["remaining"][0]["id"] == output["badId"]
assert output["remaining"][0]["status"] == "attention"
assert output["remaining"][0]["error"] == "Reply rejected"
assert output["remaining"][1]["status"] == "queued"
def test_authored_outbox_retry_is_single_flight_and_edit_rotates_identity():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let release; const gate=new Promise(resolve=>release=resolve); let calls=0; let sequence=0;
const outbox=createAuthoredOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'new-' + (++sequence),
fetchJson:async()=>{{calls++; await gate; return {{id:1}};}},
}});
const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Old',operationId:'old'}});
const edited=outbox.update(item.id,{{body:'New'}});
const flush=outbox.flush('timmy'); const retry=outbox.retry(item.id, 'timmy'); release();
Promise.all([flush,retry]).then(results=>process.stdout.write(JSON.stringify({{edited,calls,results,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["edited"]["body"] == "New"
assert output["edited"]["operationId"] == "new-1"
assert output["calls"] == 1
assert output["remaining"] == []
def test_authored_outbox_deduplicates_repeated_queue_attempts_by_operation_id():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy'}});
const first=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
const second=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
"""
output = run_node(script)
assert output["first"] == output["second"]
assert len(output["items"]) == 1
def test_authored_outbox_mirrors_to_background_sync_and_reconciles_worker_results():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const mirrors=[]; let requested=0;
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const backgroundSync = {{
reconcile: async (items, lane) => mirrors.push({{items:items.map(item=>({{...item}})),lane}}),
requestSync: async () => {{requested += 1;}},
}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync}});
const sent=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Sent',operationId:'sent'}});
const failed=outbox.enqueue({{kind:'update-reply',notificationId:2,body:'Fix me',operationId:'failed'}});
setTimeout(() => {{
const reconciled=outbox.reconcileBackground([
{{...sent,status:'sent'}},
{{...failed,status:'attention',error:'Reply rejected'}},
]);
setTimeout(() => process.stdout.write(JSON.stringify({{mirrors,requested,reconciled}})), 0);
}}, 0);
"""
output = run_node(script)
assert output["mirrors"][0]["lane"] == "authored"
assert [item["operationId"] for item in output["mirrors"][-1]["items"]] == ["failed"]
assert output["requested"] >= 1
assert [(item["operationId"], item["status"]) for item in output["reconciled"]] == [
("failed", "attention")
]
def test_authored_outbox_waits_for_durable_background_admission():
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)}};
let release; const gate = new Promise(resolve => release = resolve); const events = [];
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
backgroundSync: {{
reconcile: async (_items, lane) => {{ events.push('mirror:' + lane); await gate; events.push('committed'); }},
requestSync: async () => events.push('registered'),
}},
}});
const admission = outbox.enqueueDurably({{
kind:'issue-comment',repository:'o/r',number:1,body:'Do not lose',operationId:'message-1'
}}).then(result => {{ events.push('confirmed'); return result; }});
Promise.resolve().then(async () => {{
const pending = events.slice(); release(); const result = await admission;
process.stdout.write(JSON.stringify({{pending,events,result,items:outbox.list()}}));
}});
"""
output = run_node(script)
assert output["pending"] == ["mirror:authored"]
assert output["events"] == ["mirror:authored", "committed", "registered", "confirmed"]
assert output["result"]["durability"] == "background"
assert output["items"][0]["operationId"] == "message-1"
def test_authored_outbox_foreground_send_uses_atomic_background_delivery():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); let backgroundCalls=0; let directCalls=0;
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const backgroundSync = {{
reconcile: async()=>{{}}, requestSync:async()=>{{}},
send: async (item, owner) => {{backgroundCalls += 1; return {{message:{{id:3,owner}}}};}},
}};
const outbox=createAuthoredOutbox({{
storage,getOwnerLogin:()=>'timmy',backgroundSync,
fetchJson:async()=>{{directCalls += 1; return {{id:4}};}},
}});
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Review',operationId:'once'}});
(async()=>{{
const result=await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{backgroundCalls,directCalls,result,remaining:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output["backgroundCalls"] == 1
assert output["directCalls"] == 0
assert output["result"]["confirmed"] == [{"id": 3, "owner": "timmy"}]
assert output["remaining"] == []
@pytest.mark.anyio
async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
html = await dashboard()
assert '<script src="static/authored-outbox.js"></script>' in html
assert "const authoredOutbox = createAuthoredOutbox({" in html
assert "activeFlushLogin = contextIdentityFresh ?" in html
assert "flushAuthoredOutbox();" in html
assert "await authoredOutbox.enqueueDurably" in html
assert "Saving for background delivery…" in html
assert "Saved for next launch; background delivery unavailable." in html
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
assert "authoredOutbox.discard(item.outbox_id)" in html
assert "if (result?.queued)" in html
assert "backgroundSync: backgroundIssueSync" in html
assert "authoredOutbox.reconcileBackground(records)" in html
+539
View File
@@ -0,0 +1,539 @@
import json
import subprocess
from pathlib import Path
import httpx
import pytest
from src import main
from src.views import dashboard
SYNC = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
def run_node(script: str) -> dict:
completed = subprocess.run(
["node", "-e", script], capture_output=True, check=True, text=True
)
return json.loads(completed.stdout)
def test_closed_app_sync_delivers_matching_issue_once_with_original_idempotency_key():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const item = {{
id:'capture-1', operationId:'capture-1', ownerLogin:'timmy', status:'queued',
repository:'stackchain/api', title:'Offline report', body:'Full context',
labelIds:[3], milestoneId:4, dueDate:'2026-08-09', queuedAt:100,
}};
const state = {{item, completed:[], released:[], failed:[], calls:[]}};
const store = {{
claimNext: async owner => state.item && state.item.ownerLogin === owner ? {{...state.item}} : null,
complete: async id => {{ state.completed.push(id); state.item = null; }},
release: async id => state.released.push(id),
fail: async (id, message) => state.failed.push({{id,message}}),
}};
const fetchJson = async (url, options = {{}}) => {{
state.calls.push({{url,options}});
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
return {{repository:'stackchain/api',number:251,title:'Offline report'}};
}};
(async () => {{
const sync = createBackgroundIssueSync({{store,fetchJson}});
const result = await sync.flush();
process.stdout.write(JSON.stringify({{state,result}}));
}})();
"""
output = run_node(script)
assert output["result"]["confirmed"][0]["number"] == 251
assert output["state"]["completed"] == ["capture-1"]
assert output["state"]["released"] == []
assert output["state"]["failed"] == []
assert output["state"]["calls"][0]["url"] == "api/v1/background-identity"
mutation = output["state"]["calls"][1]
assert mutation["url"] == "api/v1/repos/stackchain/api/issues"
assert mutation["options"]["headers"]["Idempotency-Key"] == "capture-1"
assert json.loads(mutation["options"]["body"]) == {
"title": "Offline report",
"body": "Full context",
"label_ids": [3],
"milestone_id": 4,
"due_date": "2026-08-09T23:59:59Z",
}
def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const queued = [
{{id:'capture-1',operationId:'capture-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Secret title',body:'Secret body',labelIds:[]}},
{{id:'reply-1',operationId:'reply-1',ownerLogin:'timmy',status:'queued',kind:'pull-comment',repository:'stackchain/web',number:8,body:'Secret reply'}},
{{id:'bad-1',operationId:'bad-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Bad',body:'Secret failure',labelIds:[]}},
];
const store = {{
claimNext: async () => queued.shift() || null,
complete: async () => {{}}, release: async () => {{}}, fail: async () => {{}},
countBlocked: async () => 0,
}};
let issueMutations = 0;
const fetchJson = async (url) => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
if (url.endsWith('/issues') && issueMutations++ === 0) return {{number:44,repository:'stackchain/api',title:'Secret title'}};
if (url.includes('/pulls/8/comments')) return {{id:91,body:'Secret reply'}};
const error = new Error('Sensitive validation detail'); error.status = 422; throw error;
}};
(async () => {{
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify(result));
}})();
"""
output = run_node(script)
assert output["login"] == "timmy"
assert output["receipts"] == [
{
"id": "capture-1",
"status": "confirmed",
"kind": "issue",
"route": "#/my-work/issue/stackchain/api/44",
},
{
"id": "reply-1",
"status": "confirmed",
"kind": "message",
"route": "#/my-work/pull/stackchain/web/8",
},
{
"id": "bad-1",
"status": "attention",
"kind": "issue",
"route": "#/my-work/drafts",
},
]
serialized = json.dumps(output["receipts"])
assert "Secret" not in serialized
assert "Sensitive" not in serialized
@pytest.mark.parametrize(
("item", "expected_url"),
[
(
{"kind": "issue-comment", "repository": "stackchain/api", "number": 7},
"api/v1/repos/stackchain/api/issues/7/comments",
),
(
{"kind": "pull-comment", "repository": "stackchain/web", "number": 8},
"api/v1/repos/stackchain/web/pulls/8/comments",
),
(
{"kind": "update-reply", "notificationId": 9},
"api/v1/notifications/9/reply",
),
],
)
def test_closed_app_sync_delivers_each_authored_message_kind(item, expected_url):
authored = {
"id": "message-op",
"operationId": "message-op",
"ownerLogin": "timmy",
"status": "queued",
"body": "Ship this reply",
**item,
}
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let queued = {json.dumps(authored)};
const calls = [];
const store = {{
claimNext: async owner => queued?.ownerLogin === owner ? (queued = null, {json.dumps(authored)}) : null,
complete: async () => {{}}, release: async () => {{}}, fail: async () => {{}},
countBlocked: async () => 0,
}};
const fetchJson = async (url, options = {{}}) => {{
calls.push({{url, options}});
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
return {{id:42}};
}};
(async () => {{
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify({{calls,result}}));
}})();
"""
output = run_node(script)
mutation = output["calls"][1]
assert mutation["url"] == expected_url
assert mutation["options"]["headers"]["Idempotency-Key"] == "message-op"
assert json.loads(mutation["options"]["body"]) == {"body": "Ship this reply"}
assert output["result"]["confirmed"] == [{"id": 42}]
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{
getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
await store.reconcile([{{id:'message',kind:'issue-comment',ownerLogin:'timmy',status:'queued'}}], 'authored');
await store.reconcile([], 'issue');
process.stdout.write(JSON.stringify(await store.snapshot()));
}})();
"""
output = run_node(script)
assert output == [
{"id": "message", "kind": "issue-comment", "ownerLogin": "timmy", "status": "queued", "outboxLane": "authored"}
]
def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{
getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
await store.setReceiptPreference('timmy', true);
await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
const result={{
timmy:await store.getReceiptPreference('timmy'),
alexander:await store.getReceiptPreference('alexander'),
snapshot:await store.snapshot(),
blocked:await store.countBlocked('alexander'),
}};
await store.setReceiptPreference('timmy', false);
result.disabled=await store.getReceiptPreference('timmy');
process.stdout.write(JSON.stringify(result));
}})();
"""
output = run_node(script)
assert output == {
"timmy": True,
"alexander": False,
"snapshot": [
{
"id": "issue",
"ownerLogin": "timmy",
"status": "queued",
"outboxLane": "issue",
}
],
"blocked": 1,
"disabled": False,
}
def test_closed_app_sync_leaves_another_accounts_issue_queued():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const state = {{owners:[],mutations:0}};
const store = {{
claimNext: async owner => {{ state.owners.push(owner); return null; }},
countBlocked: async owner => owner === 'alexander' ? 1 : 0,
complete: async () => {{}},
}};
const fetchJson = async url => {{
if (url === 'api/v1/background-identity') return {{login:'alexander'}};
state.mutations += 1;
}};
(async () => {{
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify({{state,result}}));
}})();
"""
output = run_node(script)
assert output["state"] == {"owners": ["alexander"], "mutations": 0}
assert output["result"]["confirmed"] == []
assert output["result"]["blocked"] == 1
def test_session_expiry_during_delivery_releases_claim_without_attention():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const queued = [
{{id:'capture-auth',operationId:'stable-key',ownerLogin:'timmy',repository:'o/r',title:'Keep me',body:'',labelIds:[]}},
{{id:'capture-later',operationId:'later-key',ownerLogin:'timmy',repository:'o/r',title:'Do not try yet',body:'',labelIds:[]}},
];
const state = {{released:[],failed:[],mutationKeys:[]}};
const store = {{
claimNext: async () => queued.shift() || null,
release: async id => state.released.push(id),
fail: async (id,message) => state.failed.push({{id,message}}),
countBlocked: async () => 0,
}};
const fetchJson = async (url, options={{}}) => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
state.mutationKeys.push(options.headers['Idempotency-Key']);
const error = new Error('Authentication required'); error.status = 401; throw error;
}};
(async () => {{
let error = null;
try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }}
catch (caught) {{ error = {{message:caught.message,status:caught.status}}; }}
process.stdout.write(JSON.stringify({{state,error}}));
}})();
"""
output = run_node(script)
assert output == {
"state": {
"released": ["capture-auth"],
"failed": [],
"mutationKeys": ["stable-key"],
},
"error": {"message": "Authentication required", "status": 401},
}
def test_transient_delivery_failure_releases_claim_and_requests_another_sync():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let item = {{id:'capture-2',operationId:'capture-2',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}};
const state = {{released:[],completed:[]}};
const store = {{
claimNext: async () => item ? (item = null, {{id:'capture-2',operationId:'capture-2',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}}) : null,
release: async id => state.released.push(id),
complete: async id => state.completed.push(id),
}};
const fetchJson = async url => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
const error = new Error('Gitea unavailable'); error.status = 503; throw error;
}};
(async () => {{
let error = null;
try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }}
catch (caught) {{ error = caught.message; }}
process.stdout.write(JSON.stringify({{state,error}}));
}})();
"""
output = run_node(script)
assert output["state"]["released"] == ["capture-2"]
assert output["state"]["completed"] == []
assert output["error"] == "Gitea unavailable"
def test_permanent_delivery_failure_marks_issue_for_foreground_attention():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let claimed = false;
const state = {{failed:[],released:[]}};
const store = {{
claimNext: async () => claimed ? null : (claimed = true, {{id:'capture-3',operationId:'capture-3',ownerLogin:'timmy',repository:'o/r',title:'Invalid',body:'',labelIds:[]}}),
fail: async (id, message) => state.failed.push({{id,message}}),
release: async id => state.released.push(id),
countBlocked: async () => 0,
}};
const fetchJson = async url => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
const error = new Error('Title is invalid'); error.status = 422; throw error;
}};
(async () => {{
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify({{state,result}}));
}})();
"""
output = run_node(script)
assert output["state"]["failed"] == [
{"id": "capture-3", "message": "Title is invalid"}
]
assert output["state"]["released"] == []
assert output["result"]["attention"] == 1
def test_issue_store_atomically_grants_one_delivery_claim():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records = new Map();
let tail = Promise.resolve();
const transaction = work => {{
const run = tail.then(() => work({{
getAll: async () => [...records.values()].map(value => ({{...value}})),
put: async value => records.set(value.id, {{...value}}),
delete: async id => records.delete(id),
}}));
tail = run.catch(() => {{}});
return run;
}};
(async () => {{
const store = createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>1000,claimMs:5000}});
await store.reconcile([{{id:'same',operationId:'same',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Once',labelIds:[]}}]);
const claims = await Promise.all([store.claimNext('timmy'),store.claimNext('timmy')]);
process.stdout.write(JSON.stringify({{claims,records:[...records.values()]}}));
}})();
"""
output = run_node(script)
assert sum(claim is not None for claim in output["claims"]) == 1
assert output["records"][0]["status"] == "sending"
assert output["records"][0]["claimUntil"] == 6000
def test_foreground_and_worker_race_still_posts_one_issue():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records = new Map(); let tail = Promise.resolve();
const transaction = work => {{
const run = tail.then(() => work({{
getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}), delete:async id=>records.delete(id),
}}));
tail=run.catch(()=>{{}}); return run;
}};
const item={{id:'race',operationId:'race',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Exactly once',body:'',labelIds:[]}};
let mutations=0;
const fetchJson=async url=>{{
if(url==='api/v1/background-identity') return {{login:'timmy'}};
mutations+=1; return {{number:77}};
}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
await store.reconcile([item]);
const sync=createBackgroundIssueSync({{store,fetchJson}});
const [foreground,worker]=await Promise.all([sync.send(item,'timmy'),sync.flush()]);
process.stdout.write(JSON.stringify({{foreground,worker,mutations,remaining:[...records.values()]}}));
}})();
"""
output = run_node(script)
assert output["mutations"] == 1
assert len(output["remaining"]) == 1
assert output["remaining"][0]["status"] == "sent"
delivered = int(bool(output["foreground"].get("issue"))) + len(
output["worker"]["confirmed"]
)
assert delivered == 1
def test_foreground_send_upserts_before_claim_when_mirror_is_still_pending():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let record = null; const state={{upserts:0,mutations:0}};
const store={{
upsert:async item=>{{state.upserts+=1;record={{...item}};}},
claim:async(id,owner)=>record?.id===id&&record?.ownerLogin===owner?{{...record}}:null,
complete:async()=>{{record=null;}}, release:async()=>{{}}, fail:async()=>{{}},
}};
const fetchJson=async()=>{{state.mutations+=1;return {{number:88}};}};
(async()=>{{
const sync=createBackgroundIssueSync({{store,fetchJson}});
const result=await sync.send({{id:'early',operationId:'early',ownerLogin:'timmy',repository:'o/r',title:'Fast',body:'',labelIds:[]}},'timmy');
process.stdout.write(JSON.stringify({{state,result,record}}));
}})();
"""
output = run_node(script)
assert output["state"] == {"upserts": 1, "mutations": 1}
assert output["result"]["issue"]["number"] == 88
assert output["record"] is None
def test_completed_delivery_leaves_non_replayable_tombstone_for_next_page():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{
getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}});
await store.reconcile([{{id:'done',ownerLogin:'timmy',status:'queued'}}]);
await store.claimNext('timmy');
await store.complete('done');
const replay=await store.claimNext('timmy');
const snapshot=await store.snapshot();
process.stdout.write(JSON.stringify({{replay,snapshot}}));
}})();
"""
output = run_node(script)
assert output["replay"] is None
assert output["snapshot"] == [
{"id": "done", "ownerLogin": "timmy", "status": "sent", "outboxLane": "issue", "claimUntil": 0}
]
def test_user_edit_resets_worker_attention_item_for_retry():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map([['edit',{{id:'edit',ownerLogin:'timmy',status:'attention',title:'Bad',error:'Invalid'}}]]);
let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{
getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}});
await store.reconcile([{{id:'edit',ownerLogin:'timmy',status:'queued',title:'Fixed'}}]);
const claimed=await store.claimNext('timmy');
process.stdout.write(JSON.stringify(claimed));
}})();
"""
output = run_node(script)
assert output["title"] == "Fixed"
assert output["status"] == "sending"
assert "error" not in output
@pytest.mark.anyio
async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch):
calls = 0
async def user():
nonlocal calls
calls += 1
return {"id": 7, "login": "timmy", "email": "private@example.com"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/background-identity")
assert response.status_code == 200
assert response.json() == {"login": "timmy"}
assert response.headers["cache-control"] == "no-store"
assert calls == 1
@pytest.mark.anyio
async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback():
html = await dashboard()
assert '<script src="static/background-issue-sync.js"></script>' in html
assert "const backgroundIssueStore = createIssueSyncStore();" in html
assert "createBackgroundIssueSync({" in html
assert "backgroundSync: backgroundIssueSync" in html
assert "registration.sync.register('stackchain-issue-outbox-v1')" in html
assert "backgroundIssueSync.snapshot().then(records => {" in html
assert "issueOutbox.reconcileBackground(records);" in html
assert "authoredOutbox.reconcileBackground(records);" in html
assert "if ('indexedDB' in window)" in html
@pytest.mark.anyio
async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in():
html = await dashboard()
assert 'id="delivery-receipts" type="checkbox"' in html
assert "deliveryReceipts.addEventListener('change', async () => {" in html
assert "await Notification.requestPermission()" in html
assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html
assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html
assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html
assert "if (window.location.hash !== '#/my-work/drafts') return;" 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
+489
View File
@@ -0,0 +1,489 @@
import asyncio
import time
import httpx
import pytest
from src import main
from src.session_store import SessionStoreError
@pytest.fixture
def access_control(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3")
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60")
@pytest.mark.anyio
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
called = False
async def user():
nonlocal called
called = True
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
health = await client.get("/healthz")
responses = [
await client.get("/readyz"),
await client.get("/login"),
await client.get("/"),
await client.get("/api/v1/context"),
]
assert health.status_code == 200
assert [response.status_code for response in responses] == [503, 503, 503, 503]
assert all(response.headers["cache-control"] == "no-store" for response in responses)
assert all(response.json() == {
"detail": "Dashboard authentication is not configured"
} for response in responses)
assert called is False
@pytest.mark.anyio
async def test_operator_mode_rejects_reused_or_incomplete_secrets(monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "same-secret-with-at-least-thirty-two-characters")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "same-secret-with-at-least-thirty-two-characters")
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
reused = await client.get("/login")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
incomplete = await client.get("/login")
assert reused.status_code == 503
assert incomplete.status_code == 503
assert "same-secret" not in reused.text
@pytest.mark.anyio
async def test_insecure_local_mode_is_restricted_to_loopback(monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
local_transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 1234))
remote_transport = httpx.ASGITransport(app=main.app, client=("203.0.113.9", 1234))
async with httpx.AsyncClient(transport=local_transport, base_url="http://test") as client:
local = await client.get("/login")
async with httpx.AsyncClient(transport=remote_transport, base_url="http://test") as client:
remote = await client.get("/login")
assert local.status_code == 200
assert remote.status_code == 403
assert remote.json() == {"detail": "Insecure local mode requires a loopback client"}
@pytest.mark.anyio
async def test_anonymous_private_request_is_rejected_before_gitea(access_control, monkeypatch):
called = False
async def user():
nonlocal called
called = True
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get("/api/v1/context")
assert response.status_code == 401
assert response.json() == {"detail": "Authentication required"}
assert response.headers["cache-control"] == "no-store"
assert called is False
@pytest.mark.anyio
async def test_sign_in_creates_secure_session_without_echoing_access_token(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
assert response.status_code == 200
assert response.json() == {"authenticated": True}
cookies = response.headers.get_list("set-cookie")
assert any("stackchain_session=" in value and "HttpOnly" in value and "Secure" in value and "SameSite=strict" in value for value in cookies)
assert any("stackchain_csrf=" in value and "Secure" in value and "SameSite=strict" in value and "HttpOnly" not in value for value in cookies)
assert "correct horse battery staple" not in response.text
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_sign_in_throttles_repeated_failures_with_retry_guidance(access_control):
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
failures = [
await client.post("/api/v1/session", json={"access_token": "wrong"})
for _ in range(3)
]
blocked = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
assert [response.status_code for response in failures] == [401, 401, 401]
assert blocked.status_code == 429
assert blocked.json() == {"detail": "Too many sign-in attempts"}
assert blocked.headers["retry-after"].isdigit()
assert blocked.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_successful_sign_in_clears_prior_failures(access_control):
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
for _ in range(2):
await client.post("/api/v1/session", json={"access_token": "wrong"})
success = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
after_success = [
await client.post("/api/v1/session", json={"access_token": "wrong"})
for _ in range(3)
]
assert success.status_code == 200
assert [response.status_code for response in after_success] == [401, 401, 401]
@pytest.mark.anyio
async def test_authenticated_get_reaches_private_api(access_control, monkeypatch):
async def user():
return {"id": 1, "login": "timmy", "full_name": "", "email": ""}
async def empty():
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
response = await client.get("/api/v1/context")
assert signed_in.status_code == 200
assert response.status_code == 200
assert response.json()["user"]["login"] == "timmy"
@pytest.mark.anyio
async def test_session_status_reuses_the_middleware_validation(access_control, monkeypatch):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
original_store = main.dashboard_auth._session_store()
lookups = 0
class CountingStore:
def is_active(self, session_id, expires_at):
nonlocal lookups
lookups += 1
return original_store.is_active(session_id, expires_at)
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: CountingStore())
response = await client.get("/api/v1/session")
assert response.status_code == 200
assert response.json()["authenticated"] is True
assert lookups == 1
@pytest.mark.anyio
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
response = await client.get("/api/v1/session")
assert response.status_code == 200
assert response.json() == {
"authenticated": True,
"csrf_token": client.cookies["stackchain_csrf"],
}
assert "correct horse battery staple" not in response.text
@pytest.mark.anyio
async def test_mutation_requires_same_origin_and_session_csrf(access_control, monkeypatch):
calls = 0
async def mark_read(notification_id):
nonlocal calls
calls += 1
return {"id": notification_id, "read": True}
monkeypatch.setattr(main, "mark_notification_read", mark_read)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
missing = await client.patch("/api/v1/notifications/7/read")
foreign = await client.patch(
"/api/v1/notifications/7/read",
headers={
"Origin": "https://evil.example",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
valid = await client.patch(
"/api/v1/notifications/7/read",
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
assert missing.status_code == 403
assert foreign.status_code == 403
assert valid.status_code == 200
assert calls == 1
@pytest.mark.anyio
async def test_logout_clears_session_and_blocks_private_routes(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
csrf = client.cookies["stackchain_csrf"]
response = await client.delete(
"/api/v1/session",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
private = await client.get("/api/v1/background-identity")
assert response.status_code == 200
assert response.json() == {"authenticated": False, "clear_private_device_data": True}
assert private.status_code == 401
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
@pytest.mark.anyio
async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, monkeypatch):
calls = 0
async def user():
nonlocal calls
calls += 1
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
captured = client.cookies["stackchain_session"]
csrf = client.cookies["stackchain_csrf"]
async with httpx.AsyncClient(
transport=transport,
base_url="https://test",
headers={"Cookie": f"stackchain_session={captured}"},
) as replay:
before = await replay.get("/api/v1/background-identity")
await client.delete(
"/api/v1/session",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
async with httpx.AsyncClient(
transport=transport,
base_url="https://test",
headers={"Cookie": f"stackchain_session={captured}"},
) as replay:
after = await replay.get("/api/v1/background-identity")
assert before.status_code == 200
assert after.status_code == 401
assert after.headers["cache-control"] == "no-store"
assert calls == 1
@pytest.mark.anyio
async def test_session_registry_latency_does_not_block_the_event_loop(access_control, monkeypatch):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
class SlowStore:
def is_active(self, session_id, expires_at):
time.sleep(0.15)
return True
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
private_request = asyncio.create_task(client.get("/api/v1/session"))
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.01)
heartbeat_elapsed = time.perf_counter() - started
response = await private_request
assert response.status_code == 200
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_session_registry_read_failure_fails_closed_before_gitea(
access_control, monkeypatch
):
called = False
async def user():
nonlocal called
called = True
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
class BrokenStore:
def is_active(self, session_id, expires_at):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
response = await client.get("/api/v1/background-identity")
health = await client.get("/healthz")
assert response.status_code == 503
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
assert response.headers["cache-control"] == "no-store"
assert "database path" not in response.text
assert called is False
assert health.status_code == 200
@pytest.mark.anyio
async def test_sign_in_registry_write_failure_issues_no_cookie(access_control, monkeypatch):
class BrokenStore:
def activate(self, session_id, expires_at):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
assert response.status_code == 503
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
assert response.headers["cache-control"] == "no-store"
assert response.headers.get_list("set-cookie") == []
assert "database path" not in response.text
@pytest.mark.anyio
async def test_logout_registry_failure_does_not_claim_revocation(access_control, monkeypatch):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
csrf = client.cookies["stackchain_csrf"]
class BrokenStore:
def is_active(self, session_id, expires_at):
return True
def revoke(self, session_id):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
response = await client.delete(
"/api/v1/session",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
assert response.status_code == 503
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
assert response.headers["cache-control"] == "no-store"
assert response.headers.get_list("set-cookie") == []
assert "database path" not in response.text
@pytest.mark.anyio
async def test_public_routes_skip_session_registry_validation(access_control, monkeypatch):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
class BrokenStore:
def is_active(self, session_id, expires_at):
raise SessionStoreError("public routes must not read the registry")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
login = await client.get("/login")
manifest = await client.get("/manifest.webmanifest")
static = await client.get("/static/session.js")
assert signed_in.status_code == 200
assert [login.status_code, manifest.status_code, static.status_code] == [200, 200, 200]
@pytest.mark.anyio
async def test_public_routes_remain_available_and_readiness_hides_identity(access_control, monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
health = await client.get("/healthz")
login = await client.get("/login")
ready = await client.get("/readyz")
assert health.status_code == 200
assert login.status_code == 200
assert 'name="access_token"' in login.text
assert ready.status_code == 200
assert ready.json() == {"status": "ready", "service": "stackchain-dashboard"}
@pytest.mark.anyio
async def test_subpath_deployment_scopes_routes_and_session_cookies(access_control):
transport = httpx.ASGITransport(app=main.app, root_path="/dashboard")
async with httpx.AsyncClient(
transport=transport, base_url="https://test/dashboard/"
) as client:
login = await client.get("login")
signed_in = await client.post(
"api/v1/session",
json={"access_token": "correct horse battery staple"},
)
status = await client.get("api/v1/session")
assert login.status_code == 200
assert signed_in.status_code == 200
assert status.status_code == 200
assert any(
"stackchain_session=" in value and "Path=/dashboard" in value
for value in signed_in.headers.get_list("set-cookie")
)
+93
View File
@@ -0,0 +1,93 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
ROOT = Path(__file__).resolve().parents[1]
SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict:
harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
key(index) {{ return Array.from(this.values.keys())[index] || null; }},
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
}};
const boundary = createSessionBoundary({{
cookie: () => 'other=x; stackchain_csrf=csrf-proof; theme=dark',
origin: 'https://forge.example',
base: '/dashboard/',
fetchImpl: async (url, options = {{}}) => {{
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }});
return new Response('{{}}', {{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }});
}},
localStorage: storage,
sessionStorage: storage,
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }},
location: {{ assign: value => {{ state.assigned = value; }} }},
}});
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
return json.loads(result.stdout)
def test_mutating_same_origin_fetch_receives_csrf_proof():
result = run_session_scenario(
"""
await boundary.fetch('/dashboard/api/v1/notifications/7/read', { method: 'PATCH' });
process.stdout.write(JSON.stringify(state));
"""
)
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_sign_out_clears_only_dashboard_private_device_state():
result = run_session_scenario(
"""
await boundary.signOut();
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
request = result["requests"][0]
assert request["url"] == "/dashboard/api/v1/session"
assert request["method"] == "DELETE"
assert request["headers"]["x-csrf-token"] == "csrf-proof"
assert "stackchain.private" in result["removed"]
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
result = run_session_scenario(
"""
await boundary.resumeQueuedWork();
process.stdout.write(JSON.stringify(state));
"""
)
assert result["workerMessages"] == [{"type": "stackchain-resume-outbox"}]
@pytest.mark.anyio
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
html = await dashboard()
assert '<script src="static/session.js"></script>' in html
assert html.index('static/session.js') < html.index('static/markdown.js')
assert '<button id="sign-out" type="button">Sign out &amp; clear this device</button>' in html
+181
View File
@@ -0,0 +1,181 @@
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"
def test_draft_inbox_marks_mismatched_and_legacy_outbox_content_copy_only():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-outbox.v1', JSON.stringify({{version:2,items:[
{{id:'other',repository:'o/r',title:'Other issue',body:'Private context',ownerLogin:'alexander',status:'queued'}},
{{id:'legacy',repository:'o/r',title:'Legacy issue',body:'Old context',status:'queued'}}
]}})],
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
{{id:'mine',kind:'issue-comment',repository:'o/r',number:2,body:'My comment',ownerLogin:'timmy',status:'queued'}}
]}})],
]);
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const drafts = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list();
process.stdout.write(JSON.stringify(drafts));
"""
output = run_node(script)
by_id = {item["outbox_id"]: item for item in output}
assert by_id["other"]["quarantined"] is True
assert by_id["other"]["ownership"] == "Queued by alexander — current account is timmy"
assert by_id["other"]["copy_text"] == "Other issue\n\nPrivate context"
assert by_id["legacy"]["ownership"] == "Queued by an unknown account — current account is timmy"
assert by_id["mine"]["quarantined"] is False
@pytest.mark.anyio
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
html = await dashboard()
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
@pytest.mark.anyio
async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot():
html = await dashboard()
assert "let confirmedOwnerLogin = '';" in html
assert "let activeFlushLogin = '';" in html
assert "getOwnerLogin: () => confirmedOwnerLogin" in html
assert "getCurrentLogin: () => activeFlushLogin" in html
assert "const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&" in html
assert "!contextFreshness?.degraded && !contextFreshness?.revalidating;" in html
assert "activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';" in html
assert "issueOutbox.flush(activeFlushLogin)" in html
assert "authoredOutbox.flush(activeFlushLogin)" in html
assert "activeFlushLogin = '';" in html
assert 'class="draft-copy"' in html
assert "navigator.clipboard.writeText(item.copy_text)" in html
assert "item.quarantined" in html
+76
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,
},
}
@@ -200,3 +222,57 @@ async def test_notification_detail_never_follows_foreign_api_urls():
assert result["url"] == ""
assert result["subject_body"] == ""
assert result["latest_comment"]["body"] == ""
@pytest.mark.anyio
@pytest.mark.parametrize("subject_kind", ["issues", "pulls"])
async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kind):
requests = []
def upstream(request):
requests.append((request.method, str(request.url), request.content))
if request.method == "GET":
return httpx.Response(200, json={
"id": 42,
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Pull" if subject_kind == "pulls" else "Issue",
"url": (
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/"
f"{subject_kind}/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",
})
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.reply_to_notification(42, "Please retry the worker.")
finally:
await gitea_proxy.stop_client()
assert requests == [
(
"GET",
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
b"",
),
(
"POST",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments",
b'{"body":"Please retry the worker."}',
),
]
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",
}
+227
View File
@@ -28,6 +28,198 @@ async def test_gitea_transport_is_reused_across_requests_and_closed():
assert client.is_closed
@pytest.mark.anyio
async def test_gitea_transport_coalesces_identical_concurrent_gets():
calls = 0
release = asyncio.Event()
async def handler(request):
nonlocal calls
calls += 1
await release.wait()
return httpx.Response(200, json={"path": request.url.path})
gitea_proxy.start_client(
transport=httpx.MockTransport(handler),
max_concurrency=4,
admission_timeout=0.1,
)
try:
requests = [asyncio.create_task(gitea_proxy.fetch("user")) for _ in range(20)]
await asyncio.sleep(0)
release.set()
results = await asyncio.gather(*requests)
finally:
await gitea_proxy.stop_client()
assert calls == 1
assert results == [{"path": "/api/v1/user"}] * 20
@pytest.mark.anyio
async def test_cancelling_one_coalesced_waiter_keeps_the_shared_get_running():
started = asyncio.Event()
release = asyncio.Event()
async def handler(request):
started.set()
await release.wait()
return httpx.Response(200, json={"path": request.url.path})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
owner = asyncio.create_task(gitea_proxy.fetch("user"))
try:
await started.wait()
waiter = asyncio.create_task(gitea_proxy.fetch("user"))
await asyncio.sleep(0)
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
release.set()
assert await owner == {"path": "/api/v1/user"}
finally:
release.set()
await asyncio.gather(owner, return_exceptions=True)
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_gitea_transport_rejects_reads_beyond_bounded_capacity():
active = 0
peak = 0
saturated = asyncio.Event()
release = asyncio.Event()
async def handler(request):
nonlocal active, peak
active += 1
peak = max(peak, active)
if active == 2:
saturated.set()
try:
await release.wait()
return httpx.Response(200, json={"path": request.url.path})
finally:
active -= 1
gitea_proxy.start_client(
transport=httpx.MockTransport(handler),
max_concurrency=3,
admission_timeout=0.02,
)
first = asyncio.create_task(gitea_proxy.fetch("first"))
second = asyncio.create_task(gitea_proxy.fetch("second"))
try:
await asyncio.wait_for(saturated.wait(), timeout=0.2)
with pytest.raises(gitea_proxy.GiteaOverloadedError):
await gitea_proxy.fetch("third")
finally:
release.set()
await asyncio.gather(first, second)
await gitea_proxy.stop_client()
assert peak == 2
@pytest.mark.anyio
async def test_gitea_transport_reserves_one_slot_for_mutations_and_bounds_them():
active = 0
peak = 0
reads_started = asyncio.Event()
mutation_started = asyncio.Event()
release = asyncio.Event()
async def handler(request):
nonlocal active, peak
active += 1
peak = max(peak, active)
if active == 2:
reads_started.set()
if request.method == "POST":
mutation_started.set()
try:
await release.wait()
return httpx.Response(200, json={"method": request.method})
finally:
active -= 1
client = gitea_proxy.start_client(
transport=httpx.MockTransport(handler),
max_concurrency=3,
admission_timeout=0.02,
)
reads = [
asyncio.create_task(gitea_proxy.fetch("first")),
asyncio.create_task(gitea_proxy.fetch("second")),
]
mutation = None
try:
await asyncio.wait_for(reads_started.wait(), timeout=0.2)
mutation = asyncio.create_task(client.post("/api/v1/mutate", json={"value": 1}))
await asyncio.wait_for(mutation_started.wait(), timeout=0.2)
with pytest.raises(gitea_proxy.GiteaOverloadedError):
await asyncio.wait_for(
client.post("/api/v1/mutate", json={"value": 1}), timeout=0.1
)
finally:
release.set()
await asyncio.gather(*reads, *(task for task in [mutation] if task is not None))
await gitea_proxy.stop_client()
assert peak == 3
@pytest.mark.anyio
async def test_gitea_transport_reads_bulkhead_configuration_from_environment(monkeypatch):
monkeypatch.setenv("GITEA_MAX_CONCURRENCY", "12")
monkeypatch.setenv("GITEA_ADMISSION_TIMEOUT_SECONDS", "0.75")
client = gitea_proxy.start_client(
transport=httpx.MockTransport(lambda request: httpx.Response(200, json={}))
)
try:
assert client.max_concurrency == 12
assert client.admission_timeout == 0.75
finally:
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_streaming_reads_share_the_same_bounded_read_capacity():
active = 0
saturated = asyncio.Event()
release = asyncio.Event()
async def handler(request):
nonlocal active
active += 1
if active == 2:
saturated.set()
try:
await release.wait()
return httpx.Response(200, content=b"diff")
finally:
active -= 1
gitea_proxy.start_client(
transport=httpx.MockTransport(handler),
max_concurrency=3,
admission_timeout=0.02,
)
streams = [
asyncio.create_task(gitea_proxy.fetch_text("first", 100)),
asyncio.create_task(gitea_proxy.fetch_text("second", 100)),
]
try:
await asyncio.wait_for(saturated.wait(), timeout=0.2)
with pytest.raises(gitea_proxy.GiteaOverloadedError):
await asyncio.wait_for(gitea_proxy.fetch("third"), timeout=0.1)
finally:
release.set()
await asyncio.gather(*streams)
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_application_lifespan_opens_and_closes_gitea_transport(monkeypatch):
calls = []
@@ -79,3 +271,38 @@ async def test_application_shutdown_finishes_snapshot_before_closing_transport(m
main._live_snapshot_task = None
assert calls == ["start", "snapshot cancelled", "stop (done)"]
@pytest.mark.anyio
async def test_application_shutdown_cancels_available_work_scan_before_transport(monkeypatch):
calls = []
started = asyncio.Event()
async def active_scan():
started.set()
try:
await asyncio.Event().wait()
finally:
calls.append("available scan cancelled")
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
async def stop_client():
task = main._available_issue_snapshot_task
calls.append(f"stop ({'done' if task is not None and task.done() else 'active'})")
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
try:
async with main.app.router.lifespan_context(main.app):
main._available_issue_snapshot_task = asyncio.create_task(active_scan())
await started.wait()
finally:
task = main._available_issue_snapshot_task
if task is not None and not task.done():
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
main._available_issue_snapshot_task = None
assert calls == ["start", "available scan cancelled", "stop (done)"]
+446 -39
View File
@@ -2,27 +2,385 @@ import asyncio
import json
import pytest
import httpx
from src import gitea_proxy
from src import main
@pytest.fixture(autouse=True)
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():
task.cancel()
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
async def test_work_page_preserves_total_and_reason_without_loading_other_pages():
requests = []
def upstream(request):
requests.append(str(request.url))
return httpx.Response(
200,
headers={"X-Total-Count": "84"},
json=[{
"id": 51,
"number": 51,
"title": "Older review",
"state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/pulls/51",
}],
)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.work_page("review", page=2)
finally:
await gitea_proxy.stop_client()
assert requests == [
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&review_requested=true&type=pulls&limit=50&page=2"
]
assert result["page"] == 2
assert result["total"] == 84
assert result["has_more"] is False
assert result["items"][0]["work_reasons"] == ["review_requested"]
@pytest.mark.anyio
async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_priority():
requests = []
def upstream(request):
requests.append(str(request.url))
return httpx.Response(
200,
headers={"X-Total-Count": "4"},
json=[
{"id": 1, "number": 1, "title": "Ordinary", "state": "open",
"updated_at": "2026-08-07T12:00:00Z", "assignees": None,
"pull_request": None,
"labels": [], "repository": {"full_name": "stackchain/api"}},
{"id": 2, "number": 2, "title": "Claimed", "state": "open",
"assignees": [{"login": "alex"}], "repository": {"full_name": "stackchain/api"}},
{"id": 3, "number": 3, "title": "A pull", "state": "open",
"assignees": [], "pull_request": {"merged": False},
"repository": {"full_name": "stackchain/api"}},
{"id": 4, "number": 4, "title": "Critical", "state": "open",
"updated_at": "2026-08-07T10:00:00Z", "assignees": [],
"pull_request": None,
"labels": [{"name": "critical"}],
"repository": {"full_name": "stackchain/web"}},
],
)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.available_issue_page(page=1)
finally:
await gitea_proxy.stop_client()
assert requests == [
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&type=issues&limit=50&page=1"
]
assert [item["title"] for item in result["items"]] == ["Critical", "Ordinary"]
assert result == {
"items": result["items"], "page": 1, "total": 2, "has_more": False,
}
@pytest.mark.anyio
async def test_available_issue_page_ranks_all_upstream_pages_before_logical_pagination():
requests = []
def issue(number, *, assigned=True, critical=False, updated_at="2026-08-07T12:00:00Z"):
return {
"id": number, "number": number, "title": f"Issue {number}", "state": "open",
"updated_at": updated_at, "assignees": [{"login": "alex"}] if assigned else None,
"pull_request": None,
"labels": [{"name": "critical"}] if critical else [],
"repository": {"full_name": "stackchain/api"},
}
first_page = [issue(number) for number in range(1, 51)]
first_page[0] = issue(1, assigned=False, updated_at="2026-08-07T13:00:00Z")
def upstream(request):
requests.append(str(request.url))
page = int(request.url.params["page"])
payload = first_page if page == 1 else [
issue(51, assigned=False, critical=True, updated_at="2026-08-07T10:00:00Z"),
issue(52, assigned=False, updated_at="2026-08-07T11:00:00Z"),
]
return httpx.Response(200, headers={"X-Total-Count": "52"}, json=payload)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.available_issue_page(page=1, limit=2)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 2
assert [item["number"] for item in result["items"]] == [51, 1]
assert result == {
"items": result["items"], "page": 1, "total": 3, "has_more": True,
}
@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 = []
async def available():
calls.append(True)
return [{"number": number} for number in range(1, 52)]
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/available-issues?page=1")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"items": [{"number": number} for number in range(1, 51)],
"page": 1, "total": 51, "has_more": True,
}
assert calls == [True]
@pytest.mark.anyio
async def test_available_issue_endpoint_coalesces_cold_scan_and_reuses_it_for_pages(monkeypatch):
calls = 0
started = asyncio.Event()
release = asyncio.Event()
async def available():
nonlocal calls
calls += 1
started.set()
await release.wait()
return [{"number": number} for number in range(1, 64)]
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = asyncio.create_task(client.get("/api/v1/available-issues?page=1"))
second = asyncio.create_task(client.get("/api/v1/available-issues?page=2"))
await asyncio.wait_for(started.wait(), timeout=1)
release.set()
first_response, second_response = await asyncio.gather(first, second)
repeated_response = await client.get("/api/v1/available-issues?page=1")
assert calls == 1
assert len(first_response.json()["items"]) == 50
assert len(second_response.json()["items"]) == 13
assert second_response.json()["total"] == 63
assert second_response.json()["has_more"] is False
assert repeated_response.status_code == 200
@pytest.mark.anyio
async def test_available_issue_endpoint_retains_last_snapshot_on_refresh_failure(monkeypatch):
async def unavailable():
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:
response = await client.get("/api/v1/available-issues?page=1")
assert response.status_code == 200
assert response.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"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,
}
@pytest.mark.anyio
async def test_confirmed_claim_is_removed_from_retained_available_snapshot(monkeypatch):
main._available_issue_snapshot_value = [
{"number": 7, "repository": "stackchain/api"},
{"number": 8, "repository": "stackchain/web"},
]
main._available_issue_snapshot_created_at = 10**12
async def claim(repository, number):
return {"repository": repository, "number": number, "assignees": ["timmy"]}
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
claimed = await client.patch("/api/v1/repos/stackchain/api/issues/7/claim")
available = await client.get("/api/v1/available-issues?page=1")
assert claimed.status_code == 200
assert available.json() == {
"items": [{"number": 8, "repository": "stackchain/web"}],
"page": 1, "total": 1, "has_more": False,
}
@pytest.mark.anyio
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
async def fake_page(stream, page=1, limit=50):
assert page == 1
totals = {"issue": 84, "pull": 61, "review": 73}
return {
"items": [], "page": 1, "total": totals[stream],
"has_more": True, "stream": stream,
}
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
assigned_issues = await gitea_proxy.issues()
pulls = await gitea_proxy.pull_requests()
assert assigned_issues.pagination == {
"issue": {"page": 1, "total": 84, "has_more": True}
}
assert pulls.pagination == {
"pull": {"page": 1, "total": 61, "has_more": True},
"review": {"page": 1, "total": 73, "has_more": True},
}
@pytest.mark.anyio
async def test_work_collections_include_supported_review_request_search(monkeypatch):
requested_paths = []
requested_streams = []
async def fake_fetch(path):
requested_paths.append(path)
return []
async def fake_page(stream, page=1, limit=50):
requested_streams.append((stream, page, limit))
return {"stream": stream, "items": [], "page": page, "total": 0, "has_more": False}
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
assert await gitea_proxy.issues() == []
assert await gitea_proxy.pull_requests() == []
assert requested_paths == [
"repos/issues/search?state=open&assigned=true&type=issues&limit=50",
"repos/issues/search?state=open&assigned=true&type=pulls&limit=50",
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
assert requested_streams == [
("issue", 1, 50),
("pull", 1, 50),
("review", 1, 50),
]
@@ -41,12 +399,16 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
"repository": {"full_name": "stackchain/mobile"},
}
async def fake_fetch(path):
if "assigned=true" in path:
return [assigned]
return [assigned.copy(), review_only]
async def fake_page(stream, page=1, limit=50):
items = [assigned] if stream == "pull" else [assigned.copy(), review_only]
reason = "assigned_to_me" if stream == "pull" else "review_requested"
return {
"stream": stream,
"items": [{**item, "work_reasons": [reason]} for item in items],
"page": page, "total": len(items), "has_more": False,
}
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
pulls = await gitea_proxy.pull_requests()
@@ -60,13 +422,13 @@ async def test_pull_request_searches_start_concurrently(monkeypatch):
started = [asyncio.Event(), asyncio.Event()]
release = asyncio.Event()
async def fake_fetch(path):
index = 0 if "assigned=true" in path else 1
async def fake_page(stream, page=1, limit=50):
index = 0 if stream == "pull" else 1
started[index].set()
await release.wait()
return []
return {"stream": stream, "items": [], "page": page, "total": 0, "has_more": False}
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
task = asyncio.create_task(gitea_proxy.pull_requests())
try:
await asyncio.wait_for(
@@ -79,32 +441,75 @@ async def test_pull_request_searches_start_concurrently(monkeypatch):
@pytest.mark.anyio
async def test_requested_review_guard_uses_only_dedicated_review_search(monkeypatch):
requested_paths = []
async def test_requested_review_guard_uses_direct_pull_and_current_user(monkeypatch):
requested = []
async def fake_fetch(path):
requested_paths.append(path)
return [
{
"number": 7,
"repository": {"full_name": "stackchain/api"},
},
{
"number": 8,
"repository": {"full_name": "stackchain/api"},
},
]
requested.append(path)
if path == "user":
return {"login": "timmy"}
assert path == "repos/stackchain/api/pulls/77"
return {
"state": "open",
"requested_reviewers": [{"login": "timmy"}],
}
async def reject_search(*args, **kwargs):
raise AssertionError("authorization must not scan review search pages")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", reject_search)
assert await gitea_proxy.is_requested_review("stackchain/api", 7) is True
assert await gitea_proxy.is_requested_review("stackchain/api", 9) is False
assert await gitea_proxy.is_requested_review("stackchain/private", 7) is False
assert requested_paths == [
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
]
assert await gitea_proxy.is_requested_review("stackchain/api", 77) is True
assert sorted(requested) == ["repos/stackchain/api/pulls/77", "user"]
@pytest.mark.anyio
async def test_assigned_issue_guard_uses_direct_target_and_current_user(monkeypatch):
requested = []
async def fake_fetch(path):
requested.append(path)
if path == "user":
return {"login": "timmy"}
assert path == "repos/stackchain/api/issues/77"
return {
"state": "open",
"assignees": [{"login": "timmy"}],
}
async def reject_search(*args, **kwargs):
raise AssertionError("authorization must not scan work search pages")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", reject_search)
assert await gitea_proxy.is_assigned_issue("stackchain/api", 77) is True
assert sorted(requested) == ["repos/stackchain/api/issues/77", "user"]
@pytest.mark.anyio
async def test_assigned_pull_guard_uses_direct_target_and_current_user(monkeypatch):
requested = []
async def fake_fetch(path):
requested.append(path)
if path == "user":
return {"login": "timmy"}
assert path == "repos/stackchain/api/pulls/77"
return {
"state": "open",
"assignees": [{"login": "timmy"}],
}
async def reject_search(*args, **kwargs):
raise AssertionError("authorization must not scan work search pages")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", reject_search)
assert await gitea_proxy.is_assigned_pull("stackchain/api", 77) is True
assert sorted(requested) == ["repos/stackchain/api/pulls/77", "user"]
@pytest.mark.anyio
@@ -125,6 +530,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
"assignees": [{"login": "timmy"}],
"repository": {"full_name": "stackchain/mobile"},
"updated_at": "2026-08-06T12:00:00Z",
"due_date": "2026-08-09T23:59:59Z",
"html_url": "https://forge.example/stackchain/mobile/issues/7",
}]
@@ -152,6 +558,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
assert payload["issues"][0]["repository"] == "stackchain/mobile"
assert payload["issues"][0]["updated_at"] == "2026-08-06T12:00:00Z"
assert payload["issues"][0]["due_date"] == "2026-08-09T23:59:59Z"
assert payload["pull_requests"][0]["repository"] == "stackchain/api"
assert payload["pull_requests"][0]["updated_at"] == "2026-08-06T11:00:00Z"
assert payload["pull_requests"][0]["labels"] == ["priority-high"]
+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"]
File diff suppressed because it is too large. Load diff
+343
View File
@@ -0,0 +1,343 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_issue_outbox_binds_items_and_only_flushes_for_the_matching_account():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const calls = [];
let confirmedLogin = 'timmy';
const outbox = createIssueOutbox({{
storage, getOwnerLogin:() => confirmedLogin, createOperationId:() => 'bound-op',
fetchJson:async url => {{ calls.push(url); return {{number:1}}; }},
}});
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bound',body:'Context'}});
confirmedLogin = 'alexander';
(async () => {{
const mismatch = await outbox.flush('alexander');
const unknown = await outbox.retry(queued.id, '');
const matched = await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{queued,mismatch,unknown,matched,calls}}));
}})();
"""
output = run_node(script)
assert output["queued"]["ownerLogin"] == "timmy"
assert output["mismatch"]["blocked"] == 1
assert output["unknown"]["blocked"] == 1
assert len(output["matched"]["confirmed"]) == 1
assert len(output["calls"]) == 1
def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let sequence = 0;
const outbox = createIssueOutbox({{storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}});
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One',labelIds:[3]}});
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two',milestoneId:4,dueDate:'2026-08-09'}});
const reloaded = createIssueOutbox({{storage}}).list();
process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.get('stackchain.issue-outbox.v1'))}}));
"""
output = run_node(script)
assert [(item["repository"], item["title"]) for item in output["items"]] == [
("stackchain/api", "First"),
("stackchain/web", "Second"),
]
assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
assert all(item["status"] == "queued" for item in output["items"])
assert output["stored"]["version"] == 2
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let sequence = 0;
const calls = [];
const outbox = createIssueOutbox({{
storage,
getOwnerLogin:()=>'timmy',
createOperationId:() => 'stable-' + (++sequence),
fetchJson:async (url, options) => {{
calls.push({{url, key:options.headers['Idempotency-Key']}});
if (calls.length === 2) {{ const error = new Error('upstream unavailable'); error.status = 503; throw error; }}
return {{repository:'stackchain/api',number:41,title:'First'}};
}},
}});
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One'}});
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two'}});
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["result"]["confirmed"][0]["number"] == 41
assert output["calls"] == [
{"url": "api/v1/repos/stackchain/api/issues", "key": "stable-1"},
{"url": "api/v1/repos/stackchain/web/issues", "key": "stable-2"},
]
assert len(output["remaining"]) == 1
assert output["remaining"][0]["title"] == "Second"
assert output["remaining"][0]["status"] == "queued"
def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let invalid = true;
const calls = [];
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'stable-edit',
fetchJson:async (_url, options) => {{
calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }}
return {{repository:'stackchain/api',number:42,title:'Fixed title'}};
}},
}});
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bad',body:'Context'}});
outbox.flush('timmy').then(async () => {{
const attention = outbox.list()[0];
outbox.update(queued.id, {{...attention,title:'Fixed title'}});
invalid = false;
const result = await outbox.retry(queued.id, 'timmy');
process.stdout.write(JSON.stringify({{attention,result,calls,remaining:outbox.list()}}));
}});
"""
output = run_node(script)
assert output["attention"]["status"] == "attention"
assert output["attention"]["error"] == "Title is invalid"
assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"]
assert output["calls"][1]["body"]["title"] == "Fixed title"
assert output["result"]["confirmed"][0]["number"] == 42
assert output["remaining"] == []
def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let calls = 0;
let release;
const gate = new Promise(resolve => {{ release = resolve; }});
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'single-flight',
fetchJson:async () => {{ calls += 1; await gate; return {{repository:'stackchain/api',number:43,title:'Once'}}; }},
}});
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Once',body:'Context'}});
const reconnect = outbox.flush('timmy');
const sendNow = outbox.retry(queued.id, 'timmy');
release();
Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.stringify({{calls,results,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["calls"] == 1
assert output["remaining"] == []
assert sum(len(result["confirmed"]) for result in output["results"]) >= 1
def test_issue_outbox_mirrors_queue_and_registers_background_sync():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const state = {{snapshots:[],syncs:0}};
const backgroundSync = {{
reconcile: async items => state.snapshots.push(items.map(item => ({{...item}}))),
requestSync: async () => {{ state.syncs += 1; }},
}};
const outbox = createIssueOutbox({{
storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'background-1',
}});
outbox.enqueue({{repository:'stackchain/api',title:'Close the app',body:'Still deliver'}});
setTimeout(() => process.stdout.write(JSON.stringify(state)), 0);
"""
output = run_node(script)
assert output["snapshots"][0][0]["operationId"] == "background-1"
assert output["snapshots"][0][0]["ownerLogin"] == "timmy"
assert output["syncs"] == 1
def test_issue_outbox_confirms_durable_admission_only_after_mirror_and_sync():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let releaseMirror; let releaseSync;
const mirrorGate = new Promise(resolve => releaseMirror = resolve);
const syncGate = new Promise(resolve => releaseSync = resolve);
const events = [];
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'durable-1',
backgroundSync: {{
reconcile: async items => {{ events.push('mirror-start'); await mirrorGate; events.push('mirror-committed'); }},
requestSync: async () => {{ events.push('sync-start'); await syncGate; events.push('sync-registered'); }},
}},
}});
const admission = outbox.enqueueDurably({{repository:'stackchain/api',title:'Keep this',body:'Draft'}})
.then(result => {{ events.push('confirmed'); return result; }});
Promise.resolve().then(async () => {{
const beforeMirror = events.slice();
releaseMirror(); await new Promise(resolve => setTimeout(resolve, 0));
const beforeSync = events.slice();
releaseSync();
const result = await admission;
process.stdout.write(JSON.stringify({{beforeMirror,beforeSync,events,result,items:outbox.list()}}));
}});
"""
output = run_node(script)
assert output["beforeMirror"] == ["mirror-start"]
assert output["beforeSync"] == ["mirror-start", "mirror-committed", "sync-start"]
assert output["events"][-1] == "confirmed"
assert output["result"]["background"] is True
assert output["result"]["item"]["id"] == "durable-1"
assert output["items"][0]["title"] == "Keep this"
def test_issue_outbox_reports_degraded_admission_without_losing_foreground_item():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'foreground-1',
backgroundSync: {{
reconcile: async () => {{}},
requestSync: async () => {{ throw new Error('Background Sync unavailable'); }},
}},
}});
outbox.enqueueDurably({{repository:'stackchain/api',title:'Retain me',body:'Draft'}}).then(result =>
process.stdout.write(JSON.stringify({{
background:result.background, durability:result.durability,
error:result.error.message, item:result.item, persisted:outbox.list()
}}))
);
"""
output = run_node(script)
assert output["background"] is False
assert output["durability"] == "foreground-only"
assert output["error"] == "Background Sync unavailable"
assert output["persisted"] == [output["item"]]
def test_foreground_delivery_uses_same_atomic_background_claim():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const state = {{backgroundCalls:0,directCalls:0}};
const backgroundSync = {{
reconcile: async () => {{}}, requestSync: async () => {{}},
send: async (item, login) => {{ state.backgroundCalls += 1; return {{issue:{{number:9}}, claimed:item.id + ':' + login}}; }},
}};
const outbox = createIssueOutbox({{
storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'shared-claim',
fetchJson:async () => {{ state.directCalls += 1; return {{number:10}}; }},
}});
outbox.enqueue({{repository:'o/r',title:'One owner'}});
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{state,result,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["state"] == {"backgroundCalls": 1, "directCalls": 0}
assert output["result"]["confirmed"][0]["number"] == 9
assert output["remaining"] == []
def test_foreground_background_delivery_surfaces_permanent_failure_immediately():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const error=new Error('Title is invalid');error.status=422;
const backgroundSync={{reconcile:async()=>{{}},requestSync:async()=>{{}},send:async()=>({{attention:true,error}})}};
const outbox=createIssueOutbox({{storage,backgroundSync,getOwnerLogin:()=>'timmy',createOperationId:()=>'invalid'}});
outbox.enqueue({{repository:'o/r',title:'Bad'}});
outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result,items:outbox.list()}})));
"""
output = run_node(script)
assert output["items"][0]["status"] == "attention"
assert output["items"][0]["error"] == "Title is invalid"
assert output["result"]["confirmed"] == []
def test_page_reconciles_worker_success_and_attention_into_visible_outbox():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:2,items:[
{{id:'sent',operationId:'sent',ownerLogin:'timmy',status:'queued',title:'Done'}},
{{id:'bad',operationId:'bad',ownerLogin:'timmy',status:'queued',title:'Fix me'}},
]}}));
const outbox=createIssueOutbox({{storage}});
outbox.reconcileBackground([
{{id:'sent',status:'sent'}},
{{id:'bad',status:'attention',error:'Title is invalid'}},
]);
process.stdout.write(JSON.stringify(outbox.list()));
"""
output = run_node(script)
assert output == [
{
"id": "bad",
"operationId": "bad",
"ownerLogin": "timmy",
"status": "attention",
"title": "Fix me",
"error": "Title is invalid",
}
]
@pytest.mark.anyio
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
html = await dashboard()
assert '<script src="static/issue-outbox.js"></script>' in html
assert "const issueOutbox = createIssueOutbox({" in html
assert "await issueOutbox.enqueueDurably(captureDraft)" in html
assert "Saving for background delivery…" in html
assert "Saved for next launch; background delivery unavailable." in html
assert "throw new Error('Background Sync unavailable')" in html
assert "issueOutbox.retry(queued.id, activeFlushLogin)" in html
assert "navigator.onLine" in html
assert "activeFlushLogin = contextIdentityFresh ?" in html
assert "flushIssueOutbox();" in html
assert 'class="draft-send"' in html
assert 'class="draft-edit"' in html
assert 'Queued for sync' in html
assert 'Needs attention' in html
assert "issueOutbox.discard(item.outbox_id)" in html
assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html
+90 -10
View File
@@ -11,14 +11,30 @@ def reset_live_snapshot_task():
main._live_snapshot_task = None
main._live_snapshot_value = None
main._live_snapshot_created_at = None
main._live_snapshot_failure_count = 0
main._live_snapshot_retry_at = None
main._live_section_created_at = {
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_section_failure_count = {
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_section_retry_at = {
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_snapshot_refreshing_sections = set()
yield
main._live_snapshot_task = None
main._live_snapshot_value = None
main._live_snapshot_created_at = None
main._live_snapshot_failure_count = 0
main._live_snapshot_retry_at = None
main._live_section_created_at = {
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_section_failure_count = {
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_section_retry_at = {
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_snapshot_refreshing_sections = set()
def payload(response):
@@ -209,7 +225,7 @@ async def test_stale_snapshot_returns_immediately_while_one_refresh_revalidates(
release_refresh = asyncio.Event()
builds = 0
async def snapshot():
async def snapshot(_sections=None):
nonlocal builds
builds += 1
if builds == 2:
@@ -247,7 +263,7 @@ async def test_failed_revalidation_enters_cooldown_and_keeps_last_snapshot(monke
now = 100.0
builds = 0
async def snapshot():
async def snapshot(_sections=None):
nonlocal builds
builds += 1
if builds > 1:
@@ -288,7 +304,7 @@ async def test_partial_refresh_updates_fresh_sections_and_retains_failed_section
now = 100.0
builds = 0
async def snapshot():
async def snapshot(_sections=None):
nonlocal builds
builds += 1
if builds == 1:
@@ -332,6 +348,70 @@ async def test_partial_refresh_updates_fresh_sections_and_retains_failed_section
assert result["freshness"]["revalidating"] is False
@pytest.mark.anyio
async def test_notification_cooldown_does_not_stop_due_work_and_activity_refreshes(monkeypatch):
now = 100.0
calls = {"context": 0, "events": 0, "notifications": 0}
event_refresh_times = []
notification_outage = False
async def user():
return {"id": 1, "login": "timmy"}
async def work():
calls["context"] += 1
return []
async def empty_work():
return []
async def events(_authenticated_user):
calls["events"] += 1
event_refresh_times.append(now)
return [{"generation": calls["events"]}]
async def updates():
calls["notifications"] += 1
if notification_outage:
raise ConnectionError("notifications unavailable")
return []
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", work)
monkeypatch.setattr(main, "issues", empty_work)
monkeypatch.setattr(main, "pull_requests", empty_work)
monkeypatch.setattr(main, "activity_events", events)
monkeypatch.setattr(main, "notifications", updates)
await main.live_snapshot()
notification_outage = True
for elapsed in (
main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1,
main.LIVE_SNAPSHOT_RETRY_BASE_SECONDS,
):
now += elapsed
await main.live_snapshot()
assert main._live_snapshot_task is not None
await main._live_snapshot_task
# Notification backoff is now 10 seconds. The healthy sections become due
# one second before that cooldown expires and must refresh independently.
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
response = payload(await main.live_snapshot())
assert main._live_snapshot_task is not None
await main._live_snapshot_task
refreshed = payload(await main.live_snapshot())
assert calls == {"context": 3, "events": 3, "notifications": 3}
assert event_refresh_times == [100.0, 109.0, 123.0]
assert response["freshness"]["sections"]["context"]["revalidating"] is True
assert refreshed["events"] == [{"generation": 3}]
assert refreshed["sections"]["notifications"] == "stale"
assert refreshed["freshness"]["sections"]["notifications"]["retry_in_seconds"] == 1
@pytest.mark.anyio
async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state(monkeypatch):
now = 100.0
@@ -339,7 +419,7 @@ async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state
retry_started = asyncio.Event()
release_retry = asyncio.Event()
async def snapshot():
async def snapshot(_sections=None):
nonlocal builds
builds += 1
if builds == 1:
@@ -399,7 +479,7 @@ async def test_cancelling_one_waiter_does_not_cancel_the_shared_snapshot(monkeyp
release = asyncio.Event()
snapshot_calls = 0
async def blocked_snapshot():
async def blocked_snapshot(_sections=None):
nonlocal snapshot_calls
snapshot_calls += 1
started.set()
@@ -437,7 +517,7 @@ async def test_shared_snapshot_deadline_cancels_upstream_work_for_all_waiters(mo
started = asyncio.Event()
cancelled = asyncio.Event()
async def snapshot_that_exceeds_deadline():
async def snapshot_that_exceeds_deadline(_sections=None):
started.set()
try:
await asyncio.Event().wait()
+74
View File
@@ -0,0 +1,74 @@
import sqlite3
from src.login_attempt_store import LoginAttemptStore, client_source
def test_forwarded_client_is_used_only_for_explicitly_trusted_proxies():
trusted = "127.0.0.0/8, 10.0.0.0/8"
assert client_source("198.51.100.4", "203.0.113.7", trusted) == "198.51.100.4"
assert (
client_source("127.0.0.1", "203.0.113.7, 10.1.2.3", trusted)
== "203.0.113.7"
)
assert client_source("127.0.0.1", "not-an-ip", trusted) == "127.0.0.1"
def test_failure_budget_is_shared_across_store_instances_and_expires(tmp_path):
now = [1_000.0]
database = tmp_path / "login-attempts.sqlite3"
first = LoginAttemptStore(
database,
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
)
second = LoginAttemptStore(
database,
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
)
assert first.retry_after("203.0.113.7") == 0
first.record_failure("203.0.113.7")
second.record_failure("203.0.113.7")
first.record_failure("203.0.113.7")
assert second.retry_after("203.0.113.7") == 60
now[0] = 1_060.0
assert first.retry_after("203.0.113.7") == 0
def test_recording_a_failure_prunes_expired_source_records(tmp_path):
now = [1_000.0]
store = LoginAttemptStore(
tmp_path / "login-attempts.sqlite3",
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
)
store.record_failure("203.0.113.1")
store.record_failure("203.0.113.2")
now[0] = 1_060.0
store.record_failure("203.0.113.3")
with sqlite3.connect(store.path) as connection:
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (1,)
def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
store = LoginAttemptStore(
tmp_path / "login-attempts.sqlite3",
clock=lambda: 1_000.0,
max_failures=3,
window_seconds=60,
max_entries=2,
)
for address in ("203.0.113.1", "203.0.113.2", "203.0.113.3"):
store.record_failure(address)
with sqlite3.connect(store.path) as connection:
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
+60
View File
@@ -0,0 +1,60 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import login
ROOT = Path(__file__).resolve().parents[1]
LOGIN_JS = ROOT / "frontend" / "login.js"
def test_rate_limited_login_disables_submit_and_counts_down():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{ reset: 0, interval: null }};
const status = {{ textContent: '' }};
const button = {{ disabled: false }};
const form = {{ reset: () => state.reset += 1 }};
const controller = createLoginController({{
form, status, button,
fetchImpl: async () => new Response(JSON.stringify({{ detail: 'Too many sign-in attempts' }}), {{ status: 429, headers: {{ 'Retry-After': '2' }} }}),
location: {{ replace: () => {{}} }},
setIntervalImpl: callback => {{ state.interval = callback; return 1; }},
clearIntervalImpl: () => {{}},
}});
(async () => {{
await controller.submit('never-store-this-token');
state.initial = {{ disabled: button.disabled, status: status.textContent, reset: state.reset }};
state.interval();
state.afterTick = {{ disabled: button.disabled, status: status.textContent }};
state.interval();
state.finished = {{ disabled: button.disabled, status: status.textContent }};
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["initial"] == {
"disabled": True,
"status": "Too many attempts. Try again in 2 seconds.",
"reset": 1,
}
assert state["afterTick"]["disabled"] is True
assert state["finished"] == {
"disabled": False,
"status": "You can try signing in again.",
}
@pytest.mark.anyio
async def test_login_page_loads_rate_limit_controller():
html = await login()
assert '<script src="static/login.js"></script>' in html
assert "main{box-sizing:border-box" in html
+123
View File
@@ -0,0 +1,123 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
class FakeElement {{
constructor() {{
this.listeners = {{}};
this.attributes = {{}};
this.classList = {{ values:new Set(), contains:value => this.classList.values.has(value) }};
this.hidden = false;
this.focuses = 0;
}}
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
click() {{ this.listeners.click({{currentTarget:this}}); }}
setAttribute(name, value) {{ this.attributes[name] = value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
focus() {{ this.focuses += 1; }}
}}
const nav = new FakeElement();
const buttons = Object.fromEntries(['work','find','new','search','drafts'].map(name => [name, new FakeElement()]));
const overlay = new FakeElement();
const calls = [];
let observerCallback;
const dock = createDock({{
nav, buttons, overlays:[overlay],
actions: Object.fromEntries(Object.keys(buttons).map(name => [name, () => calls.push(name)])),
observe(callback) {{ observerCallback = callback; return {{disconnect() {{}}}}; }},
}});
dock.start();
buttons.work.click();
buttons.find.click();
overlay.classList.values.add('open'); observerCallback();
const hiddenWhileOpen = nav.hidden;
overlay.classList.values.delete('open'); observerCallback();
buttons.drafts.click();
process.stdout.write(JSON.stringify({{
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden,
findFocuses:buttons.find.focuses,
current:Object.fromEntries(Object.entries(buttons).map(([name, button]) => [name, button.attributes['aria-current'] || null])),
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": ["work", "find", "drafts"],
"hiddenWhileOpen": True,
"hiddenAfterClose": False,
"findFocuses": 1,
"current": {
"work": None,
"find": None,
"new": None,
"search": None,
"drafts": "page",
},
}
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
const badge = {{ textContent:'', hidden:true }};
const dock = createDock({{ nav:{{}}, buttons:{{work}}, attentionBadge:badge }});
dock.updateAttention(3);
const pending = {{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
dock.updateAttention(0);
process.stdout.write(JSON.stringify({{
pending,
empty:{{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }},
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"pending": {"count": "3", "hidden": False, "label": "Work, 3 items need attention"},
"empty": {"count": "0", "hidden": True, "label": "Work"},
}
@pytest.mark.anyio
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
html = await dashboard()
assert '<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">' in html
assert html.count('class="mobile-task-action" data-mobile-task=') == 5
for task in ("work", "find", "new", "search", "drafts"):
assert f'data-mobile-task="{task}"' in html
assert 'id="mobile-draft-count"' in html
assert 'data-work-filter="attention"' in html
assert 'id="mobile-attention-count"' in html
assert '.mobile-task-dock { display:none;' in html
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
assert '<script src="static/mobile-task-dock.js"></script>' in html
assert "createMobileTaskDock({" in html
assert "work: openMobileWork" in html
assert "counts.attention ? 'attention' : 'all'" in html
assert "qs('[data-work-filter=\"' + filter + '\"]').click()" in html
assert "find: () => qs('#find-work').click()" in html
assert "new: () => qs('#new-issue').click()" in html
assert "search: () => qs('#open-palette').click()" in html
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
assert "draftCount.textContent = sourceDraftCount.textContent" in html
assert "mobileTaskDock.updateAttention(counts.attention)" in html
+2648 -5
View File
File diff suppressed because it is too large. Load diff
+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):
+2 -2
View File
@@ -214,7 +214,7 @@ async def test_inflight_refresh_cannot_restore_a_notification_marked_read(monkey
async def mark(_thread_id):
return None
async def stale_upstream_snapshot():
async def stale_upstream_snapshot(_sections):
refresh_started.set()
await release_refresh.wait()
return {
@@ -231,7 +231,7 @@ async def test_inflight_refresh_cannot_restore_a_notification_marked_read(monkey
monkeypatch.setattr(main, "_live_snapshot_task", None)
monkeypatch.setattr(main, "_live_snapshot_value", {"notifications": [{"id": 42}]})
refresh = main._start_live_snapshot_refresh()
refresh = main._start_live_snapshot_refresh(set(main.LIVE_SNAPSHOT_SECTIONS))
await refresh_started.wait()
await main.read_notification(42)
release_refresh.set()
+125
View File
@@ -0,0 +1,125 @@
import asyncio
import httpx
import pytest
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
async def test_notification_reply_api_validates_and_posts_without_marking_read(monkeypatch):
calls = []
async def reply(thread_id, body):
calls.append((thread_id, body))
return {
"id": 91,
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
}
async def must_not_mark_read(_thread_id):
raise AssertionError("replying must not mark the update read")
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
monkeypatch.setattr(main, "mark_notification_read", must_not_mark_read)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/notifications/42/reply",
json={"body": " Please retry the worker. "},
)
empty = await client.post(
"/api/v1/notifications/42/reply", json={"body": " "}
)
oversized = await client.post(
"/api/v1/notifications/42/reply", json={"body": "x" * 10_001}
)
assert response.status_code == 201
assert response.json() == {
"id": 91,
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
}
assert response.headers["cache-control"] == "no-store"
assert empty.status_code == 422
assert oversized.status_code == 422
assert calls == [(42, "Please retry the worker.")]
@pytest.mark.anyio
@pytest.mark.parametrize("failure", ["timeout", "upstream"])
async def test_notification_reply_failure_is_sanitized_retryable_and_no_store(
monkeypatch, failure
):
async def reply(_thread_id, _body):
if failure == "timeout":
await asyncio.sleep(0.05)
raise httpx.HTTPError("token=secret upstream exploded")
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 0.01)
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/notifications/42/reply", json={"body": "Retry it"}
)
assert response.status_code == 503
assert response.json() == {
"error": "The reply could not be posted. Your draft is safe; please retry."
}
assert response.headers["retry-after"] == "1"
assert response.headers["cache-control"] == "no-store"
assert "secret" not in response.text
@pytest.mark.anyio
async def test_notification_reply_cannot_be_invoked_by_a_foreign_browser_origin():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.options(
"/api/v1/notifications/42/reply",
headers={
"Origin": "https://evil.example",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type",
},
)
assert response.status_code != 200
assert "access-control-allow-origin" not in response.headers
+115
View File
@@ -0,0 +1,115 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
OFFLINE_WORK = Path(__file__).parents[1] / "frontend" / "offline-work.js"
def run_scenario(scenario: str) -> dict:
script = f"""
const createOfflineWorkStore = require({json.dumps(str(OFFLINE_WORK))});
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
{scenario}
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(result.stdout)
def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
store.setEnabled(true);
store.save({
user:{login:'timmy', full_name:'Timmy', token:'secret'},
issues:[{id:1, number:8, title:'Ship offline work', body:'private body', state:'open',
repository:'stackchain/dashboard', labels:['P0'], assignees:['timmy'],
updated_at:'2026-08-07T11:00:00Z', url:'https://forge.example/issues/8'}],
pull_requests:[],
notifications:[{id:9, number:8, title:'Updated', unread:true, subject_body:'private comment',
repository:'stackchain/dashboard', subject_type:'Issue', updated_at:'2026-08-07T11:30:00Z',
url:'https://forge.example/issues/8'}],
work_pagination:{issue:{page:1,total:1,has_more:false}},
repos:[{private:true}], events:[{body:'private event'}],
});
const restarted = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:01:00Z')});
process.stdout.write(JSON.stringify({loaded:restarted.load('timmy'), raw:[...values.values()].join(' ')}));
""")
assert result["loaded"]["user"] == {"login": "timmy", "full_name": "Timmy"}
assert result["loaded"]["issues"][0]["title"] == "Ship offline work"
assert result["loaded"]["notifications"][0]["unread"] is True
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
assert "private body" not in result["raw"]
assert "private comment" not in result["raw"]
assert "private event" not in result["raw"]
assert "secret" not in result["raw"]
def test_expired_snapshot_is_deleted_and_wrong_user_cannot_load_it():
result = run_scenario("""
const writer = createOfflineWorkStore({storage, now:() => new Date('2026-08-01T12:00:00Z'), maxAgeMs:1000});
writer.setEnabled(true);
writer.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
const wrongUser = writer.load('alexander');
const expired = createOfflineWorkStore({storage, now:() => new Date('2026-08-01T12:00:02Z'), maxAgeMs:1000});
const expiredValue = expired.load('timmy');
process.stdout.write(JSON.stringify({wrongUser, expiredValue, snapshotStillStored:values.has('stackchain.offline-work.snapshot.v1')}));
""")
assert result == {
"wrongUser": None,
"expiredValue": None,
"snapshotStillStored": False,
}
def test_opting_out_deletes_the_persisted_snapshot():
result = run_scenario("""
const store = createOfflineWorkStore({storage});
store.setEnabled(true);
store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
store.setEnabled(false);
process.stdout.write(JSON.stringify({enabled:store.enabled(), loaded:store.load()}));
""")
assert result == {"enabled": False, "loaded": None}
@pytest.mark.anyio
async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
html = await dashboard()
assert '<script src="static/offline-work.js"></script>' in html
assert 'id="keep-work-offline"' in html
assert 'Keep My Work available offline' in html
assert 'id="clear-offline-work"' in html
assert 'id="offline-work-status"' in html
assert 'offlineWorkStore.save({' in html
assert 'offlineWorkStore.load()' in html
assert 'Offline · saved ' in html
assert "setOfflineWorkMode(true)" in html
assert "if (offlineWorkMode)" in html
assert '.offline-work-controls button { min-height:44px;' in html
@pytest.mark.anyio
async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snapshot():
html = await dashboard()
assert "if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;" in html
assert "Outage · saved " in html
assert "Server unavailable · showing private My Work saved " in html
assert "Live details and actions will return automatically." in html
assert "function renderLiveSnapshot(snapshot) {\n setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
+134
View File
@@ -0,0 +1,134 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
ROOT = Path(__file__).parents[1]
COORDINATOR = ROOT / "frontend" / "outbox-coordinator.js"
ISSUE_OUTBOX = ROOT / "frontend" / "issue-outbox.js"
AUTHORED_OUTBOX = ROOT / "frontend" / "authored-outbox.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_two_tabs_share_one_flush_lease_for_each_outbox():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const createIssueOutbox = require({json.dumps(str(ISSUE_OUTBOX))});
const createAuthoredOutbox = require({json.dumps(str(AUTHORED_OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const held = new Set();
const locks = {{request: async (name, options, work) => {{
if (held.has(name)) return work(null);
held.add(name);
try {{ return await work({{name}}); }} finally {{ held.delete(name); }}
}}}};
let releaseIssue, releaseMessage;
const issueGate = new Promise(resolve => releaseIssue = resolve);
const messageGate = new Promise(resolve => releaseMessage = resolve);
let issueCalls = 0, messageCalls = 0;
const issueA = createIssueOutbox({{storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'issue-1',
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'a'}}),fetchJson:async()=>{{issueCalls++;await issueGate;return {{number:1}};}}}});
const issueB = createIssueOutbox({{storage,getOwnerLogin:()=> 'timmy',
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'b'}}),fetchJson:async()=>{{issueCalls++;return {{number:1}};}}}});
issueA.enqueue({{repository:'o/r',title:'One'}});
const messageA = createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy',
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'a'}}),fetchJson:async()=>{{messageCalls++;await messageGate;return {{id:1}};}}}});
const messageB = createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy',
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'b'}}),fetchJson:async()=>{{messageCalls++;return {{id:1}};}}}});
messageA.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Reply',operationId:'message-1'}});
(async()=>{{
const issueFirst=issueA.flush('timmy'); const issueSecond=issueB.retry('issue-1', 'timmy');
const messageFirst=messageA.flush('timmy'); const messageSecond=messageB.retry('message-1', 'timmy');
await Promise.resolve();
releaseIssue(); releaseMessage();
const results=await Promise.all([issueFirst,issueSecond,messageFirst,messageSecond]);
process.stdout.write(JSON.stringify({{issueCalls,messageCalls,results,issueRemaining:issueA.list(),messageRemaining:messageA.list()}}));
}})();
"""
output = run_node(script)
assert output["issueCalls"] == 1
assert output["messageCalls"] == 1
assert output["issueRemaining"] == []
assert output["messageRemaining"] == []
assert sum(result.get("lease_skipped", False) for result in output["results"]) == 2
def test_fallback_lease_expires_and_stale_owner_cannot_release_successor():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let clock=100;
let releaseFirst;
const firstGate=new Promise(resolve=>releaseFirst=resolve);
const first=createCoordinator({{storage,locks:null,channelFactory:null,tabId:'first',now:()=>clock,leaseMs:50}});
const second=createCoordinator({{storage,locks:null,channelFactory:null,tabId:'second',now:()=>clock,leaseMs:50}});
(async()=>{{
const running=first.runExclusive('issue', async()=>{{await firstGate;return 'first-done';}});
await Promise.resolve();
const blocked=await second.runExclusive('issue', async()=> 'too-early');
clock=151;
const takeover=await second.runExclusive('issue', async()=> 'taken-over');
releaseFirst();
const original=await running;
const after=await second.runExclusive('issue', async()=> 'after-release');
process.stdout.write(JSON.stringify({{blocked,takeover,original,after,lease:storage.getItem('stackchain.outbox-lease.issue.v1')}}));
}})();
"""
output = run_node(script)
assert output["blocked"]["lease_skipped"] is True
assert output["takeover"] == "taken-over"
assert output["original"] == "first-done"
assert output["after"] == "after-release"
assert output["lease"] is None
def test_queue_change_notifications_cross_tabs_and_can_unsubscribe():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const channels=[];
function channelFactory() {{
const channel={{onmessage:null,postMessage(message){{channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data:message}}));}},close(){{}}}};
channels.push(channel); return channel;
}}
const first=createCoordinator({{storage,channelFactory,tabId:'first'}});
const second=createCoordinator({{storage,channelFactory,tabId:'second'}});
const seen=[];
const unsubscribe=second.subscribe(change=>seen.push(change));
first.notify('issue');
first.notify('authored');
unsubscribe();
first.notify('issue');
process.stdout.write(JSON.stringify({{seen,signal:JSON.parse(storage.getItem('stackchain.outbox-change.v1'))}}));
"""
output = run_node(script)
assert [change["queue"] for change in output["seen"]] == ["issue", "authored"]
assert output["signal"]["queue"] == "issue"
assert output["signal"]["tabId"] == "first"
@pytest.mark.anyio
async def test_dashboard_wires_cross_tab_draft_refresh_and_precaches_coordinator():
html = await dashboard()
worker = (ROOT / "frontend" / "service-worker.js").read_text()
assert '<script src="static/outbox-coordinator.js"></script>' in html
assert "const outboxCoordinator = createOutboxCoordinator" in html
assert "coordinator: outboxCoordinator" in html
assert "outboxCoordinator.subscribe" in html
assert "refreshMyWorkView()" in html
assert "BASE + 'static/outbox-coordinator.js'" in worker
+294
View File
@@ -0,0 +1,294 @@
import httpx
import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
async def assigned(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def detail(repository, number):
assert (repository, number) == ("stackchain/api", 7)
return {
"repository": repository,
"number": number,
"title": "Ship mobile flow",
"body": "Ready to merge",
"url": "https://forge.example/stackchain/api/pulls/7",
"author": "alex",
"head_sha": "abc123",
"state": "open",
"draft": False,
"mergeable": True,
"merged": False,
"ci_state": "success",
"files": [{"filename": "src/api.py", "additions": 8, "deletions": 2}],
"comments": [{"id": 9, "author": "sam", "body": "Ship it"}],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned, raising=False)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_detail", detail, 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/detail")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["head_sha"] == "abc123"
assert response.json()["mergeable"] is True
@pytest.mark.anyio
async def test_assigned_pull_detail_rejects_unassigned_pull(monkeypatch):
async def assigned(repository, number):
return False
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned, 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/private/secret/pulls/9/detail")
assert response.status_code == 404
@pytest.mark.anyio
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"):
return httpx.Response(200, json={
"number": 7,
"title": "Review this patch",
"state": "open",
"mergeable": True,
"head": {"sha": "abc123"},
})
if path.endswith("/pulls/7/files"):
return httpx.Response(200, json=[
{"filename": "src/api.py", "status": "modified", "additions": 1, "deletions": 1},
{"filename": "static/logo.png", "status": "modified"},
])
if path.endswith("/commits/abc123/status"):
return httpx.Response(200, json={"state": "success"})
if path.endswith("/issues/7/comments"):
return httpx.Response(200, json=[])
if path.endswith("/pulls/7.diff"):
return httpx.Response(200, text=(
"diff --git a/src/api.py b/src/api.py\n"
"--- a/src/api.py\n+++ b/src/api.py\n"
"@@ -1 +1 @@\n-old\n+new\n"
"diff --git a/static/logo.png b/static/logo.png\n"
"Binary files a/static/logo.png and b/static/logo.png differ\n"
))
raise AssertionError(f"unexpected request: {request.method} {path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
detail = await gitea_proxy.pull_completion_review("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert detail["files"][0]["diff_available"] is True
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
async def test_assigned_pull_comment_posts_only_after_assignment_check(monkeypatch):
calls = []
async def assigned(repository, number):
return True
async def comment(repository, number, body):
calls.append((repository, number, body))
return {"id": 91, "author": "timmy", "body": body}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", 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/pulls/7/comments",
json={"body": " Ready to ship. "},
)
assert response.status_code == 201
assert calls == [("stackchain/api", 7, "Ready to ship.")]
@pytest.mark.anyio
async def test_assigned_pull_merge_requires_current_eligible_head(monkeypatch):
calls = []
async def assigned(repository, number):
return True
async def merge(repository, number, expected_head_sha):
calls.append((repository, number, expected_head_sha))
return {"number": number, "merged": True, "state": "closed"}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge, raising=False)
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/pulls/7/merge",
json={"expected_head_sha": "abc123"},
)
assert response.status_code == 200
assert response.json() == {"number": 7, "merged": True, "state": "closed"}
assert calls == [("stackchain/api", 7, "abc123")]
@pytest.mark.anyio
async def test_assigned_pull_merge_returns_conflict_without_mutating_stale_head(monkeypatch):
async def assigned(repository, number):
return True
async def merge(*args):
raise gitea_proxy.StalePullError("changed")
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge, raising=False)
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/pulls/7/merge",
json={"expected_head_sha": "abc123"},
)
assert response.status_code == 409
assert "New commits" in response.json()["error"]
@pytest.mark.anyio
async def test_gitea_merge_rejects_failed_ci_without_upstream_mutation():
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, "state": "open", "draft": False, "mergeable": True,
"merged": False, "head": {"sha": "abc123"},
})
return httpx.Response(200, json={"state": "failure"})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(gitea_proxy.PullNotMergeableError):
await gitea_proxy.merge_assigned_pull("stackchain/api", 7, "abc123")
finally:
await gitea_proxy.stop_client()
assert requests == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/commits/abc123/status"),
]
+312 -4
View File
@@ -3,7 +3,7 @@ import asyncio
import httpx
import pytest
from src import main
from src import gitea_proxy, main
@pytest.mark.anyio
@@ -76,13 +76,321 @@ async def test_review_detail_has_one_retryable_deadline_and_cancels_pending_work
@pytest.mark.anyio
async def test_public_review_endpoint_does_not_expose_service_token_mutations():
async def test_review_submission_posts_validated_decision_for_requested_current_head(monkeypatch):
calls = []
async def requested(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def submit(repository, number, expected_head_sha, decision, body):
calls.append((repository, number, expected_head_sha, decision, body))
return {"id": 91, "state": "APPROVED", "url": "https://forge.example/reviews/91"}
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit, raising=False)
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/pulls/7/review",
json={"action": "approve"},
json={
"decision": "approve",
"body": "Looks good on mobile.",
"expected_head_sha": "abc123",
},
)
assert response.status_code == 405
assert response.status_code == 201
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"id": 91,
"state": "APPROVED",
"url": "https://forge.example/reviews/91",
}
assert calls == [
("stackchain/api", 7, "abc123", "approve", "Looks good on mobile.")
]
@pytest.mark.anyio
async def test_review_submission_forwards_valid_inline_comments(monkeypatch):
calls = []
async def requested(repository, number):
return True
async def submit(repository, number, expected_head_sha, decision, body, comments):
calls.append((repository, number, expected_head_sha, decision, body, comments))
return {"id": 92, "state": "REQUEST_CHANGES", "url": "https://forge.example/reviews/92"}
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
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/pulls/7/review",
json={
"decision": "request_changes",
"body": "One inline blocker.",
"expected_head_sha": "abc123",
"comments": [{
"path": "src/api.py", "body": "Handle the empty value.",
"new_position": 42,
}],
},
)
assert response.status_code == 201
assert calls == [(
"stackchain/api", 7, "abc123", "request_changes", "One inline blocker.",
[{"path": "src/api.py", "body": "Handle the empty value.", "new_position": 42, "old_position": None}],
)]
@pytest.mark.anyio
async def test_gitea_review_submission_checks_head_then_maps_decision_upstream():
requests = []
async def handler(request):
requests.append(request)
if request.method == "GET":
return httpx.Response(200, json={"head": {"sha": "abc123"}})
return httpx.Response(
200,
json={
"id": 91,
"state": "APPROVED",
"html_url": "https://forge.example/reviews/91",
},
)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.submit_pull_review(
"stackchain/api", 7, "abc123", "approve", "Looks good."
)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("POST", "/api/v1/repos/stackchain/api/pulls/7/reviews"),
]
assert requests[1].content == b'{"body":"Looks good.","event":"APPROVE","commit_id":"abc123"}'
assert result == {
"id": 91,
"state": "APPROVED",
"url": "https://forge.example/reviews/91",
}
@pytest.mark.anyio
async def test_gitea_review_submission_sends_inline_comments_in_single_review_request():
requests = []
async def handler(request):
requests.append(request)
if request.url.path.endswith("/files"):
return httpx.Response(200, json=[{"filename": "src/api.py"}])
if request.method == "GET":
return httpx.Response(200, json={"head": {"sha": "abc123"}})
return httpx.Response(200, json={"id": 93, "state": "COMMENT"})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
await gitea_proxy.submit_pull_review(
"stackchain/api", 7, "abc123", "comment", "Summary",
[{"path": "src/api.py", "body": "Handle empty values.", "new_position": 42, "old_position": None}],
)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/pulls/7/files"),
("POST", "/api/v1/repos/stackchain/api/pulls/7/reviews"),
]
assert requests[-1].content == (
b'{"body":"Summary","event":"COMMENT","commit_id":"abc123","comments":'
b'[{"path":"src/api.py","body":"Handle empty values.","new_position":42,"old_position":null}]}'
)
@pytest.mark.anyio
async def test_gitea_review_submission_rejects_changed_head_without_posting():
methods = []
async def handler(request):
methods.append(request.method)
return httpx.Response(200, json={"head": {"sha": "new456"}})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(gitea_proxy.StaleReviewError):
await gitea_proxy.submit_pull_review(
"stackchain/api", 7, "abc123", "request_changes", "Please revise."
)
finally:
await gitea_proxy.stop_client()
assert methods == ["GET"]
@pytest.mark.anyio
async def test_gitea_review_submission_rejects_inline_comment_for_unchanged_path_without_posting():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/files"):
return httpx.Response(200, json=[{"filename": "src/api.py"}])
return httpx.Response(200, json={"head": {"sha": "abc123"}})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(gitea_proxy.InvalidReviewCommentError):
await gitea_proxy.submit_pull_review(
"stackchain/api", 7, "abc123", "comment", "Review note.",
[{"path": "src/unknown.py", "body": "Not in this change", "new_position": 4}],
)
finally:
await gitea_proxy.stop_client()
assert requests == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/pulls/7/files"),
]
@pytest.mark.anyio
async def test_review_submission_returns_conflict_when_pull_head_changed(monkeypatch):
async def requested(repository, number):
return True
async def submit(*args):
raise gitea_proxy.StaleReviewError("changed")
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
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/pulls/7/review",
json={
"decision": "request_changes",
"body": "Please revise.",
"expected_head_sha": "abc123",
},
)
assert response.status_code == 409
assert response.json() == {
"error": "New commits were pushed. Refresh the review before submitting."
}
@pytest.mark.anyio
async def test_review_submission_returns_validation_error_when_inline_path_is_stale(monkeypatch):
async def requested(repository, number):
return True
async def submit(*args):
raise gitea_proxy.InvalidReviewCommentError("unknown path")
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
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/pulls/7/review",
json={
"decision": "comment", "body": "Note", "expected_head_sha": "abc123",
"comments": [{"path": "gone.py", "body": "Stale", "new_position": 1}],
},
)
assert response.status_code == 422
assert response.json() == {
"error": "An inline comment no longer matches this pull request. Refresh the review."
}
@pytest.mark.anyio
async def test_review_submission_rejects_unsupported_decision_before_upstream(monkeypatch):
called = False
async def requested(repository, number):
nonlocal called
called = True
return True
monkeypatch.setattr(main, "is_requested_review", requested)
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/pulls/7/review",
json={
"decision": "merge",
"body": "Ship it.",
"expected_head_sha": "abc123",
},
)
assert response.status_code == 422
assert called is False
@pytest.mark.anyio
async def test_review_submission_rejects_oversized_feedback_before_upstream(monkeypatch):
called = False
async def requested(repository, number):
nonlocal called
called = True
return True
monkeypatch.setattr(main, "is_requested_review", requested)
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/pulls/7/review",
json={
"decision": "comment",
"body": "x" * 10_001,
"expected_head_sha": "abc123",
},
)
assert response.status_code == 422
assert called is False
@pytest.mark.anyio
async def test_review_submission_deadline_cancels_request_check(monkeypatch):
cancelled = asyncio.Event()
async def requested(repository, number):
try:
await asyncio.Event().wait()
finally:
cancelled.set()
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main, "REVIEW_DETAIL_TIMEOUT_SECONDS", 0.01)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await asyncio.wait_for(
client.post(
"/api/v1/repos/stackchain/api/pulls/7/review",
json={
"decision": "comment",
"body": "Review note.",
"expected_head_sha": "abc123",
},
),
timeout=0.2,
)
assert response.status_code == 503
assert response.headers["retry-after"] == "1"
assert cancelled.is_set()
+306
View File
@@ -0,0 +1,306 @@
import json
import subprocess
from pathlib import Path
import pytest
WORKER = Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js"
def run_worker_scenario(scenario: str) -> dict:
harness = f"""
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, notifications: [], focused: [], opened: [], 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; }},
matchAll: async () => state.clientList || [],
openWindow: async url => {{ state.opened.push(url); }},
}},
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
__issueSync: {{
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
getReceiptPreference: async login => state.receiptLogin === login,
}},
}},
importScripts: () => {{}},
caches: {{
open: async () => cache,
keys: async () => ['stackchain-dashboard-old', 'another-app-cache'],
delete: async key => {{ state.deleted.push(key); return true; }},
match: async request => cache.match(request),
}},
fetch: async request => {{
state.fetches.push(String(request.url || request));
if (state.failFetch) throw new Error('offline');
return new Response('network', {{ status: state.fetchStatus }});
}},
}};
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8'), context);
async function dispatch(name, request) {{
let pending;
let response;
listeners[name]({{
request,
waitUntil: promise => {{ pending = promise; }},
respondWith: promise => {{ response = promise; }},
}});
if (pending) await pending;
return response ? await response : null;
}}
async function dispatchSync(tag) {{
let pending;
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchMessage(data) {{
let pending;
listeners.message({{ data, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchNotificationClick(route) {{
let pending;
listeners.notificationclick({{
notification: {{data: {{route}}, close: () => {{ state.notificationClosed = true; }}}},
waitUntil: promise => {{ pending = promise; }},
}});
if (pending) await pending;
}}
(async () => {{
{scenario}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
completed = subprocess.run(
["node", "-e", harness], capture_output=True, check=True, text=True
)
return json.loads(completed.stdout)
def test_deadline_attention_flow_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v19" in source
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
result = run_worker_scenario(
"""
await dispatchSync('stackchain-issue-outbox-v1');
await dispatchSync('another-app-sync');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["backgroundFlushes"] == 1
def test_authenticated_page_message_resumes_queued_background_delivery():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-resume-outbox'});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["backgroundFlushes"] == 1
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
result = run_worker_scenario(
"""
state.receiptLogin = 'timmy';
state.flushResult = {login:'timmy', receipts:[
{id:'capture-1',status:'confirmed',kind:'issue',route:'#/my-work/issue/stackchain/api/44'},
{id:'bad-1',status:'attention',kind:'message',route:'#/my-work/drafts'},
]};
state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}];
await dispatchSync('stackchain-issue-outbox-v1');
await dispatchNotificationClick('#/my-work/issue/stackchain/api/44');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["notifications"] == [
{
"title": "Queued issue created",
"options": {
"body": "Tap to open it in Stackchain.",
"tag": "stackchain-delivery-capture-1",
"data": {"route": "#/my-work/issue/stackchain/api/44"},
},
},
{
"title": "Queued work needs attention",
"options": {
"body": "Tap to review it in Drafts.",
"tag": "stackchain-delivery-bad-1",
"data": {"route": "#/my-work/drafts"},
},
},
]
assert result["focused"] == [
"https://forge.example/dashboard/#/my-work/issue/stackchain/api/44"
]
assert result["opened"] == []
assert result["notificationClosed"] is True
def test_background_mutations_obtain_session_bound_csrf_proof():
source = WORKER.read_text()
assert "async function sessionCsrf()" in source
assert "BASE + 'api/v1/session'" in source
assert "headers.set('X-CSRF-Token', csrf)" in source
def test_install_precaches_complete_subpath_scoped_app_shell():
result = run_worker_scenario(
"""
await dispatch('install');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["skipped"] is True
assert result["added"][0] == "/dashboard/"
assert set(result["added"]) == {
"/dashboard/",
"/dashboard/manifest.webmanifest",
"/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js",
"/dashboard/static/markdown.js",
"/dashboard/static/commands.js",
"/dashboard/static/search-preview.js",
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/my-work.js",
"/dashboard/static/later-work.js",
"/dashboard/static/detail-defer.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/conversation.js",
"/dashboard/static/issue-sheet.js",
"/dashboard/static/create-issue-sheet.js",
"/dashboard/static/pull-sheet.js",
"/dashboard/static/review-sheet.js",
"/dashboard/static/work-route.js",
"/dashboard/static/context-poller.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/background-issue-sync.js",
}
def test_activate_deletes_only_stale_stackchain_caches():
result = run_worker_scenario(
"""
await dispatch('activate');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["claimed"] is True
assert result["deleted"] == ["stackchain-dashboard-old"]
def test_offline_navigation_returns_cached_shell_for_share_target_url():
result = run_worker_scenario(
"""
state.failFetch = true;
state.cachedBody = 'cached dashboard';
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate',
url: 'https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com',
});
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
"""
)
assert result["body"] == "cached dashboard"
assert result["state"]["fetches"] == [
"https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com"
]
def test_cross_origin_navigation_is_not_intercepted_or_cached():
result = run_worker_scenario(
"""
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://outside.example/page',
});
process.stdout.write(JSON.stringify({ intercepted: response !== null, state }));
"""
)
assert result["intercepted"] is False
assert result["state"]["fetches"] == []
assert result["state"]["puts"] == []
@pytest.mark.parametrize("status", [500, 502, 503, 504])
def test_server_outage_navigation_returns_working_cached_shell_without_replacing_it(status):
result = run_worker_scenario(
f"""
state.fetchStatus = {status};
state.cachedBody = 'cached dashboard';
const response = await dispatch('fetch', {{
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
}});
process.stdout.write(JSON.stringify({{ status: response.status, body: await response.text(), state }}));
"""
)
assert result["status"] == 200
assert result["body"] == "cached dashboard"
assert result["state"]["puts"] == []
def test_server_outage_without_cached_shell_preserves_network_error():
result = run_worker_scenario(
"""
state.fetchStatus = 503;
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
});
process.stdout.write(JSON.stringify({ status: response.status, body: await response.text(), state }));
"""
)
assert result["status"] == 503
assert result["body"] == "network"
assert result["state"]["puts"] == []
def test_client_error_navigation_is_not_hidden_by_cached_shell():
result = run_worker_scenario(
"""
state.fetchStatus = 401;
state.cachedBody = 'cached dashboard';
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
});
process.stdout.write(JSON.stringify({ status: response.status, body: await response.text(), state }));
"""
)
assert result["status"] == 401
assert result["body"] == "network"
assert result["state"]["puts"] == []
+61
View File
@@ -0,0 +1,61 @@
import sqlite3
import pytest
from src import session_store
from src.session_store import SessionStore, SessionStoreError
def test_revocation_is_durable_and_scoped_to_one_session(tmp_path):
now = [1_000.0]
database = tmp_path / "sessions.sqlite3"
first = SessionStore(database, clock=lambda: now[0])
first.activate("first-session-secret", 2_000)
first.activate("second-session-secret", 2_000)
reconstructed = SessionStore(database, clock=lambda: now[0])
reconstructed.revoke("first-session-secret")
assert reconstructed.is_active("first-session-secret", 2_000) is False
assert reconstructed.is_active("second-session-secret", 2_000) is True
assert b"first-session-secret" not in database.read_bytes()
assert b"second-session-secret" not in database.read_bytes()
def test_validation_does_not_create_a_missing_registry(tmp_path):
database = tmp_path / "sessions.sqlite3"
store = SessionStore(database, clock=lambda: 1_000.0)
with pytest.raises(SessionStoreError):
store.is_active("unknown-session", 2_000)
assert database.exists() is False
def test_validation_executes_only_a_read_query(tmp_path, monkeypatch):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate("active-session", 2_000)
statements = []
connect = sqlite3.connect
def traced_connect(*args, **kwargs):
connection = connect(*args, **kwargs)
connection.set_trace_callback(statements.append)
return connection
monkeypatch.setattr(session_store.sqlite3, "connect", traced_connect)
assert store.is_active("active-session", 2_000) is True
assert [statement.split()[0].upper() for statement in statements] == ["SELECT"]
def test_expired_sessions_are_rejected_without_writing_during_validation(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("expiring-session", 1_001)
now[0] = 1_001.0
assert store.is_active("expiring-session", 1_001) is False
with sqlite3.connect(store.path) as connection:
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
+11
View File
@@ -44,3 +44,14 @@ async def test_dashboard_announces_failed_refresh_and_retry_without_blanking_pan
assert "snapshot.freshness?.degraded" in html
assert "Refresh failed · showing last known data" in html
assert "snapshot.freshness.retry_in_seconds" in html
@pytest.mark.anyio
async def test_dashboard_reports_each_live_section_from_its_own_freshness():
html = await dashboard()
assert "snapshot.freshness?.sections?.notifications" in html
assert "snapshot.freshness?.sections?.events" in html
assert "const notificationsFresh = Array.isArray(snapshot.notifications)" not in html
assert "Unread updates unavailable · showing last known updates" in html
assert "Activity refresh failed · showing last activity" in html
+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
+113
View File
@@ -0,0 +1,113 @@
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable(monkeypatch):
requested = []
async def page_loader(stream, page):
requested.append((stream, page))
return {
"stream": stream,
"page": page,
"total": 84,
"has_more": False,
"items": [{
"id": 51, "number": 51, "title": "Older issue", "state": "open",
"labels": [], "assignees": [{"login": "timmy"}],
"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",
}],
}
monkeypatch.setattr(main.gitea_proxy, "work_page", page_loader)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/work/issue?page=2")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("issue", 2)]
assert response.json() == {
"stream": "issue", "page": 2, "total": 84, "has_more": False,
"items": [{
"id": 51, "number": 51, "title": "Older issue", "state": "open",
"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",
}],
}
@pytest.mark.anyio
async def test_work_page_endpoint_rejects_unknown_stream_before_upstream_io(monkeypatch):
called = False
async def page_loader(stream, page):
nonlocal called
called = True
monkeypatch.setattr(main.gitea_proxy, "work_page", page_loader)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/work/unknown?page=2")
assert response.status_code == 422
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()