Compare commits

...
Author SHA1 Message Date
timmy be5603d41b feat: surface authored pulls in My Work (Closes #1346)
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m20s
CI / lint (pull_request) Successful in 3m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 11:13:54 +00:00
timmy 4e074ae18e Merge pull request 'Publish draft pull requests from mobile' (#1345) from timmy/1344-publish-draft-pull into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m14s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m10s
2026-08-24 10:16:34 +00:00
timmy a4ae3e1c7f feat: publish draft pull requests from mobile (Closes #1344)
CI / lint (pull_request) Successful in 3m22s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 10:06:51 +00:00
timmy 1a2297e943 Merge pull request 'Load pull review feedback on demand without blocking mobile review' (#1343) from timmy/1342-lazy-review-feedback into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m23s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m28s
2026-08-24 09:26:53 +00:00
timmy 611e79fc1e feat: load pull review feedback on demand (Closes #1342)
CI / lint (pull_request) Successful in 3m25s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 09:16:30 +00:00
rockachopa 50817bed9a Merge pull request 'Cancel and replace pending pull review requests from mobile' (#1341) from timmy/1340-cancel-pending-review into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m25s
2026-08-24 08:25:56 +00:00
timmy 1f9f0f5c22 test: gate mobile review cancellation journey
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 08:16:21 +00:00
timmy d776a2b003 feat: cancel pending pull review requests (Closes #1340)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 5m18s
CI / lint (pull_request) Successful in 3m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 08:01:25 +00:00
timmy 7517d81046 Merge pull request 'Address requested review feedback from mobile pull detail' (#1339) from timmy/1338-address-review-feedback into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m45s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m33s
2026-08-24 06:53:19 +00:00
timmy 091a01ba67 feat: address requested review feedback (Closes #1338)
CI / lint (pull_request) Successful in 3m35s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 5m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 06:42:45 +00:00
rockachopa bad14d1b94 Merge pull request 'Show requested-change feedback in mobile pull detail' (#1337) from timmy/1336-review-feedback into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m34s
2026-08-24 05:48:23 +00:00
timmy c4bb2f5eba feat: show requested-change feedback (Closes #1336)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m24s
CI / lint (pull_request) Successful in 3m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 05:38:30 +00:00
rockachopa d2a815c4bc Merge pull request 'Show reviewer decisions before merging a pull request' (#1335) from timmy/1334-reviewer-decisions into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m34s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m45s
2026-08-24 04:41:15 +00:00
timmy 3bc1d2f602 test: exercise mobile reviewer status journey
CI / lint (pull_request) Successful in 3m51s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 04:28:24 +00:00
timmy bd96c3caa3 feat: show pull reviewer decisions (Closes #1334)
CI / lint (pull_request) Successful in 3m51s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 5m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 04:09:05 +00:00
rockachopa f8ab6977ed Merge pull request 'Revise pull request context before requesting review' (#1333) from timmy/1332-revise-pull-context into main
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 5m25s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m36s
2026-08-24 03:21:15 +00:00
timmy 8285b39e06 feat: revise pull review context from mobile (Closes #1332)
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 03:11:11 +00:00
rockachopa 8c4969cc44 Merge pull request 'Request teammate review from mobile pull detail' (#1331) from timmy/1330-request-teammate-review into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m31s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m40s
2026-08-24 02:21:19 +00:00
timmy 1f950f93dd feat: request teammate pull reviews (Closes #1330)
CI / lint (pull_request) Successful in 3m36s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 02:11:24 +00:00
rockachopa 505a40d077 Merge pull request 'Review watched pull request changes before acknowledgement' (#1329) from timmy/1328-review-watched-pr-changes into main
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 5m32s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m20s
2026-08-24 01:02:29 +00:00
timmy 0cabce66ed test: account for conditional Changes navigation
CI / lint (pull_request) Successful in 3m24s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 00:52:55 +00:00
timmy df90fb1e0e feat: review watched pull request changes (Closes #1328)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 5m6s
CI / lint (pull_request) Successful in 3m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 00:40:28 +00:00
rockachopa e39e92b025 Merge pull request 'Plan Following changes and continue review' (#1327) from timmy/1326-plan-following-change-continue-review into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m21s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m27s
2026-08-23 23:43:39 +00:00
timmy 07836f7176 feat: plan Following changes and continue review (Closes #1326)
CI / lint (pull_request) Successful in 3m25s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 23:33:36 +00:00
rockachopa 0f36d3077b Merge pull request 'Combine Following changes with Updates in the app badge' (#1325)
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m29s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m26s
Closes #1324
2026-08-23 22:48:16 +00:00
timmy 33c8761e80 feat: combine review attention app badge (Closes #1324)
CI / lint (pull_request) Successful in 3m29s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 22:38:21 +00:00
rockachopa 157703527a Merge pull request 'Explain Following changes before acknowledging review' (#1323) from timmy/1322-explain-following-changes into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m27s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m27s
2026-08-23 21:39:31 +00:00
timmy 5921027e80 feat: explain Following changes before review (Closes #1322)
CI / lint (pull_request) Successful in 4m4s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m48s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 21:28:50 +00:00
rockachopa 31700d5c71 Merge pull request 'Keep a Following change for later and continue review' (#1321) from timmy/1320-keep-following-for-later into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m53s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m47s
2026-08-23 20:35:33 +00:00
timmy c3cb498ce1 feat: keep Following changes for later (Closes #1320)
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 6m34s
CI / lint (pull_request) Successful in 3m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 20:24:14 +00:00
timmy f168a0187d Merge pull request 'feat: CI browser journey loses active break state after corrupt-record recovery' (#1319) from timmy/1315-ci-browser-journey-loses-active-break-state-afte into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m37s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m14s
2026-08-23 19:46:52 +00:00
timmy 877c995b47 fix: preserve active break during session adoption (Closes #1315)
CI / lint (pull_request) Successful in 3m35s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 19:26:02 +00:00
rockachopa e572b3410e Merge pull request 'Open Following alerts directly in changed-first review' (#1318) from timmy/1317-open-following-alert-review into main
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 5m33s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m58s
2026-08-23 18:57:50 +00:00
timmy a2501e47c3 feat: open Following alerts in changed review (Closes #1317)
CI / lint (pull_request) Successful in 3m29s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 18:37:57 +00:00
timmy 2b6730288a Merge pull request 'feat: Notify mobile operators when Following changes' (#1316) from timmy/1313-notify-mobile-operators-when-following-changes into main
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 6m6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m48s
2026-08-23 18:02:52 +00:00
timmy 0efd44fd90 feat: notify devices when Following changes (Closes #1313)
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 17:16:28 +00:00
timmy 187b0decae Merge pull request 'feat: CI browser journey intermittently leaves week reschedule dialog open' (#1312) from timmy/1311-ci-browser-journey-intermittently-leaves-week-re into main
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 6m15s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m46s
2026-08-23 16:07:23 +00:00
timmy 75846cf045 fix: retry unchanged reschedule intent after stale revision (Closes #1311)
CI / lint (pull_request) Successful in 3m47s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m58s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 15:43:30 +00:00
rockachopa 544ffb4ae7 Merge pull request 'Watch assigned issues and pull requests from mobile detail' (#1310)
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 5m25s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m23s
Closes #1309
2026-08-23 15:14:34 +00:00
timmy eea9097740 test: cover detail watch in packaged mobile CI
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m13s
CI / lint (pull_request) Successful in 3m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 15:05:19 +00:00
timmy 35a88bb398 feat: watch assigned work from mobile detail (Closes #1309)
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 5m41s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 14:42:06 +00:00
rockachopa 4e3f67df3d Merge pull request 'Follow pull requests from mobile Search into Following' (#1308) from timmy/1307-follow-pulls-from-search into main
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 5m20s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m45s
2026-08-23 13:51:13 +00:00
timmy 7131792ded feat: follow pull requests from mobile Search (Closes #1307)
CI / lint (pull_request) Successful in 3m34s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m17s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 13:41:33 +00:00
rockachopa 41c28cc70a Merge pull request 'Retire closed issues during Following review' (#1306) from timmy/1305-retire-closed-following into main
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 5m50s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m47s
2026-08-23 12:36:07 +00:00
timmy aa12c4ab9d test: validate closed Following retirement on phone
CI / lint (pull_request) Successful in 3m25s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 12:26:39 +00:00
timmy c4bb7a9be1 feat: retire closed issues during Following review (Closes #1305)
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 5m22s
CI / lint (pull_request) Successful in 3m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 12:12:55 +00:00
rockachopa fdf38a72ac Merge pull request 'Follow any open issue from mobile Search' (#1304) from timmy/1303-follow-any-open-issue into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m34s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m31s
Follow any open issue from mobile Search (#1304)
2026-08-23 11:13:07 +00:00
timmy 29e3f55e07 feat: follow any open issue from search (Closes #1303)
CI / lint (pull_request) Successful in 3m54s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 11:02:33 +00:00
rockachopa 0a18d1ac0f Review unseen Following changes during Prepare Today (#1302)
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m21s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m33s
Closes #1301
2026-08-23 10:19:29 +00:00
timmy 12c0e18dfe feat: review Following during Prepare Today (Closes #1301)
CI / lint (pull_request) Successful in 3m34s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 10:09:12 +00:00
rockachopa 875ec45928 Merge pull request 'Add a sequential review pass for Following' (#1300) from timmy/1299-following-review-pass into main
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 5m22s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m34s
2026-08-23 09:26:23 +00:00
timmy b79973fc2e feat: add sequential Following review pass (Closes #1299)
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 09:15:57 +00:00
timmy 6b98eb5cbe Merge pull request 'Turn Following into a changed-first mobile review inbox' (#1298) from timmy/1297-changed-first-following into main
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 4m18s
2026-08-23 08:04:15 +00:00
timmy 32e2a228b9 feat: make Following a changed-first inbox (Closes #1297)
CI / lint (pull_request) Successful in 4m12s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 07:38:53 +00:00
timmy bbf397454d Merge pull request 'Make Search watch admission truthful and recoverable' (#1296) from timmy/1295-truthful-search-watch-admission into main
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 6m41s
CI / release-candidate (push) Successful in 11s
CI / lint (push) Successful in 3m48s
2026-08-23 06:36:03 +00:00
timmy d3ba5975a0 refactor: make Following sync outcome explicit
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 7m9s
CI / lint (pull_request) Successful in 4m13s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 06:23:57 +00:00
timmy 6c12731215 fix: make Search watch admission recoverable (Closes #1295)
CI / lint (pull_request) Successful in 4m17s
CI / build-release (pull_request) Successful in 10s
CI / browser-journey (pull_request) Failing after 7m36s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 06:05:48 +00:00
timmy 4fe07bcd52 Merge pull request 'Add a mobile Following queue for watched issues' (#1294) from timmy/1293-mobile-following-watched-issues into main
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 7m5s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 4m6s
2026-08-23 04:57:11 +00:00
timmy 846c28001e fix: defer Following sync until user intent
CI / lint (pull_request) Successful in 4m8s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 6m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 04:45:27 +00:00
timmy 5c43a5678a feat: add mobile Following queue for watched issues (Closes #1293)
CI / lint (pull_request) Successful in 4m15s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Failing after 6m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 04:27:23 +00:00
timmy 4b4e55dbe2 Merge pull request 'feat: CI timing gate flakes under runner load' (#1292) from timmy/1289-ci-timing-gate-flakes-under-runner-load into main
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 7m13s
CI / release-candidate (push) Successful in 10s
CI / lint (push) Successful in 4m4s
2026-08-23 02:45:12 +00:00
timmy fe1049ee99 test: make health timing gate deterministic (Closes #1289)
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m24s
CI / lint (pull_request) Successful in 4m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 02:40:35 +00:00
timmy 73017c1a30 Merge pull request 'Watch an unassigned issue from mobile Search' (#1291) from timmy/1290-watch-mobile-search into main
CI / build-release (push) Successful in 9s
CI / browser-journey (push) Successful in 6m52s
CI / release-candidate (push) Successful in 11s
CI / lint (push) Successful in 4m15s
2026-08-23 02:08:24 +00:00
timmy 9c187b5ae5 feat: watch issues from mobile search (Closes #1290)
CI / lint (pull_request) Successful in 4m17s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 7m6s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 01:55:53 +00:00
timmy 7fbbc2f942 Merge pull request 'feat: Disk usage 93%' (#1288) from timmy/1287-disk-usage-93 into main
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 7m30s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 4m7s
2026-08-23 01:37:44 +00:00
timmy 1b448fef23 docs: add disk capacity incident runbook (Closes #1287)
CI / build-release (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 3m53s
CI / browser-journey (pull_request) Successful in 6m55s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 01:10:16 +00:00
timmy 881653eef1 Merge pull request 'Make calendar-import performance gates deterministic' (#1286) from timmy/1285-calendar-import-performance-gate into main
CI / build-release (push) Successful in 19s
CI / browser-journey (push) Failing after 12m8s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 5m17s
2026-08-23 00:32:32 +00:00
timmy d8476c6f7b perf: reuse calendar timezone formatters (Closes #1285)
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 5m51s
CI / lint (pull_request) Successful in 4m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-23 00:19:34 +00:00
timmy 6eb2709477 Merge pull request 'Keep the installed app badge current from background Updates' (#1283) from timmy/1281-background-updates-app-badge into main
CI / build-release (push) Successful in 11s
CI / browser-journey (push) Failing after 9m59s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 4m14s
2026-08-22 22:36:24 +00:00
timmy 05405bf2e8 feat: keep background Updates badge current (Closes #1281)
CI / lint (pull_request) Successful in 4m52s
CI / build-release (pull_request) Successful in 15s
CI / browser-journey (pull_request) Successful in 7m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-22 22:11:52 +00:00
75 changed files with 7767 additions and 202 deletions

No files matched your search

+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium
- name: Exercise packaged mobile work journeys
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py tests/e2e/test_mobile_authored_pull_queue_release.py
release-candidate:
runs-on: ubuntu-latest
+35 -5
View File
@@ -107,11 +107,40 @@ device can reopen the exact Search with one tap while stale writes surface a con
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path.
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
after the account-scoped collection confirms the same mutation. This completes the read → watch → revisit flow without
changing ownership, review assignment, or scheduling work.
Following is a read-first, account-scoped collection: it is encrypted at rest, revisioned, bounded to
50 typed items, and synchronized across signed-in devices. Opening a row reuses the correct issue or
pull-request Search Preview; confirmed **Stop watching** removes an open item. When watched work
closes or merges, the sequential review exposes **Stop watching & next** so the completed item can be
retired without leaving the preview; the next captured change opens immediately, and retiring the
final item completes the Following phase. Changed cards explain whether work closed, reopened, changed title, or
received other activity. During review, conversation messages newer than the prior reviewed revision are highlighted,
and the revision is acknowledged only after both detail and conversation context load; a failed conversation load keeps
that exact change unseen for Retry or **Keep for later & next**. For open work that still needs thought, **Keep for later & next**
restores only the loaded revision to the unseen queue and continues the captured pass without changing
Gitea state, ownership, planning, or watch status. Failed or unconfirmed Gitea mutations leave the collection
and current review position unchanged. Following counts never influence the recommended Work queue. Set
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
Each device can explicitly opt in to **Notify me when Following changes**. Stackchain polls that
channel independently, validates the device session immediately before delivery, and checkpoints
the exact unseen revision set so unchanged work stays silent across workers. Lock-screen payloads
contain only a bounded count and the `#/my-work/following` route—never repository names, titles,
bodies, or comments. Disabling Following alerts leaves Updates, deadlines, and start-day reminders
unchanged.
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and
Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day
checkpoint: finishing Agenda, Updates, or the final Filed review returns to a focused handoff using fresh queue
counts, while reopening the queue sheet resumes the next live phase. **Finish for now** removes only that local
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, Filed,
and unseen Following activity, then opens the highest-priority non-empty review queue. Following refreshes only when
Prepare Today is opened; selecting that phase starts its changed-first sequential review directly. Starting the pass
saves a confirmed-account, local-day checkpoint: finishing Agenda, Updates, the final Filed review, or the final
Following revision returns to a focused handoff using fresh queue counts, while reopening the queue sheet resumes the
next live phase. **Finish for now** removes only that local
checkpoint. Once urgent review is clear the pass continues the existing Today plan, or opens Find Work when Today
is empty; the briefing and checkpoint never change Gitea state.
Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the
@@ -381,7 +410,8 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
# three individual alerts followed by one private digest that opens Updates. A later
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
# that thread's updated_at revision; unchanged and older snapshots remain silent.
# that thread's updated_at revision; unchanged and older snapshots remain silent. Opt-in
# Following alerts use generic copy and deep-link to the changed-first Following review.
# Browser push services must resolve exclusively to public IP addresses. Stackchain
# validates endpoints at enrollment and again before delivery, rejects redirects,
# and removes legacy subscriptions that resolve to private or reserved networks.
+17
View File
@@ -23,3 +23,20 @@
- API contracts + versioning
- independent deployability
- observability: metrics, traces, structured logs
# Disk capacity incident
Root filesystem usage at or above 85% is an operations incident.
1. Capture the initial state with `df -h / /var/lib/gitea`.
2. Inspect directory sizes and active processes before cleanup. Reclaim only
disposable caches and abandoned temporary environments. Responders must not
delete Gitea data, repositories, databases, secrets, or active release
artifacts.
3. Re-run `df -h / /var/lib/gitea`; usage must be below 85%.
4. Confirm the release path with `systemctl is-active gitea act_runner`, then run
the repository test suite.
Record the before and after usage in the incident ticket. If safe cleanup cannot
restore capacity below 85%, keep the incident open and escalate storage
expansion rather than removing durable data.
+59 -4
View File
@@ -955,12 +955,15 @@ textarea { resize: vertical; min-height: 120px; }
.filed-claim-actions button { min-width:0; min-height:44px; }
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.issue-sheet-actions #watch-issue-detail, .pull-sheet-actions #watch-pull-detail { min-width:0; min-height:44px; }
.detail-watch-status:not(:empty) { margin-top:8px; overflow-wrap:anywhere; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.issue-handoff, .pull-ownership { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div, .pull-ownership > div { display:grid; gap:8px; margin-top:10px; }
.issue-handoff select, .pull-ownership select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
.issue-handoff, .pull-ownership, .pull-review-request { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div, .pull-ownership > div, .pull-review-request > div { display:grid; gap:8px; margin-top:10px; }
.issue-handoff select, .pull-ownership select, .pull-review-request select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
.issue-handoff select, .issue-handoff button { min-height:44px; }
.pull-ownership select, .pull-ownership button { min-height:44px; }
.pull-review-request select, .pull-review-request button { min-height:44px; }
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
.new-issue { min-height:44px; }
.find-work-action { min-height:44px; }
@@ -1038,7 +1041,14 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-conversation { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
.search-preview-conversation h2 { margin:0; font-size:1rem; }
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
.search-preview-comment.new-since-review { padding-left:10px; border-left:3px solid var(--accent); background:#10233a; }
.search-preview-conversation button { min-height:44px; width:100%; }
.search-preview-review { display:grid; gap:8px; min-width:0; padding-top:8px; border-top:1px solid #2a496e; }
.search-preview-review[hidden] { display:none; }
.search-preview-review h2 { margin:0; font-size:1rem; }
.search-preview-review button { min-height:44px; width:100%; }
.search-preview-file { min-width:0; margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; overflow:hidden; }
.search-preview-file > strong, .search-preview-file > .small { display:block; overflow-wrap:anywhere; }
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
.search-preview-reply[hidden] { display:none; }
.search-preview-reply h2 { margin:0; font-size:1rem; }
@@ -1050,10 +1060,13 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-reply .conversation-photo-actions { max-width:100%; }
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
#keep-following-status { display:block; min-height:20px; overflow-wrap:anywhere; }
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.search-preview-primary-actions.following-disposition-mode { grid-template-columns:repeat(3,minmax(0,1fr)); }
@media (max-width:420px) {
.search-preview-primary-actions { grid-template-columns:1fr; }
.search-preview-primary-actions.following-disposition-mode { grid-template-columns:1fr; }
}
@media (max-width:600px) {
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-top:max(12px,env(safe-area-inset-top)); }
@@ -1077,12 +1090,25 @@ textarea { resize: vertical; min-height: 120px; }
padding-bottom:calc(6px + env(safe-area-inset-bottom));
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
}
#search-preview-overview, #search-preview-conversation,
#search-preview-overview, #search-preview-conversation, #search-preview-review,
#search-preview-reply-workspace, #search-preview-actions { scroll-margin-top:72px; }
#search-preview-actions { position:static; }
}
@media (min-width:601px) { .mobile-search-preview-nav { display:none; } }
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.following-sheet { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto 0 0 auto; padding:0; border:1px solid #2a496e; color:var(--text); background:#102641; }
.following-sheet::backdrop { background:rgba(3,9,18,.74); }
.following-panel { box-sizing:border-box; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
.following-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.following-panel h2, .following-panel p { margin:0; }
.following-panel header button, #retry-following { min-height:44px; }
.following-review-action { box-sizing:border-box; width:100%; min-height:44px; }
.following-list { display:grid; gap:8px; min-width:0; max-height:70dvh; overflow:auto; }
.following-card { box-sizing:border-box; display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; min-width:0; min-height:52px; padding:10px 12px; text-align:left; }
.following-card span:first-child { min-width:0; display:grid; gap:3px; }
.following-card em { color:var(--accent); font-size:.75rem; font-style:normal; font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
.following-card.has-unseen-change { border-color:var(--accent); }
.following-card strong, .following-card small { overflow-wrap:anywhere; }
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
.markdown-content > :first-child { margin-top:0; }
.markdown-content > :last-child { margin-bottom:0; }
@@ -1233,6 +1259,34 @@ textarea { resize: vertical; min-height: 120px; }
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
.pull-review-tools button { min-height:44px; }
.pull-reviewer-summary { margin:10px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
.pull-reviewer-summary h3 { margin:0 0 6px; font-size:14px; }
.pull-reviewer-status { padding:8px 0; border-top:1px solid #203a5c; overflow-wrap:anywhere; }
.pull-reviewer-status:first-child { border-top:0; }
.pull-reviewer-status-heading { display:flex; justify-content:space-between; gap:10px; }
.pull-reviewer-status-heading span { text-align:right; }
.cancel-review-request { min-height:44px; width:100%; margin-top:8px; }
.pull-review-feedback { margin-top:8px; border:1px solid #2a496e; border-radius:8px; padding:0 8px 8px; }
.pull-review-feedback > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
.pull-review-feedback-summary { margin:4px 0 10px; white-space:pre-wrap; }
.pull-review-feedback-file { margin-top:8px; padding-top:8px; border-top:1px solid #203a5c; }
.pull-review-feedback-file h4 { margin:0 0 4px; overflow-wrap:anywhere; }
.pull-review-feedback-file ul { margin:0; padding-left:20px; }
.pull-review-feedback-file li { margin:6px 0; }
.pull-review-feedback-file small { display:block; color:#93c5fd; }
.pull-review-feedback-file button { min-height:44px; width:100%; margin-top:4px; }
.address-review-feedback, [data-load-review-feedback] { min-height:44px; width:100%; margin-top:8px; }
.pull-feedback-pass { margin-top:10px; padding:10px; border:1px solid #3b82f6; border-radius:10px; overflow-wrap:anywhere; }
.pull-feedback-pass-heading { display:flex; align-items:start; justify-content:space-between; gap:8px; }
.pull-feedback-pass-heading h4 { margin:2px 0 8px; }
.pull-feedback-pass p { white-space:pre-wrap; }
.pull-feedback-pass textarea { width:100%; min-height:88px; box-sizing:border-box; }
.pull-feedback-dispositions { display:grid; grid-template-columns:1fr; gap:6px; margin:10px 0; padding:8px; }
.pull-feedback-dispositions button[aria-pressed="true"] { border-color:#60a5fa; background:#173b63; }
.pull-feedback-navigation { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:8px; }
.pull-feedback-pass button { min-height:44px; }
#finish-pull-feedback, #request-feedback-review { width:100%; margin-top:8px; }
#request-updated-pull-review { min-height:44px; width:100%; margin-top:8px; }
.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; }
.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
.pull-review summary h2 { margin:0; font-size:16px; }
@@ -1367,6 +1421,7 @@ textarea { resize: vertical; min-height: 120px; }
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.following-sheet { width:100%; max-width:none; border:0; border-radius:18px 18px 0 0; }
.mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-first-task::backdrop { background:rgba(3,9,18,.78); }
+127 -37
View File
@@ -3,11 +3,12 @@
const qs = (s, el=document) => el.querySelector(s);
const announceWork = message => qs('#my-work-action-status').textContent = message;
const fmt = (d) => new Date(d).toLocaleString();
const mobileAppBadge = createMobileAppBadge({
const appBadge = createMobileAppBadge({
control:qs('#app-badge-control'), status:qs('#app-badge-status'),
container:qs('#app-badge-setting'), navigator, storage:localStorage,
serviceWorker:navigator.serviceWorker,
});
mobileAppBadge.start();
appBadge.start();
const cardPlanning = createCardPlanning(document);
const mobileComposerViewport = createMobileComposerViewport({
viewport: window.visualViewport,
@@ -102,8 +103,38 @@
const result = await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin);
(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result);
}
let mobileQueueCounts = {};
let mobilePreparationItems = {};
let queueCounts = {};
let preparationItems = {};
const followingQueue = attachFollowing(item => {
searchPreviewReturnKind = 'following';
return searchPreview.open(item);
}, {
onCount:(count, items) => {
appBadge.reconcile('following', count, true);
queueCounts.following = count;
queueCounts.followingUnavailable = false;
preparationItems.following = items.filter(item => item.has_unseen_change === true);
mobileStartDay.render();
},
onStatus:status => {
if (status === 'loading') return;
queueCounts.followingUnavailable = status === 'error';
mobileStartDay.render();
},
onReviewComplete:() => mobileStartDay.completePhase('following'),
});
function bindDetailWatch(kind, button, status) {
const feature=createDetailWatch({fetchJson:fetchReviewJson,refreshFollowing:()=>followingQueue.load(),onState:(state,watching,error)=>{
button.disabled=['loading','watching','unwatching'].includes(state);
button.textContent=watching?'Stop watching':'Watch '+(kind==='pull'?'pull request':'issue');
status.textContent=state==='ready'?(watching?'Watching · available in Following.':''):
searchPreviewWatchStatus({status:state==='error'?'watch-error':state,error,detail:{kind}});
}});
button.addEventListener('click',()=>feature.toggle().catch(()=>{}));
return feature;
}
const issueDetailWatch = bindDetailWatch('issue',qs('#watch-issue-detail'),qs('#issue-watch-status'));
const pullDetailWatch = bindDetailWatch('pull',qs('#watch-pull-detail'),qs('#pull-watch-status'));
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
getItems: () => draftInbox.partition(lastDrafts).deliveries,
getIndex: item => lastDrafts.indexOf(item),
@@ -131,14 +162,16 @@
openToday: () => mobileWorkEntry.open(),
openAgenda: openAgendaSession,
openUpdates: openUpdateTriage,
openFollowing:followingQueue.open,
openFiled: openFiledFollowUp,
selectFilter: selectMobileQueue,
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
announce: announceWork,
getCounts: () => mobileQueueCounts,
getCounts: () => queueCounts,
openFindWork: () => qs('#find-work').click(),
});
function openMobileStartDay() {
followingQueue.load().catch(() => {});
const state = mobileStartDay.state();
mobileStartDay.render();
qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';
@@ -169,13 +202,15 @@
findWork: () => qs('#find-work').click(),
});
const mobileStartDay = createMobileStartDay({
getCounts: () => mobileQueueCounts,
getPhaseItems: () => mobilePreparationItems,
getCounts: () => queueCounts,
getPhaseItems: () => preparationItems,
getLogin: () => confirmedOwnerLogin,
openQueue: name => {
const sheet = qs('#mobile-queue-sheet');
if (sheet.open) sheet.close();
return name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name);
return name === 'find' ? qs('#find-work').click() :
name === 'following' ? (queueCounts.followingUnavailable ? followingQueue.open() :
followingQueue.review()) : mobileQueueLauncher.open(name);
},
onHandoff: current => {
qs('#mobile-queue-heading').textContent = 'Prepare Today · ' + current.label;
@@ -293,7 +328,7 @@
let launchFilterResolved = false;
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
if (['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
if (['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
selectedWorkFilter = savedFilter;
savedWorkFilter = savedFilter;
launchFilterResolved = true;
@@ -1252,7 +1287,8 @@
if (continuing) return await completeOwnershipExitToday(item);
paintMyWork(lastContextSnapshot);
return false;
});
}, ()=>selectedPullDetail, ()=>confirmedOwnerLogin);
}
attachReleaseReceipt();
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
@@ -1492,7 +1528,7 @@
},
onPagination: pagination => {
notificationPagination = pagination;
mobileAppBadge.reconcile(pagination.total, {authoritative:true});
appBadge.reconcile('updates', pagination.total, true);
const loaded = Math.min(pagination.total, pagination.page * 50);
qs('#notification-page-status').textContent = pagination.total ?
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
@@ -3395,7 +3431,7 @@
counts.later = laterMyWork.length;
counts.draft = lastDrafts.length;
counts.delivery = draftInbox.partition(lastDrafts).actionable;
mobilePreparationItems = {
preparationItems = {
delivery:draftInbox.partition(lastDrafts).deliveries,
agenda:agendaMyWork(activeMyWork),
attention:activeMyWork.filter(item => item.needs_attention),
@@ -3416,7 +3452,7 @@
const element = qs('[data-work-count="' + filter + '"]');
if (element) element.textContent = count;
});
mobileQueueCounts = counts;
queueCounts = counts;
mobileStartDay.reconcile({
authoritative:authoritativeMyWorkRefresh,
authoritativePhases:['delivery'],
@@ -3462,10 +3498,10 @@
if (selectedWorkFilter === 'agenda') return ['issue'];
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
if (selectedWorkFilter === 'filed') return ['filed'];
if (selectedWorkFilter === 'issue') return ['issue'];
if (selectedWorkFilter === 'pull') return ['pull'];
if (selectedWorkFilter === 'review') return ['review'];
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review'];
if (['issue', 'pull', 'review', 'authored'].includes(selectedWorkFilter)) {
return [selectedWorkFilter];
}
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review', 'authored'];
return [];
}
@@ -3711,7 +3747,7 @@
}
function updateWorkPaginationControls() {
const labels = { issue: 'issues', filed: 'filed issues', pull: 'pull requests', review: 'review requests' };
const labels = { issue: 'issues', filed: 'filed issues', pull: 'pull requests', review: 'review requests', authored: 'pull requests' };
const streams = activeWorkStreams();
const incomplete = streams.filter(stream => workPagination[stream]?.has_more);
const summaries = streams.flatMap(stream => {
@@ -4253,6 +4289,15 @@
issueConversation = null;
issueTrigger = trigger;
qs('#issue-sheet').classList.add('open');
if (offlineDetail) {
qs('#watch-issue-detail').hidden = true;
qs('#issue-watch-status').textContent = 'Reconnect to change watch status.';
} else {
qs('#watch-issue-detail').hidden = item.state !== 'open';
void issueDetailWatch.open(item).catch(error => {
if (selectedIssue === item) qs('#issue-watch-status').textContent = error.message + ' Retry.';
});
}
qs('#issue-sheet-key').textContent = item.key || '';
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
qs('#issue-sheet-status').textContent = 'Loading issue…';
@@ -4492,7 +4537,7 @@
qs('#pull-review-progress').textContent = pullReviewState.total ?
pullReviewState.reviewed.length + ' of ' + pullReviewState.total + ' files reviewed' : 'No changed files to review';
qs('#next-unreviewed-pull-file').disabled = pullReviewState.complete;
const eligibility = createPullSheet.mergeEligibility(detail, pullReviewState);
const eligibility = createPullSheet.review(detail, pullReviewState, document);
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
@@ -4529,9 +4574,7 @@
return;
}
selectedPullDetail = { ...selectedPullDetail, ...status };
const eligibility = createPullSheet.mergeEligibility(selectedPullDetail, pullReviewState);
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
renderPullReview(selectedPullDetail);
qs('#pull-review-status').textContent = 'Checks refreshed for the current head.';
}
@@ -4575,10 +4618,20 @@
pullConversation = null;
pullReviewState = null;
qs('#pull-sheet').classList.add('open');
if (offlineDetail) {
qs('#watch-pull-detail').hidden = true;
qs('#pull-watch-status').textContent = 'Reconnect to change watch status.';
} else {
qs('#watch-pull-detail').hidden = item.state !== 'open';
void pullDetailWatch.open(item).catch(error => {
if (selectedPull === item) qs('#pull-watch-status').textContent = error.message + ' Retry.';
});
}
qs('#pull-sheet-key').textContent = item.key || '';
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
qs('#pull-sheet-status').textContent = 'Loading pull request…';
qs('#pull-sheet-body').textContent = '';
qs('#pull-files').textContent = '';
qs('#pull-review').open = false;
qs('#pull-review-status').textContent = 'Expand to load changed files and merge readiness.';
@@ -4594,6 +4647,7 @@
qs('#pull-checks-summary').textContent = 'Not loaded';
qs('#pull-check-list').textContent = '';
qs('#pull-checks').open = false;
qs('#pull-merge-state').textContent = 'Review data not loaded';
qs('#merge-pull').disabled = true;
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
@@ -4606,9 +4660,11 @@
const detail = offlineDetail || await pullController.load(item);
if (selectedPull !== item) return;
selectedPullDetail = detail;
pullController.setReviewDetail(detail);
pullConversation = pullController.conversation(item, detail.conversation);
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
qs('#pull-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
renderPullConversation(pullConversation.snapshot());
qs('#open-pull-gitea').href = detail.url || item.url || '#';
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
@@ -5609,6 +5665,7 @@
const renderSearchConversation = conversation =>
renderSearchPreviewConversation(conversation, document, escapeHtml, fmt, renderMarkdown);
function renderSearchPreview(state) {
followingQueue.preview(state);
const sheet = qs('#search-preview');
const status = qs('#search-preview-status');
const claimButton = qs('#claim-search-result');
@@ -5616,10 +5673,11 @@
const queueButton = qs('#queue-search-result');
const planButton = qs('#plan-search-result');
const startButton = qs('#start-search-result');
const watchButton = qs('#watch-search-result');
const shareButton = qs('#share-search-result');
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
? 'Back to blockers' : 'Back to search';
? 'Back to blockers' : searchPreviewReturnKind === 'following' ? 'Back to Following' : 'Back to search';
if (state.status === 'closed') {
searchVoiceReply.cancel();
sheet.classList.remove('open');
@@ -5635,8 +5693,10 @@
planButton.disabled = false;
startButton.hidden = true;
startButton.disabled = false;
watchButton.hidden = true;
watchButton.disabled = false;
shareButton.disabled = true;
renderSearchPreviewReply(state, null, searchPreview, document);
renderSearchPreviewWorkspaces(state,null,searchPreview,document,escapeHtml);
if (state.status === 'loading') {
searchReplyAttachmentTarget = { ...state.item };
@@ -5659,6 +5719,7 @@
const detail = state.detail;
if (!detail) return;
searchPreviewDetail = detail;
shareButton.disabled = state.status === 'sharing';
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
@@ -5669,7 +5730,7 @@
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
renderSearchConversation(state.conversation);
renderSearchPreviewReply(state, detail, searchPreview, document);
renderSearchPreviewWorkspaces(state,detail,searchPreview,document,escapeHtml);
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
@@ -5689,6 +5750,8 @@
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
renderSearchPreviewWatch(detail, state, watchButton);
followingQueue.preview(state);
const shareStatus = {
sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.',
'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.',
@@ -5697,21 +5760,22 @@
else if (state.status === 'reopening') status.textContent = 'Reopening…';
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
else if (state.status === 'claimed') status.textContent = 'Assigned. Opening My Work…';
else if (state.status.includes('watch')) status.textContent = searchPreviewWatchStatus(state);
else if (detail.claimable) status.textContent = 'Open and unassigned.';
else if (detail.assigned_to_me) status.textContent = 'Already in My Work.';
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
else status.textContent = 'Ready.';
}
const searchSubscription = searchPreviewSubscriptionOptions(fetchReviewJson);
const searchPreview = createSearchPreview({
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page),{
headers:{Accept:'application/json'},
}),
mutate: (detail, action) => fetchReviewJson(
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
{ method:'PATCH', headers:{ Accept:'application/json' } }
fetchJson:searchSubscription.preview,
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page)),
fetchReview:searchSubscription.review,
mutate:(detail,action)=>fetchReviewJson(
searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'}
),
watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
followingQueue.load().catch(() => {}).then(() => result)),
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
queueReply:async (item,body,operationId) => {
searchReplyAttachmentTarget = item;
@@ -5738,7 +5802,14 @@
searchReplyAttachmentController.clear();
},
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
getSession:() => followingQueue.session() || commandSearchState,
loadMore:() => commandSearch.loadMore(),
onNavigate:item => {
if (!followingQueue.session()) taskOverlayHistory.update({preview:item});
},
onOpened:item => followingQueue.previewLoaded(item),
afterUnwatch:item => followingQueue.retire(item),
navigationRoot:document,
onState: renderSearchPreview,
});
const searchWeekPlan = createSearchWeekPlan({
@@ -5748,7 +5819,7 @@
createSearchWeekPlanUI({planner:searchWeekPlan,document,window,escapeHtml,escapeAttribute:escAttr,
getDetail:()=>searchPreviewDetail,
announce:message=>{qs('#search-preview-status').textContent=message;},
onPlanned:item=>{searchPreviewDetail={...item,assigned_to_me:true,claimable:false};},
onPlanned:item=>(searchPreviewDetail=item,item.following&&next()),
});
searchBatchPlanning = mountSearchBatchPlanning(
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
@@ -5804,6 +5875,12 @@
schedule: callback => requestAnimationFrame(callback),
});
function closeSearchPreview(navigate = true) {
if (searchPreviewReturnKind === 'following') {
searchPreview.close();
searchPreviewReturnKind = null;
followingQueue.returnToFollowing();
return;
}
if (navigate && taskOverlayHistory.current() === 'search-preview') {
taskOverlayHistory.close();
return;
@@ -6074,9 +6151,18 @@
}
});
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && searchPreviewReturnKind === 'following' &&
qs('#search-preview').classList.contains('open')) {
e.preventDefault();
closeSearchPreview(false);
}
});
qs('#share-search-result').addEventListener('click', () => {
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
});
wireSearchPreviewWatch(qs('#watch-search-result'), searchPreview, () => searchPreviewDetail);
document.querySelectorAll('.share-work-route').forEach(button => {
button.addEventListener('click', async () => {
const status = qs('#work-route-share-status');
@@ -7159,6 +7245,7 @@
if (event.currentTarget.open) loadPullReview();
});
qs('#pull-review-retry').addEventListener('click', loadPullReview);
qs('#refresh-pull-checks').addEventListener('click', async () => {
if (!selectedPull || !selectedPullDetail?.head_sha) return;
const item = selectedPull;
@@ -8152,6 +8239,7 @@
return true;
}
async function openWorkQueueRoute(filter, action = null) {
if (filter === 'following') return followingQueue.route();
if (!selectWorkQueue(filter, { preserveRoute:true })) return;
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
@@ -8229,6 +8317,8 @@
startDayControl:qs('#push-start-day'),
startDayStatus:qs('#push-start-day-status'),
startDayHour:qs('#push-start-day-hour'),
followingControl:qs('#push-following'),
followingStatus:qs('#push-following-status'),
deadlineSnooze:qs('#deadline-snooze'),
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
@@ -8298,8 +8388,8 @@
protectStorage:() => deviceStorage.requestPersistence(),
notificationReadiness:() => pushController?.notificationReadiness()
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
appBadgeReadiness:() => mobileAppBadge.readiness(),
enableAppBadge:() => mobileAppBadge.enable(),
appBadgeReadiness:() => appBadge.readiness(),
enableAppBadge:() => appBadge.enable(),
enablePush:async () => {
const controller = await pushControllerReady;
if (!controller) return;
+292
View File
@@ -0,0 +1,292 @@
(function (root, factory) {
const exports = factory();
if (typeof module === 'object' && module.exports) {
module.exports = exports.createFollowing;
module.exports.attachFollowing = exports.attachFollowing;
module.exports.planningDisposition = exports.planningDisposition;
} else {
root.createFollowing = exports.createFollowing;
root.attachFollowing = exports.attachFollowing;
root.followingPlanningDisposition = exports.planningDisposition;
}
})(typeof self !== 'undefined' ? self : this, function () {
function planningDisposition(detail, controls) {
const active = detail?.following === true && detail.kind === 'issue' && detail.state === 'open' &&
Boolean(detail.claimable || detail.assigned_to_me);
const result = active ? {active:true,today:'Add to Today & next',later:'Later & next',week:'Week Ahead & next'} : {active:false};
if (!controls && globalThis.document) {
const query = selector => globalThis.document.querySelector(selector);
controls = {root:query('.search-preview-primary-actions'),claim:query('#claim-search-result'),
today:query('#queue-search-result'),later:query('#defer-search-result'),week:query('#plan-search-result'),
start:query('#start-search-result'),watch:query('#watch-search-result')};
}
controls?.root?.classList.toggle('following-disposition-mode', active);
if (active && controls) {
controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true;
controls.today.textContent = result.today;
controls.later.textContent = result.later;
controls.week.textContent = result.week;
}
return result;
}
function createFollowing(options) {
let generation = 0;
let snapshot = {revision:0, items:[]};
let review = null;
function sameItem(left, right) {
return (left?.kind || 'issue') === (right?.kind || 'issue') &&
left?.repository === right?.repository && Number(left?.number) === Number(right?.number);
}
function publish(status, error) {
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
state.degraded = snapshot.degraded === true;
state.refreshFailures = Number(snapshot.refreshFailures) || 0;
if (review?.acknowledged.size) state.reviewSummary = {
reviewed:review.acknowledged.size,
remaining:snapshot.items.filter(item => item.has_unseen_change === true).length,
};
if (error) state.error = error;
options.render?.(state);
if (status === 'ready') options.onCount?.(
snapshot.items.filter(item => item.has_unseen_change === true).length);
return state;
}
async function load() {
const requestGeneration = ++generation;
publish('loading');
try {
const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}});
if (requestGeneration !== generation) return snapshot;
snapshot = {
revision:Number(result?.revision) || 0,
items:Array.isArray(result?.items) ? result.items.slice(0, 50)
.map(item => ({...item, kind:item.kind === 'pull' ? 'pull' : 'issue'})) : [],
degraded:result?.degraded === true,
refreshFailures:Number(result?.refresh_failures) || 0,
};
publish('ready');
return snapshot;
} catch (error) {
if (requestGeneration === generation) publish('error', error);
throw error;
}
}
async function acknowledge(item) {
const current = snapshot.items.find(candidate => sameItem(candidate, item) &&
candidate.updated_at === item?.updated_at);
if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
await options.onAcknowledge(current);
current.has_unseen_change = false;
review?.acknowledged.add(current.kind + ':' + current.repository + '#' + current.number + '@' + current.updated_at);
publish('ready');
return true;
}
async function keepForLater(item) {
const exact = candidate => sameItem(candidate, item) && candidate.updated_at === item?.updated_at;
const current = snapshot.items.find(exact);
const index = review?.active ? review.items.findIndex(exact) : -1;
if (!current || index < 0 || typeof options.onKeep !== 'function') return null;
await options.onKeep(current);
current.has_unseen_change = true;
review.acknowledged.delete(current.kind + ':' + current.repository + '#' +
current.number + '@' + current.updated_at);
review.items.splice(index, 1);
const next = review.items[index] || null;
if (!next) review.active = false;
publish('ready');
if (next) await open(snapshot.items.findIndex(candidate => sameItem(candidate, next)));
return next;
}
async function open(index) {
const item = snapshot.items[Number(index)];
if (!item) return false;
await options.onOpen?.({...item, following:true});
await acknowledge(item);
return true;
}
async function startReview() {
const items = snapshot.items
.filter(item => item.has_unseen_change === true)
.map(item => ({...item, following:true}));
if (!items.length) return false;
review = {items, more:false, active:true, acknowledged:new Set()};
await open(snapshot.items.indexOf(snapshot.items.find(item => sameItem(item, items[0]))));
return true;
}
function finishReview() {
const completed = Boolean(review?.completed || (review?.active &&
!snapshot.items.some(item => item.has_unseen_change === true)));
if (review) review.active = false;
if (completed && !review.completed) {
review.completed = true;
options.onReviewComplete?.();
}
publish('ready');
return completed;
}
function retire(item) {
const same = candidate => sameItem(candidate, item);
const index = review?.active ? review.items.findIndex(same) : -1;
snapshot.items = snapshot.items.filter(candidate => !same(candidate));
if (index < 0) {
publish('ready');
return null;
}
review.items.splice(index, 1);
const next = review.items[index] || null;
if (!next) {
review.active = false;
if (!review.completed) {
review.completed = true;
options.onReviewComplete?.();
}
}
publish('ready');
return next ? {...next} : null;
}
return {
load, open, startReview, previewLoaded:acknowledge, keepForLater, finishReview, retire,
session:() => review?.active ? {items:[...review.items], more:false} : null,
items:() => snapshot.items.map(item => ({...item})),
count:() => snapshot.items.length,
};
}
function attachFollowing(onOpen, hooks = {}) {
const document = globalThis.document;
const query = selector => document.querySelector(selector);
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[character]);
const formatTime = value => new Date(value).toLocaleString();
const fetchJson = async (url, options) => {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
return payload;
};
const changeRevision = (item, action) => {
const [owner, repo] = item.repository.split('/');
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
encodeURIComponent(repo) + '/issues/' + item.number + '/' + action +
'?kind=' + encodeURIComponent(item.kind), {
method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
body:JSON.stringify({updated_at:item.updated_at}),
});
};
let feature;
let activeReviewItem = null;
const show = () => query('#following-sheet').open || query('#following-sheet').showModal();
function render(state) {
hooks.onStatus?.(state.status);
const list = query('#following-list');
const status = query('#following-status');
const reviewButton = query('#review-following');
query('#retry-following').hidden = state.status !== 'error';
if (state.status === 'loading') return void (status.textContent = 'Loading watched items…');
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
const unseen = state.items.filter(item => item.has_unseen_change === true).length;
reviewButton.hidden = unseen === 0;
reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
status.textContent = (state.reviewSummary
? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. '
: '') + (state.degraded ? 'Some watched items could not be refreshed. Showing last known details. ' : '') + (state.items.length
? state.items.length + (state.items.length === 1 ? ' watched item.' : ' watched items.')
: 'No watched items yet. Watch an issue or pull request from Search to keep it here.');
list.innerHTML = state.items.map((item, index) =>
'<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
'" type="button" data-following-index="' + index + '"><span>' +
(item.has_unseen_change ? '<em>' + escapeHtml(item.change_summary || 'New activity') + '</em>' : '') + '<strong>' +
escapeHtml(item.title) + '</strong><small>' + escapeHtml(
(item.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + item.repository + ' #' + item.number +
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
'</small></span><span aria-hidden="true">›</span></button>').join('');
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
query('#following-sheet').close();
feature.open(Number(button.dataset.followingIndex)).catch(() => {});
}));
}
feature = createFollowing({
fetchJson, render,
onCount:count => {
const value = query('[data-mobile-queue-count="following"]');
value.textContent = count;
value.closest('button').setAttribute('aria-label', 'Following, ' + count +
(count === 1 ? ' unseen change' : ' unseen changes'));
hooks.onCount?.(count, feature.items());
},
onOpen:item => {
activeReviewItem = item;
return onOpen(item);
},
onReviewComplete:hooks.onReviewComplete,
onAcknowledge:item => changeRevision(item, 'seen'),
onKeep:item => changeRevision(item, 'keep'),
});
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
query('#review-following').addEventListener('click', () => {
query('#following-sheet').close();
feature.startReview().catch(() => {
show();
});
});
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
const keepButton = query('#keep-following-for-later');
keepButton.addEventListener('click', async () => {
const status = query('#keep-following-status');
keepButton.disabled = true;
status.textContent = 'Keeping this change for later…';
try {
if (!await feature.keepForLater(activeReviewItem)) query('#close-search-preview').click();
status.textContent = 'Kept for later.';
} catch (_) {
status.textContent = 'Could not keep this change for later. Retry when ready.';
keepButton.disabled = false;
}
});
return {
load:feature.load,
review:feature.startReview,
async route() {
await feature.load().then(feature.startReview).catch(() => false) || show();
},
open() {
show();
feature.load().catch(() => {});
return 'opened-following';
},
session:feature.session,
previewLoaded:feature.previewLoaded,
keepForLater:feature.keepForLater,
retire:feature.retire,
preview(state) {
const disposition = planningDisposition(state?.detail || state?.item);
keepButton.hidden = state?.item?.following !== true || disposition.active;
keepButton.disabled = false;
if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = '';
},
returnToFollowing() {
const completed = feature.finishReview();
if (completed) return 'completed-following';
show();
globalThis.requestAnimationFrame?.(() => {
const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following');
target?.focus();
});
return 'returned-following';
},
};
}
return {createFollowing, attachFollowing, planningDisposition};
});
+84 -3
View File
@@ -197,6 +197,7 @@
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">0</span></button>
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
<button class="work-filter" data-work-filter="filed" aria-pressed="false">Filed <span data-work-count="filed">0</span></button>
<button class="work-filter" data-work-filter="authored" aria-pressed="false">My PRs <span data-work-count="authored">0</span></button>
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
@@ -215,12 +216,14 @@
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<span class="app-badge-setting" id="app-badge-setting">
<label class="app-badge-control" for="app-badge-control"><input id="app-badge-control" type="checkbox" /> Show unread Updates on the app icon</label>
<label class="app-badge-control" for="app-badge-control"><input id="app-badge-control" type="checkbox" /> Show review attention on the app icon</label>
<span class="small" id="app-badge-status" role="status" aria-live="polite"></span>
</span>
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
<label class="push-update-control" for="push-following"><input id="push-following" type="checkbox" /> Notify me when Following changes</label>
<span class="small" id="push-following-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
@@ -884,6 +887,7 @@
<nav class="mobile-search-preview-nav" aria-label="Search preview sections">
<button type="button" data-search-preview-section="overview">Overview</button>
<button type="button" data-search-preview-section="conversation">Conversation</button>
<button type="button" data-search-preview-section="changes" hidden>Changes</button>
<button type="button" data-search-preview-section="reply">Reply</button>
<button type="button" data-search-preview-section="actions">Act</button>
</nav>
@@ -899,6 +903,12 @@
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
</section>
<section id="search-preview-review" class="search-preview-review" aria-labelledby="search-preview-review-title" hidden>
<h2 id="search-preview-review-title">Changes</h2>
<div id="search-preview-review-status" class="small" aria-live="polite"></div>
<div id="search-preview-files"></div>
<button id="retry-search-preview-review" type="button" hidden>Retry changes</button>
</section>
<section class="search-preview-reply" id="search-preview-reply-workspace" aria-labelledby="search-preview-reply-title" hidden>
<h2 id="search-preview-reply-title">Reply</h2>
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
@@ -957,8 +967,10 @@
<button id="queue-search-result" type="button" hidden>Assign &amp; add to Today</button>
<button id="plan-search-result" type="button" hidden>Plan ahead</button>
<button id="start-search-result" type="button" hidden>Assign &amp; start</button>
<button id="watch-search-result" type="button" hidden>Watch issue</button>
<button id="keep-following-for-later" type="button" hidden>Keep for later &amp; next</button>
</div>
<span id="keep-following-status" class="small" role="status" aria-live="polite"></span>
<button id="share-search-result" type="button" disabled>Share result</button>
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
@@ -1181,11 +1193,13 @@
</section>
<div class="issue-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<button id="watch-issue-detail" type="button">Watch issue</button>
<button id="release-issue" type="button">Release assignment</button>
<button id="close-issue" type="button">Close issue</button>
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date &amp; time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div>
<div id="issue-watch-status" class="detail-watch-status small" aria-live="polite"></div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
@@ -1638,6 +1652,16 @@
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
<div class="pull-sheet-content markdown-content" id="pull-sheet-body"></div>
<button id="edit-pull-content" type="button" hidden>Edit review context</button>
<form class="issue-edit-form" id="pull-edit-form" hidden>
<label>Title<input id="pull-edit-title" maxlength="255" required /></label>
<label>Description<textarea id="pull-edit-body" maxlength="10000"></textarea></label>
<div class="issue-edit-actions">
<button id="save-pull-content" type="submit">Save context</button>
<button id="cancel-pull-edit" type="button">Cancel</button>
</div>
<div id="pull-edit-status" class="small" role="status" aria-live="assertive"></div>
</form>
</div>
<section id="pull-conversation" tabindex="-1">
<h2>Full conversation</h2><div id="pull-comments"></div>
@@ -1698,6 +1722,36 @@
<div id="pull-review-status" class="small" aria-live="polite">Expand to load changed files and merge readiness.</div>
<button class="pull-retry" id="pull-review-retry" type="button" hidden>Retry review data</button>
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Review data not loaded</span></div>
<section class="pull-reviewer-summary" aria-labelledby="pull-reviewer-heading">
<h3 id="pull-reviewer-heading">Reviewer status</h3>
<div id="pull-reviewer-status" class="small" aria-live="polite">Reviewer status not loaded.</div>
<div id="pull-reviewer-statuses"></div>
<button id="request-updated-pull-review" type="button" hidden>Request updated review</button>
<section class="pull-feedback-pass" id="pull-feedback-pass" aria-labelledby="pull-feedback-heading" hidden>
<div class="pull-feedback-pass-heading">
<div><span class="small" id="pull-feedback-progress"></span><h4 id="pull-feedback-heading">Address review feedback</h4></div>
<button id="close-pull-feedback" type="button">Close</button>
</div>
<p class="small" id="pull-feedback-location"></p>
<p id="pull-feedback-body"></p>
<div class="small" id="pull-feedback-context" aria-live="polite"></div>
<fieldset class="pull-feedback-dispositions">
<legend>Disposition</legend>
<button type="button" data-feedback-disposition="addressed">Addressed</button>
<button type="button" data-feedback-disposition="discussion">Needs discussion</button>
<button type="button" data-feedback-disposition="skipped">Skip for now</button>
</fieldset>
<label for="pull-feedback-note">Response note (optional)</label>
<textarea id="pull-feedback-note" maxlength="1000" placeholder="What changed or needs discussion?"></textarea>
<div class="pull-feedback-navigation">
<button id="previous-pull-feedback" type="button">Previous</button>
<button id="next-pull-feedback" type="button">Next</button>
</div>
<button id="finish-pull-feedback" type="button" disabled>Post feedback response</button>
<button id="request-feedback-review" type="button" hidden>Request updated review from this reviewer</button>
<div id="pull-feedback-status" class="small" role="status" aria-live="assertive"></div>
</section>
</section>
<details class="ci-checks" id="pull-checks">
<summary><span>Checks</span><span class="small" id="pull-checks-summary">Not loaded</span></summary>
<button class="ci-check-refresh" id="refresh-pull-checks" type="button">Refresh checks</button>
@@ -1707,6 +1761,18 @@
<div id="pull-files"></div>
<button id="merge-pull" type="button" disabled>Merge</button>
</details>
<details class="pull-review-request" id="pull-review-request">
<summary>Request review</summary>
<div>
<button id="publish-draft-pull" type="button" hidden>Ready for review</button>
<div id="publish-draft-pull-status" class="small" aria-live="assertive"></div>
<button id="load-pull-reviewers" type="button">Choose reviewer</button>
<label for="pull-review-recipient" class="small">Eligible repository reviewer</label>
<select id="pull-review-recipient" disabled><option value="">Select a teammate</option></select>
<button id="confirm-pull-review" type="button" disabled>Request review</button>
<div id="pull-review-request-status" class="small" aria-live="assertive">Load teammates to request a review.</div>
</div>
</details>
<details class="pull-ownership" id="pull-ownership">
<summary>Ownership</summary>
<div>
@@ -1719,10 +1785,12 @@
</details>
<div class="pull-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<button id="watch-pull-detail" type="button">Watch pull request</button>
<button id="release-pull" type="button">Release assignment</button>
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date &amp; time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
</div>
<div id="pull-watch-status" class="detail-watch-status small" aria-live="polite"></div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
@@ -2003,7 +2071,7 @@
<div>
<h3 id="mobile-start-day-heading">Prepare Today</h3>
<p id="mobile-start-day-summary" class="small" aria-live="polite">Reviewing urgent queues…</p>
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, and Filed</p>
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, Filed, and Following</p>
</div>
<button class="mobile-start-day-action" id="mobile-start-day-action" type="button">Prepare Today</button>
<button class="mobile-start-day-finish" id="finish-mobile-start-day" type="button" hidden>Finish for now</button>
@@ -2016,7 +2084,9 @@
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="following" type="button" aria-label="Following, 0 unseen changes"><span><strong>Following</strong><small>Issues and pull requests you watch</small></span><span data-mobile-queue-count="following">0</span></button>
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
<button data-mobile-queue="authored" type="button"><span><strong>My PRs</strong><small>Pull requests you authored</small></span><span data-mobile-queue-count="authored">0</span></button>
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
@@ -2025,6 +2095,16 @@
</section>
</dialog>
<dialog class="following-sheet" id="following-sheet" aria-labelledby="following-title">
<section class="following-panel">
<header><div><p class="small muted">Read without taking ownership</p><h2 id="following-title">Following</h2></div><button id="close-following" type="button">Close</button></header>
<p id="following-status" class="small" role="status" aria-live="polite"></p>
<button id="review-following" class="following-review-action" type="button" hidden>Review new activity</button>
<div id="following-list" class="following-list"></div>
<button id="retry-following" type="button" hidden>Retry</button>
</section>
</dialog>
<dialog id="mobile-delivery-recovery" class="mobile-delivery-recovery" aria-labelledby="mobile-delivery-recovery-title">
<section class="mobile-delivery-recovery-panel">
<header>
@@ -2076,6 +2156,7 @@
<script src="static/commands.js"></script>
<script src="static/saved-searches.js"></script>
<script src="static/search-preview.js"></script>
<script src="static/following.js"></script>
<script src="static/search-reply-draft-store.js"></script>
<script src="static/conversation-reply-draft-store.js"></script>
<script src="static/conversation-photo-drafts.js"></script>
+37 -6
View File
@@ -7,18 +7,39 @@
container,
navigator,
storage,
serviceWorker,
}) {
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
let enabled = false;
let confirmedCount = 0;
const confirmedCounts = {updates:0, following:0};
let renderedCount = null;
function available() {
return typeof navigator?.setAppBadge === 'function'
&& typeof navigator?.clearAppBadge === 'function';
}
async function syncPreference() {
try {
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({type:'stackchain-app-badge-preference', enabled});
return Boolean(registration?.active);
} catch (_error) {
return false;
}
}
async function syncCount(channel, count) {
if (!enabled) return;
try {
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({type:'stackchain-app-badge-count', channel, count});
} catch (_error) { /* A later refresh can retry. */ }
}
async function render() {
const confirmedCount = Math.min(9999, confirmedCounts.updates + confirmedCounts.following);
if (!enabled || !available() || renderedCount === confirmedCount) return true;
try {
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
@@ -36,7 +57,14 @@
enabled = Boolean(control?.checked);
if (enabled) storage?.setItem(ENABLED_KEY, 'true');
else storage?.removeItem(ENABLED_KEY);
await syncPreference();
if (enabled) {
if (confirmedCounts.updates > 0) await syncCount('updates', confirmedCounts.updates);
if (confirmedCounts.following > 0) await syncCount('following', confirmedCounts.following);
}
if (!enabled && available()) {
confirmedCounts.updates = 0;
confirmedCounts.following = 0;
try {
await navigator.clearAppBadge();
renderedCount = null;
@@ -58,6 +86,7 @@
return false;
}
enabled = storage?.getItem(ENABLED_KEY) === 'true';
if (enabled) void syncPreference();
if (control) {
control.checked = enabled;
control.addEventListener('change', change);
@@ -69,8 +98,8 @@
function readiness() {
if (!available()) return {state:'unavailable', detail:'App icon badges are unavailable in this browser.'};
return enabled
? {state:'complete', detail:'The app icon shows the confirmed unread Updates count.'}
: {state:'incomplete', detail:'Show the confirmed unread Updates count without opening Stackchain.'};
? {state:'complete', detail:'The app icon shows review attention.'}
: {state:'incomplete', detail:'Show review attention without opening Stackchain.'};
}
async function enable() {
@@ -80,9 +109,11 @@
return true;
}
async function reconcile(count, {authoritative = false} = {}) {
if (!authoritative || !Number.isSafeInteger(count) || count < 0) return false;
confirmedCount = count;
async function reconcile(channel, count, authoritative = false) {
if (!Object.hasOwn(confirmedCounts, channel)
|| authoritative !== true || !Number.isSafeInteger(count) || count < 0 || count > 9999) return false;
confirmedCounts[channel] = count;
await syncCount(channel, count);
return render();
}
+1
View File
@@ -33,6 +33,7 @@
if (name === 'today') return options.openToday();
if (name === 'agenda') return options.openAgenda();
if (name === 'update' && options.openUpdates) return options.openUpdates();
if (name === 'following' && options.openFollowing) return options.openFollowing();
if (name === 'filed' && options.openFiled) return options.openFiled();
options.selectFilter(name);
const action = options.firstAction(name);
+2
View File
@@ -98,12 +98,14 @@ function attachMobileSearchPreviewNavigation({document, window}) {
buttons:{
overview:bySection('overview'),
conversation:bySection('conversation'),
changes:bySection('changes'),
reply:bySection('reply'),
actions:bySection('actions'),
},
targets:{
overview:document.getElementById('search-preview-overview'),
conversation:document.getElementById('search-preview-conversation'),
changes:document.getElementById('search-preview-review'),
reply,
actions:document.getElementById('search-preview-actions'),
},
+12 -3
View File
@@ -10,6 +10,7 @@
['attention', 'Attention'],
['update', 'Updates'],
['filed', 'Filed'],
['following', 'Following'],
];
const anonymousItems = new WeakMap();
let anonymousItemSequence = 0;
@@ -102,19 +103,27 @@
function briefing() {
const counts = options.getCounts ? options.getCounts() : {};
const phases = reviewPhases(counts);
let phases = reviewPhases(counts);
const followingUnavailable = counts.followingUnavailable === true;
if (followingUnavailable) {
phases = phases.filter(phase => phase.name !== 'following');
phases.push({name:'following', label:'Following unavailable', count:count(counts.following)});
}
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
const today = count(counts.today);
const delivery = phases.find(phase => phase.name === 'delivery')?.count || 0;
const other = total - delivery;
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
const nextLabel = next === 'delivery' ? 'Review Delivery' :
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'));
(next === 'following' && followingUnavailable ? 'Retry Following' :
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work')));
const followingRetry = next === 'following' && followingUnavailable;
return {
total,
next,
label: nextLabel,
summary: delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
summary: followingRetry ? 'Following needs retry before Today · ' + today + ' planned' :
delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') +
' · ' + today + ' planned' :
total ? total + ' items before Today · ' + today + ' planned' :
+6 -1
View File
@@ -71,6 +71,7 @@ function buildMyWork(data, now = new Date()) {
const assigned = (item.assignees || []).includes(login);
const isReview = (item.work_reasons || []).includes('review_requested');
const isFiled = (item.work_reasons || []).includes('created_by_me');
const isAuthored = (item.work_reasons || []).includes('authored_by_me');
const isCompleted = isFiled && item.state === 'closed';
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
const normalized = {
@@ -78,6 +79,7 @@ function buildMyWork(data, now = new Date()) {
key: (item.repository || 'unknown') + '#' + item.number,
is_review: isReview,
is_filed: isFiled,
is_authored: isAuthored,
is_completed: isCompleted,
is_assigned: assigned,
has_update: false,
@@ -85,7 +87,8 @@ function buildMyWork(data, now = new Date()) {
reason: priorityLabel ? priorityLabel + ' priority' :
(due && due.priority < 4 ? due.label :
(isReview ? 'Needs your review' : (isCompleted ? 'Completed · review outcome' :
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work'))))),
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' :
(isAuthored ? 'Authored by you' : 'Open work')))))),
_priority: priorityLabel ? 0 :
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))),
};
@@ -657,6 +660,7 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
let filtered = items;
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
else if (selectedFilter === 'filed') filtered = items.filter((item) => item.is_filed);
else if (selectedFilter === 'authored') filtered = items.filter((item) => item.is_authored);
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);
@@ -1080,6 +1084,7 @@ function countMyWork(items) {
all: items.length,
attention: items.filter(needsAttention).length,
filed: items.filter((item) => item.is_filed).length,
authored: items.filter((item) => item.is_authored).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,
+1
View File
@@ -7,6 +7,7 @@
'stackchain-search-reply-drafts-v1',
'stackchain-conversation-reply-drafts-v1',
'stackchain-today-action-mailbox-v1',
'stackchain-app-badge-preference-v1',
]);
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
else root.stackchainPrivateDatabases = databases;
+628 -5
View File
@@ -12,12 +12,107 @@ function mergeEligibility(detail, reviewState) {
'CI blocked by ' + blockers.join(', ') : 'CI must succeed before merging' };
}
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
const reviewer = (detail.reviewers || []).find(item => item?.blocking);
if (reviewer?.status === 'waiting') {
return { allowed: false, reason: 'Waiting for @' + reviewer.login + '’s review' };
}
if (reviewer?.status === 'changes_requested') {
return { allowed: false, reason: '@' + reviewer.login + ' requested changes' };
}
if (reviewer?.status === 'outdated') {
return { allowed: false, reason: '@' + reviewer.login + '’s review is outdated after new commits' };
}
if (reviewState && !reviewState.complete) {
return { allowed: false, reason: 'Review every changed file before merging' };
}
return { allowed: true, reason: 'Ready to merge' };
}
function renderReviewerFeedback(reviewer, escapeHtml) {
const groups = new Map();
(Array.isArray(reviewer?.comments) ? reviewer.comments : []).forEach(comment => {
if (!comment?.path || !comment?.body) return;
if (!groups.has(comment.path)) groups.set(comment.path, []);
groups.get(comment.path).push(comment);
});
const summary = reviewer?.summary ? '<p class="pull-review-feedback-summary">' +
escapeHtml(reviewer.summary) + '</p>' : '';
const files = Array.from(groups.entries()).map(([path, comments]) =>
'<section class="pull-review-feedback-file"><h4>' + escapeHtml(path) + '</h4><ul>' +
comments.map(comment => '<li>' + escapeHtml(comment.body) +
(comment.line ? '<small>Line ' + Number(comment.line) + '</small>' : '') + '</li>').join('') +
'</ul><button type="button" data-review-feedback-file="' + escapeHtml(path) +
'">View in changes</button></section>'
).join('');
const loadable = Number.isInteger(reviewer?.review_id) && reviewer.review_id > 0 &&
['commented', 'changes_requested', 'outdated'].includes(reviewer?.status);
const feedbackState = Array.isArray(reviewer?.comments) ? 'loaded' : reviewer?.feedback_state;
const feedbackAction = !loadable || feedbackState === 'loaded' ? '' :
feedbackState === 'loading' ? '<p class="small" role="status">Loading inline feedback…</p>' :
'<p class="small" role="status">' + (feedbackState === 'error' ? 'Feedback unavailable. ' : '') +
'<button type="button" data-load-review-feedback="' + Number(reviewer.review_id) + '">' +
(feedbackState === 'error' ? 'Retry feedback' : 'Load inline feedback') + '</button></p>';
const emptyLoaded = loadable && feedbackState === 'loaded' && !files ?
'<p class="small">No inline comments on this review.</p>' : '';
if (!summary && !files && !feedbackAction && !emptyLoaded) return '';
const reviewedHead = reviewer?.status === 'outdated' && reviewer?.head_sha ?
'<p class="small">Reviewed head ' + escapeHtml(reviewer.head_sha.slice(0, 8)) + '</p>' : '';
const addressable = Number.isInteger(reviewer?.review_id) && reviewer.review_id > 0 &&
['changes_requested', 'outdated'].includes(reviewer?.status) && feedbackState === 'loaded' &&
(reviewer.comments || []).some(comment => Number.isInteger(comment?.id) && comment.id > 0);
const address = addressable ? '<button type="button" class="address-review-feedback" data-address-review-feedback="' +
Number(reviewer.review_id) + '">Address feedback</button>' : '';
return '<details class="pull-review-feedback"><summary>Review feedback</summary>' +
reviewedHead + summary + feedbackAction + emptyLoaded + files + address + '</details>';
}
function renderReviewerStatuses(reviewers, escapeHtml) {
const labels = {
waiting: 'Waiting for review',
approved: 'Approved current head',
changes_requested: 'Changes requested',
commented: 'Commented',
outdated: 'Outdated after new commits',
};
return (Array.isArray(reviewers) ? reviewers : []).map(reviewer => {
const status = labels[reviewer?.status] || 'Status unavailable';
const cancel = reviewer?.status === 'waiting' && reviewer?.login ?
'<button type="button" class="cancel-review-request" data-cancel-review-request="' +
escapeHtml(reviewer.login) + '">Cancel request</button>' : '';
return '<article class="pull-reviewer-status" data-reviewer-status="' +
escapeHtml(reviewer?.status || 'unknown') + '" data-review-id="' +
(Number.isInteger(reviewer?.review_id) ? Number(reviewer.review_id) : '') +
'"><div class="pull-reviewer-status-heading"><strong>@' +
escapeHtml(reviewer?.login || 'unknown') + '</strong><span>' + escapeHtml(status) + '</span></div>' +
renderReviewerFeedback(reviewer, escapeHtml) + cancel + '</article>';
}).join('');
}
function renderReviewerPanel(doc, detail, escapeHtml = value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')) {
const reviewers = Array.isArray(detail?.reviewers) ? detail.reviewers : [];
const openFeedback = new Set(Array.from(doc.querySelectorAll('.pull-review-feedback[open]'))
.map(panel => panel.closest('.pull-reviewer-status')?.dataset.reviewId).filter(Boolean));
doc.querySelector('#pull-reviewer-statuses').innerHTML = renderReviewerStatuses(reviewers, escapeHtml);
openFeedback.forEach(reviewId => {
const panel = doc.querySelector('.pull-reviewer-status[data-review-id="' + reviewId + '"] .pull-review-feedback');
if (panel) panel.open = true;
});
doc.querySelectorAll('[data-review-feedback-file]').forEach(button => {
button.addEventListener('click', () => focusPullFile(doc, button.dataset.reviewFeedbackFile));
});
doc.querySelector('#pull-reviewer-status').textContent = reviewers.length ?
reviewers.length + ' reviewer status' + (reviewers.length === 1 ? '' : 'es') + ' for this pull request.' :
'No review has been requested; existing merge policy is unchanged.';
doc.querySelector('#request-updated-pull-review').hidden =
!reviewers.some(reviewer => reviewer.status === 'outdated');
}
function reviewEligibility(detail, reviewState, doc) {
renderReviewerPanel(doc, detail);
return mergeEligibility(detail, reviewState);
}
function renderFile(file, index, reviewed, escapeHtml) {
const filename = escapeHtml(file.filename || 'Unknown file');
const panelId = 'pull-diff-' + index;
@@ -41,18 +136,22 @@ function renderFile(file, index, reviewed, escapeHtml) {
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
}
function focusNextUnreviewed(doc, detail, state, controller) {
if (!detail || !state) return;
const filename = controller.nextUnreviewed(detail, state);
function focusPullFile(doc, filename) {
const article = Array.from(doc.querySelectorAll('#pull-files .pull-file'))
.find(file => file.dataset.pullFilename === filename);
const toggle = article?.querySelector('.pull-file-toggle');
const panel = toggle && doc.getElementById(toggle.getAttribute('aria-controls'));
if (!toggle || !panel) return;
if (!toggle || !panel) return false;
toggle.setAttribute('aria-expanded', 'true');
panel.hidden = false;
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
toggle.focus();
return true;
}
function focusNextUnreviewed(doc, detail, state, controller) {
if (!detail || !state) return;
focusPullFile(doc, controller.nextUnreviewed(detail, state));
}
function removeFromSnapshot(data, item) {
@@ -99,9 +198,374 @@ function resetOwnershipControls(doc, item, checkpointed) {
qs('#release-pull').disabled = false;
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
qs('#edit-pull-content').hidden = true;
qs('#pull-edit-form').hidden = true;
qs('#pull-edit-status').textContent = '';
}
function bindOwnershipControls(doc, controller, getSelected, finish) {
function resetReviewRequestControls(doc, detail) {
const qs = selector => doc.querySelector(selector);
qs('#pull-review-request').open = false;
qs('#pull-review-request').dataset.headSha = detail?.head_sha || '';
qs('#pull-review-recipient').innerHTML = '<option value="">Select a teammate</option>';
qs('#pull-review-recipient').disabled = true;
qs('#confirm-pull-review').disabled = true;
qs('#load-pull-reviewers').disabled = !detail?.head_sha;
qs('#pull-review-request-status').textContent = detail?.head_sha ?
'Load teammates to request a review.' : 'Load the current pull request before requesting review.';
qs('#pull-reviewer-status').textContent = 'Reviewer status not loaded.';
qs('#pull-reviewer-statuses').textContent = '';
qs('#request-updated-pull-review').hidden = true;
qs('#pull-feedback-pass').hidden = true;
qs('#pull-feedback-status').textContent = '';
}
function bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin) {
const qs = selector => doc.querySelector(selector);
const load = qs('#load-pull-reviewers');
if (load.dataset.reviewRequestBound === 'true') return;
load.dataset.reviewRequestBound = 'true';
controller.setReviewDetail = detail => {
resetReviewRequestControls(doc, detail);
controller.edit?.setDetail(detail, Boolean(detail?.saved_at));
const publishable = detail?.state === 'open' && detail?.draft === true &&
detail?.author === getLogin?.() && detail?.head_sha;
qs('#publish-draft-pull').hidden = !publishable;
if (publishable) qs('#pull-review-request').open = true;
qs('#publish-draft-pull-status').textContent = '';
};
resetReviewRequestControls(doc, null);
qs('#request-updated-pull-review').addEventListener('click', () => {
qs('#pull-review-request').open = true;
qs('#pull-review-request').scrollIntoView({ block:'center', behavior:'smooth' });
load.focus();
});
controller.onReviewRequested = result => {
const detail = getDetail?.();
if (!detail || result.head_sha !== detail.head_sha) return;
detail.reviewers = (detail.reviewers || []).filter(reviewer => reviewer.login !== result.reviewer);
detail.reviewers.push({login:result.reviewer, status:'waiting', head_sha:result.head_sha, blocking:true});
renderReviewerPanel(doc, detail);
qs('#pull-merge-state').textContent = 'Waiting for @' + result.reviewer + '’s review';
qs('#merge-pull').disabled = true;
};
qs('#publish-draft-pull').addEventListener('click', async () => {
const selected = getSelected?.();
const detail = getDetail?.();
if (!selected || !detail?.head_sha || detail.draft !== true ||
!globalThis.confirm('Publish ' + selected.key + ' as ready for review?')) return;
const button = qs('#publish-draft-pull');
button.disabled = true;
qs('#publish-draft-pull-status').textContent = 'Publishing draft…';
try {
const result = await controller.publishReady(selected, detail.head_sha);
if (getSelected?.() !== selected || getDetail?.() !== detail) return;
Object.assign(detail, result);
selected.title = result.title;
qs('#pull-sheet-title').textContent = result.title;
controller.setReviewDetail(detail);
qs('#publish-draft-pull-status').textContent = 'Published and ready for review.';
qs('#pull-review-request').open = true;
load.focus();
} catch (error) {
qs('#publish-draft-pull-status').textContent = error.message + ' The pull request remains a draft; retry.';
button.disabled = false;
button.focus();
}
});
qs('#pull-reviewer-statuses').addEventListener('click', async event => {
const button = event.target.closest?.('[data-cancel-review-request]');
if (!button || button.disabled) return;
const selected = getSelected?.();
const detail = getDetail?.();
const reviewer = button.dataset.cancelReviewRequest;
if (!selected || !detail?.head_sha || !reviewer ||
!globalThis.confirm('Cancel @' + reviewer + '’s review request for ' + selected.key + '?')) return;
button.disabled = true;
qs('#pull-reviewer-status').textContent = 'Cancelling @' + reviewer + '’s review request…';
try {
const result = await controller.cancelReview(selected, reviewer, detail.head_sha);
if (result?.head_sha !== detail.head_sha || result?.requested_reviewers?.includes(reviewer)) {
throw new Error('Cancellation was not confirmed.');
}
detail.reviewers = (detail.reviewers || []).filter(candidate =>
candidate.login !== reviewer || candidate.status !== 'waiting');
renderReviewerPanel(doc, detail);
const eligibility = mergeEligibility(detail, controller.reviewState(selected, detail));
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-review-request').open = true;
qs('#pull-review-request-status').textContent = 'Review request cancelled. Choose a replacement reviewer.';
load.disabled = false;
load.focus();
} catch (error) {
qs('#pull-reviewer-status').textContent = error.message + ' Refresh review data before retrying.';
button.disabled = false;
button.focus();
}
});
load.addEventListener('click', async () => {
const selected = getSelected();
if (!selected) return;
const select = qs('#pull-review-recipient');
load.disabled = true;
qs('#pull-review-request-status').textContent = 'Loading eligible reviewers…';
try {
const count = renderHandoffCandidates(select, await controller.loadReviewCandidates(selected), doc);
select.disabled = !count;
qs('#confirm-pull-review').disabled = true;
qs('#pull-review-request-status').textContent = count ?
'Choose a teammate to review the current head.' : 'No eligible reviewers were found.';
if (count) select.focus();
else load.disabled = false;
} catch (error) {
qs('#pull-review-request-status').textContent = error.message + ' Retry loading reviewers.';
load.disabled = false;
load.focus();
}
});
qs('#pull-review-recipient').addEventListener('change', event => {
qs('#confirm-pull-review').disabled = !event.target.value;
});
qs('#confirm-pull-review').addEventListener('click', async () => {
const selected = getSelected();
const detail = { head_sha: qs('#pull-review-request').dataset.headSha };
const reviewer = qs('#pull-review-recipient').value;
if (!selected || !detail?.head_sha || !reviewer ||
!globalThis.confirm('Request review of ' + selected.key + ' at ' + detail.head_sha.slice(0, 8) + ' from @' + reviewer + '?')) return;
const button = qs('#confirm-pull-review');
button.disabled = true;
qs('#pull-review-request-status').textContent = 'Requesting review…';
try {
await controller.requestReview(selected, reviewer, detail.head_sha);
qs('#pull-review-request-status').textContent = 'Review requested from @' + reviewer + '.';
} catch (error) {
qs('#pull-review-request-status').textContent = error.message + ' Selection kept; retry the request.';
button.disabled = false;
button.focus();
}
});
}
function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin) {
const qs = selector => doc.querySelector(selector);
const panel = qs('#pull-feedback-pass');
if (panel.dataset.feedbackBound === 'true') return;
panel.dataset.feedbackBound = 'true';
let active = null;
const comments = () => (active?.reviewer?.comments || [])
.filter(comment => Number.isInteger(comment?.id) && comment.id > 0);
const current = () => comments()[active?.state?.index || 0];
const save = () => {
if (active) controller.saveFeedbackPass(active.item, active.detail, active.reviewer, active.login, active.state);
};
const render = () => {
if (!active) return;
const items = comments();
active.state.index = Math.max(0, Math.min(Number(active.state.index) || 0, items.length - 1));
const comment = current();
const response = active.state.responses[String(comment.id)] || {};
qs('#pull-feedback-progress').textContent = 'Comment ' + (active.state.index + 1) + ' of ' + items.length;
qs('#pull-feedback-heading').textContent = 'Address @' + active.reviewer.login + '’s feedback';
qs('#pull-feedback-location').textContent = comment.path + (comment.line ? ' · line ' + Number(comment.line) : '');
qs('#pull-feedback-body').textContent = comment.body;
qs('#pull-feedback-note').value = response.note || '';
panel.querySelectorAll('[data-feedback-disposition]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.feedbackDisposition === response.disposition));
});
qs('#previous-pull-feedback').disabled = active.state.index === 0;
qs('#next-pull-feedback').disabled = active.state.index >= items.length - 1;
qs('#finish-pull-feedback').disabled = active.state.posted === true ||
items.some(item => !active.state.responses[String(item.id)]?.disposition);
qs('#request-feedback-review').hidden = active.state.posted !== true;
const found = focusPullFile(doc, comment.path);
qs('#pull-feedback-context').textContent = found ?
'Matching change opened below.' : 'Matching change is unavailable in this preview.';
panel.scrollIntoView({ block:'center', behavior:'smooth' });
};
qs('#pull-reviewer-statuses').addEventListener('click', async event => {
const loadButton = event.target.closest?.('[data-load-review-feedback]');
if (loadButton && !loadButton.disabled) {
const detail = getDetail?.();
const item = getSelected?.();
const reviewId = Number(loadButton.dataset.loadReviewFeedback);
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
if (!detail || !item || !reviewer) return;
const refresh = reviewer.feedback_state === 'error';
reviewer.feedback_state = 'loading';
renderReviewerPanel(doc, detail);
try {
const result = await controller.loadFeedback(item, detail, reviewer, { refresh });
if (getDetail?.() !== detail || getSelected?.() !== item ||
result.head_sha !== detail.head_sha || Number(result.review_id) !== reviewId) return;
reviewer.comments = Array.isArray(result.comments) ? result.comments : [];
reviewer.feedback_state = 'loaded';
renderReviewerPanel(doc, detail);
doc.querySelector('[data-address-review-feedback="' + reviewId + '"]')?.focus();
} catch (error) {
if (getDetail?.() !== detail || getSelected?.() !== item) return;
reviewer.feedback_state = 'error';
renderReviewerPanel(doc, detail);
doc.querySelector('[data-load-review-feedback="' + reviewId + '"]')?.focus();
}
return;
}
const button = event.target.closest?.('[data-address-review-feedback]');
if (!button) return;
const detail = getDetail?.();
const item = getSelected?.();
const reviewId = Number(button.dataset.addressReviewFeedback);
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
if (!detail || !item || !reviewer) return;
const login = getLogin?.() || '';
active = { item, detail, reviewer, login,
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
panel.hidden = false;
qs('#pull-feedback-status').textContent = active.state.posted ?
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
render();
});
panel.querySelector('.pull-feedback-dispositions').addEventListener('click', event => {
const disposition = event.target.dataset?.feedbackDisposition;
const comment = current();
if (!active || !comment || !['addressed', 'discussion', 'skipped'].includes(disposition)) return;
const key = String(comment.id);
active.state.responses[key] = { ...(active.state.responses[key] || {}), disposition };
save();
render();
});
qs('#pull-feedback-note').addEventListener('input', event => {
const comment = current();
if (!active || !comment) return;
const key = String(comment.id);
active.state.responses[key] = { ...(active.state.responses[key] || {}), note:event.target.value.slice(0, 1000) };
save();
});
qs('#previous-pull-feedback').addEventListener('click', () => {
if (!active) return;
active.state.index -= 1; save(); render();
});
qs('#next-pull-feedback').addEventListener('click', () => {
if (!active) return;
active.state.index += 1; save(); render();
});
qs('#close-pull-feedback').addEventListener('click', () => {
panel.hidden = true;
doc.querySelector('[data-address-review-feedback="' + active?.reviewer?.review_id + '"]')?.focus();
});
qs('#finish-pull-feedback').addEventListener('click', async () => {
if (!active || qs('#finish-pull-feedback').disabled) return;
const latest = getDetail?.();
if (!latest || latest.head_sha !== active.state.headSha) {
qs('#pull-feedback-status').textContent = 'The pull request changed. Refresh review data before posting.';
return;
}
if (!globalThis.confirm('Post one feedback response to ' + active.item.key + '?')) return;
qs('#finish-pull-feedback').disabled = true;
qs('#pull-feedback-status').textContent = 'Posting feedback response…';
try {
await controller.postFeedbackResponse(active.item, active.detail, active.reviewer, active.login, active.state);
active.state.posted = true;
save();
qs('#pull-feedback-status').textContent = 'Response posted. Request an updated review when ready.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Progress kept; retry without duplicating the response.';
qs('#finish-pull-feedback').disabled = false;
}
});
qs('#request-feedback-review').addEventListener('click', async () => {
if (!active?.state?.posted) return;
const button = qs('#request-feedback-review');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Requesting updated review…';
try {
await controller.requestReview(active.item, active.reviewer.login, active.detail.head_sha);
qs('#pull-feedback-status').textContent = 'Response posted; updated review requested from @' + active.reviewer.login + '.';
button.hidden = true;
} catch (error) {
qs('#pull-feedback-status').textContent = 'Response posted; review not requested. ' + error.message;
button.disabled = false;
}
});
}
function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
const qs = selector => doc.querySelector(selector);
const form = qs('#pull-edit-form');
const controls = () => form.querySelectorAll('input,textarea,button');
const draft = () => ({
title:qs('#pull-edit-title').value.trim(), body:qs('#pull-edit-body').value,
expectedHeadSha:getDetail()?.head_sha || '',
});
qs('#edit-pull-content').addEventListener('click', () => {
const item = getSelected(), detail = getDetail();
if (!item || !detail) return;
const saved = controller.loadEditDraft(item) || {
title:detail.title || '', body:detail.body || '', expectedHeadSha:detail.head_sha || '',
};
qs('#pull-edit-title').value = saved.title;
qs('#pull-edit-body').value = saved.body;
form.hidden = false;
qs('#pull-edit-status').textContent = '';
qs('#pull-edit-title').focus();
});
qs('#cancel-pull-edit').addEventListener('click', () => {
form.hidden = true;
qs('#edit-pull-content').focus();
});
['#pull-edit-title', '#pull-edit-body'].forEach(selector => qs(selector).addEventListener('input', () => {
const item = getSelected();
if (item && getDetail()) controller.saveEditDraft(item, draft());
}));
form.addEventListener('submit', async event => {
event.preventDefault();
const item = getSelected(), update = draft();
if (!item || !getDetail()) return;
if (!update.title) {
qs('#pull-edit-status').textContent = 'Title is required.';
qs('#pull-edit-title').focus();
return;
}
controls().forEach(control => { control.disabled = true; });
qs('#pull-edit-status').textContent = 'Saving review context…';
try {
const result = await controller.updateContent(item, update);
if (getSelected() !== item) return;
const detail = getDetail();
Object.assign(detail, result);
item.title = result.title;
qs('#pull-sheet-title').textContent = result.title;
qs('#pull-sheet-body').innerHTML = renderMarkdown(result.body || 'No description provided.');
controller.setReviewDetail(detail);
qs('#pull-sheet-status').textContent = 'Review context updated · ready to request review';
form.hidden = true;
qs('#pull-edit-status').textContent = '';
qs('#edit-pull-content').focus();
} catch (error) {
qs('#pull-edit-status').textContent = error.message + ' Your change is still here; retry.';
qs('#pull-edit-title').focus();
} finally {
controls().forEach(control => { control.disabled = false; });
}
});
return {
reset() {
qs('#edit-pull-content').hidden = true;
form.hidden = true;
qs('#pull-edit-status').textContent = '';
},
setDetail(detail, offline = false) {
const editable = !offline && detail?.state === 'open' && detail.author === getLogin();
qs('#edit-pull-content').hidden = !editable;
},
};
}
function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) {
bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin);
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
const qs = selector => doc.querySelector(selector);
const load = qs('#load-pull-handoff');
if (load.dataset.ownershipBound === 'true') return;
@@ -172,13 +636,24 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
let checkRequest = null;
let ownershipRequest = null;
let candidateRequest = null;
let reviewCandidateRequest = null;
let reviewRequestMutation = null;
let reviewCancelMutation = null;
let readyMutation = null;
let editRequest = null;
let feedbackRequest = null;
const reviewRequests = new Map();
const reviewCache = new Map();
const feedbackRequests = new Map();
const feedbackCache = 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 editDraftKey = item => 'stackchain.pull-edit.v1:' + item.repository + '#' + item.number;
const reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha;
const feedbackKey = (item, detail, reviewer, login) => 'stackchain.feedback-pass.v1:' +
item.repository + '#' + item.number + ':' + reviewer.review_id + ':' + reviewer.head_sha + ':' + detail.head_sha + ':' + login;
const fileNames = detail => (detail?.files || []).map(file => file.filename).filter(Boolean);
function reviewState(item, detail) {
@@ -210,6 +685,25 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
reviewRequests.set(key, request);
return request;
},
loadFeedback(item, detail, reviewer, { refresh = false } = {}) {
const reviewId = Number(reviewer?.review_id);
const headSha = detail?.head_sha || '';
const key = item.repository + '#' + item.number + ':' + headSha + ':' + reviewId;
if (!refresh && feedbackCache.has(key)) return Promise.resolve(feedbackCache.get(key));
if (feedbackRequests.has(key)) return feedbackRequests.get(key);
const request = fetchJson(pathFor(item) + '/reviews/' + encodeURIComponent(reviewId) +
'/feedback?expected_head_sha=' + encodeURIComponent(headSha), {
headers: { Accept: 'application/json' },
}).then(result => {
if (result?.head_sha !== headSha || Number(result?.review_id) !== reviewId) {
throw new Error('Review feedback changed. Reload review data before retrying.');
}
feedbackCache.set(key, result);
return result;
}).finally(() => feedbackRequests.delete(key));
feedbackRequests.set(key, request);
return request;
},
loadChecks(item) {
if (checkRequest) return checkRequest;
checkRequest = fetchJson(pathFor(item) + '/checks', {
@@ -224,6 +718,129 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}).finally(() => { candidateRequest = null; });
return candidateRequest;
},
loadReviewCandidates(item) {
if (reviewCandidateRequest) return reviewCandidateRequest;
reviewCandidateRequest = fetchJson(pathFor(item) + '/review-candidates', {
headers: { Accept: 'application/json' },
}).finally(() => { reviewCandidateRequest = null; });
return reviewCandidateRequest;
},
requestReview(item, reviewer, expectedHeadSha) {
if (reviewRequestMutation) return reviewRequestMutation;
reviewRequestMutation = fetchJson(pathFor(item) + '/request-review', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ reviewer, expected_head_sha: expectedHeadSha }),
}).then(result => {
this.onReviewRequested?.(result);
return result;
}).finally(() => { reviewRequestMutation = null; });
return reviewRequestMutation;
},
cancelReview(item, reviewer, expectedHeadSha) {
if (reviewCancelMutation) return reviewCancelMutation;
reviewCancelMutation = fetchJson(pathFor(item) + '/request-review', {
method: 'DELETE',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ reviewer, expected_head_sha: expectedHeadSha }),
}).then(result => {
this.onReviewCancelled?.(result);
return result;
}).finally(() => { reviewCancelMutation = null; });
return reviewCancelMutation;
},
publishReady(item, expectedHeadSha) {
if (readyMutation) return readyMutation;
readyMutation = fetchJson(pathFor(item) + '/ready', {
method:'PATCH',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({ expected_head_sha:expectedHeadSha }),
}).then(result => {
if (result?.number !== item.number || result?.head_sha !== expectedHeadSha ||
result?.state !== 'open' || result?.draft !== false || !result?.title) {
throw new Error('Draft publication was not confirmed.');
}
return result;
}).finally(() => { readyMutation = null; });
return readyMutation;
},
loadEditDraft(item) {
try {
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
return value && typeof value.title === 'string' && typeof value.body === 'string' &&
typeof value.expectedHeadSha === '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. */ }
},
loadFeedbackPass(item, detail, reviewer, login) {
const fresh = { headSha:detail.head_sha, index:0, responses:{}, posted:false };
try {
const saved = JSON.parse(storage?.getItem(feedbackKey(item, detail, reviewer, login)) || 'null');
return saved && saved.headSha === detail.head_sha && saved.responses && typeof saved.responses === 'object'
? { ...fresh, ...saved } : fresh;
} catch (_error) { return fresh; }
},
saveFeedbackPass(item, detail, reviewer, login, state) {
try { storage?.setItem(feedbackKey(item, detail, reviewer, login), JSON.stringify(state)); }
catch (_error) { /* The visible pass remains usable without storage. */ }
},
feedbackSummary(reviewer, state) {
const labels = { addressed:'Addressed', discussion:'Needs discussion', skipped:'Skip for now' };
const lines = (reviewer.comments || []).filter(comment => Number.isInteger(comment?.id) && comment.id > 0)
.map(comment => {
const response = state.responses[String(comment.id)] || {};
const path = String(comment.path || '').replaceAll('`', "'").replaceAll('\n', ' ').slice(0, 300);
const body = String(comment.body || '').replaceAll('\n', ' ').slice(0, 180);
const note = String(response.note || '').replaceAll('\n', ' ').slice(0, 1000);
return '- `' + path + '` · comment #' + comment.id + ' · **' + (labels[response.disposition] || 'Unresolved') +
'** — ' + body + (note ? '\n - Response: ' + note : '');
});
return ('Addressed review feedback from @' + reviewer.login + ' (review #' + reviewer.review_id + ').\n\n' +
lines.join('\n')).slice(0, 10000);
},
postFeedbackResponse(item, detail, reviewer, login, state) {
if (feedbackRequest) return feedbackRequest;
const key = feedbackKey(item, detail, reviewer, login);
let operationId;
try {
operationId = storage?.getItem(key + ':operation') || String(createOperationId()).slice(0, 128);
storage?.setItem(key + ':operation', operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
feedbackRequest = fetchJson(pathFor(item) + '/comments', {
method:'POST',
headers:{ Accept:'application/json', 'Content-Type':'application/json', 'Idempotency-Key':operationId },
body:JSON.stringify({ body:this.feedbackSummary(reviewer, state) }),
}).then(result => {
try { storage?.removeItem(key + ':operation'); } catch (_error) { /* Confirmed upstream. */ }
return result;
}).finally(() => { feedbackRequest = null; });
return feedbackRequest;
},
updateContent(item, draft) {
if (editRequest) return editRequest;
this.saveEditDraft(item, draft);
editRequest = fetchJson(pathFor(item) + '/content', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
title: draft.title,
body: draft.body,
expected_head_sha: draft.expectedHeadSha,
}),
}).then(result => {
if (result?.number !== item.number || result?.title !== draft.title ||
result?.body !== draft.body || result?.head_sha !== draft.expectedHeadSha) {
throw new Error('Pull request update was not confirmed.');
}
try { storage?.removeItem(editDraftKey(item)); }
catch (_error) { /* Confirmed upstream content is authoritative. */ }
return result;
}).finally(() => { editRequest = null; });
return editRequest;
},
handoff(item, recipient) {
if (ownershipRequest) return ownershipRequest;
ownershipRequest = fetchJson(pathFor(item) + '/handoff', {
@@ -314,6 +931,8 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}
createPullSheet.mergeEligibility = mergeEligibility;
createPullSheet.review = reviewEligibility;
createPullSheet.renderReviewerStatuses = renderReviewerStatuses;
createPullSheet.renderFile = renderFile;
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
createPullSheet.removeFromSnapshot = removeFromSnapshot;
@@ -323,4 +942,8 @@ createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
createPullSheet.ownershipExitMessage = ownershipExitMessage;
createPullSheet.resetOwnershipControls = resetOwnershipControls;
createPullSheet.bindOwnershipControls = bindOwnershipControls;
createPullSheet.bindContextEditor = bindContextEditor;
createPullSheet.resetReviewRequestControls = resetReviewRequestControls;
createPullSheet.bindReviewRequestControls = bindReviewRequestControls;
createPullSheet.bindFeedbackControls = bindFeedbackControls;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
+48 -2
View File
@@ -4,6 +4,7 @@
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour,
followingControl, followingStatus,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
notification, serviceWorker, fetchJson,
}) {
@@ -129,12 +130,15 @@
if (testControl) testControl.hidden = true;
if (deadlineControl) deadlineControl.checked = false;
if (startDayControl) startDayControl.checked = false;
if (followingControl) followingControl.checked = false;
configuration.subscribed = false;
configuration.deadline_enabled = false;
configuration.start_day_enabled = false;
configuration.following_enabled = false;
pendingIntent = null;
status.textContent = 'New update notifications are off for this device.';
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
if (followingStatus) followingStatus.textContent = 'Following change alerts are off for this device.';
}
async function ensureSubscription() {
@@ -266,13 +270,45 @@
}
}
async function changeFollowing() {
followingControl.disabled = true;
try {
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (followingControl.checked) pendingIntent = 'following';
if (followingControl.checked && !subscription) subscription = await ensureSubscription();
if (followingControl.checked && !subscription) {
followingControl.checked = false;
followingStatus.textContent = status.textContent;
return false;
}
await fetchJson('api/v1/push-subscription/following', {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:followingControl.checked}),
});
configuration.following_enabled = followingControl.checked;
pendingIntent = null;
followingStatus.textContent = followingControl.checked
? 'Following change alerts enabled for this device.'
: 'Following change alerts are off for this device.';
return true;
} catch (_error) {
followingControl.checked = !followingControl.checked;
followingStatus.textContent = 'Could not change Following alerts. Check your connection and try again.';
return false;
} finally {
followingControl.disabled = false;
}
}
async function enableDeadline() {
deadlineControl.checked = true;
return changeDeadline();
}
async function recoverPermission(intent = null) {
if (!pendingIntent && ['updates', 'deadline', 'start-day'].includes(intent)) pendingIntent = intent;
if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following'].includes(intent)) pendingIntent = intent;
if (!pendingIntent || notification.permission !== 'granted') return false;
if (recoveryPromise) return recoveryPromise;
recoveryPromise = (async () => {
@@ -284,6 +320,10 @@
startDayControl.checked = true;
return changeStartDay();
}
if (pendingIntent === 'following') {
followingControl.checked = true;
return changeFollowing();
}
return Boolean(await enable());
})();
try {
@@ -299,18 +339,21 @@
testControl?.addEventListener('click', testDelivery);
deadlineControl?.addEventListener('change', changeDeadline);
startDayControl?.addEventListener('change', changeStartDay);
followingControl?.addEventListener('change', changeFollowing);
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
configuration = await fetchJson('api/v1/push-subscription');
if (!configuration.available) {
control.disabled = true;
if (deadlineControl) deadlineControl.disabled = true;
if (startDayControl) startDayControl.disabled = true;
if (followingControl) followingControl.disabled = true;
status.textContent = 'New update notifications are not available on this server.';
return;
}
control.checked = Boolean(configuration.subscribed);
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
if (followingControl) followingControl.checked = Boolean(configuration.following_enabled);
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
@@ -321,8 +364,11 @@
if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled
? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.`
: 'Start-day reminders are off for this device.';
if (followingStatus) followingStatus.textContent = configuration.following_enabled
? 'Following change alerts enabled for this device.'
: 'Following change alerts are off for this device.';
renderDeadlineSnooze();
}
return {init, change, changeDeadline, changeStartDay, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
return {init, change, changeDeadline, changeStartDay, changeFollowing, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
});
+237 -10
View File
@@ -30,8 +30,82 @@
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/conversation?' + query.toString();
};
root.followingPullReviewPath = item => {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
return 'api/v1/following/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review-data';
};
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/subscription?kind=' + encodeURIComponent(item.kind);
root.searchPreviewSubscriptionOptions = fetchJson => {
const options = {
load:async detail => {
if (['issue', 'pull'].includes(detail.kind) && detail.state === 'closed' && detail.following === true) {
return {...detail, watching:true};
}
if (!(['issue', 'pull'].includes(detail.kind) && detail.state === 'open')) return detail;
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
return {...detail, watching:result.watching === true};
},
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
}),
review:item => fetchJson(root.followingPullReviewPath(item)),
};
options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), {
headers:{Accept:'application/json'},
})});
return options;
};
root.createDetailWatch = ({fetchJson,refreshFollowing,onState}) => {
const api=root.searchPreviewSubscriptionOptions(fetchJson);
let item,watching=false,mutation;
return {
async open(next) {
item=next; onState('loading',watching);
const result=await api.load(next).catch(error=>{
if(item===next)onState('error',watching,error);throw error;
});
if (item !== next) return;
watching=result.watching === true; onState('ready',watching);
},
toggle() {
if (mutation) return mutation;
const next=!watching;
onState(next?'watching':'unwatching',watching);
mutation=api.watch(item,next).then(async result => {
if (result?.watching !== next || result?.following_synced !== true)
throw new Error(result?.error || 'Unconfirmed.');
watching=next; await refreshFollowing();
onState(watching?'watched':'unwatched',watching);
}).catch(error => {onState('error',watching,error);throw error;})
.finally(()=>{mutation=null;});
return mutation;
},
};
};
root.searchPreviewWatchStatus = state => ({
watching:'Starting watch…', unwatching:'Stopping watch…',
watched:'Watching · available in Following. Future activity will appear in Updates.',
unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.',
'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch ' +
(state.detail?.kind === 'pull' ? 'pull request' : 'issue') + ' to repair.',
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
})[state.status] || '';
root.renderSearchPreviewWatch = (detail, state, button) => {
const watchableKind = ['issue', 'pull'].includes(detail.kind);
const retiring = watchableKind && detail.state === 'closed' &&
detail.following === true && detail.watching === true;
button.hidden = !(watchableKind && detail.state === 'open') && !retiring;
button.textContent = retiring ? 'Stop watching & next' :
(detail.watching ? 'Stop watching' : 'Watch ' + (detail.kind === 'pull' ? 'pull request' : 'issue'));
button.disabled = state.status === 'watching' || state.status === 'unwatching';
};
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
const detail = getDetail();
if (detail) preview.setWatching(detail.watching !== true).catch(() => {});
});
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
storage,
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
@@ -50,10 +124,15 @@
retry.hidden = older.hidden = true;
return;
}
const isNew = comment => Boolean(conversation.reviewedAt && comment.created_at &&
new Date(comment.created_at).getTime() > new Date(conversation.reviewedAt).getTime());
const newCount = (conversation.comments || []).filter(isNew).length;
comments.innerHTML = (conversation.comments || []).map(comment =>
'<article class="search-preview-comment" data-comment-id="' + Number(comment.id || 0) + '">' +
'<article class="search-preview-comment' + (isNew(comment) ? ' new-since-review' : '') +
'" data-comment-id="' + Number(comment.id || 0) + '">' +
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '</div>' +
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') +
(isNew(comment) ? ' · <strong>New since last review</strong>' : '') + '</div>' +
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
).join('');
retry.hidden = conversation.status !== 'error';
@@ -65,7 +144,8 @@
'Conversation unavailable. Preview and planning actions still work.';
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
else status.textContent = conversation.comments.length +
(conversation.comments.length === 1 ? ' message.' : ' messages.');
(conversation.comments.length === 1 ? ' message' : ' messages') +
(newCount ? ' · ' + newCount + ' new since last review' : '') + '.';
};
root.renderSearchPreviewReply = (state, detail, preview, document) => {
const section = document.querySelector('.search-preview-reply');
@@ -90,9 +170,58 @@
else if (state.status === 'reply-error') status.textContent =
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
};
root.renderSearchPreviewReview = (review, document, escapeHtml) => {
const section = document.querySelector('#search-preview-review');
const status = document.querySelector('#search-preview-review-status');
const files = document.querySelector('#search-preview-files');
const retry = document.querySelector('#retry-search-preview-review');
section.hidden = !review;
retry.hidden = review?.status !== 'error';
files.innerHTML = '';
if (!review) {
status.textContent = '';
return;
}
if (review.status === 'loading') {
status.textContent = 'Loading CI and changed files…';
return;
}
if (review.status === 'error') {
status.textContent = 'Changes unavailable. This revision has not been marked reviewed.';
return;
}
const data = review.data || {};
const changed = Array.isArray(data.files) ? data.files : [];
const ci = ({success:'CI passed', failure:'CI failed', error:'CI failed', pending:'CI pending'})[
data.ci_state
] || 'CI status unavailable';
status.textContent = ci + ' · ' + changed.length + ' changed ' +
(changed.length === 1 ? 'file.' : 'files.');
files.innerHTML = changed.map(file => {
const lines = (Array.isArray(file.diff_lines) ? file.diff_lines : []).map(raw => {
const line = String(raw);
const kind = line.startsWith('@@') ? 'hunk' : line.startsWith('+') ? 'added' :
line.startsWith('-') ? 'removed' : 'context';
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(line) + '</span>';
}).join('');
const diff = file.diff_available
? '<pre class="pull-diff">' + 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">' +
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') + '</div>';
return '<article class="search-preview-file"><strong>' + escapeHtml(file.filename || 'Unknown file') +
'</strong><span class="small">' + escapeHtml(file.status || 'changed') + ' · +' +
Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span>' + diff + '</article>';
}).join('');
};
root.renderSearchPreviewWorkspaces = (state, detail, preview, document, escapeHtml) => {
root.renderSearchPreviewReply(state, detail, preview, document);
root.renderSearchPreviewReview(state.review, document, escapeHtml);
document.querySelector('[data-search-preview-section="changes"]').hidden = !state.review;
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
return function createSearchPreview({ fetchJson, fetchConversation, fetchReview, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
@@ -105,13 +234,24 @@
let shareRequest = null;
let moveRequest = null;
let replyRequest = null;
let watchRequest = null;
let conversation = null;
let review = null;
let openedRevision = null;
function sameItem(left, right) {
return left && right && left.kind === right.kind && left.repository === right.repository &&
Number(left.number) === Number(right.number);
}
async function notifyOpened(item, requestGeneration) {
const revision = [item?.kind, item?.repository, item?.number, item?.updated_at].join(':');
if (requestGeneration !== generation || openedRevision === revision) return false;
await onOpened?.({...item});
if (requestGeneration === generation) openedRevision = revision;
return true;
}
function replyKey(item, suffix) {
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
@@ -143,6 +283,10 @@
}
function publish(state) {
if (conversation && !Object.prototype.hasOwnProperty.call(state, 'conversation')) {
state = {...state, conversation};
}
if (review && !Object.prototype.hasOwnProperty.call(state, 'review')) state = {...state, review};
const position = navigation(state.item || current);
if (navigationRoot) {
const bar = navigationRoot.querySelector('.search-preview-navigation');
@@ -176,7 +320,10 @@
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
const previousComments = page && Array.isArray(conversation?.comments)
? conversation.comments : [];
conversation = { status:'loading', comments:previousComments, olderPage:page ?? null };
conversation = {
status:'loading', comments:previousComments, olderPage:page ?? null,
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
};
publish({ status:'ready', item:current, detail, conversation });
return fetchConversation(detail, page).then(result => {
if (requestGeneration !== generation) return result;
@@ -188,6 +335,7 @@
status:'ready',
comments,
olderPage:result?.older_page ?? null,
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
};
publish({ status:'ready', item:current, detail:current, conversation });
return result;
@@ -196,6 +344,7 @@
conversation = {
status:'error', comments:previousComments,
olderPage:page ?? conversation?.olderPage ?? null, error,
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
};
publish({ status:'ready', item:current, detail:current, conversation });
}
@@ -203,6 +352,37 @@
});
}
function loadReview(detail, requestGeneration) {
if (!(detail?.following === true && detail?.kind === 'pull' && typeof fetchReview === 'function')) {
review = null;
return Promise.resolve(null);
}
review = {status:'loading', data:null};
publish({status:'ready', item:current, detail:current, conversation, review});
return fetchReview(detail).then(data => {
if (requestGeneration === generation) {
review = {status:'ready', data};
publish({status:'ready', item:current, detail:current, conversation, review});
}
return data;
}).catch(error => {
if (requestGeneration === generation) {
review = {status:'error', data:null, error};
publish({status:'ready', item:current, detail:current, conversation, review});
}
return null;
});
}
async function acknowledgeWhenContextReady(requestGeneration) {
const conversationReady = typeof fetchConversation !== 'function' || conversation?.status === 'ready';
const reviewRequired = current?.following === true && current?.kind === 'pull' &&
typeof fetchReview === 'function';
if (conversationReady && (!reviewRequired || review?.status === 'ready')) {
await notifyOpened(current, requestGeneration);
}
}
const api = {
hasReplyAttachments() {
return Boolean(hasAttachments?.());
@@ -213,14 +393,24 @@
const requestGeneration = generation;
current = { ...item };
conversation = null;
review = null;
openedRevision = null;
publish({ status: 'loading', item: current });
return fetchJson(current).then(detail => {
return fetchJson(current).then(async detail => {
if (requestGeneration === generation) {
current = { ...current, ...detail };
if (typeof fetchConversation === 'function') {
loadConversation(current, requestGeneration);
const context = loadConversation(current, requestGeneration);
if (current.following === true) {
const reviewContext = loadReview(current, requestGeneration);
await Promise.all([context, reviewContext]);
await acknowledgeWhenContextReady(requestGeneration);
} else {
await notifyOpened(current, requestGeneration);
}
} else {
publish({ status: 'ready', item: current, detail });
await notifyOpened(current, requestGeneration);
}
}
return detail;
@@ -269,9 +459,19 @@
})().finally(() => { moveRequest = null; });
return moveRequest;
},
retryConversation() {
if (!current) return Promise.resolve(null);
return loadConversation(current, generation);
async retryConversation() {
if (!current) return null;
const requestGeneration = generation;
const result = await loadConversation(current, requestGeneration);
if (current?.following === true) await acknowledgeWhenContextReady(requestGeneration);
return result;
},
async retryReview() {
if (!current) return null;
const requestGeneration = generation;
const result = await loadReview(current, requestGeneration);
await acknowledgeWhenContextReady(requestGeneration);
return result;
},
loadOlderConversation() {
if (!current || !conversation?.olderPage) return Promise.resolve(null);
@@ -348,11 +548,38 @@
reopen(detail) {
return run('reopen', 'reopening', 'reopened', detail);
},
setWatching(watching) {
if (watchRequest) return watchRequest;
if (!current || typeof watch !== 'function') {
return Promise.reject(new Error('Watching is unavailable.'));
}
const detail = current;
publish({ status:watching ? 'watching' : 'unwatching', item:current, detail });
watchRequest = watch(detail, watching).then(async result => {
current = { ...current, watching:result?.watching === true };
const status = result?.following_synced === false
? 'watch-partial' : (watching ? 'watched' : 'unwatched');
publish({ status, item:current, detail:current, result });
if (!watching && result?.watching === false && typeof afterUnwatch === 'function') {
const next = await afterUnwatch({...current});
if (next) {
onNavigate?.(next);
await api.open(next);
}
}
return result;
}).catch(error => {
publish({ status:'watch-error', item:current, detail, error });
throw error;
}).finally(() => { watchRequest = null; });
return watchRequest;
},
};
if (navigationRoot) {
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
navigationRoot.querySelector('#retry-search-preview-review').addEventListener('click', () => api.retryReview());
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
const reply = navigationRoot.querySelector('#search-preview-reply');
reply?.addEventListener('input', event => {
+3 -3
View File
@@ -42,7 +42,7 @@ function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5}
await week.flush();
return {status:'planned',date:options.date,estimate,item};
} catch(_error) {
return {status:'planned-pending',date:options.date,estimate};
return {status:'planned-pending',date:options.date,estimate,item};
}
};
running=perform().finally(()=>{running=null;});
@@ -99,8 +99,8 @@ function createSearchWeekPlanUI({planner,document,window,getDetail,onPlanned=()=
else if(outcome.status==='day-full')status.textContent='That day already has '+outcome.limit+' items. Choose another day.';
else if(outcome.status==='already-planned')status.textContent='Already planned for '+outcome.date+'. Choose Move to change the day.';
else if(outcome.status==='assigned-not-planned')status.textContent='Assigned, not planned. Find it in My Work and retry.';
else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');onPlanned(detail,true);close(true);}
else if(outcome.status==='planned'){announce('Planned for '+date+'.');onPlanned(detail,false);close(true);}
else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');await onPlanned(outcome.item||detail,true,outcome);close(true);}
else if(outcome.status==='planned'){announce('Planned for '+date+'.');await onPlanned(outcome.item||detail,false,outcome);close(true);}
else status.textContent='Could not plan this issue. Retry.';
button.disabled=false;
}
+145 -7
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v130';
const CACHE = 'stackchain-dashboard-shell-v138';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@@ -9,6 +9,90 @@ const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
const TODAY_ACTION_TTL_MS = 2 * 60 * 1000;
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
function createAppBadgePreference() {
const dbName = 'stackchain-app-badge-preference-v1';
const storeName = 'preferences';
const open = () => new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
});
return {
async get() {
const database = await open();
const enabled = await new Promise((resolve, reject) => {
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('enabled');
request.onsuccess = () => resolve(request.result === true);
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
});
database.close();
return enabled;
},
async set(enabled) {
const database = await open();
await new Promise((resolve, reject) => {
const transaction = database.transaction(storeName, 'readwrite');
transaction.objectStore(storeName).put(enabled === true, 'enabled');
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error || new Error('App badge preference could not be saved.'));
transaction.onabort = transaction.onerror;
});
database.close();
},
async getCounts() {
const database = await open();
const counts = {};
for (const channel of ['updates', 'following']) {
counts[channel] = await new Promise((resolve, reject) => {
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('count:' + channel);
request.onsuccess = () => resolve(Number.isSafeInteger(request.result) ? request.result : 0);
request.onerror = () => reject(request.error || new Error('App badge count unavailable.'));
});
}
database.close();
return counts;
},
async setCount(channel, count) {
const database = await open();
await new Promise((resolve, reject) => {
const transaction = database.transaction(storeName, 'readwrite');
transaction.objectStore(storeName).put(count, 'count:' + channel);
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error || new Error('App badge count could not be saved.'));
transaction.onabort = transaction.onerror;
});
database.close();
},
async clearCounts() {
await this.setCount('updates', 0);
await this.setCount('following', 0);
},
};
}
const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBadgePreference();
let renderedBackgroundBadgeCount = null;
async function reconcileBackgroundAppBadge(channel, count) {
if (!['updates', 'following'].includes(channel)
|| !Number.isSafeInteger(count) || count < 0 || count > 9999
|| typeof self.registration.setAppBadge !== 'function'
|| typeof self.registration.clearAppBadge !== 'function') return false;
let enabled = false;
try { enabled = await appBadgePreference.get(); } catch (_error) { return false; }
if (!enabled) return false;
try {
await appBadgePreference.setCount(channel, count);
const counts = await appBadgePreference.getCounts();
const total = Math.min(9999, counts.updates + counts.following);
if (renderedBackgroundBadgeCount === total) return false;
if (total > 0) await self.registration.setAppBadge(total);
else await self.registration.clearAppBadge();
renderedBackgroundBadgeCount = total;
return true;
} catch (_error) { return false; }
}
function createTodayActionStore() {
const dbName = 'stackchain-today-action-mailbox-v1';
const storeName = 'commands';
@@ -79,6 +163,7 @@ const SHELL = [
BASE + 'static/commands.js',
BASE + 'static/saved-searches.js',
BASE + 'static/search-preview.js',
BASE + 'static/following.js',
BASE + 'static/search-reply-draft-store.js',
BASE + 'static/conversation-reply-draft-store.js',
BASE + 'static/conversation-photo-drafts.js',
@@ -524,6 +609,35 @@ async function updateTodayLockScreen(active, running, rawActionToken = '', rawBr
}
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-app-badge-count') {
event.waitUntil((async () => {
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
const channel = event.data.channel;
const count = event.data.count;
if (!['updates', 'following'].includes(channel)
|| !Number.isSafeInteger(count) || count < 0 || count > 9999) return;
try {
if (await appBadgePreference.get()) {
await appBadgePreference.setCount(channel, count);
renderedBackgroundBadgeCount = null;
}
} catch (_error) { /* A later authoritative refresh can restore the count. */ }
})());
}
if (event.data?.type === 'stackchain-app-badge-preference') {
event.waitUntil((async () => {
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
const enabled = event.data.enabled === true;
try {
await appBadgePreference.set(enabled);
renderedBackgroundBadgeCount = null;
if (!enabled && typeof self.registration.clearAppBadge === 'function') {
await appBadgePreference.clearCounts();
await self.registration.clearAppBadge();
}
} catch (_error) { /* Page preference remains authoritative on next launch. */ }
})());
}
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
await issueSync.resume();
await flushAndNotify();
@@ -582,7 +696,9 @@ self.addEventListener('push', event => {
const tag = String(payload.tag || '');
const notificationId = Number(payload.notification_id);
const updateCount = Number(payload.update_count);
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
const deadlineCount = Number(payload.deadline_count);
const followingCount = Number(payload.following_count);
const planDate = String(payload.plan_date || '');
if (
route === '#/my-work/start-day'
@@ -619,6 +735,24 @@ self.addEventListener('push', event => {
));
return;
}
if (
route === '#/my-work/following'
&& /^stackchain-following-[0-9a-f]{16}$/.test(tag)
&& Number.isSafeInteger(followingCount)
&& followingCount > 0
&& followingCount <= 50
) {
event.waitUntil(Promise.all([
reconcileBackgroundAppBadge('following', followingCount),
self.registration.showNotification(
followingCount + ' watched item' + (followingCount === 1 ? '' : 's') + ' changed', {
body: 'Open Following to review the latest activity.',
tag,
data: {route},
}),
]));
return;
}
if (
route === '#/my-work/updates'
&& tag === 'stackchain-update-digest'
@@ -626,11 +760,12 @@ self.addEventListener('push', event => {
&& updateCount > 0
&& updateCount <= 50
) {
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
body: 'Tap to review them in Stackchain.',
tag,
data: {route},
}));
event.waitUntil(Promise.all([
reconcileBackgroundAppBadge('updates', unreadCount),
self.registration.showNotification(updateCount + ' new work updates', {
body: 'Tap to review them in Stackchain.', tag, data: {route},
}),
]));
return;
}
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
@@ -650,7 +785,10 @@ self.addEventListener('push', event => {
];
options.data.notificationId = notificationId;
}
event.waitUntil(self.registration.showNotification('New work update', options));
event.waitUntil(Promise.all([
reconcileBackgroundAppBadge('updates', unreadCount),
self.registration.showNotification('New work update', options),
]));
});
async function openWorkRoute(route) {
+1 -1
View File
@@ -126,7 +126,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.attention_interruption = null;
state.search_interruption = null;
state.detour_interruption = null;
state.timed_break = null;
if(running || validBreak(state)?.identity !== identity) state.timed_break=null;
return write(state);
},
activate(identity) {
+39 -5
View File
@@ -70,11 +70,12 @@ function createTodayWeekReschedule({
throw conflict;
}
function flush() {
function flush(retryConflict = false) {
if (flushing) return flushing;
if (!pending()) return Promise.resolve(false);
flushing = (async () => {
let result = null;
let conflictRetried = false;
while (true) {
const records = readQueue();
const record = records[0];
@@ -83,7 +84,25 @@ function createTodayWeekReschedule({
result = await api('api/v1/week/reschedule', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body),
});
} catch (error) { await restoreConflict(error); }
} catch (error) {
if (error?.status === 409 && retryConflict && !conflictRetried) {
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
adoptToday(today); week.adopt(weekState);
const latest = readQueue();
if (today?.ids?.includes(record.body.identity) &&
latest[0]?.body?.operation_id === record.body.operation_id) {
latest[0] = {...latest[0], body:{
...latest[0].body,
today_revision:today.revision,
week_revision:weekState.revision,
}};
if (!saveQueue(latest)) throw new Error('Could not update this saved move after plans changed.');
conflictRetried = true;
continue;
}
}
await restoreConflict(error);
}
adoptToday(result.today);
week.adopt(result.week);
const latest = readQueue();
@@ -207,9 +226,24 @@ function createTodayWeekReschedule({
}
let result;
try {
result = admitted ? await flush() : await api('api/v1/week/reschedule', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
});
if (admitted) {
result = await flush(true);
} else {
try {
result = await api('api/v1/week/reschedule', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
});
} catch (error) {
if (error?.status !== 409) throw error;
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
adoptToday(today); week.adopt(weekState);
if (!today?.ids?.includes(body.identity)) throw error;
body.today_revision = today.revision; body.week_revision = weekState.revision;
result = await api('api/v1/week/reschedule', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
});
}
}
} catch(error) {
if (admitted && error?.status === 409) await restoreConflict(error);
if (admitted && error?.status !== 401 && error?.status !== 403) {
+12 -6
View File
@@ -1,5 +1,6 @@
function createWeekCalendarImport() {
const maxBytes=1024*1024;
const zonedInstantFormatters=new Map(),zonePartFormatters=new Map(),supportedTimeZones=new Map();
function clock(value) {
const match=/^(\d{2}):(\d{2})$/.exec(String(value||''));
if(!match)return null;
@@ -19,9 +20,11 @@ function createWeekCalendarImport() {
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/.exec(value||'');
if(!match)return null;
try {
const wanted=match.slice(1).map(Number),formatter=new Intl.DateTimeFormat('en-CA',{
const wanted=match.slice(1).map(Number);
if(!zonedInstantFormatters.has(timeZone))zonedInstantFormatters.set(timeZone,new Intl.DateTimeFormat('en-CA',{
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23',
});
}));
const formatter=zonedInstantFormatters.get(timeZone);
let result=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5]);
for(let attempt=0;attempt<2;attempt+=1) {
const shown=Object.fromEntries(formatter.formatToParts(new Date(result)).map(part=>[part.type,part.value]));
@@ -33,15 +36,18 @@ function createWeekCalendarImport() {
}
function supportedTimeZone(timeZone) {
if(!timeZone)return true;
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);return true;}
catch(error) {return false;}
if(supportedTimeZones.has(timeZone))return supportedTimeZones.get(timeZone);
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);supportedTimeZones.set(timeZone,true);return true;}
catch(error) {supportedTimeZones.set(timeZone,false);return false;}
}
function zoneParts(value,timeZone) {
const date=new Date(value);
if(!timeZone)return {year:date.getFullYear(),month:date.getMonth()+1,day:date.getDate(),weekday:date.getDay(),
hour:date.getHours(),minute:date.getMinutes(),second:date.getSeconds()};
const formatter=new Intl.DateTimeFormat('en-US',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',
hour:'2-digit',minute:'2-digit',second:'2-digit',weekday:'short',hourCycle:'h23'});
if(!zonePartFormatters.has(timeZone))zonePartFormatters.set(timeZone,new Intl.DateTimeFormat('en-US',{
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',weekday:'short',hourCycle:'h23',
}));
const formatter=zonePartFormatters.get(timeZone);
const parts=Object.fromEntries(formatter.formatToParts(date).map(part=>[part.type,part.value]));
return {year:Number(parts.year),month:Number(parts.month),day:Number(parts.day),
weekday:['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(parts.weekday),hour:Number(parts.hour),
+1 -1
View File
@@ -6,7 +6,7 @@
'use strict';
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'authored', 'update', 'later', 'draft', 'following'];
const sections = {
issue: ['overview', 'conversation', 'reply', 'actions'],
filed: ['overview', 'conversation', 'reply', 'actions'],
+344
View File
@@ -0,0 +1,344 @@
"""Encrypted, account-scoped registry of explicitly followed Gitea issues."""
import re
import sqlite3
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_STATES = {"open", "closed"}
_KINDS = {"issue", "pull"}
class FollowingStore:
def __init__(
self,
path: str | Path,
*,
limit: int = 50,
timeout: float = 1.0,
encryption_key: bytes | None = None,
):
self.path = Path(path)
self.limit = limit
self.timeout = timeout
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(),
store="following",
)
self._initialize()
def _connect(self) -> sqlite3.Connection:
return connect_private_sqlite(self.path, timeout=self.timeout)
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS following_issues (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
items TEXT NOT NULL
)
"""
)
@staticmethod
def _login(login: str) -> str:
normalized = str(login).strip().lower()
if not normalized:
raise ValueError("login is required")
return normalized
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
if row is None:
return {"revision": 0, "items": []}, False
items, legacy = self._cipher.open(row[1], binding=f"items:{login}")
if not isinstance(items, list):
raise PrivateStateEncryptionError("private state could not be decrypted")
return {"revision": int(row[0]), "items": items}, legacy
def _seal(self, login: str, items: list[dict]) -> str:
return self._cipher.seal(items, binding=f"items:{login}")
@staticmethod
def _normalize_item(raw: dict) -> dict:
if not isinstance(raw, dict):
raise ValueError("following item must be an object")
repository = raw.get("repository")
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
raise ValueError("repository is invalid")
number = raw.get("number")
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
raise ValueError("number is invalid")
kind = raw.get("kind", "issue")
if kind not in _KINDS:
raise ValueError("kind is invalid")
title = raw.get("title")
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300:
raise ValueError("title is invalid")
state = raw.get("state")
if state not in _STATES:
raise ValueError("state is invalid")
updated_at = raw.get("updated_at")
if not isinstance(updated_at, str) or not updated_at or len(updated_at) > 64:
raise ValueError("updated_at is invalid")
url = raw.get("url")
if not isinstance(url, str) or not url.startswith(("http://", "https://")) or len(url) > 2048:
raise ValueError("url is invalid")
last_seen_updated_at = raw.get("last_seen_updated_at", updated_at)
if (
not isinstance(last_seen_updated_at, str)
or not last_seen_updated_at
or len(last_seen_updated_at) > 64
):
raise ValueError("last seen update is invalid")
item = {
"repository": repository,
"kind": kind,
"number": number,
"title": title.strip(),
"state": state,
"updated_at": updated_at,
"url": url,
"last_seen_updated_at": last_seen_updated_at,
}
kept_updated_at = raw.get("kept_updated_at")
if kept_updated_at is not None:
if not isinstance(kept_updated_at, str) or not kept_updated_at or len(kept_updated_at) > 64:
raise ValueError("kept update is invalid")
item["kept_updated_at"] = kept_updated_at
reviewed_title = raw.get("reviewed_title")
if reviewed_title is not None:
if not isinstance(reviewed_title, str) or not reviewed_title.strip() or len(reviewed_title.strip()) > 300:
raise ValueError("reviewed title is invalid")
item["reviewed_title"] = reviewed_title.strip()
reviewed_state = raw.get("reviewed_state")
if reviewed_state is not None:
if reviewed_state not in _STATES:
raise ValueError("reviewed state is invalid")
item["reviewed_state"] = reviewed_state
return item
@classmethod
def _present(cls, snapshot: dict) -> dict:
changed = []
unchanged = []
for raw in snapshot["items"]:
stored = cls._normalize_item(raw)
unseen = (stored["updated_at"] != stored["last_seen_updated_at"] or
stored.get("kept_updated_at") == stored["updated_at"])
item = {key: value for key, value in stored.items()
if key not in {"last_seen_updated_at", "kept_updated_at",
"reviewed_title", "reviewed_state"}}
item["has_unseen_change"] = unseen
if unseen:
item["reviewed_at"] = stored["last_seen_updated_at"]
if stored.get("reviewed_state") == "open" and stored["state"] == "closed":
item["change_summary"] = "Closed since last review"
elif stored.get("reviewed_state") == "closed" and stored["state"] == "open":
item["change_summary"] = "Reopened since last review"
elif stored.get("reviewed_title") not in {None, stored["title"]}:
item["change_summary"] = "Title changed since last review"
else:
item["change_summary"] = "New activity"
(changed if unseen else unchanged).append(item)
changed.sort(key=lambda item: item["updated_at"], reverse=True)
return {"revision": snapshot["revision"], "items": changed + unchanged}
@staticmethod
def _identity(item: dict) -> tuple[str, str, int]:
return item.get("kind", "issue"), item["repository"].lower(), item["number"]
def get(self, login: str) -> dict:
login = self._login(login)
with self._connect() as connection:
row = connection.execute(
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
).fetchone()
snapshot, legacy = self._snapshot(row, login)
snapshot["items"] = [self._normalize_item(item) for item in snapshot["items"]]
if row is not None and legacy:
connection.execute(
"UPDATE following_issues SET items = ? WHERE login = ? AND items = ?",
(self._seal(login, snapshot["items"]), login, row[1]),
)
return self._present(snapshot)
def preflight(self, login: str, raw_item: dict, watching: bool) -> dict:
"""Validate a requested change and capacity without mutating the registry."""
login = self._login(login)
item = self._normalize_item(raw_item)
if not isinstance(watching, bool):
raise ValueError("watching is invalid")
if not watching:
return item
identity = self._identity(item)
with self._connect() as connection:
row = connection.execute(
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
).fetchone()
current, _legacy = self._snapshot(row, login)
if (
len(current["items"]) >= self.limit
and not any(self._identity(candidate) == identity for candidate in current["items"])
):
raise ValueError(f"following is limited to {self.limit} issues")
return item
def set_watching(self, login: str, raw_item: dict, watching: bool) -> dict:
login = self._login(login)
item = self._normalize_item(raw_item)
if not isinstance(watching, bool):
raise ValueError("watching is invalid")
identity = self._identity(item)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
).fetchone()
current, _legacy = self._snapshot(row, login)
items = [self._normalize_item(candidate) for candidate in current["items"]]
index = next(
(position for position, candidate in enumerate(items)
if self._identity(candidate) == identity),
None,
)
if watching:
if index is None:
if len(items) >= self.limit:
raise ValueError(f"following is limited to {self.limit} issues")
items.insert(0, item)
else:
item["last_seen_updated_at"] = items[index]["last_seen_updated_at"]
for key in ("kept_updated_at", "reviewed_title", "reviewed_state"):
if key in items[index]:
item[key] = items[index][key]
if items[index] == item:
return self._present({"revision": current["revision"], "items": items})
items.pop(index)
items.insert(0, item)
elif index is None:
return self._present({"revision": current["revision"], "items": items})
else:
items.pop(index)
revision = current["revision"] + 1
connection.execute(
"INSERT INTO following_issues(login, revision, items) VALUES (?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, items=excluded.items",
(login, revision, self._seal(login, items)),
)
return self._present({"revision": revision, "items": items})
def refresh(self, login: str, raw_items: list[dict]) -> dict:
"""Merge successful upstream snapshots while preserving seen revisions."""
login = self._login(login)
fresh = {self._identity(item): self._normalize_item(item) for item in raw_items}
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
).fetchone()
current, _legacy = self._snapshot(row, login)
items = [self._normalize_item(candidate) for candidate in current["items"]]
changed = False
for index, item in enumerate(items):
update = fresh.get(self._identity(item))
if update is None:
continue
update["last_seen_updated_at"] = item["last_seen_updated_at"]
for key in ("kept_updated_at", "reviewed_title", "reviewed_state"):
if key in item:
update[key] = item[key]
if update["updated_at"] != item["updated_at"]:
update.setdefault("reviewed_title", item["title"])
update.setdefault("reviewed_state", item["state"])
if update != item:
items[index] = update
changed = True
revision = current["revision"]
if changed:
revision += 1
connection.execute(
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
(revision, self._seal(login, items), login),
)
return self._present({"revision": revision, "items": items})
def acknowledge(
self,
login: str,
repository: str,
number: int,
updated_at: str,
*,
kind: str = "issue",
) -> dict:
"""Mark only the exact upstream revision successfully opened by the operator."""
login = self._login(login)
if kind not in _KINDS:
raise ValueError("kind is invalid")
identity = (kind, str(repository).lower(), number)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
).fetchone()
current, _legacy = self._snapshot(row, login)
items = [self._normalize_item(candidate) for candidate in current["items"]]
revision = current["revision"]
for item in items:
if self._identity(item) != identity or item["updated_at"] != updated_at:
continue
if (item["last_seen_updated_at"] != updated_at or
item.get("kept_updated_at") == updated_at):
item["last_seen_updated_at"] = updated_at
item.pop("kept_updated_at", None)
item.pop("reviewed_title", None)
item.pop("reviewed_state", None)
revision += 1
connection.execute(
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
(revision, self._seal(login, items), login),
)
break
return self._present({"revision": revision, "items": items})
def keep_unseen(
self,
login: str,
repository: str,
number: int,
updated_at: str,
*,
kind: str = "issue",
) -> dict:
"""Restore only the exact loaded revision to the unseen review queue."""
login = self._login(login)
if kind not in _KINDS:
raise ValueError("kind is invalid")
identity = (kind, str(repository).lower(), number)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
).fetchone()
current, _legacy = self._snapshot(row, login)
items = [self._normalize_item(candidate) for candidate in current["items"]]
revision = current["revision"]
for item in items:
if self._identity(item) != identity or item["updated_at"] != updated_at:
continue
if item.get("kept_updated_at") != updated_at:
item["kept_updated_at"] = updated_at
revision += 1
connection.execute(
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
(revision, self._seal(login, items), login),
)
break
return self._present({"revision": revision, "items": items})
+2 -2
View File
@@ -35,11 +35,11 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"planning": (
"static/plan-today.js", "static/plan-today-readiness.js",
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js", "static/following.js",
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
),
"today-timer": (
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-composer-viewport.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
+427 -17
View File
@@ -370,6 +370,7 @@ WORK_SEARCHES = {
"filed": ("created=true", "issues", "created_by_me"),
"pull": ("assigned=true", "pulls", "assigned_to_me"),
"review": ("review_requested=true", "pulls", "review_requested"),
"authored": ("created=true", "pulls", "authored_by_me"),
}
@@ -608,6 +609,8 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
"state": state,
"updated_at": issue.get("updated_at", "")
if isinstance(issue.get("updated_at"), str) else "",
"author": author.get("login", "") if isinstance(author.get("login"), str) else "",
"labels": [
label["name"] for label in labels
@@ -1007,6 +1010,39 @@ async def _notification_subscription(repository: str, number: str) -> dict:
return value if isinstance(value, dict) else {}
async def issue_subscription(repository: str, number: int) -> dict:
"""Return the current operator's authoritative issue subscription state."""
value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
if not isinstance(value, dict):
raise ValueError("Gitea subscription response was not an object")
return {
"watching": value.get("subscribed") is True and value.get("ignored") is not True
}
async def set_issue_subscription(repository: str, number: int, watching: bool) -> dict:
"""Set and then confirm the current operator's issue subscription state."""
user = await fetch("user")
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("Gitea did not identify the current operator")
path = (
f"/api/v1/repos/{repository}/issues/{number}/subscriptions/"
f"{quote(login, safe='')}"
)
client = _get_client()
response = (
await client.put(path, headers=_auth())
if watching
else await client.delete(path, headers=_auth())
)
response.raise_for_status()
confirmed = await issue_subscription(repository, number)
if confirmed["watching"] is not watching:
raise ValueError("Gitea did not confirm the requested subscription state")
return confirmed
async def notification_detail(thread_id: int) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
@@ -1927,6 +1963,182 @@ async def pull_handoff_candidates(repository: str) -> list[dict]:
][:25]
async def pull_review_candidates(repository: str, number: int) -> list[dict]:
pull, candidates = await asyncio.gather(
fetch(f"repos/{repository}/pulls/{number}"),
pull_handoff_candidates(repository),
)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
excluded = {
item["login"].casefold()
for item in (pull.get("requested_reviewers") or [])
if isinstance(item, dict) and isinstance(item.get("login"), str)
}
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
if isinstance(author.get("login"), str):
excluded.add(author["login"].casefold())
return [
item for item in candidates
if item["login"].casefold() not in excluded
][:25]
async def request_assigned_pull_review(
repository: str, number: int, reviewer: str, expected_head_sha: str
) -> dict:
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
if (
pull.get("state") != "open"
or pull.get("merged") is True
or not _login_in_users(login, pull.get("assignees"))
or head.get("sha") != expected_head_sha
):
raise IssueNotAvailableError("Pull request is no longer eligible for review request")
eligible = {
item["login"] for item in await pull_review_candidates(repository, number)
}
if reviewer not in eligible:
raise IssueNotAvailableError("Reviewer is not eligible")
response = await _get_client().post(
f"/api/v1/repos/{repository}/pulls/{number}/requested_reviewers",
headers=_auth(),
json={"reviewers": [reviewer]},
)
response.raise_for_status()
confirmed_response = await _get_client().get(
f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth()
)
confirmed_response.raise_for_status()
confirmed = confirmed_response.json()
requested = [
item["login"] for item in (confirmed.get("requested_reviewers") or [])
if isinstance(item, dict) and isinstance(item.get("login"), str)
] if isinstance(confirmed, dict) else []
if reviewer not in requested:
raise ValueError("Gitea did not confirm the requested reviewer")
return {
"repository": repository,
"number": number,
"head_sha": expected_head_sha,
"requested_reviewers": requested,
"reviewer": reviewer,
}
async def publish_authored_assigned_pull(
repository: str, number: int, expected_head_sha: str
) -> dict:
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
title = pull.get("title") if isinstance(pull.get("title"), str) else ""
ready_title = re.sub(
r"^(?:WIP\s*:|\[WIP\]|Draft\s*:|\[Draft\])\s*",
"",
title,
count=1,
flags=re.IGNORECASE,
).strip()
if (
pull.get("state") != "open"
or pull.get("merged") is True
or pull.get("draft") is not True
or author.get("login", "").casefold() != login.casefold()
or not _login_in_users(login, pull.get("assignees"))
or head.get("sha") != expected_head_sha
or not ready_title
or ready_title == title
):
raise IssueNotAvailableError("Draft pull request is no longer publishable")
response = await _get_client().patch(
f"/api/v1/repos/{repository}/pulls/{number}",
headers=_auth(),
json={"title": ready_title},
)
response.raise_for_status()
confirmed_response = await _get_client().get(
f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth()
)
confirmed_response.raise_for_status()
confirmed = confirmed_response.json()
confirmed_head = (
confirmed.get("head")
if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict)
else {}
)
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or confirmed.get("state") != "open"
or confirmed.get("draft") is not False
or confirmed.get("title") != ready_title
or confirmed_head.get("sha") != expected_head_sha
):
raise ValueError("Gitea did not confirm the pull request is ready")
return {
"repository": repository,
"number": number,
"title": ready_title,
"head_sha": expected_head_sha,
"state": "open",
"draft": False,
}
async def cancel_assigned_pull_review(
repository: str, number: int, reviewer: str, expected_head_sha: str
) -> dict:
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
requested = [
item["login"] for item in (pull.get("requested_reviewers") or [])
if isinstance(item, dict) and isinstance(item.get("login"), str)
]
if (
pull.get("state") != "open"
or pull.get("merged") is True
or not _login_in_users(login, pull.get("assignees"))
or head.get("sha") != expected_head_sha
or reviewer not in requested
):
raise IssueNotAvailableError("Pending review request changed")
response = await _get_client().request(
"DELETE",
f"/api/v1/repos/{repository}/pulls/{number}/requested_reviewers",
headers=_auth(),
json={"reviewers": [reviewer]},
)
response.raise_for_status()
confirmed_response = await _get_client().get(
f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth()
)
confirmed_response.raise_for_status()
confirmed = confirmed_response.json()
remaining = [
item["login"] for item in (confirmed.get("requested_reviewers") or [])
if isinstance(item, dict) and isinstance(item.get("login"), str)
] if isinstance(confirmed, dict) else []
confirmed_head = confirmed.get("head") if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict) else {}
if reviewer in remaining or confirmed_head.get("sha") != expected_head_sha:
raise ValueError("Gitea did not confirm reviewer cancellation")
return {
"repository": repository,
"number": number,
"head_sha": expected_head_sha,
"requested_reviewers": remaining,
"reviewer": reviewer,
}
async def _change_assigned_pull_owners(
repository: str, number: int, recipient: str | None
) -> dict:
@@ -2557,29 +2769,34 @@ async def is_open_authored_issue(repository: str, number: int) -> bool:
async def pull_requests() -> WorkItems:
assigned, review_requested = await asyncio.gather(
work_page("pull"),
work_page("review"),
streams = ("pull", "review", "authored")
outcomes = await asyncio.gather(
*(work_page(stream) for stream in streams),
return_exceptions=True,
)
for outcome in outcomes:
if isinstance(outcome, asyncio.CancelledError):
raise outcome
if all(isinstance(outcome, BaseException) for outcome in outcomes):
raise outcomes[0]
merged: dict[int, dict] = {}
for result in (
assigned,
review_requested,
):
for pull in result["items"]:
pagination = {}
for stream, outcome in zip(streams, outcomes, strict=True):
if isinstance(outcome, BaseException):
pagination[stream] = {
"page": 1, "total": 0, "has_more": False, "unavailable": True,
}
continue
pagination[stream] = _page_metadata(outcome)
for pull in outcome["items"]:
identity = pull.get("id")
if identity not in merged:
merged[identity] = {**pull, "work_reasons": []}
for reason in pull.get("work_reasons", []):
if reason not in merged[identity]["work_reasons"]:
merged[identity]["work_reasons"].append(reason)
return WorkItems(
list(merged.values()),
{
"pull": _page_metadata(assigned),
"review": _page_metadata(review_requested),
},
)
return WorkItems(list(merged.values()), pagination)
async def is_requested_review(repository: str, number: int) -> bool:
@@ -2602,6 +2819,52 @@ async def is_assigned_pull(repository: str, number: int) -> bool:
)
async def update_authored_assigned_pull(
repository: str,
number: int,
title: str,
body: str,
expected_head_sha: str,
) -> dict:
path = f"repos/{repository}/pulls/{number}"
login, pull = await _current_login_and_target(path)
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
if (
pull.get("state") != "open"
or pull.get("merged") is True
or author.get("login") != login
or not _login_in_users(login, pull.get("assignees"))
):
raise IssueNotAvailableError("pull request not found")
if head.get("sha") != expected_head_sha:
raise IssueEditConflictError("pull request head changed upstream")
response = await _get_client().patch(
f"/api/v1/{path}",
headers=_auth(),
json={"title": title, "body": body},
)
response.raise_for_status()
confirmed = response.json()
confirmed_head = confirmed.get("head") if isinstance(confirmed.get("head"), dict) else {}
if (
confirmed.get("number") != number
or confirmed.get("title") != title
or confirmed.get("body", "") != body
or confirmed_head.get("sha") != expected_head_sha
):
raise ValueError("Gitea did not confirm the pull request update")
return {
"repository": repository,
"number": number,
"title": title,
"body": body,
"head_sha": expected_head_sha,
"state": confirmed.get("state", "open"),
"url": _safe_gitea_web_url(confirmed.get("html_url")),
}
async def pull_completion_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
@@ -2624,6 +2887,106 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
}
def _normalize_review_comments(comments: object) -> list[dict]:
normalized = []
for comment in (comments if isinstance(comments, list) else [])[:100]:
if not isinstance(comment, dict):
continue
path = comment.get("path")
body = comment.get("body")
if not isinstance(path, str) or not path.strip() or not isinstance(body, str) or not body.strip():
continue
item = {"path": path.strip()[:300], "body": body.strip()[:500]}
comment_id = comment.get("id")
if isinstance(comment_id, int) and comment_id > 0:
item["id"] = comment_id
position = comment.get("new_position") or comment.get("old_position")
if isinstance(position, int) and position > 0:
item["line"] = position
normalized.append(item)
if len(normalized) == 20:
break
return normalized
def _latest_feedback_review_ids(reviews: object) -> dict[str, int]:
latest: dict[str, tuple[int, str]] = {}
for review in (reviews if isinstance(reviews, list) else [])[:100]:
if not isinstance(review, dict):
continue
user = review.get("user") if isinstance(review.get("user"), dict) else {}
login = user.get("login")
review_id = review.get("id")
state = review.get("state")
if (
not isinstance(login, str) or not login or not isinstance(review_id, int)
or review_id <= 0 or not isinstance(state, str)
):
continue
key = login.casefold()
if key not in latest or review_id >= latest[key][0]:
latest[key] = (review_id, state)
feedback = [
(key, review_id) for key, (review_id, state) in latest.items()
if state in {"REQUEST_CHANGES", "COMMENT"}
]
return dict(feedback[:10])
def _normalize_reviewer_statuses(pull: dict, reviews: object, head_sha: str) -> list[dict]:
"""Return one bounded, current decision per reviewer without exposing raw review data."""
latest: dict[str, dict] = {}
for review in (reviews if isinstance(reviews, list) else [])[:100]:
if not isinstance(review, dict):
continue
user = review.get("user") if isinstance(review.get("user"), dict) else {}
login = user.get("login")
state = review.get("state")
commit_id = review.get("commit_id")
if (
not isinstance(login, str)
or not login
or state not in {"APPROVED", "REQUEST_CHANGES", "COMMENT"}
or not isinstance(commit_id, str)
or not commit_id
):
continue
previous = latest.get(login.casefold())
review_id = review.get("id") if isinstance(review.get("id"), int) else 0
if previous is None or review_id >= previous["id"]:
body = review.get("body")
latest[login.casefold()] = {
"id": review_id, "login": login, "state": state, "commit_id": commit_id,
"summary": body.strip()[:500] if isinstance(body, str) and body.strip() else "",
}
statuses: dict[str, dict] = {}
state_names = {
"APPROVED": "approved", "REQUEST_CHANGES": "changes_requested", "COMMENT": "commented",
}
for key, review in latest.items():
current = review["commit_id"] == head_sha
status = state_names[review["state"]] if current else "outdated"
statuses[key] = {
"review_id": review["id"],
"login": review["login"],
"status": status,
"head_sha": review["commit_id"],
"blocking": status in {"changes_requested", "outdated"},
}
if review["summary"]:
statuses[key]["summary"] = review["summary"]
requested = pull.get("requested_reviewers") if isinstance(pull, dict) else []
for reviewer in (requested if isinstance(requested, list) else [])[:25]:
login = reviewer.get("login") if isinstance(reviewer, dict) else None
if isinstance(login, str) and login:
statuses[login.casefold()] = {
"login": login, "status": "waiting", "head_sha": head_sha, "blocking": True,
}
return sorted(statuses.values(), key=lambda item: item["login"].casefold())
async def pull_completion_review(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
@@ -2633,15 +2996,17 @@ async def pull_completion_review(repository: str, number: int) -> dict:
head: dict = head_value if isinstance(head_value, dict) else {}
sha_value = head.get("sha")
sha = sha_value if isinstance(sha_value, str) else ""
files, status, diff_result = await asyncio.gather(
files, status, diff_result, reviews = await asyncio.gather(
fetch(f"{base}/files"),
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch_text(
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
),
fetch(f"{base}/reviews?limit=100"),
)
diff, diff_truncated = diff_result
previews = _diff_previews(diff, diff_truncated)
reviewers = _normalize_reviewer_statuses(pull, reviews, sha)
return {
"repository": repository,
"number": number,
@@ -2652,6 +3017,7 @@ async def pull_completion_review(repository: str, number: int) -> dict:
"merged": pull.get("merged") is True,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"checks": _normalize_commit_checks(status),
"reviewers": reviewers,
"files": [
{
"filename": item.get("filename", ""),
@@ -2674,6 +3040,40 @@ async def pull_completion_review(repository: str, number: int) -> dict:
}
async def pull_review_feedback(
repository: str, number: int, review_id: int, expected_head_sha: str
) -> dict:
"""Load optional inline feedback only for a review on the confirmed pull head."""
base = f"repos/{repository}/pulls/{number}"
pull, reviews = await asyncio.gather(
fetch(base),
fetch(f"{base}/reviews?limit=100"),
)
head = pull.get("head") if isinstance(pull, dict) else None
head_sha = head.get("sha") if isinstance(head, dict) else None
if head_sha != expected_head_sha:
raise StaleReviewError("Pull request head changed")
review_items = reviews if isinstance(reviews, list) else []
latest_ids = set(_latest_feedback_review_ids(review_items).values())
review = next(
(
item for item in review_items
if isinstance(item, dict) and item.get("id") == review_id
),
None,
)
if review is None or review_id not in latest_ids:
raise StaleReviewError("Review feedback is no longer current")
comments = await fetch(f"{base}/reviews/{review_id}/comments")
reviewed_head = review.get("commit_id")
return {
"review_id": review_id,
"head_sha": head_sha,
"reviewed_head_sha": reviewed_head if isinstance(reviewed_head, str) else "",
"comments": _normalize_review_comments(comments),
}
async def release_receipt_status(repository: str, commit_sha: str) -> dict:
"""Return CI and release evidence for one exact merge commit."""
status, releases = await asyncio.gather(
@@ -2726,7 +3126,10 @@ async def pull_check_status(repository: str, number: int) -> dict:
head: dict = head_value if isinstance(head_value, dict) else {}
sha_value = head.get("sha")
sha = sha_value if isinstance(sha_value, str) else ""
status = await fetch(f"repos/{repository}/commits/{sha}/status")
status, reviews = await asyncio.gather(
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch(f"{base}/reviews?limit=100"),
)
return {
"repository": repository,
"number": number,
@@ -2737,6 +3140,7 @@ async def pull_check_status(repository: str, number: int) -> dict:
"merged": pull.get("merged") is True,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"checks": _normalize_commit_checks(status),
"reviewers": _normalize_reviewer_statuses(pull, reviews, sha),
}
@@ -2777,6 +3181,12 @@ async def merge_assigned_pull(
or ci_state != "success"
):
raise PullNotMergeableError("Pull request is not currently safe to merge")
reviews = await fetch(f"{base}/reviews?limit=100")
if any(
reviewer["blocking"]
for reviewer in _normalize_reviewer_statuses(pull, reviews, current_sha)
):
raise PullNotMergeableError("Pull request review is not currently resolved")
response = await _get_client().post(
f"/api/v1/{base}/merge",
headers=_auth(),
+527 -3
View File
@@ -55,6 +55,7 @@ from src.passkey_store import PasskeyStore
from src.push_notifications import (
PushConfiguration,
dispatch_deadline_reminders,
dispatch_following_changes,
dispatch_start_day_reminders,
dispatch_unread_updates,
send_web_push,
@@ -63,6 +64,7 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en
from src.push_subscription_store import build_push_subscription_store
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
from src.following_store import FollowingStore
from src.unfiled_draft_store import (
UnfiledDraftConflict,
UnfiledDraftEncryptionError,
@@ -130,6 +132,10 @@ async def _start_day_plan_snapshot() -> dict:
return await asyncio.to_thread(_today_store().get_start_day_plan, login)
async def _following_push_snapshot() -> dict:
return await get_following(Response())
async def _push_poll_loop() -> None:
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
deadline_interval = max(
@@ -172,6 +178,17 @@ async def _push_poll_loop() -> None:
max_concurrency=max_concurrency,
)
async def dispatch_following() -> None:
await dispatch_following_changes(
_push_subscription_store,
_push_configuration(),
_following_push_snapshot,
session_statuses=dashboard_auth.managed_session_statuses,
send_timeout_seconds=send_timeout,
lease_seconds=lease_seconds,
max_concurrency=max_concurrency,
)
async def dispatch_start_day() -> None:
await dispatch_start_day_reminders(
_push_subscription_store,
@@ -185,6 +202,7 @@ async def _push_poll_loop() -> None:
channel_tasks = (
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
asyncio.create_task(_push_channel_loop(dispatch_following, interval=interval)),
asyncio.create_task(
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
),
@@ -473,6 +491,10 @@ class StartDayReminderPayload(BaseModel):
return value
class FollowingNotificationPayload(BaseModel):
enabled: bool
StepUpAction = Literal[
"merge_pull",
"submit_pull_review",
@@ -1086,6 +1108,37 @@ class IssueHandoff(BaseModel):
)
class PullReviewRequest(BaseModel):
reviewer: str = Field(
min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$"
)
expected_head_sha: str = Field(
min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
)
class PullReadyRequest(BaseModel):
expected_head_sha: str = Field(
min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
)
class PullContentUpdate(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
expected_head_sha: str = Field(
min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
)
@field_validator("title")
@classmethod
def strip_title(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("title cannot be blank")
return value
class IssueReassignment(IssueHandoff):
expected_assignees: list[str] = Field(min_length=1, max_length=10)
@@ -1276,7 +1329,7 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
user=p["user"].get("login", "") if isinstance(p.get("user"), dict) else "",
labels=[label.get("name", "") for label in (p.get("labels") or []) if isinstance(label, dict)],
assignees=[assignee.get("login", "") for assignee in (p.get("assignees") or []) if isinstance(assignee, dict)],
work_reasons=[reason for reason in (p.get("work_reasons") or []) if reason in ("assigned_to_me", "review_requested")],
work_reasons=[reason for reason in (p.get("work_reasons") or []) if reason in ("assigned_to_me", "review_requested", "authored_by_me")],
repository=p["repository"].get("full_name", "") if isinstance(p.get("repository"), dict) else "",
updated_at=p.get("updated_at") or "", url=p["html_url"],
)
@@ -1317,7 +1370,11 @@ def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
for item in items
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
]
reason = "assigned_to_me" if stream == "pull" else "review_requested"
reason = {
"pull": "assigned_to_me",
"review": "review_requested",
"authored": "authored_by_me",
}[stream]
return [
PullRequest(
id=item["id"], number=item["number"], title=item["title"],
@@ -2321,6 +2378,9 @@ async def push_status(request: Request):
start_day_preferences = await asyncio.to_thread(
_push_subscription_store.start_day_preferences, device_id
)
following_preferences = await asyncio.to_thread(
_push_subscription_store.following_preferences, device_id
)
delivery_health = await asyncio.to_thread(
_push_subscription_store.delivery_health, device_id
)
@@ -2336,6 +2396,7 @@ async def push_status(request: Request):
"start_day_enabled": start_day_preferences["enabled"],
"start_day_timezone": start_day_preferences["timezone"],
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
"following_enabled": following_preferences["enabled"],
"delivery_health": delivery_health,
}
@@ -2484,6 +2545,25 @@ async def update_start_day_reminders(payload: StartDayReminderPayload, request:
}
@app.put("/api/v1/push-subscription/following")
async def update_following_notifications(
payload: FollowingNotificationPayload, request: Request
):
device_id = await dashboard_auth.session_management_id(
request.state.dashboard_session
)
if payload.enabled and not await asyncio.to_thread(
_push_subscription_store.is_subscribed, device_id
):
raise HTTPException(status_code=409, detail="Enable device notifications first")
await asyncio.to_thread(
_push_subscription_store.set_following_preferences,
device_id,
enabled=payload.enabled,
)
return {"following_enabled": payload.enabled}
@app.patch("/api/v1/push-subscription/deadlines/snooze")
async def snooze_deadline_reminder(request: Request):
device_id = await dashboard_auth.session_management_id(
@@ -2557,6 +2637,16 @@ def _saved_search_store() -> SavedSearchStore:
)
def _following_store() -> FollowingStore:
return FollowingStore(
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
)
class FollowingSeenRevision(BaseModel):
updated_at: str = Field(min_length=1, max_length=64)
def _completed_filed_review_store() -> CompletedFiledReviewStore:
return CompletedFiledReviewStore(
os.getenv(
@@ -2601,6 +2691,142 @@ async def _confirmed_login() -> str:
return login.strip().lower()
@app.get("/api/v1/following")
async def get_following(response: Response):
login = await _confirmed_login()
store = _following_store()
try:
snapshot = await asyncio.to_thread(store.get, login)
semaphore = asyncio.Semaphore(5)
async def refresh_item(item: dict) -> dict:
async with semaphore:
preview = await asyncio.wait_for(
gitea_proxy.work_preview(item["repository"], item["kind"], item["number"]),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
return store._normalize_item({
"repository": item["repository"],
"kind": item["kind"],
"number": item["number"],
"title": preview.get("title", ""),
"state": preview.get("state", ""),
"updated_at": preview.get("updated_at", ""),
"url": preview.get("url", ""),
})
results = await asyncio.gather(
*(refresh_item(item) for item in snapshot["items"]),
return_exceptions=True,
)
refreshed = [result for result in results if isinstance(result, dict)]
failures = len(results) - len(refreshed)
snapshot = await asyncio.to_thread(store.refresh, login, refreshed)
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Following synchronization is unavailable",
headers={"Retry-After": "1"},
)
response.headers["Cache-Control"] = "no-store"
return {**snapshot, "degraded": failures > 0, "refresh_failures": failures}
@app.put("/api/v1/following/{owner}/{repo}/issues/{number}/seen")
async def acknowledge_following_revision(
payload: FollowingSeenRevision,
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(default="issue"),
):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_following_store().acknowledge,
login,
f"{owner}/{repo}",
number,
payload.updated_at,
kind=kind,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Following synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.put("/api/v1/following/{owner}/{repo}/issues/{number}/keep")
async def keep_following_revision(
payload: FollowingSeenRevision,
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(default="issue"),
):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_following_store().keep_unseen,
login,
f"{owner}/{repo}",
number,
payload.updated_at,
kind=kind,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Following synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/following/{owner}/{repo}/pulls/{number}/review-data")
async def following_pull_review_data(
owner: str, repo: str, number: int = PathParam(gt=0)
):
"""Return bounded review context only for a pull in this account's Following list."""
repository = f"{owner}/{repo}"
login = await _confirmed_login()
async def load_review():
snapshot = await asyncio.to_thread(_following_store().get, login)
watched = any(
item.get("kind") == "pull"
and item.get("repository", "").lower() == repository.lower()
and item.get("number") == number
for item in snapshot["items"]
)
if not watched:
raise HTTPException(status_code=404, detail="Watched pull request not found")
return await gitea_proxy.pull_completion_review(repository, number)
try:
result = await asyncio.wait_for(load_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS)
except HTTPException:
raise
except TimeoutError:
return JSONResponse(
{"error": "Loading watched pull request changes timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "Watched pull request changes are temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.get("/api/v1/completed-filed-reviews")
async def get_completed_filed_reviews(response: Response):
login = await _confirmed_login()
@@ -3660,6 +3886,117 @@ async def global_search_preview(
return JSONResponse(preview)
async def _search_preview_subscription_target(
owner: str, repo: str, number: int, kind: str, *, allow_closed: bool = False
) -> tuple[str, dict]:
repository = f"{owner}/{repo}"
preview = await gitea_proxy.work_preview(repository, kind, number)
if (
preview.get("repository") != repository
or preview.get("kind") != kind
or preview.get("number") != number
or preview.get("state") not in ({"open", "closed"} if allow_closed else {"open"})
):
raise HTTPException(status_code=404, detail="Watchable search result not found")
return repository, preview
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription")
async def global_search_preview_subscription(
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
) -> JSONResponse:
try:
repository, _preview = await _search_preview_subscription_target(owner, repo, number, kind)
result = await asyncio.wait_for(
gitea_proxy.issue_subscription(repository, number),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Watch status is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.api_route(
"/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription",
methods=["PUT", "DELETE"],
)
async def mutate_global_search_preview_subscription(
request: Request,
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
) -> JSONResponse:
watching = request.method == "PUT"
try:
repository, preview = await _search_preview_subscription_target(
owner, repo, number, kind, allow_closed=not watching
)
login = await _confirmed_login()
store = _following_store()
following_item = {
"repository": repository,
"kind": kind,
"number": number,
"title": preview.get("title", ""),
"state": preview.get("state", ""),
"updated_at": preview.get("updated_at", ""),
"url": preview.get("url", ""),
}
await asyncio.to_thread(store.preflight, login, following_item, watching)
result = await asyncio.wait_for(
gitea_proxy.set_issue_subscription(repository, number, watching),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
if result.get("watching") is not watching:
raise RuntimeError("Gitea did not confirm subscription state")
try:
following = await asyncio.to_thread(
store.set_watching,
login,
following_item,
watching,
)
except Exception:
try:
compensated = await asyncio.wait_for(
gitea_proxy.set_issue_subscription(repository, number, not watching),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except Exception:
compensated = None
if not isinstance(compensated, dict) or compensated.get("watching") is not (not watching):
return JSONResponse({
**result,
"following_synced": False,
"error": "Watching in Gitea, but Following could not sync. Retry this action.",
})
raise
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Watch status was not changed. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse({
**result,
"following_synced": True,
"following_revision": following["revision"],
"following_count": len(following["items"]),
})
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation")
async def global_search_preview_conversation(
owner: str,
@@ -3823,7 +4160,7 @@ async def resolve_work_route(
@app.get("/api/v1/work/{stream}")
async def paged_work(
stream: Literal["issue", "filed", "pull", "review"],
stream: Literal["issue", "filed", "pull", "review", "authored"],
page: int = Query(ge=2, le=100),
) -> JSONResponse:
try:
@@ -6247,6 +6584,70 @@ async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt
)
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/content")
async def update_authored_assigned_pull_content(
update: PullContentUpdate,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.update_authored_assigned_pull(
repository,
number,
update.title,
update.body,
update.expected_head_sha,
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueEditConflictError:
return JSONResponse(
{"error": "This pull request changed in Gitea. Your draft is safe; reload the latest pull request before saving."},
status_code=409,
)
except gitea_proxy.IssueNotAvailableError:
raise HTTPException(status_code=404, detail="Editable pull request not found")
except Exception:
return JSONResponse(
{"error": "The pull request could not be updated. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/ready")
async def publish_authored_assigned_pull(
update: PullReadyRequest,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.publish_authored_assigned_pull(
repository, number, update.expected_head_sha
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "The draft pull request changed. Reload before publishing."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The draft pull request could not be published. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
async def assigned_pull_review_data(
owner: str, repo: str, number: int = PathParam(gt=0)
@@ -6278,6 +6679,50 @@ async def assigned_pull_review_data(
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews/{review_id}/feedback")
async def assigned_pull_review_feedback(
owner: str,
repo: str,
number: int = PathParam(gt=0),
review_id: int = PathParam(gt=0),
expected_head_sha: str = Query(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
):
repository = f"{owner}/{repo}"
async def load_feedback():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.pull_review_feedback(
repository, number, review_id, expected_head_sha
)
try:
result = await asyncio.wait_for(
load_feedback(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except gitea_proxy.StaleReviewError:
return JSONResponse(
{"error": "The pull request or review changed. Reload review data before retrying."},
status_code=409,
headers={"Cache-Control": "no-store"},
)
except TimeoutError:
return JSONResponse(
{"error": "Loading review feedback timed out. Please retry."},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "Review feedback is temporarily unavailable. Please retry."},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/checks")
async def assigned_pull_checks(
owner: str, repo: str, number: int = PathParam(gt=0)
@@ -6360,6 +6805,85 @@ async def handoff_assigned_pull(
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-candidates")
async def pull_review_candidates(
owner: str, repo: str, number: int = PathParam(gt=0)
) -> JSONResponse:
repository = f"{owner}/{repo}"
try:
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
result = await asyncio.wait_for(
gitea_proxy.pull_review_candidates(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Reviewers could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/request-review")
async def request_assigned_pull_review(
request: PullReviewRequest,
owner: str,
repo: str,
number: int = PathParam(gt=0),
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.request_assigned_pull_review(
f"{owner}/{repo}", number, request.reviewer, request.expected_head_sha
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "The pull request or reviewer changed. Reload before requesting review."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The review request could not be confirmed. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.delete("/api/v1/repos/{owner}/{repo}/pulls/{number}/request-review")
async def cancel_assigned_pull_review(
request: PullReviewRequest,
owner: str,
repo: str,
number: int = PathParam(gt=0),
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.cancel_assigned_pull_review(
f"{owner}/{repo}", number, request.reviewer, request.expected_head_sha
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "The pull request or reviewer changed. Reload before cancelling the request."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The cancellation could not be confirmed. Refresh review data before retrying."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/release")
async def release_assigned_pull(
owner: str, repo: str, number: int = PathParam(gt=0)
+133
View File
@@ -1,4 +1,5 @@
import asyncio
import hashlib
import json
import secrets
import time
@@ -99,6 +100,135 @@ async def send_web_push(
)
async def dispatch_following_changes(
store: PushSubscriptionStore,
configuration: PushConfiguration,
following: Callable[[], Awaitable[dict]],
send: Callable[[dict, str], Awaitable[None]] | None = None,
*,
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
lease_seconds: float = 60.0,
send_timeout_seconds: float = 10.0,
max_concurrency: int = 8,
) -> int:
"""Notify opted-in devices once for each privacy-safe Following change set."""
if not configuration.enabled:
return 0
owner = secrets.token_urlsafe(18)
acquired = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="following",
now=time.time(),
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
)
if not acquired:
return 0
try:
devices = await asyncio.to_thread(store.following_notification_devices)
if not devices:
return 0
snapshot = await following()
if not isinstance(snapshot, dict) or snapshot.get("complete") is False:
return 0
changed = []
for item in snapshot.get("items", []):
if not isinstance(item, dict) or item.get("has_unseen_change") is not True:
continue
repository = item.get("repository")
kind = item.get("kind")
number = item.get("number")
updated_at = item.get("updated_at")
if (
isinstance(repository, str)
and kind in {"issue", "pull"}
and isinstance(number, int)
and not isinstance(number, bool)
and number > 0
and isinstance(updated_at, str)
and updated_at
):
changed.append((repository.lower(), kind, number, updated_at))
changed.sort()
fingerprint = hashlib.sha256(
json.dumps(changed, separators=(",", ":")).encode()
).hexdigest()
if not changed:
await asyncio.gather(*(
asyncio.to_thread(store.mark_following_delivered, device.session_id, fingerprint)
for device in devices
))
return 0
pending = [device for device in devices if device.delivered_fingerprint != fingerprint]
if not pending:
return 0
if session_statuses is not None:
try:
statuses = await session_statuses([device.session_id for device in pending])
except Exception:
return 0
for device in pending:
if statuses.get(device.session_id) != "active":
await asyncio.to_thread(store.delete_session, device.session_id)
pending = [
device for device in pending if statuses.get(device.session_id) == "active"
]
count = min(len(changed), 50)
payload = json.dumps({
"title": f"{count} watched item{'s' if count != 1 else ''} changed",
"body": "Open Following to review the latest activity.",
"route": "#/my-work/following",
"tag": f"stackchain-following-{fingerprint[:16]}",
"following_count": count,
}, separators=(",", ":"))
semaphore = asyncio.Semaphore(max(1, max_concurrency))
async def dispatch_device(device) -> int:
async with semaphore:
still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="following",
now=time.time(),
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
)
if not still_owner:
return 0
try:
operation = (
send(device.subscription, payload)
if send is not None
else send_web_push(device.subscription, payload, configuration)
)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception as error:
status = getattr(getattr(error, "response", None), "status_code", None)
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
await asyncio.to_thread(store.delete_session, device.session_id)
else:
await asyncio.to_thread(
store.mark_delivery_failed,
device.session_id,
"following",
_delivery_failure_reason(error),
)
return 0
await asyncio.to_thread(
store.mark_delivery_succeeded, device.session_id, "following"
)
await asyncio.to_thread(
store.mark_following_delivered, device.session_id, fingerprint
)
return 1
results = await asyncio.gather(
*(dispatch_device(device) for device in pending), return_exceptions=True
)
return sum(result for result in results if isinstance(result, int))
finally:
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="following")
async def dispatch_unread_updates(
store: PushSubscriptionStore,
configuration: PushConfiguration,
@@ -133,6 +263,7 @@ async def dispatch_unread_updates(
for item in page.get("items", [])
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
}
unread_count = min(len(thread_revisions), 9999)
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
if session_statuses is not None:
@@ -201,6 +332,7 @@ async def dispatch_unread_updates(
"route": f"#/my-work/update/{thread_id}",
"tag": f"stackchain-update-{thread_id}",
"notification_id": thread_id,
"unread_count": unread_count,
},
separators=(",", ":"),
)
@@ -258,6 +390,7 @@ async def dispatch_unread_updates(
"route": "#/my-work/updates",
"tag": "stackchain-update-digest",
"update_count": len(overflow_revisions),
"unread_count": unread_count,
},
separators=(",", ":"),
)
+66
View File
@@ -52,6 +52,13 @@ class StartDayReminderDevice:
delivered_plan_date: str | None
@dataclass(frozen=True)
class FollowingNotificationDevice:
session_id: str
subscription: dict
delivered_fingerprint: str | None
class DisabledPushSubscriptionStore:
"""No-persistence store used when Web Push is not configured."""
@@ -81,6 +88,12 @@ class DisabledPushSubscriptionStore:
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
return []
def following_preferences(self, session_id: str) -> dict:
return {"enabled": False}
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
return []
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
return []
@@ -117,6 +130,12 @@ class DisabledPushSubscriptionStore:
def mark_start_day_reminder_delivered(self, *args, **kwargs) -> None:
return None
def set_following_preferences(self, *args, **kwargs) -> None:
return None
def mark_following_delivered(self, *args, **kwargs) -> None:
return None
def reconcile_unread(self, *args, **kwargs) -> None:
return None
@@ -236,6 +255,13 @@ class PushSubscriptionStore:
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_following_preferences (
session_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
delivered_fingerprint TEXT,
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
"""
)
delivery_columns = {
@@ -569,6 +595,46 @@ class PushSubscriptionStore:
(plan_date, session_id),
)
def set_following_preferences(self, session_id: str, *, enabled: bool) -> None:
with self._connect() as connection:
connection.execute(
"""INSERT INTO push_following_preferences(session_id, enabled)
VALUES (?, ?)
ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""",
(session_id, int(enabled)),
)
def following_preferences(self, session_id: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT enabled FROM push_following_preferences WHERE session_id = ?",
(session_id,),
).fetchone()
return {"enabled": bool(row[0]) if row else False}
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
with self._connect() as connection:
rows = connection.execute(
"""SELECT s.session_id, s.subscription_json, p.delivered_fingerprint
FROM push_subscriptions s
JOIN push_following_preferences p ON p.session_id = s.session_id
WHERE p.enabled = 1 ORDER BY s.session_id"""
).fetchall()
return [
FollowingNotificationDevice(
row[0], self._open_subscription(row[0], row[1]), row[2]
)
for row in rows
]
def mark_following_delivered(self, session_id: str, fingerprint: str) -> None:
with self._connect() as connection:
connection.execute(
"""UPDATE push_following_preferences SET delivered_fingerprint = ?
WHERE session_id = ? AND enabled = 1""",
(fingerprint, session_id),
)
def claim_unseen(
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
) -> list[PushDelivery]:
+2
View File
@@ -125,6 +125,8 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
number = 0
comments = self.server.activity_comments.get(number, [])
self._json(200, comments, **{"X-Total-Count": str(len(comments))})
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/subscriptions/check"):
self._json(200, {"subscribed": False, "ignored": False})
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"):
self._json(200, [])
elif path.startswith("/api/v1/repos/acme/mobile/issues/"):
@@ -0,0 +1,133 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_addresses_every_review_comment_and_requests_updated_review(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open",
"draft": False,
"mergeable": True,
"merged": False,
"ci_state": "success",
"head_sha": "current-head",
"reviewers": [{
"review_id": 42,
"login": "sam",
"status": "changes_requested",
"head_sha": "current-head",
"blocking": True,
"comments": [
{"id": 99, "path": "src/api.py", "body": "Handle the empty state.", "line": 12},
{"id": 100, "path": "frontend/app.js", "body": "Explain the retry.", "line": 8},
],
}],
"files": [
{"filename": "src/api.py", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -11 +11 @@", "+return empty"]},
{"filename": "frontend/app.js", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -7 +7 @@", "+showRetry()"]},
],
}
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""detail => {
globalThis.confirm = () => true;
globalThis.feedbackCalls = [];
globalThis.feedbackStored = new Map();
globalThis.feedbackStorage = {
getItem:key => feedbackStored.has(key) ? feedbackStored.get(key) : null,
setItem:(key, value) => feedbackStored.set(key, String(value)),
removeItem:key => feedbackStored.delete(key),
};
globalThis.feedbackItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
globalThis.feedbackDetail = detail;
globalThis.feedbackController = createPullSheet({
storage: feedbackStorage,
createOperationId: () => 'feedback-operation',
fetchJson: async (path, options = {}) => {
feedbackCalls.push({path, options});
if (path.endsWith('/comments')) return {id: 501, body:JSON.parse(options.body).body};
if (path.endsWith('/request-review')) return {
number:7, reviewer:'sam', head_sha:'current-head', requested_reviewers:['sam']
};
throw new Error('unexpected request');
},
});
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, false, value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;'))
).join('');
createPullSheet.bindFeedbackControls(document, feedbackController,
() => feedbackItem, () => feedbackDetail, () => 'timmy');
createPullSheet.review(detail, null, document);
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
page.locator('[data-address-review-feedback="42"]').click()
assert page.locator("#pull-feedback-progress").text_content() == "Comment 1 of 2"
assert page.locator("#finish-pull-feedback").is_disabled()
page.locator('[data-feedback-disposition="addressed"]').click()
page.locator("#next-pull-feedback").click()
page.locator("#pull-feedback-note").fill("Added a visible retry message.")
page.locator('[data-feedback-disposition="discussion"]').click()
assert not page.locator("#finish-pull-feedback").is_disabled()
page.locator("#finish-pull-feedback").click()
page.locator("#request-feedback-review").click()
page.wait_for_function("feedbackCalls.length === 2")
result = page.evaluate(
"""() => ({
calls:feedbackCalls,
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
controlHeights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
.filter(button => !button.hidden).map(button => button.getBoundingClientRect().height),
status:document.querySelector('#pull-feedback-status').textContent,
secondExpanded:document.querySelector('[data-pull-filename="frontend/app.js"] .pull-file-toggle')
.getAttribute('aria-expanded'),
persisted:Array.from(feedbackStored.keys()).some(key => key.includes('feedback-pass')),
})"""
)
result["resetHidden"] = page.evaluate(
"""() => {
createPullSheet.resetReviewRequestControls(document, feedbackDetail);
return document.querySelector('#pull-feedback-pass').hidden;
}"""
)
browser.close()
assert result["scrollWidth"] <= result["clientWidth"]
assert min(result["controlHeights"]) >= 44
assert result["secondExpanded"] == "true"
assert result["persisted"] is True
assert result["resetHidden"] is True
assert result["calls"][0]["path"].endswith("/comments")
assert result["calls"][0]["options"]["headers"]["Idempotency-Key"] == "feedback-operation"
summary = result["calls"][0]["options"]["body"]
assert "Handle the empty state" in summary
assert "Addressed" in summary
assert "Needs discussion" in summary
assert "Added a visible retry message" in summary
assert result["calls"][1]["path"].endswith("/request-review")
assert '"reviewer":"sam"' in result["calls"][1]["options"]["body"]
assert result["status"] == "Response posted; updated review requested from @sam."
@@ -0,0 +1,89 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged mobile My PRs journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 390, "height": 844},
])
def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "my-work.js")
page.add_script_tag(path=FRONTEND / "work-route.js")
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
page.evaluate("document.querySelector('#mobile-queue-sheet').showModal()")
row = page.locator('[data-mobile-queue="authored"]')
expect(row).to_have_count(1)
expect(row).to_contain_text("My PRs")
expect(row).to_contain_text("Pull requests you authored")
assert row.bounding_box()["height"] >= 44
result = page.evaluate("""() => {
const work = buildMyWork({
user:{login:'timmy'},
pull_requests:[{
number:41,title:'Ship the authored mobile flow',repository:'stackchain/dashboard',
state:'open',user:'timmy',assignees:[],work_reasons:['authored_by_me'],
url:'https://forge.example/git/stackchain/dashboard/pulls/41'
}]
});
const authored = filterMyWork(work, 'authored');
let selected = '';
let route = '';
const action = document.createElement('button');
action.className = 'my-work-card-main';
action.addEventListener('click', () => { route = createWorkRoute.serialize(authored[0]); });
const launcher = createMobileQueueLauncher({
selectFilter:name => { selected = name; },
firstAction:() => action,
announce:() => {},
});
const opened = launcher.open('authored');
return {selected, route, opened, count:authored.length};
}""")
assert result == {
"selected": "authored",
"route": "#/my-work/pull/stackchain/dashboard/41",
"opened": "opened",
"count": 1,
}
assert page.evaluate(
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
) is False
browser.close()
def test_empty_authored_queue_announces_the_standard_empty_message():
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 320, "height": 568})
page.set_content((FRONTEND / "index.html").read_text())
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
message = page.evaluate("""() => {
let announcement = '';
const launcher = createMobileQueueLauncher({
selectFilter:() => {}, firstAction:() => null,
announce:value => { announcement = value; },
});
launcher.open('authored');
return announcement;
}""")
assert message == "No work is ready to open."
browser.close()
@@ -0,0 +1,88 @@
import os
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
if os.environ.get("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("release browser journey is opt-in", allow_module_level=True)
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_cancels_pending_review_and_opens_replacement_picker(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""() => {
globalThis.confirm = () => true;
globalThis.cancelCalls = [];
globalThis.cancelItem = {
repository:'stackchain/api', number:7, key:'stackchain/api#7'
};
globalThis.cancelDetail = {
state:'open', draft:false, mergeable:true, merged:false,
ci_state:'success', head_sha:'abc1234', files:[],
reviewers:[{
login:'sam', status:'waiting', head_sha:'abc1234', blocking:true
}],
};
globalThis.cancelController = createPullSheet({
storage:null,
fetchJson:async (path, options={}) => {
cancelCalls.push({path, options});
return {
repository:'stackchain/api', number:7, reviewer:'sam',
head_sha:'abc1234', requested_reviewers:[],
};
},
});
createPullSheet.bindReviewRequestControls(
document, cancelController, () => cancelItem, () => cancelDetail
);
createPullSheet.review(cancelDetail, null, document);
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
}"""
)
cancel = page.locator('[data-cancel-review-request="sam"]')
bounds = cancel.bounding_box()
assert bounds and bounds["height"] >= 44
cancel.click()
page.wait_for_function("cancelCalls.length === 1")
page.wait_for_function(
"document.querySelector('#pull-review-request-status').textContent.includes('Choose a replacement reviewer')"
)
result = page.evaluate(
"""() => ({
calls:cancelCalls,
waitingRows:document.querySelectorAll('[data-reviewer-status="waiting"]').length,
pickerOpen:document.querySelector('#pull-review-request').open,
focused:document.activeElement?.id,
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
})"""
)
browser.close()
assert result["scrollWidth"] <= result["clientWidth"]
assert result["waitingRows"] == 0
assert result["pickerOpen"] is True
assert result["focused"] == "load-pull-reviewers"
assert len(result["calls"]) == 1
assert result["calls"][0]["options"]["method"] == "DELETE"
assert result["calls"][0]["options"]["body"] == (
'{"reviewer":"sam","expected_head_sha":"abc1234"}'
)
@@ -0,0 +1,44 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged mobile detail-watch journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 390, "height": 844},
])
def test_issue_and_pull_detail_watch_controls_are_phone_usable(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "search-preview.js")
for sheet, button, expected in [
("#issue-sheet", "#watch-issue-detail", "Watch issue"),
("#pull-sheet", "#watch-pull-detail", "Watch pull request"),
]:
page.locator(sheet).evaluate("node => node.classList.add('open')")
control = page.locator(button)
expect(control).to_be_visible()
expect(control).to_have_text(expected)
assert control.bounding_box()["height"] >= 44
control.focus()
expect(control).to_be_focused()
page.locator(sheet).evaluate("node => node.classList.remove('open')")
assert page.evaluate(
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
) is False
browser.close()
+146
View File
@@ -0,0 +1,146 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged mobile Following journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 390, "height": 844},
])
def test_following_queue_is_phone_usable_at_narrow_viewport(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "search-preview.js")
page.add_script_tag(path=FRONTEND / "following.js")
row = page.locator('[data-mobile-queue="following"]')
expect(row).to_have_count(1)
expect(row).to_contain_text("Issues and pull requests you watch")
following_alert = page.locator('label[for="push-following"]')
expect(following_alert).to_have_count(1)
expect(following_alert).to_contain_text("Notify me when Following changes")
page.locator("#work-settings-toggle").click()
expect(following_alert).to_be_visible()
assert following_alert.bounding_box()["height"] >= 44
page.evaluate("""() => {
globalThis.fetch = async () => ({
ok:true,
json:async () => ({revision:1,items:[{
repository:'stackchain/api', kind:'pull', number:42,
title:'Changed pull request with a long mobile title', state:'open',
updated_at:'2026-08-23T05:00:00Z', has_unseen_change:true,
change_summary:'Closed since last review'
}]})
});
globalThis.followingReleaseQueue = attachFollowing(() => {});
globalThis.followingReleaseQueue.open();
}""")
expect(page.locator("#following-sheet")).to_be_visible()
expect(page.locator(".following-card")).to_be_visible()
expect(page.locator(".following-card")).to_contain_text("Closed since last review")
expect(page.locator(".following-card")).to_contain_text("Pull request")
expect(page.locator("#review-following")).to_have_text("Review new activity")
assert page.locator("#review-following").bounding_box()["height"] >= 44
assert page.locator(".following-card").bounding_box()["height"] >= 44
assert page.locator("#close-following").bounding_box()["height"] >= 44
page.evaluate("""() => {
document.querySelector('#following-sheet').close();
document.querySelector('#search-preview').classList.add('open');
renderSearchPreviewReview({status:'ready',data:{ci_state:'success',files:[{
filename:'src/a/very/long/mobile/path/that-must-wrap/review.py',status:'modified',
additions:1,deletions:1,diff_available:true,
diff_lines:['@@ -1 +1 @@','-unsafe long line that scrolls inside the diff only',
'+safe long line that scrolls inside the diff only']
}]}},document,value=>String(value));
document.querySelector('[data-search-preview-section="changes"]').hidden=false;
}""")
changes = page.locator("#search-preview-review")
expect(changes).to_be_visible()
expect(changes).to_contain_text("CI passed · 1 changed file.")
expect(changes).to_contain_text("review.py")
assert page.locator('[data-search-preview-section="changes"]').bounding_box()["height"] >= 44
assert page.locator("#search-preview-review .pull-diff").evaluate(
"node => node.scrollWidth >= node.clientWidth"
) is True
assert page.evaluate(
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
) is False
retire = page.locator("#watch-search-result")
page.evaluate("""() => {
document.querySelector('#following-sheet').close();
document.querySelector('#search-preview').classList.add('open');
const button = document.querySelector('#watch-search-result');
renderSearchPreviewWatch(
{repository:'stackchain/api', number:42, kind:'issue', state:'closed',
following:true, watching:true},
{status:'ready'},
button
);
}""")
expect(retire).to_be_visible()
expect(retire).to_have_text("Stop watching & next")
assert retire.bounding_box()["height"] >= 44
page.evaluate("""() => renderSearchPreviewWatch(
{repository:'stackchain/api', number:42, kind:'pull', state:'open', watching:false},
{status:'ready'}, document.querySelector('#watch-search-result'))""")
expect(retire).to_have_text("Watch pull request")
assert retire.bounding_box()["height"] >= 44
keep = page.locator("#keep-following-for-later")
page.evaluate("document.querySelector('#keep-following-for-later').hidden = false")
expect(keep).to_be_visible()
expect(keep).to_have_text("Keep for later & next")
assert keep.bounding_box()["height"] >= 44
assert page.locator("#keep-following-status").evaluate(
"node => getComputedStyle(node).overflowWrap"
) == "anywhere"
assert page.locator("#keep-following-status").bounding_box()["height"] >= 20
overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
assert overflow is False
browser.close()
def test_following_alert_route_opens_truthful_empty_state_with_one_refresh():
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 390, "height": 844})
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "following.js")
result = page.evaluate("""async () => {
let requests=0;
globalThis.fetch=async () => {
requests += 1;
return {ok:true,json:async () => ({revision:2,items:[]})};
};
const queue=attachFollowing(() => {});
await queue.route();
return {requests,open:document.querySelector('#following-sheet').open,
status:document.querySelector('#following-status').textContent};
}""")
assert result == {
"requests": 1,
"open": True,
"status": "No watched items yet. Watch an issue or pull request from Search to keep it here.",
}
expect(page.locator("#following-sheet")).to_be_visible()
assert page.locator("#close-following").bounding_box()["height"] >= 44
browser.close()
@@ -0,0 +1,87 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_publishes_draft_and_continues_to_reviewer_picker(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"number": 7,
"title": "WIP: Ship mobile flow",
"author": "timmy",
"state": "open",
"draft": True,
"merged": False,
"head_sha": "abc1234",
}
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.on("dialog", lambda dialog: dialog.accept())
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""detail => {
window.publishDetail = detail;
window.publishItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
const controller = createPullSheet({
storage:null,
fetchJson:(path, options) => {
if (!path.endsWith('/ready') || options.method !== 'PATCH') {
return Promise.reject(new Error('unexpected request'));
}
const payload = JSON.parse(options.body);
if (payload.expected_head_sha !== detail.head_sha) {
return Promise.reject(new Error('wrong head'));
}
return Promise.resolve({
repository:'stackchain/api', number:7, title:'Ship mobile flow',
head_sha:detail.head_sha, state:'open', draft:false,
});
},
});
createPullSheet.bindOwnershipControls(
document, controller, () => window.publishItem, async () => false,
() => window.publishDetail, () => 'timmy'
);
document.querySelector('#pull-sheet').classList.add('open');
controller.setReviewDetail(detail);
}""",
detail,
)
ready = page.get_by_role("button", name="Ready for review")
before = ready.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.hidden})")
ready.click()
page.get_by_text("Published and ready for review.").wait_for()
metrics = page.evaluate(
"""() => ({
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
readyHidden:document.querySelector('#publish-draft-pull').hidden,
requestOpen:document.querySelector('#pull-review-request').open,
reviewerFocused:document.activeElement === document.querySelector('#load-pull-reviewers'),
title:window.publishDetail.title,
draft:window.publishDetail.draft,
})"""
)
browser.close()
assert before["hidden"] is False
assert before["height"] >= 44
assert metrics["scrollWidth"] <= metrics["clientWidth"]
assert metrics["readyHidden"] is True
assert metrics["requestOpen"] is True
assert metrics["reviewerFocused"] is True
assert metrics["title"] == "Ship mobile flow"
assert metrics["draft"] is False
@@ -0,0 +1,172 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_received_review_feedback_opens_matching_change(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open",
"draft": False,
"mergeable": True,
"merged": False,
"ci_state": "success",
"head_sha": "current-head",
"reviewers": [{
"login": "sam",
"status": "outdated",
"head_sha": "old-head-123456",
"blocking": True,
"summary": "Please handle the narrow empty state before merging.",
"comments": [{
"path": "src/a/very/long/mobile/path/review_target.py",
"body": "Return before parsing when the payload is empty.",
"line": 12,
}],
}],
"files": [{
"filename": "src/a/very/long/mobile/path/review_target.py",
"status": "modified",
"additions": 2,
"deletions": 1,
"diff_available": True,
"diff_lines": ["@@ -10,2 +10,3 @@", "-parse(payload)", "+if not payload: return", "+parse(payload)"],
}],
}
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""detail => {
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
createPullSheet.review(detail, null, document);
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, false, value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;'))
).join('');
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
jump = page.locator('[data-review-feedback-file]')
jump.click()
metrics = page.evaluate(
"""() => {
const jump = document.querySelector('[data-review-feedback-file]');
const toggle = document.querySelector('.pull-file-toggle');
const panel = document.getElementById(toggle.getAttribute('aria-controls'));
return {
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
jumpHeight: jump.getBoundingClientRect().height,
expanded: toggle.getAttribute('aria-expanded'),
panelHidden: panel.hidden,
focused: document.activeElement === toggle,
reviewedHead: document.querySelector('.pull-review-feedback').textContent,
updatedReviewVisible: !document.querySelector('#request-updated-pull-review').hidden,
};
}"""
)
browser.close()
assert metrics["scrollWidth"] <= metrics["clientWidth"]
assert metrics["jumpHeight"] >= 44
assert metrics["expanded"] == "true"
assert metrics["panelHidden"] is False
assert metrics["focused"] is True
assert "Reviewed head old-head" in metrics["reviewedHead"]
assert metrics["updatedReviewVisible"] is True
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_pull_review_retries_inline_feedback_without_losing_core_review(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open", "draft": False, "mergeable": True, "merged": False,
"ci_state": "success", "head_sha": "abc1234",
"reviewers": [{
"login": "sam", "review_id": 8, "status": "changes_requested",
"head_sha": "abc1234", "blocking": True,
"summary": "Please handle the empty state.",
}],
"files": [],
}
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""detail => {
window.lazyFeedbackDetail = detail;
window.lazyFeedbackItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
let attempts = 0;
const controller = createPullSheet({
storage:null,
fetchJson:() => {
attempts += 1;
if (attempts === 1) return Promise.reject(new Error('feedback unavailable'));
return Promise.resolve({
review_id:8, head_sha:'abc1234', reviewed_head_sha:'abc1234',
comments:[{id:99, path:'src/api.py', body:'Return before parsing.', line:12}],
});
},
});
createPullSheet.bindFeedbackControls(
document, controller, () => window.lazyFeedbackItem,
() => window.lazyFeedbackDetail, () => 'timmy'
);
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
const eligibility = createPullSheet.review(detail, null, document);
document.querySelector('#pull-merge-state').textContent = eligibility.reason;
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
load = page.get_by_role("button", name="Load inline feedback")
load.click()
retry = page.get_by_role("button", name="Retry feedback")
retry.wait_for()
retry.click()
page.get_by_text("Return before parsing.").wait_for()
metrics = page.evaluate(
"""() => {
const details = document.querySelector('.pull-review-feedback');
const address = document.querySelector('[data-address-review-feedback]');
return {
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
blocker: document.querySelector('#pull-merge-state').textContent,
feedback: details.textContent,
addressHeight: address.getBoundingClientRect().height,
};
}"""
)
browser.close()
assert metrics["scrollWidth"] <= metrics["clientWidth"]
assert "requested changes" in metrics["blocker"]
assert "Return before parsing." in metrics["feedback"]
assert metrics["addressHeight"] >= 44
@@ -0,0 +1,87 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_pull_reviewer_status_wraps_and_keeps_updated_review_reachable(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open",
"draft": False,
"mergeable": True,
"merged": False,
"ci_state": "success",
"head_sha": "current-head",
"reviewers": [
{
"login": "casey-with-a-very-long-mobile-login",
"status": "waiting",
"head_sha": "current-head",
"blocking": True,
},
{
"login": "sam",
"status": "changes_requested",
"head_sha": "current-head",
"blocking": True,
},
{
"login": "lee",
"status": "outdated",
"head_sha": "old-head",
"blocking": True,
},
{
"login": "pat",
"status": "approved",
"head_sha": "current-head",
"blocking": False,
},
],
}
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""detail => {
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
createPullSheet.review(detail, null, document);
}""",
detail,
)
metrics = page.evaluate(
"""() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
updatedReviewHeight: document.querySelector('#request-updated-pull-review')
.getBoundingClientRect().height,
updatedReviewVisible: !document.querySelector('#request-updated-pull-review').hidden,
statuses: Array.from(document.querySelectorAll('.pull-reviewer-status span'))
.map(node => node.textContent),
})"""
)
browser.close()
assert metrics["scrollWidth"] <= metrics["clientWidth"]
assert metrics["updatedReviewHeight"] >= 44
assert metrics["updatedReviewVisible"] is True
assert metrics["statuses"] == [
"Waiting for review",
"Changes requested",
"Outdated after new commits",
"Approved current head",
]
@@ -32,12 +32,29 @@ def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_d
page.locator("#search-preview-conversation").evaluate("node => node.style.minHeight = '500px'")
page.locator("#search-preview-reply-workspace").evaluate("node => node.style.minHeight = '500px'")
page.locator("#search-preview-actions").evaluate("node => node.style.minHeight = '300px'")
page.add_script_tag(path=FRONTEND / "search-preview.js")
page.evaluate("""() => {
const assignedIssue = {
kind:'issue', state:'open', claimable:false, assigned_to_me:false,
assignees:['alexander'], watching:false,
};
globalThis.renderSearchPreviewWatch(
assignedIssue, {status:'ready'}, document.querySelector('#watch-search-result')
);
}""")
page.add_script_tag(path=FRONTEND / "mobile-search-preview-nav.js")
watch = page.locator("#watch-search-result")
expect(watch).to_be_visible()
expect(watch).to_have_text("Watch issue")
watch_bounds = watch.bounding_box()
assert watch_bounds and watch_bounds["height"] >= 44
navigation = page.locator(".mobile-search-preview-nav")
expect(navigation).to_be_visible()
buttons = navigation.locator("button")
buttons = navigation.locator("button:visible")
assert navigation.locator("button").count() == 5
assert buttons.count() == 4
expect(navigation.locator('[data-search-preview-section="changes"]')).to_be_hidden()
for button in buttons.all():
bounds = button.bounding_box()
assert bounds and bounds["height"] >= 44
+4 -2
View File
@@ -419,11 +419,12 @@ async def test_slow_ledger_completion_does_not_block_health_requests(monkeypatch
return {"id": 249, "body": body}
started = threading.Event()
release = threading.Event()
original_complete = main._idempotency_ledger.complete
def slow_complete(key, response):
started.set()
time.sleep(0.2)
assert release.wait(1)
return original_complete(key, response)
monkeypatch.setattr(main._idempotency_ledger, "complete", slow_complete)
@@ -432,15 +433,16 @@ async def test_slow_ledger_completion_does_not_block_health_requests(monkeypatch
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)
started_at = asyncio.get_running_loop().time()
health = await client.get("/healthz")
health_elapsed = asyncio.get_running_loop().time() - started_at
release.set()
authored_response = await authored
assert health.status_code == 200
+20 -1
View File
@@ -71,7 +71,11 @@ def test_release_promotion_waits_for_packaged_mobile_journeys():
"tests/e2e/test_mobile_tomorrow_conflict_release.py "
"tests/e2e/test_mobile_week_ahead_release.py "
"tests/e2e/test_mobile_today_week_reschedule_release.py "
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
"tests/e2e/test_mobile_wrap_up_handoff_release.py "
"tests/e2e/test_mobile_following_release.py "
"tests/e2e/test_mobile_detail_watch_release.py "
"tests/e2e/test_mobile_pull_reviewer_status_release.py "
"tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q"
) in browser
assert "needs: [lint, build-release, browser-journey]" in release
@@ -81,3 +85,18 @@ def test_browser_job_runs_packaged_today_week_reschedule_journey():
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_today_week_reschedule_release.py" in browser
def test_browser_job_gates_latest_pull_review_mobile_journeys():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_address_review_feedback_release.py" in browser
assert "tests/e2e/test_mobile_cancel_pull_review_request_release.py" in browser
def test_browser_job_gates_authored_pull_queue_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_authored_pull_queue_release.py" in browser
+236 -2
View File
@@ -389,6 +389,27 @@ process.stdout.write(JSON.stringify({{
}
def test_search_preview_reports_only_successfully_loaded_revisions_to_its_source_queue():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const loaded=[];
const preview=createSearchPreview({{
fetchJson:item => item.number === 9 ? Promise.reject(new Error('preview failed')) :
Promise.resolve({{...item,title:'Loaded'}}),
mutate:async()=>{{}}, onOpened:item=>loaded.push([item.number,item.updated_at]), onState:()=>{{}},
}});
await preview.open({{repository:'stackchain/api',number:42,kind:'issue',updated_at:'rev-1'}});
await preview.open({{repository:'stackchain/api',number:9,kind:'issue',updated_at:'rev-2'}}).catch(()=>{{}});
process.stdout.write(JSON.stringify(loaded));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [[42, "rev-1"]]
def test_search_preview_queue_and_next_advances_only_after_durable_admission():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
@@ -631,7 +652,7 @@ def test_mobile_search_preview_renders_session_navigation_and_advances_after_pla
assert 'aria-label="Search result navigation"' in html
assert ".search-preview-navigation" in css
assert ".search-preview-header button, .search-preview-actions button" in css
assert "session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})]" in html
assert "getSession:() => followingQueue.session() || commandSearchState" in html
preview_source = SEARCH_PREVIEW.read_text()
assert "api.previous()" in preview_source
assert "api.next()" in preview_source
@@ -806,6 +827,219 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_watch_is_single_flight_and_keeps_visible_context_on_failure():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let calls = 0; let rejectWatch; const states = [];
const detail = {{repository:'stackchain/api',number:42,kind:'issue',claimable:true,watching:false}};
const preview = createSearchPreview({{
fetchJson:async()=>detail, mutate:async()=>{{}},
watch:()=>{{calls+=1;return new Promise((_resolve,reject)=>rejectWatch=reject);}},
onState:state=>states.push(state),
}});
await preview.open(detail);
const first=preview.setWatching(true); const second=preview.setWatching(true);
if (calls !== 1 || first !== second) throw new Error('watch mutation was not single-flight');
rejectWatch(new Error('offline')); await Promise.allSettled([first,second]);
const final=states.at(-1);
process.stdout.write(JSON.stringify({{calls,status:final.status,number:final.detail.number,
watching:final.detail.watching,error:final.error.message}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": 1,
"status": "watch-error",
"number": 42,
"watching": False,
"error": "offline",
}
def test_search_preview_preserves_authoritative_watch_and_offers_following_repair():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const states = [];
const detail = {{repository:'stackchain/api',number:42,kind:'pull',state:'open',claimable:true,watching:false}};
const preview = createSearchPreview({{
fetchJson:async()=>detail,
watch:async()=>({{watching:true,following_synced:false,error:'Watching in Gitea, but Following could not sync. Retry this action.'}}),
onState:state=>states.push(state),
}});
await preview.open(detail);
await preview.setWatching(true);
const final=states.at(-1);
process.stdout.write(JSON.stringify({{
status:final.status,
watching:final.detail.watching,
message:globalThis.searchPreviewWatchStatus(final),
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"status": "watch-partial",
"watching": True,
"message": "Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch pull request to repair.",
}
def test_mobile_search_preview_exposes_touch_safe_watch_action():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
assert '<button id="watch-search-result" type="button" hidden>Watch issue</button>' in html
assert "wireSearchPreviewWatch" in dashboard
assert "preview.setWatching" in SEARCH_PREVIEW.read_text()
assert ".search-preview-actions button" in css and "min-height:44px" in css
def test_search_preview_watch_is_available_for_open_issues_pulls_and_closed_following_retirement():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const loaded = [];
const options = globalThis.searchPreviewSubscriptionOptions(async path => {{
loaded.push(path);
return {{watching:true}};
}});
const assigned = {{repository:'stackchain/api',number:42,kind:'issue',state:'open',
claimable:false,assignees:['alexander']}};
const hydrated = await options.load(assigned);
const button = {{hidden:true,textContent:'',disabled:false}};
globalThis.renderSearchPreviewWatch(hydrated, {{status:'ready'}}, button);
const excluded = [];
for (const detail of [
{{...assigned,state:'closed'}},
{{...assigned,kind:'pull'}},
]) {{
await options.load(detail);
const candidate = {{hidden:false,textContent:'',disabled:false}};
globalThis.renderSearchPreviewWatch(detail, {{status:'ready'}}, candidate);
excluded.push(candidate.hidden);
}}
const closedFollowing = await options.load({{...assigned,state:'closed',following:true}});
const retire = {{hidden:true,textContent:'',disabled:false}};
globalThis.renderSearchPreviewWatch(closedFollowing, {{status:'ready'}}, retire);
process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,excluded,closedFollowing,retire}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["loaded"] == [
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=pull",
]
assert payload["watching"] is True
assert payload["button"] == {
"hidden": False,
"textContent": "Stop watching",
"disabled": False,
}
assert payload["excluded"] == [True, False]
assert payload["closedFollowing"]["watching"] is True
assert payload["retire"] == {
"hidden": False,
"textContent": "Stop watching & next",
"disabled": False,
}
def test_closed_following_unwatch_is_single_flight_and_advances_only_after_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let finish;
const states=[]; const retired=[]; const opened=[]; let calls=0;
const next={{repository:'stackchain/web',number:9,kind:'issue',state:'open'}};
const preview=createSearchPreview({{
fetchJson:item => Promise.resolve(item), mutate:()=>Promise.resolve(),
watch:() => {{ calls += 1; return new Promise(resolve => finish=resolve); }},
afterUnwatch:item => {{ retired.push(item.number); return next; }},
onNavigate:item => opened.push(item.number), onState:state => states.push(state),
}});
await preview.open({{repository:'stackchain/api',number:42,kind:'issue',state:'closed',following:true,watching:true}});
const first=preview.setWatching(false); const second=preview.setWatching(false);
await new Promise(resolve => setImmediate(resolve));
const before={{calls,retired:[...retired],opened:[...opened]}};
finish({{watching:false,following_synced:true}});
await Promise.all([first,second]);
process.stdout.write(JSON.stringify({{before,calls,same:first===second,retired,opened,last:states.at(-1)}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["before"] == {"calls": 1, "retired": [], "opened": []}
assert payload["same"] is True
assert payload["retired"] == [42]
assert payload["opened"] == [9]
assert payload["last"]["detail"]["number"] == 9
def test_search_subscription_preview_hydrates_detail_before_watch_state():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const calls=[];
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
calls.push(path);
if (path.includes('/subscription')) return {{watching:true}};
return {{repository:'stackchain/api',number:42,kind:'issue',state:'open',title:'Hydrated'}};
}});
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue'}});
process.stdout.write(JSON.stringify({{calls,detail}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": [
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue",
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
],
"detail": {
"repository": "stackchain/api", "number": 42, "kind": "issue",
"state": "open", "title": "Hydrated", "watching": True,
},
}
def test_closed_following_preview_preserves_origin_and_skips_status_lookup():
script = f"""
require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const calls=[];
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
calls.push(path);
return {{repository:'stackchain/api',number:42,kind:'issue',state:'closed',title:'Finished'}};
}});
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue',following:true}});
process.stdout.write(JSON.stringify({{calls,detail}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["calls"] == [
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue"
]
assert payload["detail"]["following"] is True
assert payload["detail"]["watching"] is True
def test_search_preview_shares_canonical_url_without_closing_the_preview():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
@@ -1100,7 +1334,7 @@ def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
html = dashboard_bundle_text()
assert "detail.reopenable ? 'Reopen & resume'" in html
assert "mutate: (detail, action) => fetchReviewJson(" in html
assert "mutate:(detail,action)=>fetchReviewJson(" in html
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
"qs('#close-whiteboard')", 1
)[0]
+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v130" in worker
assert "stackchain-dashboard-shell-v138" in worker
+5 -5
View File
@@ -364,7 +364,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == [
@@ -392,7 +392,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
@@ -525,7 +525,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -550,7 +550,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -648,7 +648,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
+186
View File
@@ -0,0 +1,186 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
MODULE = ROOT / "frontend" / "search-preview.js"
def run(script: str) -> dict:
harness = f"""
require({json.dumps(str(MODULE))});
const createDetailWatch = globalThis.createDetailWatch;
const state = {{ requests:[], renders:[], refreshes:0 }};
const feature = createDetailWatch({{
fetchJson: async (path, options={{}}) => {{
state.requests.push({{path,method:options.method || 'GET'}});
return {{watching:false,following_synced:true}};
}},
refreshFollowing: async () => {{ state.refreshes += 1; }},
onState: (status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
}});
(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
return json.loads(completed.stdout)
def test_detail_watch_loads_authoritative_typed_subscription_state():
result = run("""
await feature.open({repository:'stackchain/api',kind:'pull',number:84,state:'open'});
process.stdout.write(JSON.stringify(state));
""")
assert result["requests"] == [{
"path": "api/v1/repos/stackchain/api/issues/84/preview/subscription?kind=pull",
"method": "GET",
}]
assert result["renders"] == [
{"status": "loading", "watching": False},
{"status": "ready", "watching": False},
]
assert result["refreshes"] == 0
def test_detail_watch_mutation_is_single_flight_and_refreshes_following_after_confirmation():
script = f"""
require({json.dumps(str(MODULE))});
const createDetailWatch = globalThis.createDetailWatch;
const state={{requests:[],renders:[],refreshes:0}};
let finishMutation;
const feature=createDetailWatch({{
fetchJson:async (path,options={{}}) => {{
state.requests.push({{path,method:options.method || 'GET'}});
if (!options.method) return {{watching:false}};
return new Promise(resolve => {{finishMutation=() => resolve({{watching:true,following_synced:true,following_count:3}});}});
}},
refreshFollowing:async () => {{state.refreshes += 1;}},
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
}});
(async () => {{
const item={{repository:'stackchain/api',kind:'issue',number:42,state:'open'}};
await feature.open(item);
const first=feature.toggle();
const second=feature.toggle();
await new Promise(resolve => setImmediate(resolve));
state.samePromise=first === second;
finishMutation();
await Promise.all([first,second]);
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{console.error(error);process.exit(1);}});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["samePromise"] is True
assert [request["method"] for request in result["requests"]] == ["GET", "PUT"]
assert result["refreshes"] == 1
assert result["renders"][-2]["status"] == "watching"
assert result["renders"][-1]["status"] == "watched"
assert result["renders"][-1]["watching"] is True
def test_detail_watch_partial_sync_never_claims_success_or_refreshes_following():
script = f"""
require({json.dumps(str(MODULE))});
const createDetailWatch = globalThis.createDetailWatch;
const state={{renders:[],refreshes:0}};
const feature=createDetailWatch({{
fetchJson:async (_path,options={{}}) => options.method
? {{watching:true,following_synced:false,error:'Following could not sync.'}}
: {{watching:false}},
refreshFollowing:async () => {{state.refreshes += 1;}},
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
}});
(async () => {{
await feature.open({{repository:'stackchain/api',kind:'issue',number:42,state:'open'}});
try {{ await feature.toggle(); }} catch (error) {{ state.rejected=error.message; }}
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{console.error(error);process.exit(1);}});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["rejected"] == "Following could not sync."
assert result["refreshes"] == 0
assert result["renders"][-1]["status"] == "error"
assert result["renders"][-1]["watching"] is False
assert all(state["status"] != "watched" for state in result["renders"])
def test_detail_watch_is_wired_into_both_mobile_work_details_and_bundle():
html = (ROOT / "frontend" / "index.html").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
workflow = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text()
search_preview = (ROOT / "frontend" / "search-preview.js").read_text()
assert '<button id="watch-issue-detail" type="button">Watch issue</button>' in html
assert '<button id="watch-pull-detail" type="button">Watch pull request</button>' in html
assert 'id="issue-watch-status"' in html
assert 'id="pull-watch-status"' in html
assert 'static/search-preview.js' in bundle
assert 'root.createDetailWatch' in search_preview
assert 'function bindDetailWatch(' in dashboard
assert 'issueDetailWatch.open(item)' in dashboard
assert 'pullDetailWatch.open(item)' in dashboard
assert '#watch-issue-detail' in css
assert '#watch-pull-detail' in css
assert 'tests/e2e/test_mobile_detail_watch_release.py' in workflow
def test_detail_watch_ignores_a_late_subscription_response_for_the_previous_item():
script = f"""
require({json.dumps(str(MODULE))});
const createDetailWatch = globalThis.createDetailWatch;
const state={{renders:[]}};
const completions={{}};
const feature=createDetailWatch({{
fetchJson:path => new Promise(resolve => {{completions[path]=resolve;}}),
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
}});
(async () => {{
const first=feature.open({{repository:'stackchain/api',kind:'issue',number:1,state:'open'}});
const second=feature.open({{repository:'stackchain/api',kind:'issue',number:2,state:'open'}});
completions[Object.keys(completions)[1]]({{watching:true}});
await second;
completions[Object.keys(completions)[0]]({{watching:false}});
await first;
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{console.error(error);process.exit(1);}});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
ready = [state for state in result["renders"] if state["status"] == "ready"]
assert len(ready) == 1
assert ready[0]["watching"] is True
def test_detail_watch_reports_subscription_load_failure_as_recoverable_error():
script = f"""
require({json.dumps(str(MODULE))});
const state={{renders:[]}};
const feature=globalThis.createDetailWatch({{
fetchJson:async () => {{throw new Error('temporarily unavailable');}},
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
}});
(async () => {{
try {{ await feature.open({{repository:'stackchain/api',kind:'issue',number:1,state:'open'}}); }}
catch (error) {{ state.rejected=error.message; }}
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{console.error(error);process.exit(1);}});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["rejected"] == "temporarily unavailable"
assert result["renders"][-1] == {
"status": "error", "watching": False, "error": "temporarily unavailable"
}
+425
View File
@@ -0,0 +1,425 @@
import httpx
import pytest
from src import main
from src.following_store import FollowingStore
@pytest.mark.anyio
async def test_confirmed_watch_updates_account_following_collection(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"a" * 32)
detail = {
"repository": "stackchain/api",
"kind": "issue",
"number": 42,
"title": "Make mobile review useful",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
"claimable": True,
}
async def preview(repository, kind, number):
assert (repository, kind, number) == ("stackchain/api", "issue", 42)
return detail
async def set_subscription(repository, number, watching):
return {"watching": watching}
async def user():
return {"login": "Timmy"}
monkeypatch.setattr(main, "_following_store", lambda: store, raising=False)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
watched = await client.put(
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
)
following = await client.get("/api/v1/following")
assert watched.status_code == 200
assert watched.json() == {
"watching": True,
"following_synced": True,
"following_revision": 1,
"following_count": 1,
}
assert following.status_code == 200
assert following.headers["cache-control"] == "no-store"
assert following.json() == {"revision": 1, "degraded": False, "refresh_failures": 0, "items": [{
"repository": "stackchain/api",
"kind": "issue",
"number": 42,
"title": "Make mobile review useful",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
"has_unseen_change": False,
}]}
@pytest.mark.anyio
async def test_confirmed_pull_watch_round_trips_through_following(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"p" * 32)
detail = {
"repository": "stackchain/api", "kind": "pull", "number": 84,
"title": "Ship typed Following", "state": "open",
"updated_at": "2026-08-23T05:00:00Z",
"url": "https://forge.example/stackchain/api/pulls/84",
}
previews = []
async def preview(repository, kind, number):
previews.append((repository, kind, number))
return detail
async def set_subscription(repository, number, watching):
return {"watching": watching}
async def user():
return {"login": "timmy"}
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
watched = await client.put(
"/api/v1/repos/stackchain/api/issues/84/preview/subscription?kind=pull"
)
following = await client.get("/api/v1/following")
assert watched.status_code == 200
assert following.status_code == 200
assert following.json()["items"][0]["kind"] == "pull"
assert previews == [
("stackchain/api", "pull", 84),
("stackchain/api", "pull", 84),
]
@pytest.mark.anyio
async def test_watched_pull_can_load_read_only_review_context_without_assignment(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"r" * 32)
store.set_watching("timmy", {
"repository": "stackchain/api", "kind": "pull", "number": 84,
"title": "Review watched changes", "state": "open",
"updated_at": "2026-08-24T05:00:00Z",
"url": "https://forge.example/stackchain/api/pulls/84",
}, True)
review = {
"head_sha": "abc123", "ci_state": "success",
"files": [{"filename": "src/api.py", "status": "modified", "patch": "+safe"}],
}
async def user():
return {"login": "timmy"}
async def load_review(repository, number):
assert (repository, number) == ("stackchain/api", 84)
return review
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", load_review)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/following/stackchain/api/pulls/84/review-data"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == review
@pytest.mark.anyio
async def test_unwatched_pull_review_context_is_not_exposed(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"u" * 32)
async def user():
return {"login": "timmy"}
async def unexpected_review(*_args):
raise AssertionError("unwatched pull must be rejected before upstream access")
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", unexpected_review)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/following/stackchain/api/pulls/84/review-data"
)
assert response.status_code == 404
@pytest.mark.anyio
async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1)
store.set_watching("timmy", {
"repository": "stackchain/api", "number": 7, "title": "Already followed",
"state": "open", "updated_at": "2026-08-23T02:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7",
}, True)
calls = []
async def preview(repository, kind, number):
return {
"repository": repository, "kind": kind, "number": number,
"title": "Another issue", "state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42", "claimable": True,
}
async def set_subscription(*args):
calls.append(args)
return {"watching": True}
async def user():
return {"login": "timmy"}
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
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.put(
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
)
assert response.status_code == 503
assert calls == []
assert store.get("timmy")["items"][0]["number"] == 7
@pytest.mark.anyio
async def test_following_write_failure_compensates_confirmed_gitea_watch(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"c" * 32)
calls = []
async def preview(repository, kind, number):
return {
"repository": repository, "kind": kind, "number": number,
"title": "Recoverable watch", "state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42", "claimable": True,
}
async def set_subscription(repository, number, watching):
calls.append(watching)
return {"watching": watching}
async def user():
return {"login": "timmy"}
def fail_write(*args):
raise OSError("disk unavailable")
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(store, "set_watching", fail_write)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.put(
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
)
assert response.status_code == 503
assert response.json() == {"error": "Watch status was not changed. Please retry."}
assert calls == [True, False]
@pytest.mark.anyio
async def test_uncompensated_following_write_reports_authoritative_partial_outcome(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"d" * 32)
calls = []
async def preview(repository, kind, number):
return {
"repository": repository, "kind": kind, "number": number,
"title": "Split watch", "state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42", "claimable": True,
}
async def set_subscription(repository, number, watching):
calls.append(watching)
return {"watching": True}
async def user():
return {"login": "timmy"}
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(store, "set_watching", lambda *args: (_ for _ in ()).throw(OSError()))
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.put(
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
)
assert response.status_code == 200
assert response.json() == {
"watching": True,
"following_synced": False,
"error": "Watching in Gitea, but Following could not sync. Retry this action.",
}
assert calls == [True, False]
@pytest.mark.anyio
async def test_following_refreshes_changed_items_and_preserves_failed_items(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"e" * 32)
first = {
"repository": "stackchain/api", "number": 42, "title": "Old title",
"state": "open", "updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
}
failed = {**first, "number": 43, "title": "Last known",
"url": "https://forge.example/stackchain/api/issues/43"}
store.set_watching("timmy", failed, True)
store.set_watching("timmy", first, True)
async def preview(repository, kind, number):
assert (repository, kind) == ("stackchain/api", "issue")
if number == 43:
return {**failed, "kind": "issue", "title": ""}
return {**first, "kind": "issue", "title": "Fresh title", "state": "closed",
"updated_at": "2026-08-23T04:00:00Z"}
async def user():
return {"login": "timmy"}
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
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/following")
assert response.status_code == 200
payload = response.json()
assert payload["degraded"] is True
assert payload["refresh_failures"] == 1
assert [(item["number"], item["has_unseen_change"]) for item in payload["items"]] == [
(42, True), (43, False)
]
assert payload["items"][0]["title"] == "Fresh title"
assert payload["items"][1]["title"] == "Last known"
@pytest.mark.anyio
async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32)
item = {
"repository": "stackchain/api", "kind": "pull", "number": 42, "title": "Changed",
"state": "open", "updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
}
store.set_watching("timmy", item, True)
store.refresh("timmy", [{**item, "updated_at": "2026-08-23T04:00:00Z"}])
async def user():
return {"login": "timmy"}
monkeypatch.setattr(main, "_following_store", lambda: store)
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.put(
"/api/v1/following/stackchain/api/issues/42/seen?kind=pull",
json={"updated_at": "2026-08-23T04:00:00Z"},
)
assert response.status_code == 200
assert response.json()["items"][0]["has_unseen_change"] is False
@pytest.mark.anyio
async def test_following_keep_restores_only_the_loaded_revision(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32)
item = {
"repository": "stackchain/api", "kind": "pull", "number": 42, "title": "Changed",
"state": "open", "updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/pulls/42",
}
store.set_watching("timmy", item, True)
changed = {**item, "updated_at": "2026-08-23T04:00:00Z"}
store.refresh("timmy", [changed])
store.acknowledge("timmy", item["repository"], item["number"], changed["updated_at"], kind="pull")
async def user():
return {"login": "timmy"}
monkeypatch.setattr(main, "_following_store", lambda: store)
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.put(
"/api/v1/following/stackchain/api/issues/42/keep?kind=pull",
json={"updated_at": changed["updated_at"]},
)
assert response.status_code == 200
assert response.json()["items"][0]["has_unseen_change"] is True
@pytest.mark.anyio
async def test_closed_following_issue_can_be_unwatched_but_not_newly_watched(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
closed = {
"repository": "stackchain/api", "number": 42, "title": "Finished work",
"state": "closed", "updated_at": "2026-08-23T04:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
}
store.set_watching("timmy", closed, True)
mutations = []
async def preview(repository, kind, number):
return {**closed, "kind": "issue"}
async def set_subscription(repository, number, watching):
mutations.append((repository, number, watching))
return {"watching": watching}
async def user():
return {"login": "timmy"}
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
watch = await client.put(
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
)
unwatch = await client.delete(
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
)
assert watch.status_code == 404
assert unwatch.status_code == 200
assert unwatch.json() == {
"watching": False,
"following_synced": True,
"following_revision": 2,
"following_count": 0,
}
assert mutations == [("stackchain/api", 42, False)]
assert store.get("timmy")["items"] == []
+615
View File
@@ -0,0 +1,615 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
MODULE = ROOT / "frontend" / "following.js"
def run(script: str) -> dict:
harness = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{ renders:[], counts:[], opened:[], requests:[] }};
const feature = createFollowing({{
fetchJson: async path => {{
state.requests.push(path);
return {{revision:3,items:[{{repository:'stackchain/api',number:42,title:'Quiet issue',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge.example/issue/42',has_unseen_change:false}}]}};
}},
render: snapshot => state.renders.push(snapshot),
onCount: count => state.counts.push(count),
onOpen: item => state.opened.push(item),
}});
(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
return json.loads(completed.stdout)
def test_following_loads_account_collection_and_opens_existing_preview():
result = run("""
await feature.load();
feature.open(0);
process.stdout.write(JSON.stringify(state));
""")
assert result["requests"] == ["api/v1/following"]
assert result["counts"] == [0]
assert result["renders"][-1]["status"] == "ready"
assert result["renders"][-1]["items"][0]["title"] == "Quiet issue"
assert result["opened"] == [{
"repository": "stackchain/api",
"number": 42,
"title": "Quiet issue",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/issue/42",
"has_unseen_change": False,
"kind": "issue",
"following": True,
}]
def test_following_opens_pull_requests_in_the_pull_workspace():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const opened=[];
const feature=createFollowing({{
fetchJson:async () => ({{revision:1,items:[{{
repository:'stackchain/api',kind:'pull',number:84,title:'Typed review',state:'open',
updated_at:'2026-08-23T05:00:00Z',url:'https://forge.example/pulls/84',has_unseen_change:true
}}]}}),
onOpen:async item => opened.push(item), onAcknowledge:async () => {{}},
}});
(async () => {{
await feature.load();
await feature.startReview();
process.stdout.write(JSON.stringify({{opened,session:feature.session()}}));
}})().catch(error => {{console.error(error);process.exit(1);}});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["opened"][0]["kind"] == "pull"
assert result["opened"][0]["following"] is True
assert result["session"]["items"][0]["kind"] == "pull"
def test_following_route_loads_authoritative_state_before_starting_review():
following = MODULE.read_text()
start = following.index("async route()")
route = following[start:following.index("open()", start)]
assert "await feature.load().then(feature.startReview)" in route
assert ".catch(() => false)" in route
assert "show();" in route
def test_dashboard_dispatches_following_route_to_changed_first_review():
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
start = dashboard.index("async function openWorkQueueRoute")
route_handler = dashboard[start:dashboard.index("qs('#work-milestone-filter')", start)]
assert "if (filter === 'following')" in route_handler
assert "return followingQueue.route();" in route_handler
assert route_handler.index("followingQueue.route") < route_handler.index("selectWorkQueue")
def test_following_preview_wires_keep_for_later_action_and_retryable_failure():
html = (ROOT / "frontend" / "index.html").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
following = MODULE.read_text()
assert 'id="keep-following-for-later"' in html
assert 'id="keep-following-status"' in html
assert "followingQueue.preview(state)" in dashboard
assert "query('#close-search-preview').click()" in following
assert "state?.item?.following !== true" in following
assert "feature.keepForLater(activeReviewItem)" in following
assert "Kept for later" in following
assert "Could not keep this change for later" in following
assert "changeRevision(item, 'keep')" in following
assert "keepForLater:feature.keepForLater" in following
def test_following_conversation_marks_messages_newer_than_last_review():
preview_module = ROOT / "frontend" / "search-preview.js"
script = f"""
require({json.dumps(str(preview_module))});
const nodes={{
'#search-preview-comments':{{innerHTML:'',textContent:''}},
'#search-preview-conversation-status':{{textContent:''}},
'#retry-search-preview-conversation':{{hidden:true}},
'#load-older-search-preview-comments':{{hidden:true,disabled:false}},
}};
const document={{querySelector:selector=>nodes[selector]}};
global.renderSearchPreviewConversation({{
status:'ready', reviewedAt:'2026-08-23T03:00:00Z', comments:[
{{id:1,author:'Timmy',created_at:'2026-08-23T02:00:00Z',body:'Earlier'}},
{{id:2,author:'Alex',created_at:'2026-08-23T04:00:00Z',body:'New decision'}},
]
}},document,String,String,String);
process.stdout.write(JSON.stringify({{html:nodes['#search-preview-comments'].innerHTML,status:nodes['#search-preview-conversation-status'].textContent}}));
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["html"].count("new-since-review") == 1
assert "New since last review" in result["html"]
assert result["status"] == "2 messages · 1 new since last review."
def test_following_revision_is_acknowledged_only_after_conversation_context_loads():
preview_module = ROOT / "frontend" / "search-preview.js"
script = f"""
const createSearchPreview=require({json.dumps(str(preview_module))});
let unavailable=true;
const opened=[];
const preview=createSearchPreview({{
fetchJson:async item=>({{...item,title:'Changed issue'}}),
fetchConversation:async()=>{{if(unavailable)throw new Error('offline');return {{comments:[]}};}},
onOpened:async item=>opened.push(item.updated_at),
onState:()=>{{}},
}});
(async()=>{{
await preview.open({{
repository:'stackchain/api',kind:'issue',number:42,following:true,
reviewed_at:'2026-08-23T03:00:00Z',updated_at:'2026-08-23T04:00:00Z'
}});
const afterFailure=[...opened];
unavailable=false;
await preview.retryConversation();
process.stdout.write(JSON.stringify({{afterFailure,opened}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result == {
"afterFailure": [],
"opened": ["2026-08-23T04:00:00Z"],
}
def test_following_pull_revision_waits_for_changes_and_ci_context():
preview_module = ROOT / "frontend" / "search-preview.js"
script = f"""
const createSearchPreview=require({json.dumps(str(preview_module))});
let unavailable=true;
const opened=[];
const states=[];
const preview=createSearchPreview({{
fetchJson:async item=>({{...item,title:'Changed pull request'}}),
fetchConversation:async()=>({{comments:[]}}),
fetchReview:async()=>{{if(unavailable)throw new Error('diff offline');return {{ci_state:'success',files:[{{filename:'src/api.py',diff_lines:['+safe'],diff_available:true}}]}};}},
onOpened:async item=>opened.push(item.updated_at),
onState:state=>states.push({{status:state.review?.status,ci:state.review?.data?.ci_state}}),
}});
(async()=>{{
await preview.open({{
repository:'stackchain/api',kind:'pull',number:84,following:true,
reviewed_at:'2026-08-23T03:00:00Z',updated_at:'2026-08-23T04:00:00Z'
}});
const afterFailure=[...opened];
unavailable=false;
await preview.retryReview();
process.stdout.write(JSON.stringify({{afterFailure,opened,last:states.at(-1)}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result == {
"afterFailure": [],
"opened": ["2026-08-23T04:00:00Z"],
"last": {"status": "ready", "ci": "success"},
}
def test_watched_pull_changes_render_bounded_diff_and_ci_state():
preview_module = ROOT / "frontend" / "search-preview.js"
script = f"""
require({json.dumps(str(preview_module))});
const nodes={{
'#search-preview-review':{{hidden:true}},
'#search-preview-review-status':{{textContent:''}},
'#search-preview-files':{{innerHTML:''}},
'#retry-search-preview-review':{{hidden:true}},
}};
const document={{querySelector:selector=>nodes[selector]}};
global.renderSearchPreviewReview({{status:'ready',data:{{ci_state:'success',files:[{{
filename:'src/api.py',status:'modified',additions:1,deletions:0,
diff_available:true,diff_lines:['@@ -1 +1 @@','-unsafe','+safe']
}}]}}}},document,value=>String(value).replaceAll('<','&lt;'));
process.stdout.write(JSON.stringify({{
hidden:nodes['#search-preview-review'].hidden,
status:nodes['#search-preview-review-status'].textContent,
html:nodes['#search-preview-files'].innerHTML,
retry:nodes['#retry-search-preview-review'].hidden,
}}));
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["hidden"] is False
assert result["status"] == "CI passed · 1 changed file."
assert "src/api.py" in result["html"]
assert "pull-diff-line added" in result["html"]
assert result["retry"] is True
def test_following_typed_identity_prevents_issue_pull_collisions():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const acknowledged=[];
const items=['issue','pull'].map(kind => ({{
repository:'stackchain/api',kind,number:84,title:kind,state:'closed',
updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true
}}));
const feature=createFollowing({{
fetchJson:async () => ({{revision:1,items}}),
onOpen:async () => {{}}, onAcknowledge:async item => acknowledged.push(item.kind),
}});
(async () => {{
await feature.load();
await feature.previewLoaded({{...items[1]}});
feature.retire(items[1]);
process.stdout.write(JSON.stringify({{acknowledged,items:feature.items()}}));
}})().catch(error => {{console.error(error);process.exit(1);}});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["acknowledged"] == ["pull"]
assert [(item["kind"], item["has_unseen_change"]) for item in result["items"]] == [
("issue", True)
]
def test_following_opens_explicitly_but_never_becomes_work_recommendation():
launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
script = f"""
const createLauncher = require({json.dumps(str(launcher))});
const calls = [];
const feature = createLauncher({{
getCounts:() => ({{following:7}}),
openFollowing:() => {{ calls.push('following'); return 'opened-following'; }},
selectFilter:name => calls.push(name), firstAction:() => null,
announce:() => {{}}, openFindWork:() => calls.push('find'),
}});
process.stdout.write(JSON.stringify({{opened:feature.open('following'),recommended:feature.recommend(),calls}}));
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result == {
"opened": "opened-following",
"recommended": {"name": "find", "count": 0, "label": "Find Work"},
"calls": ["following"],
}
def test_following_counts_unseen_changes_and_acknowledges_after_preview_loads():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{counts:[], opened:[], acknowledged:[], renders:[]}};
let finishPreview;
const feature = createFollowing({{
fetchJson:async () => ({{revision:4,items:[
{{repository:'stackchain/api',number:42,title:'Changed',state:'open',updated_at:'2026-08-23T04:00:00Z',url:'https://forge/42',has_unseen_change:true}},
{{repository:'stackchain/api',number:43,title:'Quiet',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge/43',has_unseen_change:false}}
]}}),
render:value => state.renders.push(value),
onCount:value => state.counts.push(value),
onOpen:item => new Promise(resolve => {{ finishPreview=() => {{ state.opened.push(item.number); resolve(); }}; }}),
onAcknowledge:async item => state.acknowledged.push(item.updated_at),
}});
(async () => {{
await feature.load();
const opening=feature.open(0);
await new Promise(resolve => setImmediate(resolve));
state.before={{acknowledged:[...state.acknowledged],counts:[...state.counts]}};
finishPreview();
await opening;
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["before"] == {"acknowledged": [], "counts": [1]}
assert result["opened"] == [42]
assert result["acknowledged"] == ["2026-08-23T04:00:00Z"]
assert result["counts"] == [1, 0]
assert result["renders"][-1]["items"][0]["has_unseen_change"] is False
def test_following_review_captures_only_current_unseen_changes_in_display_order():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{opened:[], acknowledged:[]}};
const feature = createFollowing({{
fetchJson:async () => ({{revision:7,items:[
{{repository:'stackchain/api',number:42,title:'Newest',state:'open',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
{{repository:'stackchain/web',number:9,title:'Also changed',state:'open',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}},
{{repository:'stackchain/api',number:41,title:'Quiet',state:'open',updated_at:'2026-08-22T05:00:00Z',has_unseen_change:false}}
]}}),
onOpen:async item => state.opened.push(item.number),
onAcknowledge:async item => state.acknowledged.push(item.number),
}});
(async () => {{
await feature.load();
const started=await feature.startReview();
process.stdout.write(JSON.stringify({{started,session:feature.session(),state}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["started"] is True
assert [item["number"] for item in result["session"]["items"]] == [42, 9]
assert all(item["kind"] == "issue" for item in result["session"]["items"])
assert all(item["following"] is True for item in result["session"]["items"])
assert result["session"]["more"] is False
assert result["state"] == {"opened": [42], "acknowledged": [42]}
def test_following_review_acknowledges_each_loaded_revision_and_reports_completion():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{acknowledged:[], renders:[], counts:[]}};
const items=[
{{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
{{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
];
const feature = createFollowing({{
fetchJson:async () => ({{revision:7,items}}),
render:value => state.renders.push(value), onCount:value => state.counts.push(value),
onOpen:async () => {{}}, onAcknowledge:async item => state.acknowledged.push(item.number),
}});
(async () => {{
await feature.load();
await feature.startReview();
await feature.previewLoaded(feature.session().items[1]);
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["acknowledged"] == [42, 9]
assert result["counts"][-1] == 0
assert result["renders"][-1]["reviewSummary"] == {"reviewed": 2, "remaining": 0}
def test_following_keep_for_later_preserves_revision_and_advances_without_looping():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{opened:[], acknowledged:[], kept:[], counts:[]}};
const items=[
{{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
{{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
];
const feature = createFollowing({{
fetchJson:async () => ({{revision:7,items}}),
onOpen:async item => state.opened.push(item.number),
onAcknowledge:async item => state.acknowledged.push(item.number),
onKeep:async item => state.kept.push(item.number),
onCount:value => state.counts.push(value),
}});
(async () => {{
await feature.load();
await feature.startReview();
const kept = await feature.keepForLater(feature.session().items[0]);
process.stdout.write(JSON.stringify({{
kept, session:feature.session(), items:feature.items(), state
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["kept"]["number"] == 9
assert [item["number"] for item in result["session"]["items"]] == [9]
assert result["items"][0]["has_unseen_change"] is True
assert result["state"] == {
"opened": [42, 9], "acknowledged": [42, 9], "kept": [42], "counts": [2, 1, 2, 1]
}
def test_following_review_notifies_prepare_today_when_final_revision_is_loaded():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const completed = [];
const items=[
{{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
{{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
];
const feature = createFollowing({{
fetchJson:async () => ({{revision:7,items}}),
onOpen:async () => {{}},
onAcknowledge:async () => {{}},
onReviewComplete:() => completed.push('following'),
}});
(async () => {{
await feature.load();
await feature.startReview();
await feature.previewLoaded(feature.session().items[1]);
const beforeFinish = completed.slice();
feature.finishReview();
feature.finishReview();
process.stdout.write(JSON.stringify({{beforeFinish, completed}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout) == {"beforeFinish": [], "completed": ["following"]}
def test_following_retirement_removes_closed_item_and_keeps_review_moving():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{renders:[], counts:[], completed:[]}};
const items=[
{{repository:'stackchain/api',number:42,title:'Closed first',state:'closed',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
{{repository:'stackchain/web',number:9,title:'Closed second',state:'closed',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
];
const feature = createFollowing({{
fetchJson:async () => ({{revision:7,items}}),
render:value => state.renders.push(value), onCount:value => state.counts.push(value),
onOpen:async () => {{}}, onAcknowledge:async () => {{}},
onReviewComplete:() => state.completed.push('following'),
}});
(async () => {{
await feature.load();
await feature.startReview();
const first=feature.session().items[0];
const next=feature.retire(first);
await feature.previewLoaded(next);
const final=feature.retire(next);
const handoff=feature.finishReview();
process.stdout.write(JSON.stringify({{
next:next.number, final, handoff, items:feature.items(), session:feature.session(), state
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["next"] == 9
assert result["final"] is None
assert result["handoff"] is True
assert result["items"] == []
assert result["session"] is None
assert result["state"]["counts"][-1] == 0
assert result["state"]["completed"] == ["following"]
def test_following_review_never_clears_activity_newer_than_the_loaded_preview():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{acknowledged:[], counts:[]}};
let loads=0;
const original=[
{{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
{{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
];
const refreshed=[
{{...original[0],has_unseen_change:false}},
{{...original[1],updated_at:'2026-08-23T07:00:00Z',has_unseen_change:true}}
];
const feature = createFollowing({{
fetchJson:async () => ({{revision:++loads,items:loads === 1 ? original : refreshed}}),
onCount:value => state.counts.push(value), onOpen:async () => {{}},
onAcknowledge:async item => state.acknowledged.push(item.updated_at),
}});
(async () => {{
await feature.load();
await feature.startReview();
const captured=feature.session().items[1];
await feature.load();
const cleared=await feature.previewLoaded(captured);
process.stdout.write(JSON.stringify({{cleared,state}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result == {
"cleared": False,
"state": {
"acknowledged": ["2026-08-23T06:00:00Z"],
"counts": [2, 1, 1],
},
}
def test_following_review_controls_are_wired_into_the_phone_preview_flow():
html = (ROOT / "frontend" / "index.html").read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
following = MODULE.read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
service_worker = (ROOT / "frontend" / "service-worker.js").read_text()
assert 'id="review-following"' in html
assert '>Review new activity</button>' in html
assert ".following-review-action" in css
assert "min-height:44px" in css
assert "query('#review-following').addEventListener('click'" in following
assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard
assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard
assert "changeRevision(item, 'seen')" in following
assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard
assert "followingQueue.returnToFollowing()" in dashboard
assert "'Back to Following'" in dashboard
def test_following_open_issue_exposes_only_action_and_continue_planning_dispositions():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const disposition = createFollowing.planningDisposition;
process.stdout.write(JSON.stringify({{
active:disposition({{following:true,kind:'issue',state:'open',claimable:true}}),
generic:disposition({{following:false,kind:'issue',state:'open',claimable:true}}),
pull:disposition({{following:true,kind:'pull',state:'open',assigned_to_me:true}}),
}}));
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result == {
"active": {
"active": True,
"today": "Add to Today & next",
"later": "Later & next",
"week": "Week Ahead & next",
},
"generic": {"active": False},
"pull": {"active": False},
}
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
service_worker = (ROOT / "frontend" / "service-worker.js").read_text()
assert "followingQueue.preview(state);" in dashboard
assert "following-disposition-mode" in MODULE.read_text()
assert "controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true" in MODULE.read_text()
assert ".following-disposition-mode" in css
assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
assert "stackchain-dashboard-shell-v138" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
following = MODULE.read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
assert "function attachFollowing(onOpen, hooks = {})" in following
assert "hooks.onCount?.(count, feature.items())" in following
assert "hooks.onStatus?.(state.status)" in following
assert "onReviewComplete:hooks.onReviewComplete" in following
assert "review:feature.startReview" in following
assert "const completed = feature.finishReview()" in following
assert "if (completed) return 'completed-following'" in following
assert "followingQueue.load().catch(() => {})" in dashboard
assert "queueCounts.following = count" in dashboard
assert "queueCounts.followingUnavailable = status === 'error'" in dashboard
assert "preparationItems.following = items.filter" in dashboard
assert "queueCounts.followingUnavailable ? followingQueue.open()" in dashboard
assert "mobileStartDay.completePhase('following')" in dashboard
+177
View File
@@ -0,0 +1,177 @@
import sqlite3
from src.following_store import FollowingStore
KEY = b"f" * 32
ITEM = {
"repository": "stackchain/api",
"number": 42,
"title": "Make mobile review useful",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
}
def test_confirmed_watch_is_account_scoped_idempotent_and_encrypted(tmp_path):
path = tmp_path / "following.sqlite3"
store = FollowingStore(path, encryption_key=KEY, limit=50)
first = store.set_watching("Timmy", ITEM, True)
repeated = store.set_watching("timmy", ITEM, True)
assert first == repeated == {
"revision": 1,
"items": [{**ITEM, "kind": "issue", "has_unseen_change": False}],
}
assert store.get("other") == {"revision": 0, "items": []}
stored = sqlite3.connect(path).execute(
"SELECT items FROM following_issues WHERE login = ?", ("timmy",)
).fetchone()[0]
assert ITEM["title"] not in stored
assert ITEM["repository"] not in stored
def test_refresh_marks_changed_items_unseen_and_sorts_them_first(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
quiet = {**ITEM, "number": 7, "title": "Quiet", "updated_at": "2026-08-23T02:00:00Z"}
store.set_watching("timmy", quiet, True)
store.set_watching("timmy", ITEM, True)
snapshot = store.refresh("timmy", [{
**quiet, "title": "Changed upstream", "state": "closed",
"updated_at": "2026-08-23T04:00:00Z",
}])
assert [(item["number"], item["has_unseen_change"]) for item in snapshot["items"]] == [
(7, True), (42, False)
]
assert snapshot["items"][0]["title"] == "Changed upstream"
assert snapshot["items"][0]["state"] == "closed"
def test_refresh_explains_change_from_last_reviewed_snapshot(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
store.set_watching("timmy", ITEM, True)
closed = store.refresh("timmy", [{
**ITEM,
"title": "Make mobile review trustworthy",
"state": "closed",
"updated_at": "2026-08-23T04:00:00Z",
}])
assert closed["items"][0]["reviewed_at"] == ITEM["updated_at"]
assert closed["items"][0]["change_summary"] == "Closed since last review"
store.acknowledge(
"timmy", ITEM["repository"], ITEM["number"], "2026-08-23T04:00:00Z"
)
title_change = store.refresh("timmy", [{
**ITEM,
"title": "Make mobile review understandable",
"state": "closed",
"updated_at": "2026-08-23T05:00:00Z",
}])
assert title_change["items"][0]["reviewed_at"] == "2026-08-23T04:00:00Z"
assert title_change["items"][0]["change_summary"] == "Title changed since last review"
def test_acknowledgement_is_revision_conditional_and_later_change_is_unseen(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
store.set_watching("timmy", ITEM, True)
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
store.refresh("timmy", [changed])
stale = store.acknowledge("timmy", ITEM["repository"], ITEM["number"], ITEM["updated_at"])
assert stale["items"][0]["has_unseen_change"] is True
seen = store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
assert seen["items"][0]["has_unseen_change"] is False
store.refresh("timmy", [{**ITEM, "updated_at": "2026-08-23T05:00:00Z"}])
assert store.get("timmy")["items"][0]["has_unseen_change"] is True
def test_keep_for_later_restores_the_exact_seen_revision_as_unseen(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
store.set_watching("timmy", ITEM, True)
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
store.refresh("timmy", [changed])
store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
snapshot = store.keep_unseen(
"timmy", ITEM["repository"], ITEM["number"], changed["updated_at"]
)
assert snapshot["items"][0]["has_unseen_change"] is True
assert store.get("timmy")["items"][0]["has_unseen_change"] is True
def test_reopening_a_kept_revision_acknowledges_it_again(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
store.set_watching("timmy", ITEM, True)
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
store.refresh("timmy", [changed])
store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
store.keep_unseen("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
snapshot = store.acknowledge(
"timmy", ITEM["repository"], ITEM["number"], changed["updated_at"]
)
assert snapshot["items"][0]["has_unseen_change"] is False
def test_reconfirmed_watch_does_not_mark_an_unseen_change_as_seen(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
store.set_watching("timmy", ITEM, True)
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
store.refresh("timmy", [changed])
snapshot = store.set_watching("timmy", changed, True)
assert snapshot["items"][0]["has_unseen_change"] is True
def test_following_preserves_kind_and_uses_it_in_item_identity(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
issue = {**ITEM, "kind": "issue"}
pull = {
**ITEM,
"kind": "pull",
"title": "Review the mobile flow",
"url": "https://forge.example/stackchain/api/pulls/42",
}
store.set_watching("timmy", issue, True)
snapshot = store.set_watching("timmy", pull, True)
assert [(item["kind"], item["number"]) for item in snapshot["items"]] == [
("pull", 42),
("issue", 42),
]
def test_following_reads_legacy_items_as_issues(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
store.set_watching("timmy", ITEM, True)
assert store.get("timmy")["items"][0]["kind"] == "issue"
def test_acknowledgement_only_marks_the_requested_item_kind_seen(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
for kind in ("issue", "pull"):
store.set_watching("timmy", {**ITEM, "kind": kind}, True)
store.refresh("timmy", [{**ITEM, "kind": kind, "updated_at": "2026-08-23T06:00:00Z"}])
snapshot = store.acknowledge(
"timmy", ITEM["repository"], ITEM["number"], "2026-08-23T06:00:00Z", kind="pull"
)
assert {item["kind"]: item["has_unseen_change"] for item in snapshot["items"]} == {
"issue": True,
"pull": False,
}
+3 -1
View File
@@ -72,6 +72,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createQueueToday" not in first.runtime_bytes
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createPlanToday" in first.feature_bundles["planning"].runtime_bytes
assert b"function createFollowing" in first.feature_bundles["planning"].runtime_bytes
assert b"function createFollowing" not in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createTomorrowPlan" in first.feature_bundles["planning"].runtime_bytes
assert b"function createPlanToday" not in first.runtime_bytes
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
@@ -132,7 +134,7 @@ def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch():
assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [workspace.runtime_name]
assert build.dashboard_html.count(f'<script src="{workspace.runtime_name}"></script>') == 0
assert len(build.runtime_gzip_bytes) <= 100 * 1024
assert len(workspace.runtime_gzip_bytes) <= 110 * 1024
assert len(workspace.runtime_gzip_bytes) <= 111 * 1024
shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0]
assert f"BASE + '{workspace.runtime_name}'" not in shell_block
+83 -6
View File
@@ -100,6 +100,32 @@ async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_dist
}
@pytest.mark.anyio
async def test_authored_pull_page_loads_open_created_pulls_with_a_distinct_reason():
requests = []
def upstream(request):
requests.append(str(request.url))
return httpx.Response(200, headers={"X-Total-Count": "3"}, json=[{
"id": 901, "number": 41, "title": "Ship mobile flow", "state": "open",
"user": {"login": "timmy"},
"repository": {"full_name": "stackchain/dashboard"},
"html_url": "http://127.0.0.1:3000/stackchain/dashboard/pulls/41",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.work_page("authored", page=1)
finally:
await gitea_proxy.stop_client()
assert requests == [
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&created=true&type=pulls&limit=50&page=1"
]
assert result["total"] == 3
assert result["items"][0]["work_reasons"] == ["authored_by_me"]
@pytest.mark.anyio
async def test_issue_context_merges_self_assigned_and_filed_streams_without_duplicates(monkeypatch):
async def page(stream, page=1, limit=50):
@@ -628,7 +654,7 @@ async def test_confirmed_claim_is_removed_from_retained_available_snapshot(monke
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, "filed": 62, "pull": 61, "review": 73}
totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73, "authored": 12}
return {
"items": [], "page": 1, "total": totals[stream],
"has_more": True, "stream": stream,
@@ -646,6 +672,7 @@ async def test_initial_work_collections_expose_independent_pagination(monkeypatc
assert pulls.pagination == {
"pull": {"page": 1, "total": 61, "has_more": True},
"review": {"page": 1, "total": 73, "has_more": True},
"authored": {"page": 1, "total": 12, "has_more": True},
}
@@ -666,11 +693,12 @@ async def test_work_collections_include_supported_review_request_search(monkeypa
("filed", 1, 50),
("pull", 1, 50),
("review", 1, 50),
("authored", 1, 50),
]
@pytest.mark.anyio
async def test_pull_requests_merge_assignment_and_review_responsibilities(monkeypatch):
async def test_pull_requests_merge_assignment_review_and_authored_responsibilities(monkeypatch):
assigned = {
"id": 11,
"number": 7,
@@ -683,10 +711,24 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
"title": "Review mobile",
"repository": {"full_name": "stackchain/mobile"},
}
authored_only = {
"id": 13,
"number": 9,
"title": "Ship authored flow",
"repository": {"full_name": "stackchain/dashboard"},
}
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"
items = {
"pull": [assigned],
"review": [assigned.copy(), review_only],
"authored": [assigned.copy(), authored_only],
}[stream]
reason = {
"pull": "assigned_to_me",
"review": "review_requested",
"authored": "authored_by_me",
}[stream]
return {
"stream": stream,
"items": [{**item, "work_reasons": [reason]} for item in items],
@@ -697,9 +739,44 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
pulls = await gitea_proxy.pull_requests()
assert [pull["id"] for pull in pulls] == [11, 12]
assert pulls[0]["work_reasons"] == ["assigned_to_me", "review_requested"]
assert [pull["id"] for pull in pulls] == [11, 12, 13]
assert pulls[0]["work_reasons"] == [
"assigned_to_me", "review_requested", "authored_by_me",
]
assert pulls[1]["work_reasons"] == ["review_requested"]
assert pulls[2]["work_reasons"] == ["authored_by_me"]
assert pulls.pagination["authored"] == {
"page": 1, "total": 2, "has_more": False,
}
@pytest.mark.anyio
async def test_authored_pull_failure_keeps_other_pull_work_and_marks_only_that_stream_unavailable(monkeypatch):
async def fake_page(stream, page=1, limit=50):
if stream == "authored":
raise httpx.ConnectError("authored search unavailable")
item = {
"id": 11 if stream == "pull" else 12,
"number": 7 if stream == "pull" else 8,
"title": "Assigned" if stream == "pull" else "Review",
"repository": {"full_name": "stackchain/dashboard"},
"work_reasons": ["assigned_to_me" if stream == "pull" else "review_requested"],
}
return {
"stream": stream, "items": [item], "page": page,
"total": 1, "has_more": False,
}
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
pulls = await gitea_proxy.pull_requests()
assert [pull["id"] for pull in pulls] == [11, 12]
assert pulls.pagination["pull"].get("unavailable", False) is False
assert pulls.pagination["review"].get("unavailable", False) is False
assert pulls.pagination["authored"] == {
"page": 1, "total": 0, "has_more": False, "unavailable": True,
}
@pytest.mark.anyio
+106
View File
@@ -218,6 +218,110 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
assert response.json()["claimable"] is True
@pytest.mark.anyio
async def test_search_preview_subscription_reads_server_truth_without_caching(monkeypatch):
calls = []
async def preview(repository, kind, number):
calls.append(("preview", repository, kind, number))
return {"repository": repository, "kind": kind, "number": number,
"state": "open", "claimable": True}
async def subscription(repository, number):
calls.append(("subscription", repository, number))
return {"watching": True}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "issue_subscription", subscription, 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/subscription?kind=issue"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"watching": True}
assert calls == [
("preview", "stackchain/api", "issue", 42),
("subscription", "stackchain/api", 42),
]
@pytest.mark.anyio
@pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)])
async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth(
monkeypatch, tmp_path, method, watching
):
calls = []
store = main.FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
async def preview(repository, kind, number):
calls.append(("preview", repository, kind, number))
return {"repository": repository, "kind": kind, "number": number,
"title": "Assigned issue", "state": "open", "claimable": False,
"assignees": ["alexander"],
"updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/issue/42"}
async def set_subscription(repository, number, desired):
calls.append(("set", repository, number, desired))
return {"watching": desired}
async def user():
return {"login": "timmy"}
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.request(
method,
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
expected_count = 1 if watching else 0
assert response.json() == {
"watching": watching,
"following_synced": True,
"following_revision": expected_count,
"following_count": expected_count,
}
assert calls == [
("preview", "stackchain/api", "issue", 42),
("set", "stackchain/api", 42, watching),
]
@pytest.mark.anyio
async def test_issue_subscription_mutation_uses_current_login_and_requires_confirmation():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/subscriptions/check"):
return httpx.Response(200, json={"subscribed": True, "ignored": False})
return httpx.Response(201, json={})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.set_issue_subscription("stackchain/api", 42, True)
finally:
await gitea_proxy.stop_client()
assert result == {"watching": True}
assert requests == [
("GET", "/api/v1/user"),
("PUT", "/api/v1/repos/stackchain/api/issues/42/subscriptions/timmy"),
("GET", "/api/v1/repos/stackchain/api/issues/42/subscriptions/check"),
]
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["issue", "pull"])
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(
@@ -682,6 +786,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
"title": "Repair queue",
"body": "Keep mobile operators moving.",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
"user": {"login": "alex"},
"labels": [{"name": "P1"}, None],
@@ -701,6 +806,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
"title": "Repair queue",
"body": "Keep mobile operators moving.",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"author": "alex",
"labels": ["P1"],
"assignees": [],
+1 -1
View File
@@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v130" in worker
assert "stackchain-dashboard-shell-v138" in worker
+114 -13
View File
@@ -45,12 +45,12 @@ const controller = createMobileAppBadge({
},
});
controller.start();
await controller.reconcile(7, {authoritative:true});
await controller.reconcile(12, {authoritative:false});
await controller.reconcile('updates', 7, true);
await controller.reconcile('updates', 12, false);
control.checked = true;
await listeners.change();
await controller.reconcile(7, {authoritative:true});
await controller.reconcile(0, {authoritative:true});
await controller.reconcile('updates', 7, true);
await controller.reconcile('updates', 0, true);
console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-badge.enabled.v1'), status:status.textContent}));
""")
assert result == {
@@ -61,6 +61,33 @@ console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-ba
}
def test_app_badge_combines_authoritative_update_and_following_channels_independently():
result = run_badge("""
const values = [];
let clears = 0;
const controller = createMobileAppBadge({
control:{checked:true, disabled:false, addEventListener() {}},
status:{textContent:''}, container:{hidden:false},
navigator:{
async setAppBadge(value) { values.push(value); },
async clearAppBadge() { clears++; },
},
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
});
controller.start();
await controller.reconcile('updates', 3, true);
await controller.reconcile('following', 2, true);
await controller.reconcile('following', 0, true);
await controller.reconcile('following', 4, false);
await controller.reconcile('updates', 0, true);
await controller.reconcile('following', 2, true);
await controller.reconcile('updates', 5, true);
await controller.reconcile('following', 2, true);
console.log(JSON.stringify({values, clears}));
""")
assert result == {"values": [3, 5, 3, 2, 7], "clears": 1}
def test_app_badge_hides_unsupported_device_control_without_touching_storage():
result = run_badge("""
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};
@@ -92,9 +119,11 @@ def test_app_badge_is_packaged_touch_safe_and_reconciled_from_server_total():
assert 'id="app-badge-control"' in index
assert 'id="app-badge-status" role="status"' in index
assert ".app-badge-control { min-height:44px;" in css
assert "const mobileAppBadge = createMobileAppBadge({" in dashboard
assert "mobileAppBadge.start();" in dashboard
assert "mobileAppBadge.reconcile(pagination.total, {authoritative:true});" in dashboard
assert "const appBadge = createMobileAppBadge({" in dashboard
assert "appBadge.start();" in dashboard
assert "appBadge.reconcile('updates', pagination.total, true);" in dashboard
assert "appBadge.reconcile('following', count, true);" in dashboard
assert "Show review attention on the app icon" in index
def test_app_badge_exposes_device_setup_readiness_and_enable_action():
@@ -113,7 +142,7 @@ const controller = createMobileAppBadge({
},
});
controller.start();
await controller.reconcile(4, {authoritative:true});
await controller.reconcile('updates', 4, true);
const before = controller.readiness();
await controller.enable();
const after = controller.readiness();
@@ -122,11 +151,11 @@ console.log(JSON.stringify({before, after, checked:control.checked, values}));
assert result == {
"before": {
"state": "incomplete",
"detail": "Show the confirmed unread Updates count without opening Stackchain.",
"detail": "Show review attention without opening Stackchain.",
},
"after": {
"state": "complete",
"detail": "The app icon shows the confirmed unread Updates count.",
"detail": "The app icon shows review attention.",
},
"checked": True,
"values": [4],
@@ -142,8 +171,8 @@ def test_device_setup_offers_app_badge_as_an_optional_supported_step():
assert 'id="device-setup-app-badge" type="button"' in index
assert "['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable']" in setup
assert "appBadge:options.appBadgeReadiness()," in setup
assert "appBadgeReadiness:() => mobileAppBadge.readiness()," in dashboard
assert "enableAppBadge:() => mobileAppBadge.enable()," in dashboard
assert "appBadgeReadiness:() => appBadge.readiness()," in dashboard
assert "enableAppBadge:() => appBadge.enable()," in dashboard
def test_app_badge_api_failure_is_reported_without_rejecting_live_refresh():
@@ -156,7 +185,7 @@ const controller = createMobileAppBadge({
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
});
controller.start();
const reconciled = await controller.reconcile(3, {authoritative:true});
const reconciled = await controller.reconcile('updates', 3, true);
console.log(JSON.stringify({reconciled, status:status.textContent}));
""")
assert result == {
@@ -186,3 +215,75 @@ console.log(JSON.stringify({changed, saved:saved.has('stackchain.app-badge.enabl
"saved": False,
"status": "App icon badge is off, but the browser could not clear the old count.",
}
def test_app_badge_synchronizes_opt_in_with_the_active_service_worker():
result = run_badge("""
const listeners = {};
const messages = [];
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
const controller = createMobileAppBadge({
control, status:{textContent:''}, container:{hidden:false},
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
storage:{getItem() { return null; }, setItem() {}, removeItem() {}},
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
});
controller.start();
control.checked = true;
await listeners.change();
control.checked = false;
await listeners.change();
console.log(JSON.stringify(messages));
""")
assert result == [
{"type": "stackchain-app-badge-preference", "enabled": True},
{"type": "stackchain-app-badge-preference", "enabled": False},
]
def test_app_badge_synchronizes_authoritative_channel_counts_with_worker():
result = run_badge("""
const messages = [];
const controller = createMobileAppBadge({
control:{checked:true, disabled:false, addEventListener() {}}, status:{textContent:''}, container:{hidden:false},
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
});
controller.start();
await controller.reconcile('updates', 3, true);
await controller.reconcile('following', 2, true);
await controller.reconcile('following', 9, false);
await new Promise(resolve => setImmediate(resolve));
console.log(JSON.stringify(messages));
""")
assert result == [
{"type": "stackchain-app-badge-preference", "enabled": True},
{"type": "stackchain-app-badge-count", "channel": "updates", "count": 3},
{"type": "stackchain-app-badge-count", "channel": "following", "count": 2},
]
def test_enabling_badge_synchronizes_counts_confirmed_while_opted_out():
result = run_badge("""
const listeners = {};
const messages = [];
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
const controller = createMobileAppBadge({
control, status:{textContent:''}, container:{hidden:false},
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
storage:{getItem() { return null; }, setItem() {}, removeItem() {}},
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
});
controller.start();
await controller.reconcile('updates', 3, true);
await controller.reconcile('following', 2, true);
control.checked = true;
await listeners.change();
console.log(JSON.stringify(messages));
""")
assert result == [
{"type": "stackchain-app-badge-preference", "enabled": True},
{"type": "stackchain-app-badge-count", "channel": "updates", "count": 3},
{"type": "stackchain-app-badge-count", "channel": "following", "count": 2},
]
+1 -1
View File
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v130" in worker
assert "stackchain-dashboard-shell-v138" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v130" in worker
assert "stackchain-dashboard-shell-v138" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+1 -1
View File
@@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v130" in worker
assert "stackchain-dashboard-shell-v138" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
+61 -5
View File
@@ -146,6 +146,61 @@ process.stdout.write(JSON.stringify(controller.briefing()));
}
def test_prepare_today_reviews_unseen_following_after_filed_before_today():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const opened = [];
const controller = createStartDay({{
getCounts: () => ({{filed:1, following:2, today:3}}),
openQueue: name => opened.push(name),
}});
const briefing = controller.briefing();
controller.startNext();
process.stdout.write(JSON.stringify({{briefing, opened}}));
"""
output = run_node(script)
assert output == {
"briefing": {
"total": 3,
"next": "filed",
"label": "Review Filed",
"summary": "3 items before Today · 3 planned",
"phases": [
{"name": "filed", "label": "Filed", "count": 1},
{"name": "following", "label": "Following", "count": 2},
],
},
"opened": ["filed"],
}
def test_prepare_today_keeps_failed_following_refresh_retryable_before_today():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const opened = [];
const controller = createStartDay({{
getCounts: () => ({{followingUnavailable:true, today:2}}),
openQueue: name => opened.push(name),
}});
const briefing = controller.briefing();
controller.startNext();
process.stdout.write(JSON.stringify({{briefing, opened}}));
"""
output = run_node(script)
assert output == {
"briefing": {
"total": 0,
"next": "following",
"label": "Retry Following",
"summary": "Following needs retry before Today · 2 planned",
"phases": [{"name": "following", "label": "Following unavailable", "count": 0}],
},
"opened": ["following"],
}
def test_start_day_view_renders_refreshed_phases_and_launches_primary_action():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
@@ -352,13 +407,14 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert "mobileStartDay.finish()" in html
assert "openQueue: name =>" in html
assert "if (sheet.open) sheet.close();" in html
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
assert "Checking Agenda, Attention, Updates, Filed, and Following" in html
assert "mobileStartDay.render();" in html
assert ".mobile-start-day-action { width:100%; min-height:48px;" in html
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v130" in service_worker
assert "stackchain-dashboard-shell-v138" in service_worker
@pytest.mark.anyio
@@ -371,9 +427,9 @@ async def test_primary_mobile_work_action_opens_a_unique_item_prepare_today_brie
assert "qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';" in html
assert "qs('#mobile-queue-sheet').showModal();" in html
assert "qs('#mobile-start-day-action').focus();" in html
assert "let mobilePreparationItems = {};" in html
assert "getPhaseItems: () => mobilePreparationItems" in html
assert "mobilePreparationItems = {" in html
assert "let preparationItems = {};" in html
assert "getPhaseItems: () => preparationItems" in html
assert "preparationItems = {" in html
assert "delivery:draftInbox.partition(lastDrafts).deliveries" in html
assert "agenda:agendaMyWork(activeMyWork)" in html
assert "attention:activeMyWork.filter(item => item.needs_attention)" in html
+5 -3
View File
@@ -834,7 +834,8 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
assert '<script src="static/mobile-queue-launcher.js"></script>' in html
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
assert "name === 'find' ? qs('#find-work').click() :" in html
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
assert '.mobile-queue-sheet' in html
assert '.my-work-actions { display:none;' in html
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
@@ -872,7 +873,7 @@ async def test_dashboard_wires_work_dock_and_app_shortcut_to_live_queue_recommen
assert "queueLauncher: mobileQueueLauncher" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "continueWork: () => mobileWorkEntry.open()" in html
assert "mobileQueueCounts = counts;" in html
assert "queueCounts = counts;" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
@@ -893,7 +894,8 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
assert '<script src="static/mobile-task-dock.js"></script>' in html
assert "createMobileTaskDock({" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
assert "name === 'find' ? qs('#find-work').click() :" in html
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
assert "find: () => qs('#find-work').click()" in html
assert "new: () => qs('#new-issue').click()" in html
+443 -3
View File
@@ -100,6 +100,53 @@ process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
assert result[1]["reason"] == "Filed by you"
def test_authored_pull_is_classified_for_the_my_prs_queue():
payload = {
"user": {"login": "timmy"},
"pull_requests": [{
"number": 41, "title": "Ship mobile flow", "repository": "stackchain/dashboard",
"state": "open", "user": "timmy", "assignees": [],
"work_reasons": ["authored_by_me"], "updated_at": "2026-08-24T12:00:00Z",
}],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
result = json.loads(completed.stdout)
assert result[0]["is_authored"] is True
assert result[0]["reason"] == "Authored by you"
assert result[0]["kind"] == "pull"
def test_my_prs_filter_and_count_include_only_authored_pulls():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = buildMyWork({{
user:{{login:'timmy'}},
pull_requests:[
{{number:41,title:'Authored',repository:'stackchain/dashboard',state:'open',user:'timmy',
assignees:[],work_reasons:['authored_by_me']}},
{{number:42,title:'Review',repository:'stackchain/dashboard',state:'open',user:'alex',
assignees:[],work_reasons:['review_requested']}},
],
}});
process.stdout.write(JSON.stringify({{
authored:buildMyWork.filterMyWork(items,'authored').map(item=>item.number),
counts:buildMyWork.countMyWork(items),
}}));
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
result = json.loads(completed.stdout)
assert result["authored"] == [41]
assert result["counts"]["authored"] == 1
def test_completed_child_resolves_only_a_canonical_reciprocal_parent_task():
script = f"""
const sheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -676,6 +723,34 @@ process.stdout.write(JSON.stringify(routes.parse('#/my-work/agenda/protect-today
assert json.loads(result.stdout) == {
"kind": "queue", "filter": "agenda", "action": "protect-today"
}
def test_following_route_waits_for_hydration_then_opens_once():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const listeners = {{}};
const location = {{hash:'#/my-work/following'}};
const calls = [];
const controller = routes.createController({{
location,
history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
onQueue: (queue, action) => calls.push([queue, action]),
onInvalid: () => calls.push(['invalid']),
}});
controller.start();
controller.setItems([]);
controller.sync();
process.stdout.write(JSON.stringify({{route:routes.parse(location.hash), calls}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"route": {"kind": "queue", "filter": "following"},
"calls": [["following", None]],
}
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"
@@ -1031,6 +1106,24 @@ async def test_agenda_activation_checks_all_issue_pages_before_showing_empty_sta
assert "selectedWorkFilter === 'agenda' && workPagination.issue?.has_more" in html
@pytest.mark.anyio
async def test_my_prs_is_a_thumb_safe_mobile_queue_and_desktop_filter():
html = await dashboard()
markup = (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
css = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.css").read_text()
assert 'data-work-filter="authored"' in markup
assert 'data-work-count="authored"' in markup
assert 'data-mobile-queue="authored"' in markup
assert 'data-mobile-queue-count="authored"' in markup
assert '<strong>My PRs</strong>' in markup
assert ".mobile-queue-list button" in css and "min-height:56px" in css
assert "['all', 'today', 'agenda', 'attention', 'filed', 'authored'" in html
assert "['issue', 'pull', 'review', 'authored'].includes(selectedWorkFilter)" in html
assert "authored: 'pull requests'" in html
@pytest.mark.anyio
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
html = await dashboard()
@@ -4960,7 +5053,8 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
)
assert json.loads(result.stdout) == {
"all": 3, "attention": 1, "filed": 0, "issue": 1, "pull": 1, "review": 1, "update": 0
"all": 3, "attention": 1, "filed": 0, "authored": 0,
"issue": 1, "pull": 1, "review": 1, "update": 0
}
@@ -6137,7 +6231,8 @@ process.stdout.write(JSON.stringify({{
assert output["updates"][1]["kind"] == "update"
assert output["updates"][1]["update_reason"] == ""
assert output["counts"] == {
"all": 2, "attention": 2, "filed": 0, "issue": 1, "pull": 0, "review": 0, "update": 2
"all": 2, "attention": 2, "filed": 0, "authored": 0,
"issue": 1, "pull": 0, "review": 0, "update": 2
}
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
@@ -7396,6 +7491,80 @@ Promise.all([first, duplicate]).then(async comments => {{
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
def test_pull_sheet_preserves_context_draft_until_confirmed_update():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
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 = [];
let fail = true;
const controller = createPullSheet({{
storage,
fetchJson: async (url, options={{}}) => {{
const body = JSON.parse(options.body);
calls.push({{url, method:options.method, body}});
if (fail) {{ fail = false; throw new Error('network down'); }}
return {{number:7,title:body.title,body:body.body,head_sha:body.expected_head_sha,state:'open'}};
}},
}});
const item = {{repository:'stackchain/api',number:7}};
const draft = {{title:'Clear review brief',body:'Review mobile handoff.',expectedHeadSha:'abc1234'}};
(async () => {{
await controller.updateContent(item, draft).catch(() => null);
const kept = controller.loadEditDraft(item);
const result = await controller.updateContent(item, draft);
process.stdout.write(JSON.stringify({{kept,result,after:controller.loadEditDraft(item),calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert output["kept"] == {
"title": "Clear review brief",
"body": "Review mobile handoff.",
"expectedHeadSha": "abc1234",
}
assert output["after"] is None
assert output["result"]["head_sha"] == "abc1234"
assert output["calls"] == [
{
"url": "api/v1/repos/stackchain/api/pulls/7/content",
"method": "PATCH",
"body": {
"title": "Clear review brief",
"body": "Review mobile handoff.",
"expected_head_sha": "abc1234",
},
},
{
"url": "api/v1/repos/stackchain/api/pulls/7/content",
"method": "PATCH",
"body": {
"title": "Clear review brief",
"body": "Review mobile handoff.",
"expected_head_sha": "abc1234",
},
},
]
@pytest.mark.anyio
async def test_pull_overview_exposes_author_only_context_editor_with_retry_safe_status():
html = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
source = await dashboard()
pull_source = PULL_SHEET.read_text()
assert 'id="edit-pull-content"' in html
assert 'id="pull-edit-form"' in html
assert 'id="pull-edit-title"' in html
assert 'id="pull-edit-body"' in html
assert 'id="save-pull-content"' in html
assert "detail.author === getLogin()" in pull_source
assert "controller.loadEditDraft(item)" in pull_source
assert "controller.updateContent(item" in pull_source
assert "Your change is still here; retry." in pull_source
assert "controller.setReviewDetail(detail)" in pull_source
def test_pull_sheet_removes_one_pull_from_snapshot_without_touching_other_work():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -7435,6 +7604,9 @@ const elements = new Map([
['#load-pull-handoff', {{disabled:true}}],
['#release-pull', {{disabled:true,textContent:''}}],
['#pull-handoff-status', {{textContent:''}}],
['#edit-pull-content', {{hidden:false}}],
['#pull-edit-form', {{hidden:false}}],
['#pull-edit-status', {{textContent:'stale'}}],
]);
const select = {{textContent:'stale', appendChild:node => children.push(node)}};
const doc = {{
@@ -7511,6 +7683,123 @@ const item = {{repository:'stackchain/api',number:7}};
assert output["released"] == {"assignees": []}
def test_pull_sheet_loads_reviewers_and_single_flights_review_request():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
let finish;
const controller = createPullSheet({{
storage: null,
fetchJson: (url, options={{}}) => {{
calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
if (url.endsWith('/review-candidates')) return Promise.resolve([{{login:'casey',name:'Casey'}}]);
return new Promise(resolve => {{ finish = resolve; }});
}}
}});
const notified = [];
controller.onReviewRequested = result => notified.push(result);
const item = {{repository:'stackchain/api',number:7}};
(async () => {{
const candidates = await controller.loadReviewCandidates(item);
const first = controller.requestReview(item, 'casey', 'abc1234');
const duplicate = controller.requestReview(item, 'casey', 'abc1234');
finish({{reviewer:'casey',requested_reviewers:['sam','casey']}});
const results = await Promise.all([first, duplicate]);
process.stdout.write(JSON.stringify({{calls,candidates,results,same:first===duplicate,notified}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output == {
"calls": [
{"url": "api/v1/repos/stackchain/api/pulls/7/review-candidates", "method": "GET", "body": None},
{"url": "api/v1/repos/stackchain/api/pulls/7/request-review", "method": "POST", "body": {
"reviewer": "casey", "expected_head_sha": "abc1234"
}},
],
"candidates": [{"login": "casey", "name": "Casey"}],
"results": [
{"reviewer": "casey", "requested_reviewers": ["sam", "casey"]},
{"reviewer": "casey", "requested_reviewers": ["sam", "casey"]},
],
"same": True,
"notified": [{"reviewer": "casey", "requested_reviewers": ["sam", "casey"]}],
}
def test_pull_sheet_single_flights_pending_review_cancellation():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
let finish;
const controller = createPullSheet({{
storage:null,
fetchJson:(url, options={{}}) => {{
calls.push({{url, method:options.method, body:JSON.parse(options.body)}});
return new Promise(resolve => {{ finish = resolve; }});
}}
}});
const notified = [];
controller.onReviewCancelled = result => notified.push(result);
const item = {{repository:'stackchain/api',number:7}};
(async () => {{
const first = controller.cancelReview(item, 'sam', 'abc1234');
const duplicate = controller.cancelReview(item, 'sam', 'abc1234');
finish({{reviewer:'sam',head_sha:'abc1234',requested_reviewers:['casey']}});
const results = await Promise.all([first, duplicate]);
process.stdout.write(JSON.stringify({{calls,results,same:first===duplicate,notified}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output == {
"calls": [{
"url": "api/v1/repos/stackchain/api/pulls/7/request-review",
"method": "DELETE",
"body": {"reviewer": "sam", "expected_head_sha": "abc1234"},
}],
"results": [{"reviewer": "sam", "head_sha": "abc1234", "requested_reviewers": ["casey"]}] * 2,
"same": True,
"notified": [{"reviewer": "sam", "head_sha": "abc1234", "requested_reviewers": ["casey"]}],
}
def test_pull_sheet_exposes_touch_safe_review_request_flow():
root = PULL_SHEET.parents[1]
html = (root / "frontend" / "index.html").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
assert 'id="pull-review-request"' in html
assert 'id="load-pull-reviewers"' in html
assert 'id="pull-review-recipient"' in html
assert 'id="confirm-pull-review"' in html
assert 'id="pull-review-request-status" class="small" aria-live="assertive"' in html
assert '.pull-review-request select, .pull-review-request button { min-height:44px;' in css
assert "createPullSheet.bindOwnershipControls(document, pullController" in dashboard
assert "pullController.setReviewDetail(detail)" in dashboard
def test_pull_sheet_exposes_mobile_reviewer_status_and_updated_review_action():
root = PULL_SHEET.parents[1]
html = (root / "frontend" / "index.html").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
assert 'id="pull-reviewer-statuses"' in html
assert 'id="request-updated-pull-review"' in html
assert 'id="pull-reviewer-status" class="small" aria-live="polite"' in html
assert ".pull-reviewer-status" in css
assert "overflow-wrap:anywhere" in css
assert "#request-updated-pull-review { min-height:44px;" in css
assert "createPullSheet.review(detail, pullReviewState, document)" in dashboard
assert "reviewers.some(reviewer => reviewer.status === 'outdated')" in PULL_SHEET.read_text()
def test_pull_sheet_surfaces_pending_merge_confirmation_guidance():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -7557,6 +7846,126 @@ process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)
assert output[3]["allowed"] is False and "conflict" in output[3]["reason"].lower()
def test_pull_sheet_renders_reviewer_decisions_and_blocks_unresolved_requests():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const base = {{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'new'}};
const states = [
[{{login:'casey', status:'waiting', head_sha:'new', blocking:true}}],
[{{login:'sam', status:'changes_requested', head_sha:'new', blocking:true}}],
[{{login:'lee', status:'outdated', head_sha:'old', blocking:true}}],
[{{login:'pat', status:'approved', head_sha:'new', blocking:false}}],
];
process.stdout.write(JSON.stringify({{
eligibility:states.map(reviewers => createPullSheet.mergeEligibility({{...base, reviewers}})),
html:createPullSheet.renderReviewerStatuses(states.flat(), value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')),
}}));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["eligibility"] == [
{"allowed": False, "reason": "Waiting for @casey’s review"},
{"allowed": False, "reason": "@sam requested changes"},
{"allowed": False, "reason": "@lee’s review is outdated after new commits"},
{"allowed": True, "reason": "Ready to merge"},
]
assert "@casey" in output["html"] and "Waiting for review" in output["html"]
assert "Changes requested" in output["html"]
assert "Outdated after new commits" in output["html"]
assert "Approved current head" in output["html"]
def test_pull_sheet_offers_cancel_only_for_pending_review_requests():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const html = createPullSheet.renderReviewerStatuses([
{{login:'sam',status:'waiting'}},
{{login:'casey',status:'approved'}},
{{login:'lee',status:'commented'}},
], value => String(value));
process.stdout.write(html);
"""
html = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
assert html.count('data-cancel-review-request=') == 1
assert 'data-cancel-review-request="sam"' in html
assert "Cancel request" in html
assert 'data-cancel-review-request="casey"' not in html
assert 'data-cancel-review-request="lee"' not in html
def test_pull_sheet_pending_review_cancellation_is_touch_safe_and_opens_replacement_picker():
root = PULL_SHEET.parents[1]
css = (root / "frontend" / "dashboard.css").read_text()
script = PULL_SHEET.read_text()
assert ".cancel-review-request { min-height:44px;" in css
assert "controller.cancelReview(selected, reviewer, detail.head_sha)" in script
assert "Choose a replacement reviewer." in script
assert "qs('#pull-review-request').open = true" in script
def test_pull_sheet_renders_bounded_feedback_grouped_by_file():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const html = createPullSheet.renderReviewerStatuses([{{
login:'sam', status:'changes_requested', head_sha:'abc123', blocking:true,
summary:'Please <split> this helper.',
comments:[
{{path:'src/api.py', body:'Handle <empty>.', line:12}},
{{path:'src/api.py', body:'Keep the return explicit.'}},
{{path:'frontend/app.js', body:'Show the error.'}},
],
}}], value => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;'));
process.stdout.write(html);
"""
html = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
assert "Review feedback" in html
assert "Please &lt;split&gt; this helper." in html
assert html.count('data-review-feedback-file="src/api.py"') == 1
assert 'data-review-feedback-file="frontend/app.js"' in html
assert "Handle &lt;empty&gt;." in html
assert "Line 12" in html
assert "View in changes" in html
assert "Please <split>" not in html
def test_pull_sheet_marks_inline_feedback_as_on_demand_and_retryable():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const render = state => createPullSheet.renderReviewerStatuses([{{
login:'sam', review_id:8, status:'changes_requested', head_sha:'abc1234',
blocking:true, summary:'Please fix this.', feedback_state:state,
}}], value => String(value));
process.stdout.write(JSON.stringify({{idle:render(undefined), failed:render('error')}}));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert "Load inline feedback" in output["idle"]
assert 'data-load-review-feedback="8"' in output["idle"]
assert "Feedback unavailable" in output["failed"]
assert "Retry feedback" in output["failed"]
assert "Address feedback" not in output["idle"]
def test_pull_sheet_binds_on_demand_feedback_with_visible_retry_state():
script = PULL_SHEET.read_text()
assert "event.target.closest?.('[data-load-review-feedback]')" in script
assert "await controller.loadFeedback(item, detail, reviewer" in script
assert "reviewer.feedback_state = 'loading'" in script
assert "reviewer.feedback_state = 'loaded'" in script
assert "reviewer.feedback_state = 'error'" in script
assert "result.head_sha !== detail.head_sha" in script
css = (Path(__file__).parents[1] / "frontend" / "dashboard.css").read_text()
assert "[data-load-review-feedback] { min-height:44px; width:100%;" in css
def test_pull_sheet_persists_head_scoped_file_review_and_gates_merge():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -7632,6 +8041,37 @@ Promise.allSettled([first, concurrent]).then(async failed => {{
]
def test_pull_sheet_feedback_is_single_flight_cached_by_head_and_review():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
let resolveFeedback;
const sheet = createPullSheet({{storage:null, fetchJson:url => {{
calls.push(url);
return new Promise(resolve => {{ resolveFeedback = resolve; }});
}}}});
const item = {{repository:'stackchain/api', number:7}};
const detail = {{head_sha:'abc1234'}};
const reviewer = {{review_id:8, head_sha:'abc1234'}};
const first = sheet.loadFeedback(item, detail, reviewer);
const concurrent = sheet.loadFeedback(item, detail, reviewer);
resolveFeedback({{review_id:8, head_sha:'abc1234', reviewed_head_sha:'abc1234', comments:[{{id:99}}]}});
Promise.all([first, concurrent]).then(async results => {{
const cached = await sheet.loadFeedback(item, detail, reviewer);
process.stdout.write(JSON.stringify({{same:first === concurrent, results, cached, calls}}));
}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
output = json.loads(result.stdout)
assert output["same"] is True
assert output["cached"] == output["results"][0]
assert output["calls"] == [
"api/v1/repos/stackchain/api/pulls/7/reviews/8/feedback?expected_head_sha=abc1234"
]
def test_pull_sheet_refreshes_status_only_without_clearing_review_progress():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -8107,7 +8547,7 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
assert 'data-work-count="review"' in html
assert 'data-work-count="update"' in html
assert 'data-work-count="later"' in html
assert "['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert "['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert 'data-work-count="draft"' in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
+16
View File
@@ -0,0 +1,16 @@
from pathlib import Path
RUNBOOK = Path(__file__).parents[1] / "docs" / "ops-fundamentals.md"
def test_disk_capacity_runbook_requires_safe_remediation_and_release_verification():
text = " ".join(RUNBOOK.read_text().split())
assert "Disk capacity incident" in text
assert "85%" in text
assert "df -h / /var/lib/gitea" in text
assert "disposable caches and abandoned temporary environments" in text
assert "must not delete Gitea data" in text
assert "systemctl is-active gitea act_runner" in text
assert "repository test suite" in text
+1 -1
View File
@@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+1
View File
@@ -34,6 +34,7 @@ process.stdout.write(JSON.stringify(databases));
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
}
+1
View File
@@ -51,6 +51,7 @@ const clear = createPrivateDeviceDataPurger({{
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
]
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
+619 -1
View File
@@ -1,4 +1,5 @@
import asyncio
import json
import httpx
import pytest
@@ -7,6 +8,170 @@ from src import gitea_proxy, main
from src.security_event_store import SecurityEventStoreError
@pytest.mark.anyio
async def test_pull_author_can_publish_assigned_draft_at_expected_head(monkeypatch):
calls = []
async def publish(repository, number, expected_head_sha):
calls.append((repository, number, expected_head_sha))
return {
"repository": repository,
"number": number,
"title": "Ship mobile flow",
"head_sha": expected_head_sha,
"state": "open",
"draft": False,
}
monkeypatch.setattr(
main.gitea_proxy, "publish_authored_assigned_pull", publish, raising=False
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/repos/stackchain/api/pulls/7/ready",
json={"expected_head_sha": "abc1234"},
)
assert response.status_code == 200
assert response.json()["draft"] is False
assert calls == [("stackchain/api", 7, "abc1234")]
@pytest.mark.anyio
async def test_gitea_publishes_only_authored_assigned_draft_and_verifies_ready_head():
requests = []
pull = {
"number": 7,
"title": "WIP: Ship mobile flow",
"state": "open",
"draft": True,
"merged": False,
"user": {"login": "alex"},
"assignees": [{"login": "alex"}],
"head": {"sha": "abc1234"},
}
async def handler(request):
requests.append((request.method, request.url.path, request.content))
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "alex"})
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "GET":
return httpx.Response(200, json=pull)
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "PATCH":
assert json.loads(request.content) == {"title": "Ship mobile flow"}
pull.update({"title": "Ship mobile flow", "draft": False})
return httpx.Response(201, json=pull)
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.publish_authored_assigned_pull(
"stackchain/api", 7, "abc1234"
)
finally:
await gitea_proxy.stop_client()
assert result == {
"repository": "stackchain/api",
"number": 7,
"title": "Ship mobile flow",
"head_sha": "abc1234",
"state": "open",
"draft": False,
}
assert [item[:2] for item in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("PATCH", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
]
@pytest.mark.anyio
async def test_pull_author_can_update_assigned_open_pull_context(monkeypatch):
calls = []
async def update(repository, number, title, body, expected_head_sha):
calls.append((repository, number, title, body, expected_head_sha))
return {
"repository": repository,
"number": number,
"title": title,
"body": body,
"head_sha": expected_head_sha,
"state": "open",
}
monkeypatch.setattr(
main.gitea_proxy, "update_authored_assigned_pull", update, raising=False
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/repos/stackchain/api/pulls/7/content",
json={
"title": "Clarify mobile handoff",
"body": "Explain the reviewer path.",
"expected_head_sha": "abc1234",
},
)
assert response.status_code == 200
assert response.json()["title"] == "Clarify mobile handoff"
assert calls == [
(
"stackchain/api",
7,
"Clarify mobile handoff",
"Explain the reviewer path.",
"abc1234",
)
]
@pytest.mark.anyio
async def test_gitea_pull_context_update_requires_author_assignment_and_matching_head():
requests = []
async def handler(request):
requests.append((request.method, request.url.path, request.content))
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "alex"})
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "GET":
return httpx.Response(200, json={
"number": 7, "title": "Old context", "body": "Old body", "state": "open",
"merged": False, "user": {"login": "alex"}, "assignees": [{"login": "alex"}],
"head": {"sha": "abc1234"},
"html_url": "https://forge.example/stackchain/api/pulls/7",
})
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "PATCH":
assert json.loads(request.content) == {
"title": "Clear review brief", "body": "Review the mobile handoff."
}
return httpx.Response(200, json={
"number": 7, "title": "Clear review brief", "body": "Review the mobile handoff.",
"state": "open", "head": {"sha": "abc1234"},
"html_url": "https://forge.example/stackchain/api/pulls/7",
})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.update_authored_assigned_pull(
"stackchain/api", 7, "Clear review brief", "Review the mobile handoff.", "abc1234"
)
finally:
await gitea_proxy.stop_client()
assert result["title"] == "Clear review brief"
assert [request[:2] for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("PATCH", "/api/v1/repos/stackchain/api/pulls/7"),
]
@pytest.mark.anyio
async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
async def assigned(repository, number):
@@ -127,7 +292,152 @@ async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monk
@pytest.mark.anyio
async def test_gitea_pull_check_status_skips_files_reviews_and_diff():
async def test_assigned_pull_feedback_endpoint_authorizes_and_returns_one_review(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def feedback(repository, number, review_id, expected_head_sha):
calls.append(("feedback", repository, number, review_id, expected_head_sha))
return {
"review_id": review_id, "head_sha": expected_head_sha,
"reviewed_head_sha": expected_head_sha, "comments": [],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "pull_review_feedback", feedback, 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/reviews/8/feedback?expected_head_sha=abc1234"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["review_id"] == 8
assert calls == [
("assigned", "stackchain/api", 7),
("feedback", "stackchain/api", 7, 8, "abc1234"),
]
def test_reviewer_statuses_distinguish_waiting_current_and_outdated_decisions():
pull = {
"requested_reviewers": [{"login": "casey"}],
"head": {"sha": "new-head"},
}
reviews = [
{"id": 1, "user": {"login": "casey"}, "state": "APPROVED", "commit_id": "old-head"},
{"id": 2, "user": {"login": "sam"}, "state": "REQUEST_CHANGES", "commit_id": "new-head"},
{"id": 3, "user": {"login": "lee"}, "state": "COMMENT", "commit_id": "new-head"},
{"id": 4, "user": {"login": "pat"}, "state": "APPROVED", "commit_id": "new-head"},
]
assert gitea_proxy._normalize_reviewer_statuses(pull, reviews, "new-head") == [
{"login": "casey", "status": "waiting", "head_sha": "new-head", "blocking": True},
{"review_id": 3, "login": "lee", "status": "commented", "head_sha": "new-head", "blocking": False},
{"review_id": 4, "login": "pat", "status": "approved", "head_sha": "new-head", "blocking": False},
{"review_id": 2, "login": "sam", "status": "changes_requested", "head_sha": "new-head", "blocking": True},
]
pull["requested_reviewers"] = []
assert gitea_proxy._normalize_reviewer_statuses(pull, reviews[:1], "new-head") == [
{"review_id": 1, "login": "casey", "status": "outdated", "head_sha": "old-head", "blocking": True},
]
def test_reviewer_status_exposes_only_bounded_nonempty_feedback_summary():
pull = {"requested_reviewers": []}
reviews = [
{
"id": 8,
"user": {"login": "sam"},
"state": "REQUEST_CHANGES",
"commit_id": "abc123",
"body": "Please split this helper. " + ("x" * 900),
},
{
"id": 9,
"user": {"login": "lee"},
"state": "COMMENT",
"commit_id": "abc123",
"body": " ",
},
]
statuses = gitea_proxy._normalize_reviewer_statuses(pull, reviews, "abc123")
sam = next(item for item in statuses if item["login"] == "sam")
lee = next(item for item in statuses if item["login"] == "lee")
assert sam["summary"].startswith("Please split this helper.")
assert len(sam["summary"]) == 500
assert "summary" not in lee
def test_review_comments_are_bounded_and_require_a_file_and_body():
comments = [
{"id": 1, "path": "src/api.py", "body": "Handle the empty state", "new_position": 4},
{"id": 2, "path": "src/api.py", "body": "x" * 900, "old_position": 7},
{"id": 3, "path": "", "body": "missing file"},
{"id": 4, "path": "src/ignored.py", "body": " "},
"malformed",
] + [
{"id": index, "path": f"src/{index}.py", "body": "bounded"}
for index in range(5, 40)
]
normalized = gitea_proxy._normalize_review_comments(comments)
assert len(normalized) == 20
assert normalized[0] == {
"id": 1, "path": "src/api.py", "body": "Handle the empty state", "line": 4,
}
assert normalized[1]["line"] == 7
assert len(normalized[1]["body"]) == 500
assert all(item["path"] and item["body"] for item in normalized)
def test_review_feedback_preserves_stable_review_and_comment_identities():
pull = {"requested_reviewers": []}
reviews = [{
"id": 42,
"user": {"login": "sam"},
"state": "REQUEST_CHANGES",
"commit_id": "abc123",
}]
statuses = gitea_proxy._normalize_reviewer_statuses(pull, reviews, "abc123")
comments = gitea_proxy._normalize_review_comments([
{"id": 99, "path": "src/api.py", "body": "Handle the empty state", "new_position": 4},
{"id": -1, "path": "src/other.py", "body": "Invalid identity is omitted"},
])
assert statuses == [{
"review_id": 42,
"login": "sam",
"status": "changes_requested",
"head_sha": "abc123",
"blocking": True,
}]
assert comments[0]["id"] == 99
assert "id" not in comments[1]
def test_inline_feedback_is_loaded_only_for_each_reviewers_latest_decision():
reviews = [
{"id": 7, "user": {"login": "sam"}, "state": "REQUEST_CHANGES"},
{"id": 8, "user": {"login": "sam"}, "state": "APPROVED"},
{"id": 9, "user": {"login": "lee"}, "state": "COMMENT"},
{"id": 10, "user": {"login": "pat"}, "state": "PENDING"},
]
assert gitea_proxy._latest_feedback_review_ids(reviews) == {"lee": 9}
@pytest.mark.anyio
async def test_gitea_pull_check_status_refreshes_reviewer_decisions_without_files_or_diff():
requests = []
async def handler(request):
@@ -136,7 +446,10 @@ async def test_gitea_pull_check_status_skips_files_reviews_and_diff():
return httpx.Response(200, json={
"state": "open", "draft": False, "mergeable": True, "merged": False,
"head": {"sha": "abc123"},
"requested_reviewers": [{"login": "casey"}],
})
if request.url.path.endswith("/pulls/7/reviews"):
return httpx.Response(200, json=[])
if request.url.path.endswith("/commits/abc123/status"):
return httpx.Response(200, json={
"state": "success",
@@ -153,12 +466,16 @@ async def test_gitea_pull_check_status_skips_files_reviews_and_diff():
assert requests == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/commits/abc123/status"),
("GET", "/api/v1/repos/stackchain/api/pulls/7/reviews"),
]
assert status == {
"repository": "stackchain/api", "number": 7, "head_sha": "abc123",
"state": "open", "draft": False, "mergeable": True, "merged": False,
"ci_state": "success",
"checks": [{"name": "tests", "state": "success", "description": "Passed", "url": ""}],
"reviewers": [
{"login": "casey", "status": "waiting", "head_sha": "abc123", "blocking": True},
],
}
@@ -200,7 +517,12 @@ async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
"state": "open",
"mergeable": True,
"head": {"sha": "abc123"},
"requested_reviewers": [{"login": "casey"}],
})
if path.endswith("/pulls/7/reviews"):
return httpx.Response(200, json=[
{"id": 8, "user": {"login": "sam"}, "state": "APPROVED", "commit_id": "abc123"},
])
if path.endswith("/pulls/7/files"):
return httpx.Response(200, json=[
{"filename": "src/api.py", "status": "modified", "additions": 1, "deletions": 1},
@@ -230,9 +552,89 @@ async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
assert "+new" in detail["files"][0]["diff_lines"]
assert detail["files"][1]["diff_binary"] is True
assert detail["files"][1]["diff_available"] is False
assert detail["reviewers"] == [
{"login": "casey", "status": "waiting", "head_sha": "abc123", "blocking": True},
{"review_id": 8, "login": "sam", "status": "approved", "head_sha": "abc123", "blocking": False},
]
assert "conversation" not in detail
@pytest.mark.anyio
async def test_assigned_pull_review_does_not_block_on_inline_feedback():
requests = []
async def handler(request):
path = request.url.path
requests.append(path)
if path.endswith("/pulls/7"):
return httpx.Response(200, json={
"state": "open", "mergeable": True, "head": {"sha": "abc123"},
"requested_reviewers": [],
})
if path.endswith("/pulls/7/reviews"):
return httpx.Response(200, json=[
{"id": 7, "user": {"login": "sam"}, "state": "COMMENT", "commit_id": "old-head"},
{"id": 8, "user": {"login": "sam"}, "state": "REQUEST_CHANGES",
"commit_id": "abc123", "body": "Please handle the empty state."},
])
if path.endswith("/pulls/7/files"):
return httpx.Response(200, json=[{"filename": "src/api.py", "status": "modified"}])
if path.endswith("/commits/abc123/status"):
return httpx.Response(200, json={"state": "success"})
if path.endswith("/pulls/7.diff"):
return httpx.Response(200, text="")
raise AssertionError(f"optional feedback must not block core review: {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 not any(path.endswith("/comments") for path in requests)
assert detail["reviewers"] == [{
"review_id": 8, "login": "sam", "status": "changes_requested", "head_sha": "abc123", "blocking": True,
"summary": "Please handle the empty state.",
}]
@pytest.mark.anyio
async def test_pull_review_feedback_loads_one_current_bounded_review():
requests = []
async def handler(request):
path = request.url.path
requests.append(path)
if path.endswith("/pulls/7"):
return httpx.Response(200, json={"head": {"sha": "abc123"}})
if path.endswith("/pulls/7/reviews"):
return httpx.Response(200, json=[
{"id": 7, "user": {"login": "sam"}, "state": "COMMENT", "commit_id": "old-head"},
{"id": 8, "user": {"login": "sam"}, "state": "REQUEST_CHANGES", "commit_id": "abc123"},
])
if path.endswith("/pulls/7/reviews/8/comments"):
return httpx.Response(200, json=[
{"id": 99, "path": "src/api.py", "body": "Return before parsing.", "new_position": 12},
])
raise AssertionError(f"unexpected request: {request.method} {path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
feedback = await gitea_proxy.pull_review_feedback(
"stackchain/api", 7, 8, "abc123"
)
finally:
await gitea_proxy.stop_client()
assert requests[-1].endswith("/pulls/7/reviews/8/comments")
assert feedback == {
"review_id": 8,
"head_sha": "abc123",
"reviewed_head_sha": "abc123",
"comments": [{"id": 99, "path": "src/api.py", "body": "Return before parsing.", "line": 12}],
}
@pytest.mark.anyio
async def test_assigned_pull_conversation_endpoint_reuses_issue_thread_with_pull_authorization(monkeypatch):
calls = []
@@ -593,6 +995,39 @@ async def test_gitea_merge_rejects_failed_ci_without_upstream_mutation():
]
@pytest.mark.anyio
@pytest.mark.parametrize("requested,reviews", [
([{"login": "casey"}], []),
([], [{"id": 9, "user": {"login": "casey"}, "state": "REQUEST_CHANGES", "commit_id": "abc123"}]),
])
async def test_gitea_merge_rejects_unresolved_reviewer_status_without_mutation(requested, reviews):
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"},
"requested_reviewers": requested,
})
if request.url.path.endswith("/commits/abc123/status"):
return httpx.Response(200, json={"state": "success"})
if request.url.path.endswith("/pulls/7/reviews"):
return httpx.Response(200, json=reviews)
raise AssertionError(f"unexpected mutation: {request.method} {request.url.path}")
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[-1] == ("GET", "/api/v1/repos/stackchain/api/pulls/7/reviews")
assert all(method == "GET" for method, _path in requests)
@pytest.mark.anyio
async def test_assigned_pull_ownership_endpoints_list_handoff_and_release(monkeypatch):
calls = []
@@ -640,6 +1075,69 @@ async def test_assigned_pull_ownership_endpoints_list_handoff_and_release(monkey
]
@pytest.mark.anyio
async def test_assigned_pull_review_request_endpoints_list_and_submit(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def candidates(repository, number):
calls.append(("candidates", repository, number))
return [{"login": "casey", "name": "Casey"}]
async def request_review(repository, number, reviewer, expected_head_sha):
calls.append(("request", repository, number, reviewer, expected_head_sha))
return {"number": number, "reviewer": reviewer, "requested_reviewers": [reviewer]}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "pull_review_candidates", candidates, raising=False)
monkeypatch.setattr(main.gitea_proxy, "request_assigned_pull_review", request_review, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
listed = await client.get("/api/v1/repos/stackchain/api/pulls/7/review-candidates")
requested = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/request-review",
json={"reviewer": "casey", "expected_head_sha": "abc1234"},
)
assert listed.status_code == 200
assert listed.headers["cache-control"] == "no-store"
assert listed.json() == [{"login": "casey", "name": "Casey"}]
assert requested.status_code == 200
assert requested.json()["requested_reviewers"] == ["casey"]
assert calls == [
("assigned", "stackchain/api", 7),
("candidates", "stackchain/api", 7),
("request", "stackchain/api", 7, "casey", "abc1234"),
]
@pytest.mark.anyio
async def test_assigned_pull_cancel_review_endpoint_forwards_head_scoped_request(monkeypatch):
calls = []
async def cancel(repository, number, reviewer, expected_head_sha):
calls.append((repository, number, reviewer, expected_head_sha))
return {
"repository": repository, "number": number, "reviewer": reviewer,
"head_sha": expected_head_sha, "requested_reviewers": ["casey"],
}
monkeypatch.setattr(main.gitea_proxy, "cancel_assigned_pull_review", cancel, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.request(
"DELETE", "/api/v1/repos/stackchain/api/pulls/7/request-review",
json={"reviewer": "sam", "expected_head_sha": "abc1234"},
)
assert response.status_code == 200
assert response.json()["requested_reviewers"] == ["casey"]
assert calls == [("stackchain/api", 7, "sam", "abc1234")]
@pytest.mark.anyio
async def test_gitea_pull_handoff_preserves_coassignees_and_confirms_ownership_exit():
requests = []
@@ -709,6 +1207,124 @@ async def test_pull_handoff_candidates_are_bounded_and_exclude_invalid_or_curren
]
@pytest.mark.anyio
async def test_gitea_pull_review_request_filters_candidates_and_confirms_requested_reviewer():
requests = []
review_requested = False
async def handler(request):
nonlocal review_requested
requests.append((request.method, request.url.path, request.content))
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/assignees"):
return httpx.Response(200, json=[
{"login": "timmy", "full_name": "Timmy"},
{"login": "alex", "full_name": "Alexander"},
{"login": "sam", "full_name": "Sam"},
{"login": "casey", "full_name": "Casey"},
])
if request.url.path.endswith("/pulls/7"):
requested = [{"login": "sam"}]
if review_requested:
requested.append({"login": "casey"})
return httpx.Response(200, json={
"number": 7, "state": "open", "merged": False,
"head": {"sha": "abc123"}, "user": {"login": "alex"},
"assignees": [{"login": "timmy"}],
"requested_reviewers": requested,
})
if request.method == "POST" and request.url.path.endswith("/requested_reviewers"):
review_requested = True
return httpx.Response(201, json={})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
candidates = await gitea_proxy.pull_review_candidates("stackchain/api", 7)
result = await gitea_proxy.request_assigned_pull_review(
"stackchain/api", 7, "casey", "abc123"
)
finally:
await gitea_proxy.stop_client()
assert candidates == [{"login": "casey", "name": "Casey"}]
assert result == {
"repository": "stackchain/api", "number": 7, "head_sha": "abc123",
"requested_reviewers": ["sam", "casey"], "reviewer": "casey",
}
mutation = next(item for item in requests if item[0] == "POST")
assert mutation[1] == "/api/v1/repos/stackchain/api/pulls/7/requested_reviewers"
assert mutation[2] == b'{"reviewers":["casey"]}'
@pytest.mark.anyio
async def test_gitea_cancel_pending_pull_review_confirms_authoritative_removal():
requests = []
cancelled = False
async def handler(request):
nonlocal cancelled
requests.append((request.method, request.url.path, request.content))
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7, "state": "open", "merged": False,
"head": {"sha": "abc123"}, "assignees": [{"login": "timmy"}],
"requested_reviewers": [{"login": "casey"}] if cancelled else [{"login": "sam"}, {"login": "casey"}],
})
if request.method == "DELETE" and request.url.path.endswith("/requested_reviewers"):
assert request.content == b'{"reviewers":["sam"]}'
cancelled = True
return httpx.Response(204)
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.cancel_assigned_pull_review(
"stackchain/api", 7, "sam", "abc123"
)
finally:
await gitea_proxy.stop_client()
assert result == {
"repository": "stackchain/api", "number": 7, "head_sha": "abc123",
"requested_reviewers": ["casey"], "reviewer": "sam",
}
assert [method for method, _path, _body in requests] == ["GET", "GET", "DELETE", "GET"]
@pytest.mark.anyio
async def test_gitea_cancel_pending_pull_review_rejects_head_drift_before_mutation():
requests = []
async def handler(request):
requests.append(request.method)
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7, "state": "open", "merged": False,
"head": {"sha": "new-head"}, "assignees": [{"login": "timmy"}],
"requested_reviewers": [{"login": "sam"}],
})
if request.method == "DELETE":
return httpx.Response(204)
raise AssertionError(request.url.path)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(gitea_proxy.IssueNotAvailableError):
await gitea_proxy.cancel_assigned_pull_review(
"stackchain/api", 7, "sam", "old-head"
)
finally:
await gitea_proxy.stop_client()
assert requests == ["GET", "GET"]
@pytest.mark.anyio
async def test_gitea_pull_release_removes_current_login_case_insensitively():
async def handler(request):
@@ -745,6 +1361,8 @@ async def test_gitea_merge_returns_the_exact_merge_commit_for_release_tracking()
})
if request.url.path.endswith("/commits/abc123/status"):
return httpx.Response(200, json={"state": "success"})
if request.url.path.endswith("/pulls/7/reviews"):
return httpx.Response(200, json=[])
if request.url.path.endswith("/pulls/7/merge"):
return httpx.Response(200, json={"merged": True, "sha": "merge456"})
raise AssertionError(request.url.path)
+37
View File
@@ -4,6 +4,8 @@ from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "push-notifications.js"
INDEX = MODULE.parent / "index.html"
DASHBOARD = MODULE.parent / "dashboard.js"
def run_scenario(script: str) -> dict:
@@ -27,6 +29,11 @@ const startDayControl = {
};
const startDayHour = {value:'9', disabled:false};
const startDayStatus = {set textContent(value) { state.startDayText = value; }, get textContent() { return state.startDayText; }};
const followingControl = {
checked:false, disabled:false,
addEventListener:(_name, callback) => state.followingChange = callback,
};
const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }};
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback};
const deadlineSnooze = {hidden:true};
@@ -40,6 +47,7 @@ const registration = {pushManager:{
const feature = createPushNotifications({
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour,
followingControl, followingStatus,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
onReviewDeadlines:() => { state.reviewed = true; },
notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }},
@@ -77,6 +85,35 @@ process.stdout.write(JSON.stringify(state));
assert result["text"] == "New update notifications enabled for this device."
def test_following_alert_toggle_is_opt_in_and_does_not_change_other_channels():
result = run_scenario("""
state.server = {available:true,subscribed:true,following_enabled:false,deadline_enabled:true,start_day_enabled:true,public_key:'AQID'};
state.current = existing;
await feature.init();
followingControl.checked = true;
await state.followingChange();
process.stdout.write(JSON.stringify({requests:state.requests, following:followingControl.checked, deadline:deadlineControl.checked, startDay:startDayControl.checked, text:state.followingText}));
""")
assert result["following"] is True
assert result["deadline"] is True
assert result["startDay"] is True
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/following", "PUT"]
assert json.loads(result["requests"][-1][2]) == {"enabled": True}
assert result["text"] == "Following change alerts enabled for this device."
def test_device_settings_render_and_wire_the_following_alert_preference():
index = INDEX.read_text()
dashboard = DASHBOARD.read_text()
assert 'for="push-following"' in index
assert 'id="push-following" type="checkbox"' in index
assert 'id="push-following-status" role="status" aria-live="polite"' in index
assert "followingControl:qs('#push-following')" in dashboard
assert "followingStatus:qs('#push-following-status')" in dashboard
def test_degraded_device_can_run_a_test_notification_and_show_recovery():
result = run_scenario("""
state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}};
+108 -4
View File
@@ -12,6 +12,7 @@ import requests
from src import dashboard_auth, gitea_proxy, main
from src.push_notifications import (
PushConfiguration,
dispatch_following_changes,
dispatch_unread_updates,
send_web_push,
)
@@ -41,6 +42,98 @@ def test_dispatch_lease_is_exclusive_recoverable_and_owner_fenced(tmp_path):
assert second.release_dispatch_lease("worker-b") is True
def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("session-a", "session-b"):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
assert store.following_preferences("session-a") == {"enabled": False}
store.set_following_preferences("session-a", enabled=True)
devices = store.following_notification_devices()
assert [(device.session_id, device.delivered_fingerprint) for device in devices] == [
("session-a", None)
]
store.mark_following_delivered("session-a", "revision-fingerprint")
assert store.following_notification_devices()[0].delivered_fingerprint == "revision-fingerprint"
assert store.following_preferences("session-b") == {"enabled": False}
@pytest.mark.anyio
async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("active", "revoked", "disabled"):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_following_preferences("active", enabled=True)
store.set_following_preferences("revoked", enabled=True)
sent = []
async def following():
return {"items": [{
"repository": "secret/project", "kind": "issue", "number": 42,
"title": "Sensitive acquisition", "updated_at": "2026-08-23T17:00:00Z",
"has_unseen_change": True,
}]}
async def send(subscription, payload):
sent.append((subscription["endpoint"], json.loads(payload)))
async def statuses(session_ids):
return {session_id: ("active" if session_id == "active" else "revoked") for session_id in session_ids}
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_following_changes(
store, configuration, following, send, session_statuses=statuses
) == 1
assert sent[0][0].endswith("/active")
assert sent[0][1] == {
"title": "1 watched item changed",
"body": "Open Following to review the latest activity.",
"route": "#/my-work/following",
"tag": sent[0][1]["tag"],
"following_count": 1,
}
assert sent[0][1]["tag"].startswith("stackchain-following-")
assert "secret" not in json.dumps(sent[0][1]).lower()
assert "acquisition" not in json.dumps(sent[0][1]).lower()
assert await dispatch_following_changes(
store, configuration, following, send, session_statuses=statuses
) == 0
assert len(sent) == 1
assert store.subscription_for_session("revoked") is None
@pytest.mark.anyio
async def test_authenticated_device_controls_following_alerts_independently(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/session-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
monkeypatch.setattr(main, "_push_subscription_store", store)
async def management_id(_session):
return "session-a"
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
result = await main.update_following_notifications(
main.FollowingNotificationPayload(enabled=True), request
)
assert result == {"following_enabled": True}
assert (await main.push_status(request))["following_enabled"] is True
assert store.deadline_preferences("session-a")["enabled"] is False
assert store.start_day_preferences("session-a")["enabled"] is False
def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
state_dir = tmp_path / "push-state"
previous_umask = os.umask(0)
@@ -415,6 +508,7 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "4")
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
monkeypatch.setattr(main, "dispatch_start_day_reminders", hold_dispatch)
@@ -437,8 +531,8 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
async def no_wait(_seconds):
nonlocal sleeps
sleeps += 1
if sleeps <= 3:
if sleeps == 3:
if sleeps <= 4:
if sleeps == 4:
first_tick.set()
await first_tick.wait()
else:
@@ -454,15 +548,19 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
async def dispatch_start_day(*_args, **_kwargs):
calls.append("start-day")
async def dispatch_following(*_args, **_kwargs):
calls.append("following")
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_following)
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
with pytest.raises(asyncio.CancelledError):
await main._push_poll_loop()
assert sorted(calls) == ["deadline", "start-day", "unread"]
assert sorted(calls) == ["deadline", "following", "start-day", "unread"]
@pytest.mark.anyio
@@ -487,6 +585,7 @@ async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(mon
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_start_day)
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
@@ -513,7 +612,7 @@ async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch):
await main._push_poll_loop()
assert sorted(intervals) == [30.0, 600.0, 600.0]
assert sorted(intervals) == [30.0, 30.0, 600.0, 600.0]
@pytest.mark.anyio
@@ -613,6 +712,7 @@ async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path
"route": "#/my-work/update/42",
"tag": "stackchain-update-42",
"notification_id": 42,
"unread_count": 1,
}
assert "private/repo" not in json.dumps(sent)
assert "Secret title" not in json.dumps(sent)
@@ -692,7 +792,9 @@ async def test_update_burst_sends_bounded_individual_pushes_and_one_private_dige
"route": "#/my-work/updates",
"tag": "stackchain-update-digest",
"update_count": 3,
"unread_count": 5,
}
assert [payload["unread_count"] for payload in sent] == [5, 5, 5]
assert "private/repo" not in json.dumps(sent)
assert "Secret update" not in json.dumps(sent)
assert await dispatch_unread_updates(
@@ -792,6 +894,7 @@ async def test_production_notification_page_dispatches_one_unread_push(tmp_path)
"route": "#/my-work/update/42",
"tag": "stackchain-update-42",
"notification_id": 42,
"unread_count": 1,
}]
assert "private/repo" not in json.dumps(sent)
assert "Secret title" not in json.dumps(sent)
@@ -1278,6 +1381,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
"start_day_enabled": False,
"start_day_timezone": "UTC",
"start_day_reminder_hour": 9,
"following_enabled": False,
"delivery_health": {},
}
assert await main.subscribe_push(payload, request) == {"subscribed": True}
+40 -1
View File
@@ -98,7 +98,12 @@ const outcome=await planner.plan({kind:'issue',state:'open',assigned_to_me:true}
console.log(JSON.stringify(outcome));
""")
assert result == {"status": "planned-pending", "date": "2026-08-22", "estimate": 45}
assert result == {
"status": "planned-pending",
"date": "2026-08-22",
"estimate": 45,
"item": {"kind": "issue", "state": "open", "assigned_to_me": True},
}
def test_search_week_plan_stages_canonical_details_before_flushing():
@@ -122,3 +127,37 @@ console.log(JSON.stringify({outcome,calls}));
["details", "issue:stackchain/dashboard:42:", "Canonical title", "stackchain/dashboard", 42],
["flush"],
]
def test_search_week_plan_waits_for_success_callback_before_closing():
result = run_planner("""
const events=[];
const nodes={};
function node(extra={}) { return Object.assign({
hidden:true,disabled:false,textContent:'',value:'',dataset:{},
addEventListener(){},focus(){events.push('focus');},
setAttribute(){},querySelectorAll(){return[];}
},extra); }
nodes['#search-week-plan-sheet']=node();
nodes['#search-week-plan-status']=node();
nodes['#confirm-search-week-plan']=node();
nodes['#search-week-plan-copy']=node();
nodes['#search-week-plan-days']=node({dataset:{selected:'2026-08-22'}});
nodes['#search-week-plan-estimate']=node({value:'45'});
nodes['#plan-search-result']=node(); nodes['#cancel-search-week-plan']=node();
const document={querySelector:selector=>nodes[selector],addEventListener(){}};
const window={history:{state:{},pushState(value){this.state=value;},back(){events.push('back');nodes['#search-week-plan-sheet'].hidden=true;}},location:{href:'/'},addEventListener(){}};
const planner={pending:()=>false,preview:async()=>({days:[{date:'2026-08-22',label:'Sat',planned_minutes:0,capacity_minutes:90,ids:[]}]}),
plan:async()=>({status:'planned',date:'2026-08-22',estimate:45,item:{number:42}})};
const ui=createSearchWeekPlan.UI({planner,document,window,getDetail:()=>({title:'Issue',number:42}),
escapeHtml:value=>value,escapeAttribute:value=>value,announce:message=>events.push(message),
onPlanned:async()=>{events.push('callback-start');await Promise.resolve();events.push('callback-end');}});
await ui.open(node());
await ui.confirm();
events.push(nodes['#search-week-plan-sheet'].hidden?'closed':'open');
console.log(JSON.stringify({events}));
""")
assert result["events"][-4:] == [
"callback-start", "callback-end", "back", "closed"
]
+137 -17
View File
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, badgeCounts: {{updates:0, following:0}}, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@@ -78,6 +78,13 @@ const context = {{
}},
purge: async () => {{ state.todayCommands = {{}}; }},
}},
__STACKCHAIN_APP_BADGE_PREFERENCE: {{
get: async () => state.badgeEnabled,
set: async enabled => {{ state.badgeEnabled = enabled; }},
getCounts: async () => ({{...state.badgeCounts}}),
setCount: async (channel, count) => {{ state.badgeCounts[channel] = count; }},
clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0}}; }},
}},
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{
@@ -88,6 +95,8 @@ const context = {{
registration: {{
showNotification: async (title, options) => state.notifications.push({{title,options}}),
getNotifications: async () => [{{close:()=>{{state.closedNotifications += 1;}}}}],
setAppBadge: async value => state.appBadges.push(value),
clearAppBadge: async () => {{ state.clearedAppBadges += 1; }},
}},
__issueSync: {{
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
@@ -180,7 +189,7 @@ async function dispatchPush(payload) {{
def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -188,20 +197,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -210,7 +219,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source
@@ -219,7 +228,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@@ -227,14 +236,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -242,7 +251,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -250,7 +259,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -260,14 +269,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -276,21 +285,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -479,6 +488,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert result["state"]["clientMessages"] == [
@@ -800,6 +810,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
]
assert result["replies"] == [{"ok": True}]
@@ -973,6 +984,89 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c
assert "must-not-render" not in json.dumps(result["notifications"])
def test_opted_in_background_updates_reconcile_authoritative_badge_without_churn():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-app-badge-preference', enabled:true}, [], {url:'https://forge.example/dashboard/'});
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:7});
await dispatchPush({tag:'stackchain-update-digest', route:'#/my-work/updates', update_count:2, unread_count:7});
await dispatchPush({tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda', protect_route:'#/my-work/agenda/protect-today', deadline_count:1, unread_count:99});
await dispatchPush({tag:'stackchain-update-43', route:'#/my-work/update/43', notification_id:43, unread_count:'invalid'});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["badgeEnabled"] is True
assert result["appBadges"] == [7]
assert result["clearedAppBadges"] == 0
assert len(result["notifications"]) == 4
def test_background_badge_combines_following_and_updates_without_channel_overwrite():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-app-badge-preference', enabled:true}, [], {url:'https://forge.example/dashboard/'});
await dispatchPush({tag:'stackchain-following-0123456789abcdef', route:'#/my-work/following', following_count:2});
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3});
await dispatchPush({tag:'stackchain-following-fedcba9876543210', route:'#/my-work/following', following_count:'invalid'});
await dispatchPush({tag:'stackchain-following-1111111111111111', route:'#/my-work/following', following_count:1});
await dispatchPush({tag:'stackchain-update-digest', route:'#/my-work/updates', update_count:1, unread_count:3});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["appBadges"] == [2, 5, 4]
assert result["badgeCounts"] == {"updates": 3, "following": 1}
assert result["clearedAppBadges"] == 0
def test_worker_accepts_only_authenticated_authoritative_badge_channel_counts():
result = run_worker_scenario(
"""
state.badgeEnabled = true;
await dispatchMessage({type:'stackchain-app-badge-count', channel:'following', count:2}, [], {url:'https://forge.example/dashboard/'});
await dispatchMessage({type:'stackchain-app-badge-count', channel:'updates', count:8}, [], {url:'https://evil.example/dashboard/'});
await dispatchMessage({type:'stackchain-app-badge-count', channel:'following', count:-1}, [], {url:'https://forge.example/dashboard/'});
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["badgeCounts"] == {"updates": 3, "following": 2}
assert result["appBadges"] == [5]
def test_foreground_channel_sync_invalidates_worker_render_cache_for_next_push():
result = run_worker_scenario(
"""
state.badgeEnabled = true;
await dispatchPush({tag:'stackchain-following-0123456789abcdef', route:'#/my-work/following', following_count:2});
await dispatchMessage({type:'stackchain-app-badge-count', channel:'following', count:0}, [], {url:'https://forge.example/dashboard/'});
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:2});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["badgeCounts"] == {"updates": 2, "following": 0}
assert result["appBadges"] == [2, 2]
def test_disabling_background_badge_clears_it_and_future_updates_leave_it_off():
result = run_worker_scenario(
"""
state.badgeEnabled = true;
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3});
await dispatchMessage({type:'stackchain-app-badge-preference', enabled:false}, [], {url:'https://forge.example/dashboard/'});
await dispatchPush({tag:'stackchain-update-43', route:'#/my-work/update/43', notification_id:43, unread_count:4});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["badgeEnabled"] is False
assert result["appBadges"] == [3]
assert result["clearedAppBadges"] == 1
def test_deadline_digest_push_offers_protect_today_and_snooze_without_rendering_private_copy():
result = run_worker_scenario(
"""
@@ -1260,7 +1354,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -1291,6 +1385,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/commands.js",
"/dashboard/static/saved-searches.js",
"/dashboard/static/search-preview.js",
"/dashboard/static/following.js",
"/dashboard/static/search-reply-draft-store.js",
"/dashboard/static/conversation-reply-draft-store.js",
"/dashboard/static/conversation-photo-drafts.js",
@@ -1565,6 +1660,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert "private cached dashboard" not in result["body"]
@@ -1753,3 +1849,27 @@ def test_client_error_navigation_is_not_hidden_by_cached_shell():
assert result["status"] == 401
assert result["body"] == "network"
assert result["state"]["puts"] == []
def test_following_push_uses_generic_copy_and_opens_the_following_route():
result = run_worker_scenario("""
await dispatchPush({
title:'leaked title', body:'leaked body', route:'#/my-work/following',
tag:'stackchain-following-0123456789abcdef', following_count:2,
});
await dispatchNotificationClick(
'#/my-work/following', '', null, 'stackchain-following-0123456789abcdef'
);
console.log(JSON.stringify({notifications:state.notifications,opened:state.opened,closed:state.notificationClosed}));
""")
assert result["notifications"] == [{
"title": "2 watched items changed",
"options": {
"body": "Open Following to review the latest activity.",
"tag": "stackchain-following-0123456789abcdef",
"data": {"route": "#/my-work/following"},
},
}]
assert result["opened"] == ["https://forge.example/dashboard/#/my-work/following"]
assert result["closed"] is True
+1
View File
@@ -208,6 +208,7 @@ async def test_push_poll_applies_configured_concurrency_to_start_day(
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3")
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch)
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)
+29
View File
@@ -93,6 +93,35 @@ process.stdout.write(JSON.stringify({reopened,snapshot:timer.snapshot(),pending:
}
def test_adopting_paused_state_for_same_item_preserves_valid_local_break():
script = TIMER.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>100000});
timer.activate('issue:r:42:');
timer.startBreak(5);
const adopted = timer.adopt('issue:r:42:', 12000, false, [
{identity:'issue:r:42:',elapsed_ms:12000},
]);
process.stdout.write(JSON.stringify({adopted,snapshot:timer.snapshot(),pending:timer.breakSnapshot()}));
"""
assert run_node(script) == {
"adopted": True,
"snapshot": {
"identity": "issue:r:42:",
"elapsed_ms": 12000,
"running": False,
"break_deadline_at": 400_000,
},
"pending": {
"identity": "issue:r:42:",
"deadline_at": 400_000,
"expired": False,
},
}
def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
script = TIMER.read_text() + r"""
const values = new Map();
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v130';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v138';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v130" in source
assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/today-sync.js'" in source
+67
View File
@@ -64,6 +64,73 @@ console.log(JSON.stringify({opened,confirmed,requests,adoptedToday,weekState}));
assert result["confirmed"]["today"]["revision"] == 5
def test_reschedule_controller_retries_one_stale_revision_when_active_intent_is_unchanged():
result = run_controller("""
const requests=[];
let attempts=0;
const weekState={revision:7,offline_snapshot:false,timezone:'UTC',days:[
{plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}}
]};
const week={
load:async()=>weekState,
review:()=>({days:[{...weekState.days[0],label:'Tue',planned_minutes:0}]}),
adopt:value=>Object.assign(weekState,value),
};
const api=async(url,options)=>{
if(url==='api/v1/today')return {revision:5,ids:['active'],estimates:{active:45}};
if(url==='api/v1/week')return {revision:8,offline_snapshot:false,timezone:'UTC',days:[
{plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}}
]};
const body=JSON.parse(options.body);requests.push(body);attempts++;
if(attempts===1){const error=new Error('stale revision');error.status=409;throw error;}
return {today:{revision:6,ids:[],estimates:{}},week:{revision:9,timezone:'UTC',days:[
{plan_date:'2026-08-25',ids:['active'],capacity_minutes:90,estimates:{active:45}}
]}};
};
const controller=createReschedule({week,api,getToday:()=>null,adoptToday:()=>{},operationId:()=> 'move-active'});
await controller.open('active');
let message='',confirmed=null;
try{confirmed=await controller.confirm('2026-08-25',45);}catch(error){message=error.message;}
console.log(JSON.stringify({message,confirmed,requests}));
""")
assert result["message"] == ""
assert result["confirmed"]["today"]["revision"] == 6
assert [request["today_revision"] for request in result["requests"]] == [5, 5]
assert [request["week_revision"] for request in result["requests"]] == [7, 8]
assert result["requests"][0]["operation_id"] == result["requests"][1]["operation_id"]
def test_reschedule_controller_rebases_admitted_move_once_after_stale_revision():
result = run_controller("""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const requests=[];let attempts=0;
const initialWeek={revision:7,offline_snapshot:false,timezone:'UTC',days:[
{plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}}
]};
const week={load:async()=>initialWeek,review:()=>({days:[{...initialWeek.days[0],label:'Tue',planned_minutes:0}]}),adopt:()=>{}};
const api=async(url,options)=>{
if(url==='api/v1/today')return {revision:5,ids:['active'],estimates:{active:45}};
if(url==='api/v1/week')return {revision:8,offline_snapshot:false,timezone:'UTC',days:initialWeek.days};
const body=JSON.parse(options.body);requests.push(body);attempts++;
if(attempts===1){const error=new Error('stale revision');error.status=409;throw error;}
return {today:{revision:6,ids:[],estimates:{}},week:{revision:9,timezone:'UTC',days:[]}};
};
const controller=createReschedule({week,api,adoptToday:()=>{},storage,getLogin:()=> 'timmy',operationId:()=> 'move-active'});
await controller.open('active');
let message='',confirmed=null;
try{confirmed=await controller.confirm('2026-08-25',45);}catch(error){message=error.message;}
console.log(JSON.stringify({message,confirmed,requests,remaining:values.size}));
""")
assert result["message"] == ""
assert result["confirmed"]["today"]["revision"] == 6
assert [request["today_revision"] for request in result["requests"]] == [5, 5]
assert [request["week_revision"] for request in result["requests"]] == [7, 8]
assert result["remaining"] == 0
def test_reschedule_controller_blocks_offline_full_and_overloaded_days_without_mutation():
result = run_controller("""
let calls=0;
+25 -6
View File
@@ -118,12 +118,11 @@ process.env.TZ='UTC';
const event=`BEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20000101T090000\r\nDTEND;TZID=America/New_York:20000101T100000\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n`;
const source='BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n'+event.repeat(80)+'END:VCALENDAR';
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const started=Date.now();const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({elapsed:Date.now()-started,busy:days.map(day=>day.busy_minutes)}));
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
""")
assert result["busy"] == [60] * 7
assert result["elapsed"] < 1000
def test_calendar_import_fast_forwards_historical_weekly_series_to_seven_day_range():
@@ -132,12 +131,32 @@ process.env.TZ='UTC';
const event=`BEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20000101T090000\r\nDTEND;TZID=America/New_York:20000101T100000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2\r\nEND:VEVENT\r\n`;
const source='BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n'+event.repeat(80)+'END:VCALENDAR';
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const started=Date.now();const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({elapsed:Date.now()-started,busy:days.map(day=>day.busy_minutes)}));
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
""")
assert result["busy"] == [0, 0, 0, 60, 0, 60, 0]
assert result["elapsed"] < 1000
def test_calendar_import_reuses_timezone_formatters_for_large_historical_calendars():
result = run_import("""
process.env.TZ='UTC';
const NativeDateTimeFormat=Intl.DateTimeFormat;
let formatterConstructions=0;
Intl.DateTimeFormat=function(...args) {
formatterConstructions+=1;
return new NativeDateTimeFormat(...args);
};
Intl.DateTimeFormat.prototype=NativeDateTimeFormat.prototype;
const event=`BEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20000101T090000\r\nDTEND;TZID=America/New_York:20000101T100000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2\r\nEND:VEVENT\r\n`;
const source='BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n'+event.repeat(80)+'END:VCALENDAR';
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
console.log(JSON.stringify({formatterConstructions,busy:days.map(day=>day.busy_minutes)}));
""")
assert result["busy"] == [0, 0, 0, 60, 0, 60, 0]
assert result["formatterConstructions"] <= 3
def test_calendar_import_expands_bounded_daily_recurrence_and_honors_exdate():
+41
View File
@@ -90,6 +90,47 @@ async def test_filed_work_page_endpoint_preserves_authored_reason(monkeypatch):
assert response.json()["items"][0]["work_reasons"] == ["created_by_me"]
@pytest.mark.anyio
async def test_authored_pull_page_endpoint_preserves_authorship_reason(monkeypatch):
async def page_loader(stream, page):
assert (stream, page) == ("authored", 2)
return {
"stream": stream, "page": page, "total": 51, "has_more": False,
"items": [{
"id": 901, "number": 41, "title": "Ship mobile flow", "state": "open",
"user": {"login": "timmy"}, "labels": [], "assignees": [],
"work_reasons": ["authored_by_me"],
"repository": {"full_name": "stackchain/dashboard"},
"html_url": "https://forge.example/pulls/41",
}],
}
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/authored?page=2")
assert response.status_code == 200
assert response.json()["items"][0]["work_reasons"] == ["authored_by_me"]
def test_initial_context_preserves_authored_pull_reason():
payload = main._context_payload(
{"id": 1, "login": "timmy"},
[],
[],
[{
"id": 901, "number": 41, "title": "Ship mobile flow", "state": "open",
"user": {"login": "timmy"}, "labels": [], "assignees": [],
"work_reasons": ["authored_by_me"],
"repository": {"full_name": "stackchain/dashboard"},
"html_url": "https://forge.example/pulls/41",
}],
)
assert payload["pull_requests"][0]["work_reasons"] == ["authored_by_me"]
@pytest.mark.anyio
async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_data():
transport = httpx.ASGITransport(app=main.app)