Compare commits

...
Author SHA1 Message Date
timmy 33d7f4b884 Merge pull request 'Complete the mobile Filed follow-up loop' (#875) from timmy/874-mobile-filed-followup into main
CI / lint (push) Successful in 1m52s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 1m1s
CI / release-candidate (push) Successful in 6s
2026-08-15 06:32:56 +00:00
timmy 05dd6c1e9c feat: complete mobile Filed follow-up (Closes #874)
CI / lint (pull_request) Successful in 1m56s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 06:29:14 +00:00
timmy 42f2a077b2 Merge pull request 'Open delegated Filed issues in read-only detail' (#873) from timmy/872-filed-read-only-detail into main
CI / lint (push) Successful in 1m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 54s
CI / release-candidate (push) Successful in 6s
2026-08-15 05:48:39 +00:00
timmy 0bb76bdc8b feat: open delegated filings read-only (Closes #872)
CI / lint (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 05:43:28 +00:00
timmy efafaaddd2 Merge pull request 'Keep delegated filings visible in a mobile Filed queue' (#871) from timmy/870-mobile-filed-queue into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 50s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m43s
2026-08-15 05:05:10 +00:00
timmy a9ffc2d879 feat: keep delegated filings in My Work (Closes #870)
CI / lint (pull_request) Successful in 1m48s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 55s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 05:01:17 +00:00
timmy 1fdbba1ca1 Merge pull request 'File related issues from confirmed filing receipts' (#869) from timmy/868-file-related-issue into main
CI / lint (push) Successful in 1m53s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 1m30s
CI / release-candidate (push) Successful in 6s
2026-08-15 04:16:16 +00:00
timmy 4b88e1a533 feat: file related issues from confirmed receipts (Closes #868)
CI / lint (pull_request) Successful in 1m47s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 04:12:27 +00:00
timmy 7a31ea5cba Merge pull request 'Complete delegated mobile filing with a shareable issue receipt' (#867) from timmy/866-issue-filing-receipt into main
CI / lint (push) Successful in 1m48s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 50s
CI / release-candidate (push) Successful in 5s
2026-08-15 03:10:27 +00:00
timmy 6633ba5244 feat: complete delegated mobile filing handoff (Closes #866)
CI / lint (pull_request) Successful in 1m44s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 03:06:32 +00:00
timmy 5b954a7a53 Merge pull request 'Isolate the release browser journey from the unit-test job' (#865) from timmy/863-release-e2e-opt-in into main
CI / lint (push) Successful in 1m52s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 1m1s
CI / release-candidate (push) Successful in 6s
2026-08-15 02:28:01 +00:00
timmy c30206bfb2 fix: isolate release browser journey
CI / lint (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
Refs #863
2026-08-15 02:24:15 +00:00
timmy cf9a02ae38 Merge pull request 'Gate releases on a real mobile offline filing journey' (#864) from timmy/863-mobile-offline-release-gate into main
CI / build-release (push) Has been skipped
CI / browser-journey (push) Has been skipped
CI / release-candidate (push) Has been skipped
CI / lint (push) Failing after 1m52s
2026-08-15 02:17:33 +00:00
timmy 9f5aa33e95 test: gate releases on mobile offline filing
CI / lint (pull_request) Successful in 1m54s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m4s
CI / release-candidate (pull_request) Has been skipped
Closes #863
2026-08-15 02:13:15 +00:00
timmy ae3749bab9 Merge pull request 'Open ready-to-file mobile Drafts directly in final review' (#862) from timmy/861-review-ready-drafts into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m49s
2026-08-15 01:15:04 +00:00
timmy c1c4ff8e31 feat: review ready mobile drafts in one tap (Closes #861)
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 01:12:10 +00:00
timmy ff41eb1d57 Resume fully planned issue drafts across devices (#860)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m56s
Closes #859
2026-08-15 00:10:22 +00:00
timmy f23839f2b9 feat: resume fully planned drafts across devices (Closes #859)
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-15 00:07:32 +00:00
timmy 7cab4e8749 Merge pull request 'Automatically reconcile concurrent cross-device Draft saves' (#858) from timmy/857-auto-reconcile-draft-saves into main
CI / lint (push) Successful in 1m52s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 23:31:11 +00:00
timmy 8a3316a5c4 fix: reconcile concurrent Draft saves (Closes #857)
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 23:28:12 +00:00
timmy 631eb2e584 Merge pull request 'File mobile issues without assigning an owner' (#856) from timmy/855-file-mobile-issues-without-owner into main
CI / lint (push) Successful in 1m52s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-14 23:08:40 +00:00
timmy ed246ce6dd feat: file mobile issues without an owner (Closes #855)
CI / lint (pull_request) Successful in 1m50s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 23:05:59 +00:00
timmy fc35b17d24 Merge pull request 'Preserve both versions when synchronized Drafts diverge' (#854) from timmy/853-divergent-unfiled-drafts into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m46s
2026-08-14 22:35:59 +00:00
timmy cee3d93864 fix: preserve divergent synchronized Drafts (Closes #853)
CI / lint (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 22:29:37 +00:00
timmy fca99fee86 Merge pull request 'Continue unfiled issue drafts across signed-in devices' (#852) from timmy/851-cross-device-unfiled-drafts into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m49s
2026-08-14 21:42:29 +00:00
timmy 3dd0ba2024 feat: sync unfiled drafts across devices (Closes #851)
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 21:39:22 +00:00
timmy 871e35c10b Require a Today estimate before Create & start
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m55s
2026-08-14 20:44:45 +00:00
timmy 5c9420ed9c feat: require estimate before create and start (Closes #849)
CI / lint (pull_request) Successful in 1m48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 20:41:57 +00:00
timmy 8edc83893f Merge pull request 'Load mobile issue-filing metadata in one resilient request' (#848) from timmy/847-issue-filing-metadata into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m49s
2026-08-14 20:02:51 +00:00
timmy a226addcc4 feat: load resilient issue filing metadata
CI / lint (pull_request) Successful in 1m55s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 19:59:45 +00:00
timmy 2402af9e82 Merge pull request 'Select repository issue templates while filing on mobile' (#846) from timmy/845-repository-issue-templates into main
CI / lint (push) Successful in 1m44s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
2026-08-14 19:18:02 +00:00
timmy 31f95589cb feat: apply repository templates during mobile issue filing
CI / lint (pull_request) Successful in 1m49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
Closes #845
2026-08-14 19:15:19 +00:00
timmy 2ac6b1f56b Merge pull request 'feat: Choose the initial owner while filing a mobile issue' (#844) from timmy/843-choose-the-initial-owner-while-filing-a-mobile-i into main
CI / lint (push) Successful in 1m46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-14 18:32:28 +00:00
timmy 6449ead83d feat: choose initial owner during issue filing
CI / lint (pull_request) Successful in 1m48s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 18:26:33 +00:00
timmy 322f025794 Merge pull request 'Choose blockers while filing a mobile issue' (#842) from timmy/841-capture-blockers into main
CI / lint (push) Successful in 1m43s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 17:36:42 +00:00
timmy 9ab22b97f8 feat: capture blockers while filing mobile issues (Closes #841)
CI / lint (pull_request) Successful in 1m45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 17:33:45 +00:00
timmy 35747cf9fb Merge pull request 'Load comment actions only when a conversation opens' (#840) from timmy/839-lazy-comment-actions into main
CI / lint (push) Successful in 1m43s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
2026-08-14 17:03:09 +00:00
timmy f094438dab Load comment actions on conversation demand
CI / lint (pull_request) Successful in 1m53s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 17:00:31 +00:00
timmy 04f06042b8 Merge pull request 'Bound decoded screenshot pixels before mobile evidence preview' (#838) from timmy/837-bound-decoded-screenshot-pixels into main
CI / lint (push) Successful in 1m44s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 16:37:55 +00:00
timmy a476170fca feat: bound decoded screenshot pixels (Closes #837)
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 16:35:21 +00:00
timmy 5a579362ff Merge pull request 'Visually verify mobile evidence in final filing review' (#836) from timmy/835-visual-mobile-evidence-review into main
CI / lint (push) Successful in 1m47s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 16:01:02 +00:00
timmy d797179844 feat: visually review mobile issue evidence (Closes #835)
CI / lint (pull_request) Successful in 1m44s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 15:56:08 +00:00
timmy a500915223 Merge pull request 'Review complete mobile issue payload before filing' (#834) from timmy/833-mobile-issue-filing-review into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m47s
2026-08-14 15:39:36 +00:00
timmy 59a2e4f616 feat: review mobile issue payload before filing (Closes #833)
CI / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 15:36:54 +00:00
timmy e125019f57 Merge pull request 'Annotate mobile evidence before filing' (#832) from timmy/831-annotate-mobile-evidence into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m55s
2026-08-14 14:59:50 +00:00
timmy 6ba823abd2 feat: annotate mobile evidence before filing (Closes #831)
CI / lint (pull_request) Successful in 1m43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 14:57:17 +00:00
timmy f44fa6e579 Crop and redact mobile evidence before filing (#830)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m41s
Closes #829
2026-08-14 14:36:29 +00:00
timmy 87ff83bd2d feat: crop and redact mobile evidence (Closes #829)
CI / lint (pull_request) Successful in 1m49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 14:33:18 +00:00
timmy 7dd396154d Merge pull request 'Explain each mobile evidence screenshot before filing' (#828) from timmy/827-evidence-screenshot-notes into main
CI / lint (push) Successful in 1m51s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-14 13:44:13 +00:00
timmy af5fe9b69d feat: explain mobile evidence screenshots (Closes #827)
CI / lint (pull_request) Successful in 1m47s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 13:41:02 +00:00
timmy 807364c63b Merge pull request 'Review and reorder mobile evidence before filing' (#826) from timmy/825-review-reorder-mobile-evidence into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m46s
2026-08-14 12:47:07 +00:00
timmy 6315323a48 feat: review and reorder mobile evidence (Closes #825)
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 12:43:31 +00:00
timmy 6a9271cabb Merge pull request 'Capture mobile evidence bundles with one issue' (#824)
CI / lint (push) Successful in 1m49s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #823
2026-08-14 11:45:28 +00:00
timmy a1c56bd644 feat: capture mobile evidence bundles (Closes #823)
CI / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 11:43:01 +00:00
timmy ebc3b9ee4d Merge pull request 'Save and reopen synced mobile Search views' (#822)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m48s
Closes #821
2026-08-14 10:49:01 +00:00
timmy 19772b5797 feat: sync saved mobile search views (Closes #821)
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 10:46:13 +00:00
timmy 6314efb82b Merge pull request 'feat: Fix flaky live-snapshot freshness test blocking CI release' (#820) from timmy/819-fix-flaky-live-snapshot-freshness-test-blocking- into main
CI / lint (push) Successful in 1m44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-14 10:24:46 +00:00
timmy 5cda95eeb2 test: remove scheduler race from snapshot freshness check
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 10:14:09 +00:00
timmy 0c71dea00c Merge pull request 'Apply mobile Search release plans atomically' (#818) from timmy/817-atomic-search-release-plan into main
CI / lint (push) Failing after 1m54s
CI / build-release (push) Has been skipped
CI / release-candidate (push) Has been skipped
2026-08-14 10:00:02 +00:00
timmy 53539e0cdf feat: apply search release plans atomically (Closes #817)
CI / lint (pull_request) Successful in 1m43s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 09:57:15 +00:00
timmy 9a24aff537 Merge pull request 'Plan selected mobile Search issues into a release milestone' (#816) from timmy/815-plan-search-release into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m38s
Plan selected mobile Search issues into a release milestone (#816)
2026-08-14 09:34:28 +00:00
timmy 43769460fc feat: plan search batches into releases (Closes #815)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 09:30:53 +00:00
timmy 2e62eb09f5 Merge pull request 'Batch-schedule selected mobile Search issues into Later' (#814) from timmy/813-batch-defer-search-results into main
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-14 09:08:14 +00:00
timmy 1ef3e291c2 feat: batch-defer mobile search results (Closes #813)
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 09:05:30 +00:00
timmy 3694f2501b Merge pull request 'Fit mobile Search batches to Today time capacity' (#812) from timmy/811-search-batch-time-capacity into main
CI / lint (push) Successful in 1m38s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-14 08:31:24 +00:00
timmy c3d58d7111 feat: fit search batches to Today capacity (Closes #811)
CI / lint (pull_request) Successful in 1m43s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-14 08:28:49 +00:00
timmy da45cc36a3 Batch-plan mobile Search results into Today (#810)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m39s
2026-08-14 08:08:45 +00:00
80 changed files with 8852 additions and 508 deletions

No files matched your search

+22 -1
View File
@@ -36,9 +36,30 @@ jobs:
name: release-bundle
path: dist/
browser-journey:
runs-on: ubuntu-latest
needs: build-release
env:
STACKCHAIN_RUN_RELEASE_E2E: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- name: Download assembled release bundle
uses: actions/download-artifact@v3
with:
name: release-bundle
path: dist
- name: Install browser test dependencies
run: |
pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium
- name: Exercise mobile offline filing from the release artifact
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q
release-candidate:
runs-on: ubuntu-latest
needs: [lint, build-release]
needs: [lint, build-release, browser-journey]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: write
+29 -12
View File
@@ -24,19 +24,25 @@ For online delivery, the screenshot uploads before the comment is posted—to th
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
preview when the operator later chooses a repository. The source Draft remains available until its
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
either stage. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
bundle when the operator later chooses a repository. A scrollable thumbnail tray lets the operator
review every restored image before filing; **Move earlier**, **Move later**, and **Remove selected**
change the durable evidence order without changing the issue title or note. The active screenshot's
optional **Evidence note** stays paired with that image through reorder, Draft restore, offline delivery,
and retry, then appears as a Markdown-safe caption immediately before its uploaded image. The selected screenshot can be cropped, privacy-redacted, highlighted, and marked with touch-drawn arrows before a flattened derivative replaces it; undo, reset, and cancel keep editing reversible without changing its note or bundle position. The source Draft remains
available until its evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob.
Repository-aware durable admission likewise stores the evidence bundle with its account-bound outbox
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
delivery time.
Delivery creates the issue exactly once, then uploads and comments with
the image; after a partial failure, retry resumes with the confirmed issue instead of
creating a duplicate.
Delivery creates the issue exactly once, then uploads each image under a checkpointed per-image
identity and posts one ordered Markdown evidence comment. After a partial failure, retry resumes with the confirmed issue and from
the first unconfirmed image instead of duplicating the issue or earlier uploads. The installed PWA
Share Target accepts the same bounded multi-image bundle through sign-in continuation.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API. In issue, pull-request, and unread-update conversations, typing
at least two characters after `@` offers repository-scoped teammate suggestions;
@@ -56,9 +62,14 @@ repository milestone and due date; the dashboard validates both and sends them w
self-assignment in the single create request, so planned work appears in its release
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
account-bound title/description captures without selecting a repository or entering the
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
the same Gitea login, **Choose repository** restores the capture to the normal planning
and durable delivery flow. A different or unconfirmed account can only copy or discard
mutation outbox. Drafts marks them **Needs filing** and saves locally first. After a fresh
reconnect confirms the same Gitea login, a bounded, revisioned collection synchronizes the title,
description, blockers, and ordered screenshot evidence; another signed-in device can then use
**Choose repository** to continue the normal planning and durable delivery flow. Draft cards report
whether they are synced or still pending locally. Concurrent changes surface a conflict, and
successful discard or outbox admission propagates deletion so a stale device cannot resurrect the
draft. Synchronization is limited to 20 drafts and 12 MiB of decoded evidence per account; failure
never blocks local capture. A different or unconfirmed account can only copy or discard
the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
@@ -68,6 +79,12 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
Named mobile Search views preserve the query, type, status, and optional repository scope. They are
bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another
device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently
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.
Search previews also
let operators assign an eligible issue and add it to Today without starting or replacing active work.
The queue action keeps the Search query, filters, results, and scroll position available for continued
+62 -10
View File
@@ -91,11 +91,18 @@ function createIssueSyncStore({
}
continue;
}
const preservedBundle = current.operationId === replacement.operationId &&
current.attachments?.every(value => value?.data || value?.blob) &&
replacement.attachments?.every(value => value?.stored && !value.data && !value.blob)
? current.attachments : null;
if (current.operationId === replacement.operationId &&
(current.attachment?.data || current.attachment?.blob) &&
replacement.attachment?.stored &&
!replacement.attachment.data && !replacement.attachment.blob) {
replacement = { ...replacement, attachment: current.attachment };
!replacement.attachment.data && !replacement.attachment.blob || preservedBundle) {
replacement = {
...replacement,
...(preservedBundle ? {attachments:preservedBundle} : {attachment:current.attachment}),
};
incoming.set(current.id, replacement);
}
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
@@ -353,6 +360,9 @@ function createBackgroundIssueSync({
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
}
if (typeof delivered.url === 'string' && /^https?:\/\//.test(delivered.url)) {
return { id: item.id, status, kind: 'issue', url: delivered.url };
}
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
}
@@ -439,6 +449,8 @@ function createBackgroundIssueSync({
title: item.title,
body: item.body,
label_ids: item.labelIds,
...(item.unassigned ? { unassigned: true } : {}),
...(item.assignee ? { assignee: item.assignee } : {}),
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
@@ -466,6 +478,13 @@ function createBackgroundIssueSync({
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
function evidenceMarkdown(attachment, markdown, index) {
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
if (!note) return markdown;
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
}
async function deliverReplyRead(item) {
let current = item;
if (!current.replyConfirmed) {
@@ -525,8 +544,31 @@ function createBackgroundIssueSync({
deliveredIssue = await requestStage(item, request.url, request.options);
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
}
let attachmentMarkdown = item.attachmentMarkdown;
if (!attachmentMarkdown) {
const blockers = Array.isArray(item.blockers) ? item.blockers : [];
let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length);
for (let index = deliveredBlockers; index < blockers.length; index += 1) {
const blocker = blockers[index];
await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/blockers',
{
method:'PATCH',
headers:{
Accept:'application/json', 'Content-Type':'application/json',
'Idempotency-Key':stageOperationId(item.operationId, 'blocker-' + index),
},
body:JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}),
},
);
deliveredBlockers = index + 1;
await checkpointClaim(item, current => ({ ...current, deliveredIssue, deliveredBlockers }));
}
const attachments = (Array.isArray(item.attachments) ? item.attachments : [item.attachment]).filter(Boolean);
if (!attachments.length) return deliveredIssue;
const attachmentMarkdowns = Array.isArray(item.attachmentMarkdowns)
? item.attachmentMarkdowns.slice(0, attachments.length)
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
for (let index = attachmentMarkdowns.length; index < attachments.length; index += 1) {
const uploaded = await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
@@ -534,19 +576,27 @@ function createBackgroundIssueSync({
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
'Idempotency-Key': stageOperationId(
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
),
},
body: attachmentMultipart(item.attachment),
body: attachmentMultipart(attachments[index]),
},
);
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(item, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
attachmentMarkdowns.push(markdown);
await checkpointClaim(item, current => ({
...current, deliveredIssue, attachmentMarkdowns:attachmentMarkdowns.slice(),
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
}));
}
const attachmentMarkdown = attachmentMarkdowns.map((markdown, index) =>
evidenceMarkdown(attachments[index], markdown, index)).join('\n\n');
await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
@@ -638,7 +688,9 @@ function createBackgroundIssueSync({
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
const error = new Error('Issue closure was not confirmed.');
error.status = 422;
+5 -4
View File
@@ -8,6 +8,7 @@ function createBatchFindWork({
storage = typeof localStorage === 'undefined' ? null : localStorage,
owner = () => '',
journalName = 'find-work-batch',
queueFailureReason = 'assigned but Today sync is unavailable',
autoMount = true,
}) {
let request = null;
@@ -58,7 +59,7 @@ function createBatchFindWork({
entry.state = 'assigned';
writeJournal(journal);
}
const queueResult = await queue(confirmed);
const queueResult = await queue(confirmed, journal.context);
if (queueResult === 'queued' || queueResult === 'exists') {
queued.push(key(item));
entry.state = 'queued';
@@ -67,7 +68,7 @@ function createBatchFindWork({
}
writeJournal(journal);
} else {
failed.push({ key: key(item), reason: 'assigned but Today sync is unavailable', assigned: true });
failed.push({ key: key(item), reason: queueFailureReason, assigned: true });
}
} catch (error) {
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
@@ -81,7 +82,7 @@ function createBatchFindWork({
return outcome;
}
function run(items, estimates = {}) {
function run(items, estimates = {}, context = null) {
if (request) return request;
const selected = Array.isArray(items) ? items.slice() : [];
const available = Math.max(0, Number(capacity()) || 0);
@@ -112,7 +113,7 @@ function createBatchFindWork({
}
}
const journal = {
owner: String(owner() || ''), available,
owner: String(owner() || ''), available, context,
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
};
writeJournal(journal);
+49
View File
@@ -0,0 +1,49 @@
function createConversationActionHydrator({ load, activate }) {
let actions = null;
let pending = null;
const wiredRoots = new WeakSet();
function ensure() {
if (actions) return Promise.resolve(actions);
if (!pending) {
pending = load().then(() => {
actions = activate();
return actions;
}).catch(error => {
pending = null;
throw error;
});
}
return pending;
}
function show({ root, state, paint, wire, retry }) {
if (actions) {
retry.hidden = true;
if (!wiredRoots.has(root)) {
wire(actions);
wiredRoots.add(root);
}
paint(state, actions);
return Promise.resolve(true);
}
paint(state, null);
retry.hidden = true;
return ensure().then(controller => {
if (!wiredRoots.has(root)) {
wire(controller);
wiredRoots.add(root);
}
paint(state, controller);
return true;
}).catch(() => {
retry.hidden = false;
return false;
});
}
return { show, ready: () => Boolean(actions) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationActionHydrator;
+50 -2
View File
@@ -5,7 +5,40 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
return todayWork.read().length < todayWork.limit;
}
function complete(issue) {
function capacity(value) {
if (String(value ?? '').trim() === '') return { valid: false, reason: 'required' };
const minutes = Number(value);
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) {
return { valid: false, reason: 'range' };
}
const plan = todayWork.planning();
const capacityMinutes = plan.capacity_minutes;
if (!Number.isInteger(capacityMinutes)) {
return { valid: true, minutes, capacityMinutes: null, plannedMinutes: null,
remainingMinutes: null, projectedMinutes: null, fits: true };
}
const plannedMinutes = todayWork.read().reduce(
(total, identity) => total + (Number.isInteger(plan.estimates[identity]) ? plan.estimates[identity] : 0), 0
);
const remainingMinutes = capacityMinutes - plannedMinutes;
const projectedMinutes = remainingMinutes - minutes;
return { valid: true, minutes, capacityMinutes, plannedMinutes, remainingMinutes,
projectedMinutes, fits: projectedMinutes >= 0 };
}
function capacityMessage(result) {
if (!result.valid) return result.reason === 'required'
? 'Required only for Create & start.'
: 'Use a whole number from 5 to 1440 minutes.';
if (result.capacityMinutes === null) {
return result.minutes + ' min · Set a Today budget to see remaining time.';
}
return result.fits
? result.minutes + ' min · ' + result.projectedMinutes + ' min will remain in Today.'
: result.minutes + ' min · ' + Math.abs(result.projectedMinutes) + ' min over Today capacity.';
}
function complete(issue, estimateMinutes) {
const identity = todayWork.identity(issue);
if (!identity || completed.has(identity)) return 'exists';
const added = todayWork.add(issue);
@@ -17,10 +50,25 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
announce('Created, but Today could not be saved on this device.');
return 'unavailable';
}
let estimatedPlan = null;
if (Number.isInteger(estimateMinutes)) {
estimatedPlan = todayWork.planning();
estimatedPlan.estimates[identity] = estimateMinutes;
if (!todayWork.replacePlanning(estimatedPlan)) {
announce('Created and added to Today, but its estimate could not be saved on this device.');
return 'estimate-unavailable';
}
}
if (!todaySync.enqueue('add', identity)) {
announce('Created and saved to Today on this device, but account sync is unavailable.');
return 'sync-unavailable';
}
if (estimatedPlan) {
if (!todaySync.enqueueConfiguration(estimatedPlan.capacity_minutes, estimatedPlan.estimates)) {
announce('Created and estimated on this device, but Today plan sync is unavailable.');
return 'sync-unavailable';
}
}
completed.add(identity);
refresh();
todaySync.flush();
@@ -30,7 +78,7 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
return 'started';
}
return { available, complete };
return { available, capacity, capacityMessage, complete };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart;
+466 -4
View File
@@ -19,22 +19,165 @@ function normalizeSharedContent(value = {}) {
return { title, body };
}
function buildRelatedDraft(value = {}, template = null) {
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(value.repository || ''))
? String(value.repository) : '';
const labelIds = Array.from(new Set((Array.isArray(value.labelIds) ? value.labelIds : [])
.filter(id => Number.isInteger(id) && id > 0))).slice(0, 20);
const draft = {repository, title:'', body:'', labelIds};
const milestoneId = Number(value.milestoneId);
if (Number.isInteger(milestoneId) && milestoneId > 0) draft.milestoneId = milestoneId;
if (/^\d{4}-\d{2}-\d{2}$/.test(String(value.dueDate || ''))) draft.dueDate = String(value.dueDate);
if (value.unassigned === true) draft.unassigned = true;
else if (/^[A-Za-z0-9_.-]+$/.test(String(value.assignee || ''))) {
draft.assignee = String(value.assignee);
draft.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
const templateId = String(value.templateId || '').slice(0, 80);
if (templateId && template && String(template.id || '') === templateId) {
draft.templateId = templateId;
draft.templateName = String(value.templateName || template.name || 'Issue template').trim().slice(0, 80);
draft.capturedBody = '';
draft.body = String(template.body || '').trim().slice(0, 9000);
}
return draft;
}
function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
const NO_OWNER = '__unassigned__';
const select = documentRef.querySelector('#create-issue-assignee');
const status = documentRef.querySelector('#create-issue-assignee-status');
const getRepository = () => documentRef.querySelector('#create-issue-repository').value;
const loadOwners = repository => issueCapture.loadOwners(repository);
let ownerRequest = 0;
function reset(repository, selected = {}) {
select.replaceChildren();
const me = documentRef.createElement('option');
me.value = '';
me.textContent = 'Me';
select.appendChild(me);
const noOwner = documentRef.createElement('option');
noOwner.value = NO_OWNER;
noOwner.textContent = 'No owner';
select.appendChild(noOwner);
if (selected.unassigned === true) select.value = NO_OWNER;
if (selected.assignee) {
const option = documentRef.createElement('option');
option.value = selected.assignee;
option.textContent = (selected.assigneeName || selected.assignee) + ' (@' + selected.assignee + ')';
option.dataset.name = selected.assigneeName || selected.assignee;
select.appendChild(option);
select.value = selected.assignee;
}
select.dataset.repository = '';
status.textContent = repository ? 'Open the owner picker to load eligible teammates.' : 'Choose a repository first.';
}
async function load(repository) {
if (!repository || select.dataset.repository === repository) return;
const request = ++ownerRequest;
const selected = select.value;
const selectedName = select.selectedOptions?.[0]?.dataset.name || '';
status.textContent = 'Loading eligible teammates…';
try {
const owners = await loadOwners(repository);
if (request !== ownerRequest || getRepository() !== repository) return;
const selectedStillEligible = owners.some(owner => owner?.login === selected);
reset(repository, selected === NO_OWNER ? {unassigned:true} :
(selectedStillEligible ? {assignee:selected, assigneeName:selectedName} : {}));
owners.forEach(owner => {
if (!owner?.login || owner.login === selected) return;
const option = documentRef.createElement('option');
option.value = owner.login;
option.textContent = (owner.name || owner.login) + ' (@' + owner.login + ')';
option.dataset.name = owner.name || owner.login;
select.appendChild(option);
});
select.dataset.repository = repository;
status.textContent = owners.length ? 'Choose yourself, no owner, or an eligible teammate.' :
'Choose yourself or no owner; no eligible teammates are available.';
} catch (_error) {
if (request !== ownerRequest || getRepository() !== repository) return;
status.textContent = 'Teammates could not be loaded. The issue will stay assigned to you.';
}
}
function updateActions(hasRepository, hasBlockers, canStart) {
const submit = documentRef.querySelector('#submit-new-issue');
const start = documentRef.querySelector('#create-and-start-issue');
const hasNoOwner = select.value === NO_OWNER;
const hasTeammateOwner = Boolean(select.value) && !hasNoOwner;
submit.disabled = !hasRepository;
submit.textContent = hasNoOwner ? 'Create unassigned' :
(hasTeammateOwner ? 'Create & assign' : 'Create & assign to me');
start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || hasNoOwner || !canStart;
start.title = hasBlockers ? 'Blocked work cannot start until its blockers are complete.' :
(hasNoOwner ? 'No owner work cannot be added to your Today queue.' :
(hasTeammateOwner ? 'Work assigned to a teammate cannot be added to your Today queue.' : ''));
}
function fields() {
return {
assignee: select.value === NO_OWNER ? '' : select.value,
assigneeName: select.value === NO_OWNER ? '' : (select.selectedOptions?.[0]?.dataset.name || ''),
unassigned: select.value === NO_OWNER,
};
}
function draft(labelIds, blockers, trim = false) {
const value = id => documentRef.querySelector(id).value;
const clean = input => trim ? input.trim() : input;
return {
repository:value('#create-issue-repository'),
title:clean(value('#create-issue-title')), body:clean(value('#create-issue-body')),
labelIds, milestoneId:Number(value('#create-issue-milestone')) || null,
dueDate:value('#create-issue-due-date'), ...fields(), blockers,
};
}
select.addEventListener('focus', () => {
const repository = getRepository();
if (repository) load(repository);
});
select.addEventListener('change', onChange);
return { reset, load, updateActions, fields, draft };
}
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
const storageKey = 'stackchain.issue-capture.v1';
const sharedStorageKey = 'stackchain.issue-share.v1';
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
let pending = null;
let issueTemplates = [];
let activeTemplate = null;
let templateRequest = 0;
let duplicateRequest = 0;
let repositorySearchRequest = 0;
let filingMetadataRequest = 0;
let blockerSearchRequest = 0;
let duplicateState = {status: 'idle', key: '', candidates: []};
let acknowledgedDuplicateKey = '';
const repositoryPageRequests = new Map();
const safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
const safeBlockers = value => {
const seen = new Set();
return (Array.isArray(value) ? value : []).reduce((items, blocker) => {
const repository = String(blocker?.repository || '').trim();
const number = Number(blocker?.number);
const key = repository + '#' + number;
if (items.length >= 5 || seen.has(key) ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
!Number.isInteger(number) || number < 1) return items;
seen.add(key);
items.push({repository, number,
title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)});
return items;
}, []);
};
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
const safeAssignee = value => /^[A-Za-z0-9_.-]+$/.test(String(value || '')) ? String(value) : '';
const safeEstimate = value => Number.isInteger(Number(value)) && Number(value) >= 5 && Number(value) <= 1440 ?
Number(value) : null;
function loadStored() {
try {
@@ -47,10 +190,28 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
labelIds: safeLabelIds(parsed.labelIds),
operationId: String(parsed.operationId || '').slice(0, 128),
};
if (parsed.unassigned === true) draft.unassigned = true;
if (typeof parsed.templateName === 'string' && parsed.templateName.trim()) {
draft.templateName = parsed.templateName.trim().slice(0, 80);
draft.templateId = String(parsed.templateId || '').slice(0, 80);
draft.capturedBody = String(parsed.capturedBody || '').slice(0, 10000);
}
const assignee = safeAssignee(parsed.assignee);
if (assignee) {
draft.assignee = assignee;
draft.assigneeName = String(parsed.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
const milestoneId = safeMilestoneId(parsed.milestoneId);
const dueDate = safeDueDate(parsed.dueDate);
if (milestoneId !== null) draft.milestoneId = milestoneId;
if (dueDate) draft.dueDate = dueDate;
const estimateMinutes = safeEstimate(parsed.estimateMinutes);
if (estimateMinutes !== null) draft.estimateMinutes = estimateMinutes;
if (['create', 'create-and-start'].includes(parsed.completionIntent)) {
draft.completionIntent = parsed.completionIntent;
}
const blockers = safeBlockers(parsed.blockers);
if (blockers.length) draft.blockers = blockers;
return draft;
} catch (_error) {
return {...emptyDraft(), operationId: ''};
@@ -70,13 +231,33 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
body: String(draft?.body || ''),
labelIds: safeLabelIds(draft?.labelIds),
};
if (draft?.unassigned === true) safe.unassigned = true;
if (typeof draft?.templateName === 'string' && draft.templateName.trim()) {
safe.templateName = draft.templateName.trim().slice(0, 80);
safe.templateId = String(draft.templateId || '').slice(0, 80);
safe.capturedBody = String(draft.capturedBody || '').slice(0, 10000);
}
const assignee = safe.unassigned ? '' : safeAssignee(draft?.assignee);
if (assignee) {
safe.assignee = assignee;
safe.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
const milestoneId = safeMilestoneId(draft?.milestoneId);
const dueDate = safeDueDate(draft?.dueDate);
if (milestoneId !== null) safe.milestoneId = milestoneId;
if (dueDate) safe.dueDate = dueDate;
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
const estimateMinutes = safeEstimate(draft?.estimateMinutes);
if (estimateMinutes !== null) safe.estimateMinutes = estimateMinutes;
if (['create', 'create-and-start'].includes(draft?.completionIntent)) {
safe.completionIntent = draft.completionIntent;
}
const blockers = safeBlockers(draft?.blockers);
if (blockers.length) safe.blockers = blockers;
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName', 'unassigned',
'templateName', 'templateId', 'capturedBody', 'estimateMinutes', 'completionIntent']
.every(key => (previous[key] || '') === (safe[key] || '')) &&
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) &&
JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []);
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
return safe;
}
@@ -182,6 +363,266 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
);
}
function loadOwners(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
return fetchJson('api/v1/repos/' + encoded + '/issue-assignees').then(owners =>
Array.isArray(owners) ? owners : []
);
}
function loadTemplates(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
return fetchJson('api/v1/repos/' + encoded + '/issue-templates').then(templates =>
Array.isArray(templates) ? templates.slice(0, 20) : []
);
}
async function loadFilingMetadata(repository) {
const selectedRepository = String(repository || '');
const encoded = selectedRepository.split('/').map(encodeURIComponent).join('/');
const request = ++filingMetadataRequest;
const payload = await fetchJson(
'api/v1/repos/' + encoded + '/issue-filing-metadata'
);
if (request !== filingMetadataRequest) {
return {status:'stale', repository:selectedRepository};
}
const section = name => {
const value = payload?.[name];
const available = value?.available === true;
return {
available,
items:available && Array.isArray(value?.items) ? value.items : [],
...(!available && typeof value?.error === 'string' ? {error:value.error} : {}),
};
};
return {
status:'ready', repository:selectedRepository,
labels:section('labels'), milestones:section('milestones'),
templates:section('templates'),
};
}
function applyTemplate(draft, template, availableLabels = []) {
const source = {...(draft || {})};
const capturedBody = source.templateName ? String(source.capturedBody || '') : String(source.body || '');
if (!template) {
delete source.templateName;
delete source.templateId;
delete source.capturedBody;
return {...source, body: capturedBody};
}
const scaffold = String(template.body || '').trim().slice(0, 9000);
const body = [capturedBody.trim(), scaffold].filter(Boolean).join('\n\n---\n\n').slice(0, 10000);
const validByName = new Map((Array.isArray(availableLabels) ? availableLabels : [])
.filter(label => Number.isInteger(label?.id) && typeof label?.name === 'string')
.map(label => [label.name.toLowerCase(), label.id]));
const templateLabelIds = (Array.isArray(template.labels) ? template.labels : [])
.map(name => validByName.get(String(name).toLowerCase())).filter(Boolean);
return {
...source,
title: source.title || String(template.title || '').trim().slice(0, 255),
body,
labelIds: safeLabelIds([...(source.labelIds || []), ...templateLabelIds]),
templateId: String(template.id || '').slice(0, 80),
templateName: String(template.name || 'Issue template').trim().slice(0, 80),
capturedBody,
};
}
function setTemplateState(draft) {
activeTemplate = draft?.templateName ? {
templateName:draft.templateName, templateId:draft.templateId,
capturedBody:draft.capturedBody || '',
} : null;
}
function templateFields(draft) {
return activeTemplate ? {...draft, ...activeTemplate} : draft;
}
function restoreTemplate(draft, availableLabels) {
const restored = applyTemplate(templateFields(draft), null, availableLabels);
activeTemplate = null;
return restored;
}
async function loadTemplateOptions(repository, elements, selectedId = '') {
const request = ++templateRequest;
issueTemplates = [];
elements.select.innerHTML = '<option value="">Blank issue</option>';
elements.field.hidden = true;
if (!repository) return;
elements.status.textContent = 'Loading issue types…';
try {
const templates = await loadTemplates(repository);
if (request !== templateRequest || elements.getRepository() !== repository) return;
issueTemplates = templates;
templates.forEach(template => {
const option = elements.document.createElement('option');
option.value = template.id;
option.textContent = template.name;
elements.select.appendChild(option);
});
elements.field.hidden = templates.length === 0;
elements.select.value = templates.some(template => template.id === selectedId) ? selectedId : '';
elements.status.textContent = templates.length ?
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
} catch (_error) {
if (request !== templateRequest || elements.getRepository() !== repository) return;
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
}
}
function selectTemplate(identifier, draft, availableLabels) {
const template = issueTemplates.find(item => item.id === identifier) || null;
const applied = applyTemplate(templateFields(draft), template, availableLabels);
activeTemplate = template ? {
templateId:applied.templateId, templateName:applied.templateName,
capturedBody:applied.capturedBody,
} : null;
return {draft: applied, template};
}
function relatedDraft(draft) {
const template = issueTemplates.find(item => String(item?.id || '') === String(draft?.templateId || '')) || null;
return buildRelatedDraft(draft, template);
}
function bindTemplatePicker(elements, callbacks) {
let labels = [];
elements.select.addEventListener('change', event => {
const {draft, template} = selectTemplate(event.target.value, callbacks.getDraft(), labels);
callbacks.setDraft(draft);
const selected = new Set(draft.labelIds || []);
elements.labelInputs().forEach(input => { input.checked = selected.has(Number(input.value)); });
elements.status.textContent = template ?
template.name + (template.about ? ' — ' + template.about : '') : 'Blank issue selected.';
callbacks.changed();
});
return {
fields:templateFields,
reset:setTemplateState,
setLabels(value) { labels = Array.isArray(value) ? value : []; },
load(repository, selectedId) {
return loadTemplateOptions(repository, elements, selectedId);
},
render(repository, section, selectedId = '') {
++templateRequest;
issueTemplates = [];
elements.select.innerHTML = '<option value="">Blank issue</option>';
elements.field.hidden = true;
if (!repository || elements.getRepository() !== repository) return;
if (section?.available !== true) {
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
return;
}
issueTemplates = Array.isArray(section.items) ? section.items.slice(0, 20) : [];
issueTemplates.forEach(template => {
const option = elements.document.createElement('option');
option.value = template.id;
option.textContent = template.name;
elements.select.appendChild(option);
});
elements.field.hidden = issueTemplates.length === 0;
elements.select.value = issueTemplates.some(template => template.id === selectedId) ? selectedId : '';
elements.status.textContent = issueTemplates.length ?
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
},
changeRepository(draft) {
const restored = restoreTemplate(draft, labels);
callbacks.setDraft(restored);
return restored;
},
};
}
function bindFilingMetadata(elements, templatePicker) {
function renderLabels(repository, section, selectedIds = []) {
elements.labelList.replaceChildren();
if (!repository) {
elements.labelStatus.textContent = 'Choose a repository to load labels.';
return;
}
if (section?.available !== true) {
templatePicker.setLabels([]);
elements.labelStatus.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
return;
}
const priorities = new Set(['p0', 'priority-high', 'critical']);
const labels = (Array.isArray(section.items) ? section.items : []).slice().sort((left, right) =>
Number(priorities.has(String(right?.name || '').toLowerCase())) -
Number(priorities.has(String(left?.name || '').toLowerCase()))
);
templatePicker.setLabels(labels);
const selected = new Set(selectedIds.map(Number));
labels.forEach(label => {
const option = elements.document.createElement('label');
option.className = 'create-issue-label-option';
const input = elements.document.createElement('input');
input.type = 'checkbox';
input.name = 'create-issue-label';
input.value = String(Number(label.id));
input.checked = selected.has(Number(label.id));
const name = elements.document.createElement('span');
name.textContent = label.name;
option.append(input, name);
elements.labelList.appendChild(option);
});
elements.labelStatus.textContent = labels.length ?
'Select labels to triage this issue.' : 'This repository has no labels.';
}
function renderMilestones(repository, section, selectedId = null) {
elements.milestoneSelect.replaceChildren();
const blank = elements.document.createElement('option');
blank.value = '';
blank.textContent = 'No milestone';
elements.milestoneSelect.appendChild(blank);
if (!repository) {
elements.milestoneStatus.textContent = 'Choose a repository to load milestones.';
return;
}
if (section?.available !== true) {
elements.milestoneStatus.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
return;
}
const milestones = Array.isArray(section.items) ? section.items : [];
milestones.forEach(milestone => {
const option = elements.document.createElement('option');
option.value = String(Number(milestone.id));
option.textContent = milestone.title;
elements.milestoneSelect.appendChild(option);
});
if (selectedId) elements.milestoneSelect.value = String(selectedId);
elements.milestoneStatus.textContent = milestones.length ?
'Choose the release lane for this issue.' : 'This repository has no open milestones.';
}
async function load(repository, selected = {}) {
renderLabels(repository, null, selected.labelIds || []);
renderMilestones(repository, null, selected.milestoneId);
templatePicker.render(repository, null, selected.templateId);
if (!repository) return;
elements.labelStatus.textContent = 'Loading labels…';
elements.milestoneStatus.textContent = 'Loading milestones…';
elements.templateStatus.textContent = 'Loading issue types…';
try {
const metadata = await loadFilingMetadata(repository);
if (metadata.status !== 'ready' || elements.getRepository() !== repository) return;
renderLabels(repository, metadata.labels, selected.labelIds || []);
renderMilestones(repository, metadata.milestones, selected.milestoneId);
templatePicker.render(repository, metadata.templates, selected.templateId);
} catch (_error) {
if (elements.getRepository() !== repository) return;
renderLabels(repository, {available:false}, selected.labelIds || []);
renderMilestones(repository, {available:false}, selected.milestoneId);
templatePicker.render(repository, {available:false}, selected.templateId);
}
}
return {load};
}
function loadRepositoryPage(page) {
const safePage = Math.max(1, Math.floor(Number(page) || 1));
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
@@ -216,6 +657,21 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
}
async function searchBlockers(value) {
const query = String(value || '').trim().slice(0, 80);
const request = ++blockerSearchRequest;
if (query.length < 2) return {status:'idle', items:[]};
try {
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(query) + '&limit=20');
if (request !== blockerSearchRequest) return {status:'stale', items:[]};
return {status:'ready', items:(Array.isArray(payload?.items) ? payload.items : [])
.filter(item => item?.kind === 'issue' && item?.state === 'open').slice(0, 20)};
} catch (error) {
if (request !== blockerSearchRequest) return {status:'stale', items:[]};
return {status:'failed', items:[], error};
}
}
function duplicateKey(draft) {
const repository = String(draft?.repository || '').trim();
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
@@ -276,6 +732,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
},
body: JSON.stringify({
title: saved.title, body: saved.body, label_ids: saved.labelIds,
...(saved.unassigned ? {unassigned:true} : {}),
...(saved.assignee ? {assignee: saved.assignee} : {}),
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
}),
@@ -287,8 +745,10 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
searchRepositories, findDuplicates,
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadTemplates,
loadFilingMetadata, applyTemplate, setTemplateState, templateFields, restoreTemplate, loadTemplateOptions,
selectTemplate, buildRelatedDraft:relatedDraft, bindTemplatePicker, bindFilingMetadata, loadRepositoryPage,
searchRepositories, searchBlockers, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
@@ -296,5 +756,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
createIssueCapture.buildRelatedDraft = buildRelatedDraft;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
+104 -5
View File
@@ -12,6 +12,13 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
.issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.issue-filing-receipt[hidden] { display:none; }
.issue-filing-receipt-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
.issue-filing-receipt-panel h2, .issue-filing-receipt-panel h3 { margin:.25rem 0; }
.issue-filing-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.issue-filing-receipt-actions button, .issue-filing-receipt-actions .button-link { box-sizing:border-box; display:flex; align-items:center; justify-content:center; min-width:0; min-height:44px; width:100%; text-align:center; }
@media (max-width:359px) { .issue-filing-receipt-actions { grid-template-columns:1fr; } }
.live-data-status-feeds { display:grid; gap:8px; margin:14px 0; }
.live-data-status-feed { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
.live-data-status-feed strong, .live-data-status-feed span { overflow-wrap:anywhere; }
@@ -121,6 +128,15 @@ textarea { resize: vertical; min-height: 120px; }
.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
.cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
.cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; }
.saved-searches { margin-top:8px; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
.saved-search-header, .saved-search-create, .saved-search-row, .saved-search-row-actions { display:flex; align-items:center; gap:8px; }
.saved-search-header { justify-content:space-between; }
.saved-search-create { margin-top:6px; }
.saved-search-create input { flex:1; min-width:0; }
.saved-search-list { display:grid; gap:4px; margin-top:6px; }
.saved-search-row { justify-content:space-between; border-top:1px solid #1f3a5f; padding-top:4px; }
.saved-search-open { flex:1; min-width:0; text-align:left; }
.saved-search-action { min-height:44px; }
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
@@ -129,11 +145,27 @@ textarea { resize: vertical; min-height: 120px; }
.cmd-select-result { min-height:44px; width:100%; display:grid; grid-template-columns:28px 1fr auto; gap:10px; align-items:center; text-align:left; }
.cmd-select-result input { width:22px; height:22px; }
.cmd-select-result[disabled] { cursor:not-allowed; opacity:.62; }
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
.search-batch-actions[hidden] { display:none; }
.search-batch-actions span { grid-column:1 / -1; }
.search-batch-actions button, .search-batch-recovery { min-height:44px; }
.search-batch-recovery { width:100%; font-weight:700; }
.search-batch-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
.search-batch-estimate-review[hidden] { display:none; }
.search-batch-estimate-review > div:first-child, #search-batch-estimate-list { display:grid; gap:8px; }
.search-batch-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
.search-batch-estimate-row label { display:flex; align-items:center; gap:6px; }
.search-batch-estimate-review input { min-height:44px; width:76px; }
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.search-batch-estimate-actions button { min-height:44px; }
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.search-release-review[hidden] { display:none; }
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
.search-release-review label { display:grid; gap:6px; }
.search-release-review select, .search-release-review input { min-height:44px; width:100%; }
.search-release-row { padding:8px 0; border-bottom:1px solid #2a496e; }
.search-release-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.search-release-actions button { min-height:44px; }
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
@@ -257,7 +289,7 @@ textarea { resize: vertical; min-height: 120px; }
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.draft-actions button { min-height:44px; width:100%; }
.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px; width:100%; }
.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.delivery-center h3, .delivery-center p { margin:0; }
.delivery-center button { min-height:44px; flex:0 0 auto; }
@@ -265,18 +297,19 @@ textarea { resize: vertical; min-height: 120px; }
.draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; }
.draft-section > h3 { grid-column:1/-1; margin:8px 0 0; }
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
.create-issue-actions button { min-height:44px; width:100%; }
.create-issue-actions #create-issue-status { grid-column:1/-1; }
.create-issue-estimate { display:grid; gap:6px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#101d31; }
.create-issue-estimate > span:first-of-type { display:flex; align-items:center; gap:8px; }
.create-issue-estimate input { min-height:44px; width:min(9rem,60vw); }
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
.draft-filing-session[hidden] { display:none; }
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.draft-filing-session-actions button { min-height:44px; width:100%; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; }
.agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.agenda-replan-launch p { margin:4px 0 0; }
.agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; }
.agenda-replan-actions button { min-height:44px; width:100%; }
.agenda-replan-actions label { grid-column:1/-1; }
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; }
.protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }
@@ -488,6 +521,15 @@ textarea { resize: vertical; min-height: 120px; }
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
#issue-sheet.read-only .issue-comment-composer,
#issue-sheet.read-only #issue-planning,
#issue-sheet.read-only #issue-handoff,
#issue-sheet.read-only #release-issue,
#issue-sheet.read-only #close-issue,
#issue-sheet.read-only .detail-defer,
#issue-sheet.read-only #issue-blockers,
#issue-sheet.read-only #load-older-issue-comments,
#issue-sheet.read-only #retry-issue-comment-actions { display:none; }
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@@ -597,6 +639,49 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-filing { display:grid; gap:12px; min-width:0; }
.create-issue-filing[hidden] { display:none; }
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
#create-issue-attachment-preview { grid-template-columns:1fr; }
#create-issue-attachment-image { grid-row:auto; width:100%; height:auto; max-height:320px; }
.issue-evidence-tray { display:flex; gap:8px; max-width:100%; padding:2px 0 6px; overflow-x:auto; overscroll-behavior-inline:contain; scrollbar-width:thin; }
.issue-evidence-tray[hidden] { display:none; }
.issue-evidence-thumbnail { position:relative; flex:0 0 64px; min-width:44px; min-height:64px; padding:3px; border:2px solid transparent; border-radius:10px; background:#10213a; }
.issue-evidence-thumbnail[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
.issue-evidence-thumbnail img { display:block; width:56px; height:56px; border-radius:6px; object-fit:cover; }
.issue-evidence-thumbnail span { position:absolute; right:3px; bottom:3px; min-width:20px; padding:1px 4px; border-radius:999px; background:#07101d; color:#fff; font-size:12px; text-align:center; }
.issue-evidence-note { display:grid; gap:6px; min-width:0; }
.issue-evidence-note textarea { box-sizing:border-box; width:100%; min-height:72px; padding:10px; resize:vertical; border:1px solid #2a496e; border-radius:8px; background:#07101d; color:#e5e7eb; font:inherit; }
.issue-evidence-note textarea:focus-visible { outline:2px solid #60a5fa; outline-offset:2px; }
.issue-evidence-review-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.issue-evidence-review-actions button { min-width:44px; min-height:44px; }
.issue-evidence-editor { position:fixed; inset:0; z-index:70; display:grid; place-items:end center; overflow-x:hidden; background:rgba(2,6,15,.92); }
.issue-evidence-editor[hidden] { display:none; }
.issue-evidence-editor-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; display:grid; gap:12px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); background:#0b1526; border:1px solid #2a496e; border-radius:14px 14px 0 0; }
.issue-evidence-editor-panel header { display:flex; align-items:start; justify-content:space-between; gap:12px; }
.issue-evidence-editor-panel h3 { margin:0 0 4px; }
.issue-evidence-editor canvas { display:block; max-width:100%; width:100%; max-height:60dvh; object-fit:contain; touch-action:none; border:1px solid #365b86; border-radius:8px; background:#020617; }
.issue-evidence-editor-tools { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.issue-evidence-editor button { min-width:44px; min-height:44px; }
.issue-evidence-editor button[aria-pressed="true"] { border-color:#60a5fa; background:#173a64; color:#fff; }
.issue-evidence-editor-apply { width:100%; }
@media(max-width:320px) { .issue-evidence-editor-panel { padding:12px; } .issue-evidence-editor-tools { grid-template-columns:repeat(2,minmax(0,1fr)); } }
.issue-filing-review { position:fixed; inset:0; z-index:72; display:grid; place-items:end center; overflow-x:hidden; background:rgba(2,6,15,.92); }
.issue-filing-review[hidden] { display:none; }
.issue-filing-review-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
.issue-filing-review-panel h2, .issue-filing-review-panel h3, .issue-filing-review-panel p { margin:.2rem 0; }
.issue-filing-review-summary { display:grid; gap:10px; margin:0; min-width:0; }
.issue-filing-review-summary > div { display:grid; gap:4px; padding:10px; border:1px solid #2a496e; border-radius:10px; min-width:0; }
.issue-filing-review-summary dt { color:#94a3b8; font-size:12px; font-weight:700; text-transform:uppercase; }
.issue-filing-review-summary dd { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.issue-filing-review ol { display:grid; grid-template-columns:repeat(auto-fit,minmax(72px,1fr)); gap:8px; margin:0; padding:0; list-style:none; overflow-wrap:anywhere; }
.issue-filing-evidence-selector { min-height:44px; width:100%; padding:4px; border:2px solid #31577f; border-radius:10px; background:#08111f; }
.issue-filing-evidence-selector[aria-pressed="true"] { border-color:#93c5fd; box-shadow:0 0 0 2px rgba(96,165,250,.35); }
.issue-filing-evidence-selector img { display:block; width:100%; height:64px; object-fit:cover; border-radius:6px; }
.issue-filing-evidence-preview { display:grid; gap:8px; margin:10px 0 0; padding:10px; border:1px solid #31577f; border-radius:12px; background:#020617; }
.issue-filing-evidence-preview[hidden] { display:none; }
.issue-filing-evidence-preview > img { display:block; width:100%; max-height:42dvh; object-fit:contain; border-radius:8px; background:#000; }
.issue-filing-evidence-preview figcaption { display:grid; gap:3px; min-width:0; overflow-wrap:anywhere; }
.issue-filing-review-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.issue-filing-review-actions button { min-height:44px; width:100%; }
@media(max-width:320px) { .issue-filing-review-panel { padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); } }
.create-issue-repository-more { min-height:44px; width:100%; }
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
.create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
@@ -604,8 +689,18 @@ textarea { resize: vertical; min-height: 120px; }
#create-issue-repository-results[hidden] { display:none; }
.create-issue-repository-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
.create-issue-repository-result:last-child { border-bottom:0; }
.create-issue-blockers { display:grid; gap:8px; min-width:0; }
#create-issue-blocker-search { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
#create-issue-blocker-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; }
#create-issue-blocker-results[hidden] { display:none; }
.create-issue-blocker-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
#create-issue-blocker-selected { display:grid; gap:8px; margin:0; padding:0; list-style:none; min-width:0; }
.create-issue-blocker-selected { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; min-width:0; overflow-wrap:anywhere; }
.create-issue-blocker-selected button { min-height:44px; }
.create-issue-form label { display:grid; gap:6px; }
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.create-issue-owner { min-width:0; }
.create-issue-owner select { min-height:44px; width:100%; }
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
@@ -628,6 +723,7 @@ textarea { resize: vertical; min-height: 120px; }
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
.conversation-more { min-height:44px; width:100%; margin:8px 0; }
.conversation-actions-retry { min-height:44px; max-width:100%; margin:8px 0; }
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
.pull-review-file { margin-top:8px; }
@@ -717,6 +813,9 @@ textarea { resize: vertical; min-height: 120px; }
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; }
#cmd-search-repository { min-height:44px; width:100%; }
.saved-search-create { align-items:stretch; }
.saved-search-create input, .saved-search-open, .saved-search-row-actions button { min-height:44px; }
.saved-search-row { align-items:stretch; }
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-panel { width:100%; border-left:0; padding:14px; }
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
+404 -159
View File
@@ -59,10 +59,23 @@
qs('#my-work').focus();
}
let mobileQueueCounts = {};
function openFiledFollowUp() {
selectMobileQueue('filed');
const target = filedFollowUpTarget(lastMyWork);
if (!target) {
qs('#my-work-action-status').textContent = 'No filed issues are ready to open.';
return 'empty';
}
const index = lastMyWork.indexOf(target.item);
const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]');
openRoutedWork(target.kind === 'update' ? { ...target.item, kind:'update' } : target.item, trigger);
return target.kind === 'update' ? 'opened-update' : 'opened-issue';
}
const mobileQueueLauncher = createMobileQueueLauncher({
openToday: () => mobileWorkEntry.open(),
openAgenda: openAgendaSession,
openUpdates: openUpdateTriage,
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: message => { qs('#my-work-action-status').textContent = message; },
@@ -149,7 +162,7 @@
let launchFilterResolved = false;
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
if (['all', 'today', 'agenda', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
if (['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
selectedWorkFilter = savedFilter;
savedWorkFilter = savedFilter;
launchFilterResolved = true;
@@ -187,6 +200,7 @@
let pullReviewState = null;
let creatingIssue = false;
let createAndStartRequested = false;
let pendingIssueFilingIntent = 'create-and-assign';
let findingWork = false;
let availablePagination = { page: 1, total: 0, has_more: false };
let progress = null;
@@ -303,6 +317,7 @@
if (!response.ok) {
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
error.status = response.status;
error.code = payload.detail?.code;
const retryAfter = response.headers.get('Retry-After');
error.retryAfter = retryAfter === null ? undefined : Number(retryAfter);
@@ -479,9 +494,30 @@
image: qs('#create-issue-attachment-image'),
meta: qs('#create-issue-attachment-meta'),
remove: qs('#remove-create-issue-attachment'),
tray: qs('#create-issue-evidence-tray'),
earlier: qs('#move-create-issue-attachment-earlier'),
later: qs('#move-create-issue-attachment-later'),
note: qs('#create-issue-evidence-note'),
noteLabel: qs('#create-issue-evidence-note-label'),
status: qs('#create-issue-attachment-status'),
readyMessage: 'Screenshot ready to file with this issue.',
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
editor: {
document,
edit: qs('#edit-create-issue-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'),
redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'),
arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'),
reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'),
apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
readDataUrl: file => new Promise((resolve, reject) => {
@@ -497,6 +533,9 @@
loadMilestones: item => issueController.loadMilestones(item),
});
let issueCapture = null;
let issueOwnerPicker = null;
let issueTemplatePicker = null;
let issueFilingMetadata = null;
let updateFollowUp = null;
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
const unfiledCaptures = createUnfiledCaptures({
@@ -505,11 +544,16 @@
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin,
});
createUnfiledDraftSync(
unfiledCaptures, fetchReviewJson
);
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
dFS.attach(qs, {
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
captures:unfiledCaptures, capture:()=>issueCapture, attachment:createIssueAttachmentController,
getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; },
openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode,
review:r=>loadIssueFilingMetadata(r.repository,r)
.then(()=>issueCapture.findDuplicates(currentIssueCaptureDraft())).then(renderIssueDuplicates),
});
let rUC = '';
let backgroundIssueSync = null;
@@ -530,6 +574,30 @@
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
const filingReview = createIssueFilingReview({
sheet: qs('#issue-filing-review'),
confirmButton: qs('#confirm-issue-filing'),
backButton: qs('#back-to-issue-edit'),
evidenceList: qs('#issue-filing-review-evidence'),
evidenceEmpty: qs('#issue-filing-review-evidence-empty'),
evidencePreview: qs('#issue-filing-review-evidence-preview'),
evidenceImage: qs('#issue-filing-review-evidence-image'),
evidencePosition: qs('#issue-filing-review-evidence-position'),
evidenceFilename: qs('#issue-filing-review-evidence-filename'),
evidenceNote: qs('#issue-filing-review-evidence-note'),
repository: qs('#issue-filing-review-repository'),
intent: qs('#issue-filing-review-intent'),
issueType: qs('#issue-filing-review-template'),
title: qs('#issue-filing-review-title'),
body: qs('#issue-filing-review-body'),
metadata: qs('#issue-filing-review-metadata'),
blockerList: qs('#issue-filing-review-blockers'),
status: qs('#issue-filing-review-status'),
document,
createObjectURL: blob => URL.createObjectURL(blob),
revokeObjectURL: url => URL.revokeObjectURL(url),
onConfirm: admitReviewedIssue,
});
const authoredOutbox = createAuthoredOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
@@ -570,14 +638,16 @@
'comment-actions': document.querySelector('meta[name="stackchain-feature-comment-actions"]')?.content || '',
},
});
await commentActionFeatures.run('comment-actions', {
status: qs('#my-work-action-status'), retryLabel:'Reload to retry comment actions.',
}, () => {
commentActions = createCommentActions({
fetchJson: fetchReviewJson,
getLogin: () => confirmedOwnerLogin,
confirmDelete: message => window.confirm(message),
});
const commentActionHydrator = createConversationActionHydrator({
load: () => commentActionFeatures.load('comment-actions'),
activate: () => {
commentActions = createCommentActions({
fetchJson: fetchReviewJson,
getLogin: () => confirmedOwnerLogin,
confirmDelete: message => window.confirm(message),
});
return commentActions;
},
});
const issueCaptureFeatures = createFeatureLoader({
document,
@@ -595,6 +665,27 @@
}, () => {
if (!issueCapture) {
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
issueTemplatePicker = issueCapture.bindTemplatePicker({
field:qs('#create-issue-template-field'), select:qs('#create-issue-template'),
status:qs('#create-issue-template-status'), document,
getRepository:()=>qs('#create-issue-repository').value,
labelInputs:()=>document.querySelectorAll('input[name="create-issue-label"]'),
}, {
getDraft:currentIssueCaptureDraft,
setDraft:draft=>{ qs('#create-issue-title').value=draft.title; qs('#create-issue-body').value=draft.body; },
changed:()=>{ saveIssueCaptureDraft(); scheduleIssueDuplicateCheck(); },
});
issueFilingMetadata = issueCapture.bindFilingMetadata({
document,
labelList:qs('#create-issue-label-list'),
labelStatus:qs('#create-issue-label-status'),
milestoneSelect:qs('#create-issue-milestone'),
milestoneStatus:qs('#create-issue-milestone-status'),
templateStatus:qs('#create-issue-template-status'),
getRepository:()=>qs('#create-issue-repository').value,
}, issueTemplatePicker);
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
}
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
@@ -855,7 +946,7 @@
load: fetchWorkPage,
onItems: (stream, items) => {
if (!lastContextSnapshot) return;
if (stream === 'issue') lastContextSnapshot.issues = items;
if (stream === 'issue' || stream === 'filed') lastContextSnapshot.issues = items;
else lastContextSnapshot.pull_requests = items;
paintMyWork(lastContextSnapshot);
},
@@ -1191,7 +1282,7 @@
closeOpenWorkSheets();
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
else if (item.kind === 'review') openReviewSheet(item, reviewTrigger);
else if (item.kind === 'issue') openIssueSheet(item, issueTrigger);
else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
},
onQueue: openWorkQueueRoute,
@@ -1522,6 +1613,12 @@
},
announce: message => { qs('#my-work-action-status').textContent = message; },
});
function renderCreateStartCapacity() {
const result = createAndStart.capacity(qs('#create-issue-estimate').value);
qs('#create-issue-estimate-status').textContent = createAndStart.capacityMessage(result);
return result;
}
qs('#create-issue-estimate').addEventListener('input', () => { renderCreateStartCapacity(); saveIssueCaptureDraft(); });
const queueToday = createQueueToday({
todayWork,
@@ -2420,7 +2517,7 @@
function listDrafts() {
const unfiled = unfiledCaptures.list().map(item => ({
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', ...unfiledDraftSummary(item),
title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''),
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
updated_at:item.savedAt, quarantined:item.quarantined,
@@ -2508,10 +2605,11 @@
if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review'];
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', 'pull', 'review'];
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review'];
return [];
}
@@ -2573,19 +2671,16 @@
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open review</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
(isUnfiled ? 'Choose repository' : 'Resume draft') + '</button>' +
(isUnfiled ? item.action : 'Resume draft') + '</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
const state = (isOutbox || isUnfiled) ?
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
(isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' :
item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' :
item.status === 'authorization' ? 'Awaiting authorization' : 'Queued for sync')) + '</span>' +
const state = isOutbox ?
'<span class="pill">' + draftInbox.deliveryLabel(item) + '</span>' +
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
const attempt = item.last_attempt_error ? '<span class="delivery-attempt small">Last attempt ' +
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '</span>' : '';
const captureTarget = isUnfiled ? ' tabindex="-1" data-capture-id="' + escapeHtml(item.capture_id) + '"' : '';
return '<article class="my-work-card draft-card"' + captureTarget + '>' +
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
'<span class="small">' + escapeHtml([item.label, item.repository, item.details].filter(Boolean).join(' · ')) + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
@@ -2623,8 +2718,7 @@
if (!item) return;
if (item.kind === 'unfiled-issue') {
try {
dFS.start(item.capture_id);
await dFS.nextCapture();
await dFS.open(item.capture_id, item.ready ? button : null);
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.route) workRoute.open(item.route);
@@ -2652,7 +2746,9 @@
editingOutboxId = hydrated.id;
issueCapture.saveDraft(hydrated);
openCreateIssueSheet();
if (hydrated.attachment) createIssueAttachmentController.restore(hydrated.attachment);
if (hydrated.attachments || hydrated.attachment) {
createIssueAttachmentController.restore(hydrated.attachments || hydrated.attachment);
}
else createIssueAttachmentController.clear();
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
} catch (error) {
@@ -2702,7 +2798,9 @@
button.addEventListener('click', async () => {
if (!window.confirm('Discard this unfinished draft?')) return;
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id);
if (item?.kind === 'unfiled-issue') {
await unfiledCaptures.discard(item.capture_id);
}
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
else if (item) draftInbox.discard(item.id);
@@ -3054,8 +3152,8 @@
toggle?.focus();
}
function renderIssueComment(comment) {
const actions = commentActions.actionHtml?.(comment) || '';
function renderIssueComment(comment, controller = commentActions) {
const actions = controller?.actionHtml?.(comment) || '';
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
@@ -3063,19 +3161,19 @@
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
}
function renderIssueConversation(state) {
function paintIssueConversation(state, controller) {
const comments = state?.comments || [];
qs('#issue-comments').innerHTML = comments.length ?
comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
comments.map(comment => renderIssueComment(comment, controller)).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-issue-comments').hidden = !Number.isInteger(state?.older_page);
qs('#issue-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
function renderUpdateConversation(state) {
function paintUpdateConversation(state, controller) {
const comments = state?.comments || [];
qs('#update-comments').innerHTML = comments.length ?
comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
comments.map(comment => renderIssueComment(comment, controller)).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-update-comments').hidden = !Number.isInteger(state?.older_page);
qs('#update-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
@@ -3083,10 +3181,10 @@
updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest);
}
function renderPullConversation(state) {
function paintPullConversation(state, controller) {
const comments = state?.comments || [];
qs('#pull-comments').innerHTML = comments.length ? comments.map(comment =>
'<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>'
'<div class="pull-comment-card">' + renderIssueComment(comment, controller) + '</div>'
).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-pull-comments').hidden = !Number.isInteger(state?.older_page);
qs('#pull-conversation-status').textContent = comments.length ?
@@ -3108,16 +3206,47 @@
};
}
function wireCommentActions(selector) {
commentActions.wire({
function wireCommentActions(selector, controller = commentActions) {
controller.wire({
root:qs(selector), getSurface:()=>commentSurface(selector),
isOffline:()=>offlineWorkMode || navigator.onLine === false, escapeHtml,
});
}
wireCommentActions('#issue-comments');
wireCommentActions('#pull-comments');
wireCommentActions('#update-comments');
const conversationActionSurfaces = {
issue: { selector:'#issue-comments', paint:paintIssueConversation },
pull: { selector:'#pull-comments', paint:paintPullConversation },
update: { selector:'#update-comments', paint:paintUpdateConversation },
};
const latestConversationStates = {};
function wireConversationActions(kind) {
if (kind === 'issue') wireCommentActions('#issue-comments');
if (kind === 'pull') wireCommentActions('#pull-comments');
if (kind === 'update') wireCommentActions('#update-comments');
}
function showConversationWithActions(kind, state) {
const surface = conversationActionSurfaces[kind];
latestConversationStates[kind] = state;
return commentActionHydrator.show({
root:qs(surface.selector), state, paint:surface.paint,
retry:qs('#retry-' + kind + '-comment-actions'),
wire:controller => wireConversationActions(kind, controller),
});
}
function renderIssueConversation(state) { void showConversationWithActions('issue', state); }
function renderPullConversation(state) { void showConversationWithActions('pull', state); }
function renderUpdateConversation(state) { void showConversationWithActions('update', state); }
function retryConversationActions(kind) {
const state = latestConversationStates[kind];
if (state) void showConversationWithActions(kind, state);
}
qs('#retry-issue-comment-actions').addEventListener('click', () => retryConversationActions('issue'));
qs('#retry-pull-comment-actions').addEventListener('click', () => retryConversationActions('pull'));
qs('#retry-update-comment-actions').addEventListener('click', () => retryConversationActions('update'));
function renderIssueLabelEditor(item, confirmedNames, labels) {
const list = qs('#issue-label-list');
@@ -3178,6 +3307,8 @@
async function openIssueSheet(item, trigger, offlineDetail = null) {
if (!item) return;
const readOnly = issueController.readOnly(item);
qs('#issue-sheet').classList.toggle('read-only', readOnly);
issueDetailPosition.open(workDetailIdentity('issue', item));
qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false;
@@ -3246,9 +3377,12 @@
).join(' ');
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
renderIssueConversation(issueConversation.snapshot());
if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);
else renderIssueConversation(issueConversation.snapshot());
if (readOnly) qs('#load-older-issue-comments').hidden = true;
qs('#open-issue-gitea').href = detail.url || item.url || '#';
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
qs('#issue-sheet-status').textContent = readOnly ? 'Filed issue ready · read-only' :
'Issue ready · ' + (detail.state || 'open');
qs('#edit-issue-content').disabled = false;
const dueDraft = issueController.loadDueDateDraft(item);
qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10);
@@ -3454,6 +3588,11 @@
.map(input => Number(input.value)).filter(Number.isInteger);
}
function selectedIssueLabelNames() {
return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked'))
.map(input => input.closest('label')?.querySelector('span')?.textContent?.trim()).filter(Boolean);
}
function renderAvailableIssues(items) {
const list = qs('#find-work-list');
const selection = findWorkController.selection();
@@ -3587,33 +3726,23 @@
qs('#find-work').focus();
}
let issueCaptureBlockers = [];
function saveIssueCaptureDraft() {
if (!issueCapture) return;
issueCapture.saveDraft({
repository: qs('#create-issue-repository').value,
title: qs('#create-issue-title').value,
body: qs('#create-issue-body').value,
labelIds: selectedIssueLabelIds(),
milestoneId: Number(qs('#create-issue-milestone').value) || null,
dueDate: qs('#create-issue-due-date').value,
});
if (issueCapture) issueCapture.saveDraft(currentIssueCaptureDraft(false));
}
function currentIssueCaptureDraft() {
return {
repository: qs('#create-issue-repository').value,
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
labelIds: selectedIssueLabelIds(),
milestoneId: Number(qs('#create-issue-milestone').value) || null,
dueDate: qs('#create-issue-due-date').value,
};
function currentIssueCaptureDraft(trim = true) {
return withFilingEstimate(issueTemplatePicker.fields(
issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, trim)
), qs('#create-issue-estimate').value);
}
let issueCaptureRepositories = [];
let nextIssueRepositoryPage = 2;
let moreIssueRepositoriesAvailable = false;
let issueRepositorySearchTimer = null;
let captureBlockerSearchTimer = null;
function appendIssueRepositories(items) {
const select = qs('#create-issue-repository');
@@ -3633,9 +3762,56 @@
}
function updateIssueCreateActions() {
const hasRepository = Boolean(qs('#create-issue-repository').value);
qs('#submit-new-issue').disabled = !hasRepository;
qs('#create-and-start-issue').disabled = !hasRepository || !createAndStart.available();
issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value),
issueCaptureBlockers.length > 0, createAndStart.available());
}
function renderIssueCaptureBlockers(blockers) {
issueCaptureBlockers = Array.isArray(blockers) ? blockers.slice(0, 5) : [];
const selected = qs('#create-issue-blocker-selected');
selected.replaceChildren(...issueCaptureBlockers.map((blocker, index) => {
const item = document.createElement('li');
item.className = 'create-issue-blocker-selected';
const text = document.createElement('span');
text.textContent = blocker.repository + ' #' + blocker.number + ' — ' + blocker.title;
const remove = document.createElement('button');
remove.type = 'button';
remove.textContent = 'Remove';
remove.setAttribute('aria-label', 'Remove blocker ' + blocker.repository + ' #' + blocker.number);
remove.addEventListener('click', () => {
renderIssueCaptureBlockers(issueCaptureBlockers.filter((_value, position) => position !== index));
saveIssueCaptureDraft();
});
item.append(text, remove);
return item;
}));
qs('#create-issue-blocker-status').textContent = issueCaptureBlockers.length ?
issueCaptureBlockers.length + ' blocker' + (issueCaptureBlockers.length === 1 ? '' : 's') +
' selected. Blocked work will be created without starting.' : 'No blockers selected.';
updateIssueCreateActions();
}
function renderIssueCaptureBlockerResults(items) {
const results = qs('#create-issue-blocker-results');
results.replaceChildren();
(Array.isArray(items) ? items : []).filter(candidate => !issueCaptureBlockers.some(blocker =>
blocker.repository === candidate.repository && blocker.number === Number(candidate.number))).forEach(candidate => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'create-issue-blocker-result';
button.setAttribute('role', 'option');
button.textContent = candidate.repository + ' #' + candidate.number + ' — ' + candidate.title;
button.addEventListener('click', () => {
renderIssueCaptureBlockers([...issueCaptureBlockers, {
repository:candidate.repository, number:Number(candidate.number), title:candidate.title,
}]);
results.hidden = true;
qs('#create-issue-blocker-search').value = '';
saveIssueCaptureDraft();
});
results.appendChild(button);
});
results.hidden = !results.childElementCount;
}
function renderIssueRepositoryResults(items) {
@@ -3661,8 +3837,8 @@
qs('#create-issue-repository-search').value = repository;
qs('#create-issue-repository-results').hidden = true;
qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.';
loadIssueLabels(repository);
loadIssueMilestones(repository);
issueOwnerPicker.reset(repository);
loadIssueFilingMetadata(repository);
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
updateIssueCreateActions();
@@ -3719,51 +3895,8 @@
}, 350);
}
async function loadIssueLabels(repository, selectedIds = []) {
const list = qs('#create-issue-label-list');
const status = qs('#create-issue-label-status');
list.innerHTML = '';
if (!repository) {
status.textContent = 'Choose a repository to load labels.';
return;
}
status.textContent = 'Loading labels…';
try {
const labels = await issueCapture.loadLabels(repository);
const selected = new Set(selectedIds.map(Number));
list.innerHTML = labels.map(label =>
'<label class="create-issue-label-option"><input type="checkbox" name="create-issue-label" value="' +
Number(label.id) + '"' + (selected.has(Number(label.id)) ? ' checked' : '') + '><span>' +
escapeHtml(label.name) + '</span></label>'
).join('');
status.textContent = labels.length ? 'Select labels to triage this issue.' : 'This repository has no labels.';
} catch (error) {
status.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
}
}
async function loadIssueMilestones(repository, selectedId = null) {
const select = qs('#create-issue-milestone');
const status = qs('#create-issue-milestone-status');
select.innerHTML = '<option value="">No milestone</option>';
if (!repository) {
status.textContent = 'Choose a repository to load milestones.';
return;
}
status.textContent = 'Loading milestones…';
try {
const milestones = await issueCapture.loadMilestones(repository);
if (qs('#create-issue-repository').value !== repository) return;
select.innerHTML += milestones.map(milestone =>
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
).join('');
if (selectedId) select.value = String(selectedId);
status.textContent = milestones.length ?
'Choose the release lane for this issue.' : 'This repository has no open milestones.';
} catch (_error) {
if (qs('#create-issue-repository').value !== repository) return;
status.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
}
async function loadIssueFilingMetadata(repository, selected = {}) {
return issueFilingMetadata.load(repository, selected);
}
function clearSharedLaunchUrl() {
@@ -3792,6 +3925,7 @@
return;
}
const captureDraft = issueCapture.loadDraft();
issueTemplatePicker.reset(captureDraft);
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
if (!issueCaptureRepositories.length) issueCaptureRepositories = initialRepositories.slice();
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
@@ -3813,8 +3947,12 @@
setIssueFilingMode(Boolean(captureDraft.repository));
qs('#create-issue-capture-status').textContent = '';
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';
issueOwnerPicker.reset(captureDraft.repository, captureDraft);
renderIssueCaptureBlockers(captureDraft.blockers || []);
qs('#create-issue-blocker-search').value = '';
qs('#create-issue-blocker-results').hidden = true;
loadIssueFilingMetadata(qs('#create-issue-repository').value, captureDraft);
scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
qs('#create-follow-up-next').hidden = !updateFollowUp?.source();
@@ -3839,12 +3977,32 @@
}
function setIssueFilingMode(enabled) {
qs('#create-issue-filing').hidden = !enabled;
qs('.create-issue-capture-actions').hidden = enabled;
qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work';
applyIssueFilingMode(qs, enabled);
}
let suppressCreateDraftOnHistoryClose = false;
const issueFilingReceipt = createIssueFilingReceipt({
root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'),
key:qs('#issue-filing-receipt-key'), title:qs('#issue-filing-receipt-title'),
ownership:qs('#issue-filing-receipt-ownership'), openLink:qs('#issue-filing-receipt-open'),
filedButton:qs('#issue-filing-receipt-filed'),
shareButton:qs('#issue-filing-receipt-share'), relatedButton:qs('#issue-filing-receipt-related'),
fileAnotherButton:qs('#issue-filing-receipt-another'),
doneButton:qs('#issue-filing-receipt-done'), status:qs('#issue-filing-receipt-status'),
navigator,
onFileRelated:plan => {
issueCapture.saveDraft(plan);
createIssueAttachmentController.clear();
qs('#new-issue').click();
},
onFileAnother:() => qs('#new-issue').click(),
onViewFiled:issue => {
selectMobileQueue('filed');
openRoutedWork({
...issue, kind:'issue', is_filed:true, is_assigned:(issue.assignees || []).includes(confirmedOwnerLogin),
});
},
});
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
if (navigate && taskOverlayHistory.current() === 'new') {
suppressCreateDraftOnHistoryClose = !preserveDraft;
@@ -3868,6 +4026,7 @@
function applyOutboxResult(result, openCreated = false, startCreated = false) {
if (result.lease_skipped) { refreshMyWorkView(); return; }
(result.confirmed || []).forEach(confirmed => {
confirmed.work_reasons = ['created_by_me'];
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
});
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
@@ -3878,7 +4037,7 @@
const created = lastMyWork.find(item => item.kind === 'issue' &&
item.repository === completion.issue?.repository && item.number === completion.issue?.number);
if (!created) return;
const outcome = createAndStart.complete(created);
const outcome = createAndStart.complete(created, completion.estimateMinutes);
if (outcome === 'started' || outcome === 'exists') {
issueOutbox.completeIntent(completion.id, activeFlushLogin);
startedCompletions.add(created.repository + '#' + created.number);
@@ -3892,6 +4051,11 @@
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
);
const completionHandled = startedCompletions.has(confirmed.repository + '#' + confirmed.number);
if (!(confirmed.assignees || []).includes(activeFlushLogin)) {
const filing = (result.filings || []).find(candidate => candidate.issue?.repository === confirmed.repository &&
candidate.issue?.number === confirmed.number);
issueFilingReceipt.show(confirmed, qs('#new-issue'), filing?.relatedDraft);
}
if (startCreated && !(result.completions || []).length && created) {
const outcome = createAndStart.complete(created);
if (outcome !== 'started' && openCreated) openRoutedWork(created, qs('#new-issue'));
@@ -4424,8 +4588,8 @@
});
searchBatchPlanning = mountSearchBatchPlanning(
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
searchPreviewPath, queueToday, acceptClaimedIssue, index=>commandItems[index]?.result,
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr
searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, i=>commandItems[i]?.result,
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr, laterWork, laterPicker
);
searchDefer = createSearchDefer({
claim: detail => searchPreview.claim(detail),
@@ -4613,6 +4777,9 @@
},
});
taskOverlayHistory.start();
createSavedSearches.mount(
document, fetch, commandSearch, applySearchScope, taskOverlayHistory
).load();
qs('#open-palette').addEventListener('click', openCommandPalette);
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());
@@ -4932,12 +5099,10 @@
});
qs('#save-unfiled-issue').addEventListener('click', async () => {
try {
const captureDraft = {
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
attachment: await createIssueAttachmentController.serialize(),
};
if (showDraftCapacityDialog(unfiledCaptures)) return;
const evidence = await createIssueAttachmentController.serialize();
const captureDraft = currentIssueCaptureDraft();
Object.assign(captureDraft, Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence});
if (showDraftCapacityDialog(unfiledCaptures, qs)) return;
const savedCapture = await unfiledCaptures.save(captureDraft);
if (rUC) {
await unfiledCaptures.completeResume(rUC);
@@ -4956,13 +5121,16 @@
savedCard?.scrollIntoView({block:'nearest'});
savedCard?.focus({preventScroll:true});
});
qs('#my-work-action-status').textContent = 'Saved to Drafts. Choose a repository when you’re ready to file it.';
qs('#my-work-action-status').textContent = unfiledSavedMessage(captureDraft);
} catch (error) {
qs('#create-issue-capture-status').textContent = error.message;
qs('#create-issue-title').focus();
}
});
bindDraftCapacityDialog();
bindDraftCapacityDialog({
qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView,
unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture,
});
qs('#use-shared-content').addEventListener('click', () => {
if (issueCapture.pendingFollowUp()) issueCapture.acceptFollowUp();
else issueCapture.acceptSharedContent();
@@ -4997,8 +5165,9 @@
})
);
qs('#create-issue-repository').addEventListener('change', event => {
loadIssueLabels(event.target.value);
loadIssueMilestones(event.target.value);
issueTemplatePicker.changeRepository(currentIssueCaptureDraft());
issueOwnerPicker.reset(event.target.value);
loadIssueFilingMetadata(event.target.value);
qs('#create-issue-repository-search').value = event.target.value;
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
@@ -5028,6 +5197,36 @@
status.textContent = state.items.length ? 'Choose a matching repository.' : 'No accessible repositories match.';
}, 250);
});
qs('#create-issue-blocker-search').addEventListener('input', event => {
clearTimeout(captureBlockerSearchTimer);
const query = event.target.value.trim();
const results = qs('#create-issue-blocker-results');
const status = qs('#create-issue-blocker-status');
if (query.length < 2) {
issueCapture.searchBlockers(query);
results.hidden = true;
status.textContent = issueCaptureBlockers.length ? issueCaptureBlockers.length + ' blocker(s) selected.' :
(query ? 'Enter at least 2 characters to search.' : 'No blockers selected.');
return;
}
if (issueCaptureBlockers.length >= 5) {
results.hidden = true;
status.textContent = 'Five blockers selected. Remove one to choose another.';
return;
}
status.textContent = 'Searching open issues…';
captureBlockerSearchTimer = setTimeout(async () => {
const state = await issueCapture.searchBlockers(query);
if (state.status === 'stale') return;
if (state.status === 'failed') {
results.hidden = true;
status.textContent = 'Blocker search failed. Your draft is safe; retry.';
return;
}
renderIssueCaptureBlockerResults(state.items);
status.textContent = state.items.length ? 'Choose an issue that must finish first.' : 'No open issues match.';
}, 250);
});
qs('#load-more-issue-repositories').addEventListener('click', async event => {
const button = event.currentTarget;
const status = qs('#create-issue-repository-status');
@@ -5056,26 +5255,13 @@
qs('#create-issue-anyway').hidden = true;
qs('#create-issue-form').requestSubmit();
});
qs('#create-issue-form').addEventListener('submit', async event => {
event.preventDefault();
if (event.submitter) createAndStartRequested = event.submitter?.id === 'create-and-start-issue';
const followUpNextRequested = event.submitter?.id === 'create-follow-up-next';
const captureDraft = currentIssueCaptureDraft();
if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
qs('#create-issue-title').focus();
return;
}
if (issueCapture.needsDuplicateAcknowledgement(captureDraft)) {
qs('#create-issue-status').textContent = 'Review the possible existing issues, or choose Create anyway.';
qs('#create-issue-anyway').hidden = false;
qs('#create-issue-anyway').focus();
return;
}
async function admitReviewedIssue(review) {
const durableDraft = review.draft;
const deliveryDraft = {...durableDraft, relatedDraft:issueCapture.buildRelatedDraft(durableDraft)};
const followUpNextRequested = review.intent === 'follow-up-and-next';
createAndStartRequested = review.intent === 'create-and-start';
if (createAndStartRequested && !createAndStart.available()) {
qs('#create-issue-status').textContent = 'Today is full. Remove an item before creating and starting another.';
qs('#create-and-start-issue').focus();
return;
throw new Error('Today is full now. Go back and remove an item before creating and starting another.');
}
const button = qs('#submit-new-issue');
const startButton = qs('#create-and-start-issue');
@@ -5086,22 +5272,17 @@
qs('#create-issue-status').textContent = createAndStartRequested ?
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
try {
const durableDraft = {
...captureDraft,
attachment: await createIssueAttachmentController.serialize(),
...(rUC ? { sourceCaptureId: rUC } : {}),
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
};
const admission = followUpNextRequested ? (await updateFollowUp.complete({
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, durableDraft) :
issueOutbox.enqueueDurably(durableDraft),
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, deliveryDraft) :
issueOutbox.enqueueDurably(deliveryDraft),
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
advance: source => notificationReader.acceptReadAndNext(lastMyWork, source),
})).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
await issueOutbox.enqueueDurably(durableDraft));
})).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, deliveryDraft) :
await issueOutbox.enqueueDurably(deliveryDraft));
const queued = admission.item;
pendingIssueFilingIntent = 'create-and-assign';
const fS = dFS.current();
if (rUC && (!durableDraft.attachment || admission.background)) {
if (rUC && ((!durableDraft.attachment && !durableDraft.attachments) || admission.background)) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
@@ -5113,8 +5294,6 @@
editingOutboxId = queued.id;
refreshMyWorkView();
qs('#create-issue-status').textContent = 'Saved for next launch; background delivery unavailable.';
button.disabled = false;
startButton.disabled = !createAndStart.available();
return;
}
editingOutboxId = null;
@@ -5132,9 +5311,75 @@
createAndStartRequested = false;
} catch (error) {
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
throw error;
} finally {
button.disabled = false;
startButton.disabled = !createAndStart.available();
followUpButton.disabled = false;
}
}
qs('#create-issue-form').addEventListener('submit', async event => {
event.preventDefault();
if (event.submitter) pendingIssueFilingIntent =
event.submitter.id === 'create-and-start-issue' ? 'create-and-start' :
(event.submitter.id === 'create-follow-up-next' ? 'follow-up-and-next' : 'create-and-assign');
const intent = pendingIssueFilingIntent;
createAndStartRequested = intent === 'create-and-start';
const startCapacity = createAndStartRequested
? createAndStart.capacity(qs('#create-issue-estimate').value) : null;
const captureDraft = currentIssueCaptureDraft();
if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
qs('#create-issue-title').focus();
return;
}
if (issueCapture.needsDuplicateAcknowledgement(captureDraft)) {
qs('#create-issue-status').textContent = 'Review the possible existing issues, or choose Create anyway.';
qs('#create-issue-anyway').hidden = false;
qs('#create-issue-anyway').focus();
return;
}
if (createAndStartRequested && !createAndStart.available()) {
qs('#create-issue-status').textContent = 'Today is full. Remove an item before creating and starting another.';
qs('#create-and-start-issue').focus();
return;
}
if (createAndStartRequested && !startCapacity.valid) {
qs('#create-issue-status').textContent = startCapacity.reason === 'required'
? 'Add a work estimate before creating and starting.'
: 'Use a whole-number estimate from 5 to 1440 minutes.';
qs('#create-issue-estimate').focus();
renderCreateStartCapacity();
return;
}
if (createAndStartRequested && !startCapacity.fits) {
qs('#create-issue-status').textContent = 'This would put Today ' +
Math.abs(startCapacity.projectedMinutes) + ' minutes over capacity. Adjust the estimate or Today plan.';
qs('#create-issue-estimate').focus();
renderCreateStartCapacity();
return;
}
qs('#create-issue-status').textContent = 'Preparing complete filing review…';
try {
const evidence = await createIssueAttachmentController.serialize();
const milestoneOption = qs('#create-issue-milestone').selectedOptions?.[0];
const durableDraft = {
...captureDraft,
labels: selectedIssueLabelNames(),
milestoneTitle: captureDraft.milestoneId ? milestoneOption?.textContent?.trim() : '',
...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}),
...(rUC ? { sourceCaptureId: rUC } : {}),
...(createAndStartRequested ? {
completionIntent: 'create-and-start', estimateMinutes: startCapacity.minutes,
todayCapacity: startCapacity,
} : {}),
};
filingReview.open({draft: durableDraft, intent},
dFS.takeTrigger() || event.submitter || qs('#submit-new-issue'));
qs('#create-issue-status').textContent = 'Review the complete payload, then confirm filing.';
} catch (error) {
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#create-issue-title').focus();
}
});
@@ -6404,7 +6649,7 @@
if (!stream || !lastContextSnapshot) return;
const button = qs('#load-more-work');
button.disabled = true;
const existing = stream === 'issue' ?
const existing = stream === 'issue' || stream === 'filed' ?
(lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []);
try {
const loaded = await workPager.loadMore(stream, existing);
+9 -6
View File
@@ -1,4 +1,4 @@
function showDraftCapacityDialog(captures) {
function showDraftCapacityDialog(captures, qs) {
const state = captures.capacity();
if (!state.full) return false;
const oldest = state.oldest;
@@ -11,7 +11,10 @@ function showDraftCapacityDialog(captures) {
return true;
}
function bindDraftCapacityDialog() {
function bindDraftCapacityDialog({
qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView,
unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture,
}) {
qs('#keep-editing-draft').addEventListener('click', () => {
qs('#draft-capacity-sheet').hidden = true;
qs('#save-unfiled-issue').focus();
@@ -40,10 +43,10 @@ function bindDraftCapacityDialog() {
const button = qs('#replace-oldest-draft');
button.disabled = true;
try {
const draft = {
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
attachment:await createIssueAttachmentController.serialize(),
};
const draft = currentIssueCaptureDraft();
const evidence = await createIssueAttachmentController.serialize();
Object.assign(draft,
Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence});
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
qs('#draft-capacity-sheet').hidden = true;
issueCapture.clearDraft();
+30 -4
View File
@@ -1,6 +1,7 @@
function createDraftFilingSession({list}) {
let orderedIds = [];
let currentId = '';
let reviewTrigger = null;
function visibleIds() {
const available = new Set(list().map(item => item.id));
@@ -49,17 +50,42 @@ function createDraftFilingSession({list}) {
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
session.attach = (qs, dependencies) => {
const openCapture = async captureId => {
const openCapture = async (captureId, trigger = null) => {
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
dependencies.issueCapture.saveDraft(resumed);
const issueCapture = dependencies.capture?.() || dependencies.issueCapture;
issueCapture.saveDraft(resumed);
dependencies.setResumedId(captureId);
await dependencies.openSheet();
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
await dependencies.openSheet(false);
const evidence = resumed.attachments || resumed.attachment;
dependencies.attachment[evidence ? 'restore' : 'clear'](evidence);
dependencies.setFilingMode(true);
session.render();
if (trigger) {
await dependencies.review(resumed);
reviewTrigger = trigger;
const submitter = resumed.completionIntent === 'create-and-start' ?
qs('#create-and-start-issue') : qs('#submit-new-issue');
const disabled = submitter.disabled;
submitter.disabled = false;
qs('#create-issue-form').requestSubmit(submitter);
submitter.disabled = disabled;
}
};
return Object.assign(session, {
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
reviewCapture(captureId, trigger) {
session.start(captureId);
return openCapture(captureId, trigger);
},
open(captureId, trigger = null) {
session.start(captureId);
return openCapture(captureId, trigger);
},
takeTrigger() {
const trigger = reviewTrigger;
reviewTrigger = null;
return trigger;
},
render() {
const state = session.current();
qs('#draft-filing-session').hidden = !state;
+10 -1
View File
@@ -251,7 +251,16 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
return { drafts, deliveries, counts, retryable };
}
return { list, discard, partition };
function deliveryLabel(item) {
if (item.quarantined) return 'Identity protected';
if (item.status === 'completion') return 'Created · ready to start';
if (item.status === 'attention') return 'Needs attention';
if (item.status === 'sending') return 'Sending';
if (item.status === 'authorization') return 'Awaiting authorization';
return 'Queued for sync';
}
return { list, discard, partition, deliveryLabel };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
+167 -4
View File
@@ -135,6 +135,7 @@
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
<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="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>
@@ -252,6 +253,7 @@
<div class="small" id="notification-page-status" aria-live="polite"></div>
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
<div class="small" id="later-sync-status" aria-live="polite">Later is saved on this device.</div>
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
@@ -423,6 +425,25 @@
<button id="add-plan-preview" type="button">Add to Today &amp; back</button>
</div>
<div class="issue-filing-receipt" id="issue-filing-receipt" role="dialog" aria-modal="true" aria-labelledby="issue-filing-receipt-heading" hidden>
<section class="issue-filing-receipt-panel">
<div class="small">Issue created</div>
<h2 id="issue-filing-receipt-heading" tabindex="-1">Ready to hand off</h2>
<div id="issue-filing-receipt-key" class="small"></div>
<h3 id="issue-filing-receipt-title"></h3>
<p id="issue-filing-receipt-ownership"></p>
<p id="issue-filing-receipt-status" class="small" aria-live="polite"></p>
<div class="issue-filing-receipt-actions">
<button id="issue-filing-receipt-filed" type="button">View in Filed</button>
<a id="issue-filing-receipt-open" class="button-link" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<button id="issue-filing-receipt-share" type="button">Share link</button>
<button id="issue-filing-receipt-related" type="button">File related issue</button>
<button id="issue-filing-receipt-another" type="button">File another</button>
<button id="issue-filing-receipt-done" type="button">Done</button>
</div>
</section>
</div>
<div id="cmd-palette" role="dialog" aria-label="Command palette">
<div class="cmd-palette-header">
<strong>Search work</strong>
@@ -451,6 +472,18 @@
<datalist id="cmd-search-repositories"></datalist>
<span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span>
</fieldset>
<section class="saved-searches" aria-labelledby="saved-searches-heading">
<div class="saved-search-header">
<strong id="saved-searches-heading">Saved searches</strong>
<span id="saved-search-status" class="small" aria-live="polite"></span>
</div>
<div class="saved-search-create">
<label for="saved-search-name" class="sr-only">Saved search name</label>
<input id="saved-search-name" type="text" maxlength="60" autocomplete="off" placeholder="Name this search" />
<button id="save-current-search" class="saved-search-action" type="button">Save search</button>
</div>
<div id="saved-search-list" class="saved-search-list" aria-label="Saved searches"></div>
</section>
<div id="cmd-search-action-status" class="small" aria-live="polite"></div>
<button id="resume-search-batch" class="search-batch-recovery" type="button" hidden>Resume batch</button>
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
@@ -458,8 +491,33 @@
<div class="search-batch-actions" id="search-batch-actions" hidden>
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
<button id="cancel-search-selection" type="button">Cancel</button>
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
<button id="queue-selected-search-results" type="button" disabled>Assign &amp; add to Today</button>
</div>
<section class="search-batch-estimate-review" id="search-batch-estimate-review"
aria-labelledby="search-batch-estimate-heading" hidden>
<div><strong id="search-batch-estimate-heading">Fit this work into Today</strong>
<span class="small" id="search-batch-estimate-summary" aria-live="polite"></span></div>
<div id="search-batch-estimate-list"></div>
<div class="search-batch-estimate-actions">
<button id="cancel-search-batch-estimates" type="button">Back</button>
<button id="confirm-search-batch-estimates" type="button">Confirm &amp; assign</button>
</div>
</section>
<section class="search-release-review" id="search-release-review"
aria-labelledby="search-release-heading" hidden>
<div><strong id="search-release-heading">Plan selected issues into a release</strong>
<span class="small" id="search-release-summary" aria-live="polite"></span></div>
<label>Open milestone<select id="search-release-milestone" required></select></label>
<label>Shared due date <span class="small">(optional)</span>
<input id="search-release-due-date" type="date" /></label>
<div id="search-release-list"></div>
<div class="search-release-actions">
<button id="cancel-search-release" type="button">Back</button>
<button id="confirm-search-release" type="button">Assign &amp; plan</button>
</div>
</section>
</div>
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
@@ -558,6 +616,7 @@
<div id="issue-comments"></div>
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
<div id="issue-conversation-status" class="small" aria-live="assertive"></div>
<button class="conversation-actions-retry" id="retry-issue-comment-actions" type="button" hidden>Retry comment tools</button>
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
<h2 id="issue-comment-title">Add comment</h2>
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
@@ -717,15 +776,25 @@
<button id="file-new-issue" type="button">File now</button>
</div>
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
<strong id="create-issue-attachment-label">Evidence screenshots <span class="small">Optional · Up to 5 · PNG, JPEG, or WebP · 2 MB each</span></strong>
<div class="issue-attachment-controls">
<label class="issue-attachment-trigger" for="create-issue-attachment">Attach screenshot</label>
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="create-issue-attachment">Add screenshots</label>
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
</div>
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="create-issue-attachment-meta"></span>
<button id="remove-create-issue-attachment" type="button">Remove</button>
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Evidence screenshots" hidden></div>
<label class="issue-evidence-note" for="create-issue-evidence-note">
<span id="create-issue-evidence-note-label">Evidence note (optional)</span>
<textarea id="create-issue-evidence-note" maxlength="240" rows="2" disabled placeholder="What does this screenshot show?"></textarea>
</label>
<div class="issue-evidence-review-actions" aria-label="Reorder selected screenshot">
<button id="move-create-issue-attachment-earlier" type="button">Move earlier</button>
<button id="edit-create-issue-attachment" type="button">Edit screenshot</button>
<button id="remove-create-issue-attachment" type="button">Remove selected</button>
<button id="move-create-issue-attachment-later" type="button">Move later</button>
</div>
</div>
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
</section>
@@ -749,6 +818,19 @@
</div>
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
<label id="create-issue-template-field" for="create-issue-template" hidden>Issue type
<select id="create-issue-template" aria-describedby="create-issue-template-status">
<option value="">Blank issue</option>
</select>
<span id="create-issue-template-status" class="small" aria-live="polite">Blank issue selected.</span>
</label>
<label class="create-issue-owner" for="create-issue-assignee">Owner
<select id="create-issue-assignee" aria-describedby="create-issue-assignee-status">
<option value="">Me</option>
<option value="__unassigned__">No owner</option>
</select>
<span id="create-issue-assignee-status" class="small" aria-live="polite">Open the owner picker to load eligible teammates.</span>
</label>
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
<option value="">No milestone</option>
@@ -758,6 +840,15 @@
<label for="create-issue-due-date">Due date <span class="small">Optional</span>
<input id="create-issue-due-date" type="date" />
</label>
<section class="create-issue-blockers" aria-labelledby="create-issue-blockers-heading">
<strong id="create-issue-blockers-heading">Blocked by <span class="small">Optional · Up to 5 open issues</span></strong>
<label for="create-issue-blocker-search">Search open issues
<input id="create-issue-blocker-search" type="search" maxlength="80" placeholder="Issue title or key" autocomplete="off" />
</label>
<div id="create-issue-blocker-results" role="listbox" aria-label="Open issue blocker results" hidden></div>
<ul id="create-issue-blocker-selected" aria-label="Selected blockers"></ul>
<div id="create-issue-blocker-status" class="small" aria-live="polite">No blockers selected.</div>
</section>
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
<legend>Labels <span class="small">Optional</span></legend>
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
@@ -769,6 +860,10 @@
<div id="create-issue-duplicate-list" class="create-issue-duplicate-list"></div>
<button id="create-issue-anyway" type="button" hidden>Create anyway</button>
</section>
<label class="create-issue-estimate" for="create-issue-estimate">Work estimate for Today
<span><input id="create-issue-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" placeholder="45" aria-describedby="create-issue-estimate-status" /> min</span>
<span id="create-issue-estimate-status" class="small" aria-live="polite">Required only for Create &amp; start.</span>
</label>
<div class="small">The issue will be assigned to you.</div>
<div class="create-issue-actions">
<button id="submit-new-issue" type="submit">Create &amp; assign to me</button>
@@ -780,8 +875,66 @@
<div id="create-issue-capture-status" class="small" aria-live="assertive"></div>
</form>
</section>
<section class="issue-evidence-editor" id="issue-evidence-editor" role="dialog" aria-modal="true" aria-labelledby="issue-evidence-editor-heading" hidden>
<div class="issue-evidence-editor-panel">
<header>
<div><h3 id="issue-evidence-editor-heading">Edit screenshot evidence</h3><span class="small">Edits stay on this device until you file.</span></div>
<button id="cancel-issue-evidence-edit" type="button">Cancel</button>
</header>
<canvas id="issue-evidence-editor-canvas" role="img" aria-label="Screenshot editing canvas. Drag to crop, redact, highlight, or point with an arrow."></canvas>
<canvas id="issue-evidence-editor-export" hidden></canvas>
<div class="issue-evidence-editor-tools" role="toolbar" aria-label="Screenshot editing tools">
<button id="crop-issue-evidence" type="button" aria-pressed="false">Crop</button>
<button id="redact-issue-evidence" type="button" aria-pressed="true">Redact</button>
<button id="highlight-issue-evidence" type="button" aria-pressed="false">Highlight</button>
<button id="arrow-issue-evidence" type="button" aria-pressed="false">Arrow</button>
<button id="undo-issue-evidence-edit" type="button">Undo</button>
<button id="reset-issue-evidence-edit" type="button">Reset</button>
</div>
<div class="small" id="issue-evidence-editor-status" aria-live="polite">Drag over private information to hide it.</div>
<button class="issue-evidence-editor-apply" id="apply-issue-evidence-edit" type="button">Apply flattened edit</button>
</div>
</section>
</div>
<section class="issue-filing-review" id="issue-filing-review" role="dialog" aria-modal="true" aria-labelledby="issue-filing-review-heading" hidden>
<div class="issue-filing-review-panel">
<header>
<div>
<h2 id="issue-filing-review-heading">Review issue before filing</h2>
<p class="small">Confirm the complete payload that will be saved for delivery.</p>
</div>
</header>
<dl class="issue-filing-review-summary">
<div><dt>Repository</dt><dd id="issue-filing-review-repository"></dd></div>
<div><dt>Action</dt><dd id="issue-filing-review-intent"></dd></div>
<div><dt>Issue type</dt><dd id="issue-filing-review-template"></dd></div>
<div><dt>Title</dt><dd id="issue-filing-review-title"></dd></div>
<div><dt>Note</dt><dd id="issue-filing-review-body"></dd></div>
<div><dt>Planning</dt><dd id="issue-filing-review-metadata"></dd></div>
<div><dt>Blocked by</dt><dd><ul id="issue-filing-review-blockers"></ul></dd></div>
</dl>
<section aria-labelledby="issue-filing-review-evidence-heading">
<h3 id="issue-filing-review-evidence-heading">Evidence in filing order</h3>
<p class="small muted" id="issue-filing-review-evidence-empty" hidden>No screenshots will be filed.</p>
<ol id="issue-filing-review-evidence"></ol>
<figure class="issue-filing-evidence-preview" id="issue-filing-review-evidence-preview" hidden>
<img id="issue-filing-review-evidence-image" alt="" />
<figcaption>
<span class="small" id="issue-filing-review-evidence-position"></span>
<strong id="issue-filing-review-evidence-filename"></strong>
<span class="small" id="issue-filing-review-evidence-note"></span>
</figcaption>
</figure>
</section>
<div class="issue-filing-review-actions">
<button id="back-to-issue-edit" type="button">Back to edit</button>
<button id="confirm-issue-filing" type="button">Confirm filing</button>
</div>
<div class="small" id="issue-filing-review-status" aria-live="assertive"></div>
</div>
</section>
<div class="draft-capacity-sheet" id="draft-capacity-sheet" role="dialog" aria-modal="true" aria-labelledby="draft-capacity-heading" hidden>
<section class="draft-capacity-panel">
<h3 id="draft-capacity-heading">Drafts full — nothing was deleted.</h3>
@@ -818,6 +971,7 @@
<button class="conversation-more" id="load-older-update-comments" type="button" hidden>Load older messages</button>
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
<button class="update-retry" id="retry-update-conversation" type="button" hidden>Retry conversation</button>
<button class="conversation-actions-retry" id="retry-update-comment-actions" type="button" hidden>Retry comment tools</button>
<details>
<summary><h2>Subject context</h2></summary>
<div class="update-sheet-content muted markdown-content" id="update-subject-body"></div>
@@ -892,6 +1046,7 @@
<h2>Full conversation</h2><div id="pull-comments"></div>
<button class="conversation-more" id="load-older-pull-comments" type="button" hidden>Load older messages</button>
<div id="pull-conversation-status" class="small" aria-live="assertive"></div>
<button class="conversation-actions-retry" id="retry-pull-comment-actions" type="button" hidden>Retry comment tools</button>
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2>
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
@@ -1059,6 +1214,7 @@
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<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="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="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>
@@ -1079,14 +1235,17 @@
<script src="static/session.js"></script>
<script src="static/feature-loader.js"></script>
<script src="static/conversation-action-hydrator.js"></script>
<script src="static/security-center.js"></script>
<script src="static/markdown.js"></script>
<script src="static/commands.js"></script>
<script src="static/saved-searches.js"></script>
<script src="static/search-preview.js"></script>
<script src="static/search-defer.js"></script>
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/unfiled-captures.js"></script>
<script src="static/unfiled-draft-sync.js"></script>
<script src="static/shared-image-capture.js"></script>
<script src="static/draft-filing-session.js"></script>
<script src="static/draft-capacity-dialog.js"></script>
@@ -1129,7 +1288,10 @@
<script src="static/search-batch-plan.js"></script>
<script src="static/conversation.js"></script>
<script src="static/comment-actions.js"></script>
<script src="static/issue-evidence-review.js"></script>
<script src="static/issue-evidence-editor.js"></script>
<script src="static/issue-attachment.js"></script>
<script src="static/issue-filing-review.js"></script>
<script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script>
<script src="static/create-and-start.js"></script>
@@ -1160,6 +1322,7 @@
<script src="static/mobile-composer-viewport.js"></script>
<script src="static/mention-composer.js"></script>
<script src="static/push-notifications.js"></script>
<script src="static/issue-filing-receipt.js"></script>
<script src="static/dashboard.js"></script>
</body>
</html>
+235 -111
View File
@@ -1,55 +1,28 @@
(function(root, factory) {
const api = factory();
const review = typeof module === 'object' && module.exports ? require('./issue-evidence-review.js') : root.issueEvidenceReview;
const editor = typeof module === 'object' && module.exports ? require('./issue-evidence-editor.js') : root.issueEvidenceEditor;
const api = factory(review, editor);
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueAttachment = api;
})(typeof self !== 'undefined' ? self : this, function() {
})(typeof self !== 'undefined' ? self : this, function(issueEvidenceReview, issueEvidenceEditor) {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
const MAX_PIXELS = issueEvidenceReview.MAX_PIXELS;
const MAX_FILES = 5;
const MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.';
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function namedBlob(blob, name) {
if (typeof File === 'function') {
return new File([blob], name, { type: blob.type });
}
Object.defineProperty(blob, 'name', { value: name, configurable: true });
return blob;
function normalizeNote(value) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
}
async function optimizeImage(file, environment = {}) {
if (file.size <= MAX_BYTES) return file;
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
if (typeof decode !== 'function') {
throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.');
}
let bitmap;
try {
bitmap = await decode(file);
const canvas = makeCanvas();
const context = canvas && canvas.getContext && canvas.getContext('2d');
if (!context || !bitmap.width || !bitmap.height) throw new Error('decode');
let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92);
for (let attempt = 0; attempt < 10; attempt += 1) {
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise(resolve => {
canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86);
});
if (!blob) throw new Error('encode');
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
scale *= 0.8;
}
} catch (_error) {
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
} finally {
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
}
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
function escapeMarkdown(value) {
return value.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
}
const optimizeImage = issueEvidenceReview.optimizeImage;
function multipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
@@ -65,6 +38,8 @@
function create(options) {
const upload = options.upload;
const maxFiles = options.maxFiles === MAX_FILES ? MAX_FILES : 1;
const inspectPixels = options.inspectPixels === true;
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
const createOperationId = options.createOperationId || (() => {
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
@@ -72,10 +47,7 @@
}
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
});
let selected = null;
let confirmed = null;
let serialized = null;
let operationId = null;
let selected = [];
let selectionGeneration = 0;
function commitSelection(file) {
@@ -83,14 +55,18 @@
file.size <= 0 || file.size > MAX_BYTES) {
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
}
selected = file;
confirmed = null;
serialized = null;
operationId = createOperationId();
if (selected.length >= maxFiles) {
if (maxFiles === 1) selected = [];
else throw new Error(MAX_FILES_MESSAGE);
}
selected.push({file, note:'', confirmed:null, serialized:null, operationId:createOperationId()});
return state();
}
function select(file) {
if (selected.length >= maxFiles && maxFiles > 1) {
throw new Error(MAX_FILES_MESSAGE);
}
const generation = ++selectionGeneration;
if (!file || !IMAGE_TYPES.has(file.type)) {
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
@@ -98,7 +74,7 @@
if (!Number.isFinite(file.size) || file.size <= 0) {
throw new Error('Choose a screenshot that is 2 MB or smaller.');
}
if (file.size > MAX_BYTES) {
if (file.size > MAX_BYTES || inspectPixels) {
return Promise.resolve(optimizeSelectedImage(file)).then(optimized => {
if (generation !== selectionGeneration) return state();
return commitSelection(optimized);
@@ -109,87 +85,167 @@
function clear() {
selectionGeneration += 1;
selected = null;
confirmed = null;
serialized = null;
operationId = null;
selected = [];
}
function remove(index) {
const position = Number(index);
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
selectionGeneration += 1;
selected.splice(position, 1);
return state();
}
function move(fromIndex, toIndex) {
const from = Number(fromIndex);
const to = Number(toIndex);
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 ||
from >= selected.length || to < 0 || to >= selected.length || from === to) return state();
const [item] = selected.splice(from, 1);
selected.splice(to, 0, item);
return state();
}
function replace(index, file) {
const position = Number(index);
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
file.size <= 0 || file.size > MAX_BYTES) {
throw new Error('The edited screenshot must be a PNG, JPEG, or WebP image no larger than 2 MB.');
}
selectionGeneration += 1;
selected[position] = {
...selected[position], file, confirmed:null, serialized:null, operationId:createOperationId(),
};
return state();
}
function setNote(index, value) {
const position = Number(index);
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
selected[position].note = normalizeNote(value);
selected[position].serialized = null;
return state();
}
function note(index) {
const position = Number(index);
return Number.isInteger(position) && selected[position] ? selected[position].note : '';
}
function restore(value) {
if (Array.isArray(value)) {
clear();
if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE);
value.forEach(attachment => restoreOne(attachment));
return state();
}
clear();
restoreOne(value);
return state();
}
function restoreOne(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '');
const blob = value?.blob;
const data = String(value?.data || '');
if (!blob && !data) {
clear();
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const padding = (data.match(/=*$/) || [''])[0].length;
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
return state();
commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
const item = selected[selected.length - 1];
item.note = normalizeNote(value?.note);
item.serialized = {
...(blob ? { filename, contentType, blob } : { filename, contentType, data }),
...(item.note ? {note:item.note} : {}),
};
}
function state() {
return selected ? {
name: selected.name,
size: selected.size,
uploaded: Boolean(confirmed),
} : null;
const values = selected.map(item => ({
name: item.file.name,
size: item.file.size,
uploaded: Boolean(item.confirmed),
}));
return values.length > 1 ? values : (values[0] || null);
}
function serializeItem(item) {
if (!item.serialized) item.serialized = {
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
...(item.note ? {note:item.note} : {}),
};
return { ...item.serialized };
}
async function serialize() {
if (!selected) return null;
if (!serialized) {
serialized = {
filename: selected.name,
contentType: selected.type,
blob: selected.blob || selected,
};
}
return { ...serialized };
if (!selected.length) return null;
const values = selected.map(serializeItem);
return values.length > 1 ? values : values[0];
}
async function prepareComment(item, body) {
async function prepareComment(target, body) {
const text = String(body || '').trim();
if (!selected) return text;
if (!confirmed) {
const attachment = await serialize();
confirmed = await upload({
repository: item.repository,
number: item.number,
filename: attachment.filename,
content_type: attachment.contentType,
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
operation_id: operationId,
});
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
confirmed = null;
throw new Error('The server did not confirm the screenshot upload.');
if (!selected.length) return text;
const markdown = [];
for (const evidence of selected) {
if (!evidence.confirmed) {
const attachment = serializeItem(evidence);
evidence.confirmed = await upload({
repository: target.repository,
number: target.number,
filename: attachment.filename,
content_type: attachment.contentType,
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
operation_id: evidence.operationId,
});
if (!evidence.confirmed || typeof evidence.confirmed.markdown !== 'string' || !evidence.confirmed.markdown) {
evidence.confirmed = null;
throw new Error('The server did not confirm the screenshot upload.');
}
}
if (evidence.note) {
markdown.push('**Screenshot ' + (markdown.length + 1) + ' — ' + escapeMarkdown(evidence.note) + '**\n\n' +
evidence.confirmed.markdown);
} else markdown.push(evidence.confirmed.markdown);
}
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
const evidence = markdown.join('\n\n');
return text ? text + '\n\n' + evidence : evidence;
}
return { select, restore, clear, state, serialize, prepareComment };
return { select, restore, remove, move, replace, setNote, note, clear, state, serialize, prepareComment };
}
function mount(options) {
const controller = create(options);
const controller = create({
...options, inspectPixels:true,
maxFiles:options.maxFiles || (options.input?.multiple ? MAX_FILES : 1),
});
const clearSelection = controller.clear;
const restoreSelection = controller.restore;
const reviewEnabled = Boolean(options.tray && options.earlier && options.later);
let previewUrl = '';
let selectionSequence = 0;
const review = reviewEnabled ? issueEvidenceReview.create({
...options, edit:options.editor?.edit, controller,
}) : null;
function setBusy(busy) {
options.input.disabled = Boolean(busy);
options.remove.disabled = Boolean(busy);
function setBusy(value) {
options.input.disabled = Boolean(value);
options.remove.disabled = Boolean(value);
if (options.editor?.edit) options.editor.edit.disabled = Boolean(value) || !controller.state();
review?.setBusy(value);
}
function clearPreview() {
selectionSequence += 1;
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = '';
review?.clear();
if (options.editor?.edit) options.editor.edit.disabled = true;
options.image.src = '';
options.preview.hidden = true;
options.input.value = '';
@@ -203,29 +259,53 @@
options.image.src = previewUrl;
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
options.preview.hidden = false;
if (options.editor?.edit) options.editor.edit.disabled = false;
options.status.textContent = optimized ? 'Screenshot optimized and ready to upload.' :
(options.readyMessage || 'Screenshot ready to upload with this comment.');
}
options.input.addEventListener('change', event => {
const file = event.target.files && event.target.files[0];
const files = Array.from(event.target.files || []);
const sequence = ++selectionSequence;
let result;
try {
result = controller.select(file);
} catch (error) {
if (!files.length) return;
let first;
try { first = controller.select(files[0]); }
catch (error) {
options.status.textContent = error.message;
options.input.value = '';
return;
}
if (!result || typeof result.then !== 'function') {
showPreview(file, false);
if (!reviewEnabled && files.length === 1 && (!first || typeof first.then !== 'function')) {
showPreview(files[0], false);
return;
}
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
return result.then(() => controller.serialize()).then(value => {
if (sequence === selectionSequence) showPreview(value.blob, true);
let optimized = Boolean(first && typeof first.then === 'function');
if (optimized) {
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
}
const selectAll = files.slice(1).reduce((pending, file) => pending.then(async () => {
const result = controller.select(file);
if (result && typeof result.then === 'function') {
optimized = true;
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
await result;
}
}), Promise.resolve(first));
return selectAll.then(() => controller.serialize()).then(value => {
if (sequence !== selectionSequence) return;
const values = Array.isArray(value) ? value : [value];
const latest = values[values.length - 1];
if (reviewEnabled) {
review.render(values, values.length - 1, optimized);
} else {
showPreview(latest.blob, optimized);
if (values.length > 1) {
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
options.status.textContent = values.length + ' screenshots ready to file in this order.';
}
}
}).catch(error => {
if (sequence === selectionSequence) {
options.status.textContent = error.message;
@@ -236,24 +316,68 @@
});
});
options.remove.addEventListener('click', () => {
clearPreview();
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
const current = controller.state();
const count = Array.isArray(current) ? current.length : (current ? 1 : 0);
if (count <= 1) {
clearPreview();
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
return Promise.resolve();
}
const removedIndex = reviewEnabled ? review.activeIndex() : count - 1;
controller.remove(removedIndex);
selectionSequence += 1;
review?.afterRemoval(removedIndex, count);
return controller.serialize().then(value => {
const values = Array.isArray(value) ? value : [value];
if (reviewEnabled) {
review.render(values, review.activeIndex());
} else {
const latest = values[values.length - 1];
showPreview(latest.blob, false);
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
}
options.status.textContent = options.removedMessage || 'Latest screenshot removed. Your text is unchanged.';
});
});
function restorePreview(value) {
clearPreview();
const restored = controller.restore(value);
previewUrl = value.blob ? options.createObjectURL(value.blob) :
'data:' + value.contentType + ';base64,' + value.data;
const restored = restoreSelection(value);
const values = Array.isArray(value) ? value : [value];
if (reviewEnabled) {
review.render(values, values.length - 1);
return restored;
}
const latest = values[values.length - 1];
previewUrl = latest.blob ? options.createObjectURL(latest.blob) :
'data:' + latest.contentType + ';base64,' + latest.data;
options.image.src = previewUrl;
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
options.meta.textContent = values.length > 1 ?
values.length + ' screenshots ready · latest: ' + latest.filename :
restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
options.status.textContent = values.length > 1 ?
values.length + ' screenshots ready to file in this order.' :
(options.readyMessage || 'Screenshot ready to upload with this comment.');
return restored;
}
if (review && options.editor && issueEvidenceEditor) {
issueEvidenceEditor.mount({
...options.editor,
controller,
getActiveIndex:review.activeIndex,
optimizeImage,
onApplied: async index => {
const value = await controller.serialize();
review.render(value, index);
options.status.textContent = 'Edited screenshot flattened and ready to file.';
},
});
options.editor.edit.disabled = !controller.state();
}
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
}
return { create, mount, multipart, optimizeImage, MAX_BYTES };
return { create, mount, multipart, optimizeImage, MAX_BYTES, MAX_PIXELS };
});
+305
View File
@@ -0,0 +1,305 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueEvidenceEditor = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
function boundedRect(value, bounds) {
const x = Math.max(bounds.x, Math.min(bounds.x + bounds.width - 1, Math.round(Number(value.x) || 0)));
const y = Math.max(bounds.y, Math.min(bounds.y + bounds.height - 1, Math.round(Number(value.y) || 0)));
const width = Math.max(1, Math.min(bounds.x + bounds.width - x, Math.round(Number(value.width) || 0)));
const height = Math.max(1, Math.min(bounds.y + bounds.height - y, Math.round(Number(value.height) || 0)));
return { x, y, width, height };
}
function boundedPoint(value, bounds) {
return {
x: Math.max(bounds.x, Math.min(bounds.x + bounds.width, Math.round(Number(value.x) || 0))),
y: Math.max(bounds.y, Math.min(bounds.y + bounds.height, Math.round(Number(value.y) || 0))),
};
}
function createModel(size) {
const full = { x:0, y:0, width:Math.max(1, Math.round(size.width)), height:Math.max(1, Math.round(size.height)) };
let crop = { ...full };
let redactions = [];
let annotations = [];
let history = [];
function remember() {
history.push({
crop:{...crop}, redactions:redactions.map(value => ({...value})),
annotations:annotations.map(value => ({...value})),
});
}
function intersects(annotation, bounds) {
const left = annotation.type === 'arrow' ? Math.min(annotation.startX, annotation.endX) : annotation.x;
const top = annotation.type === 'arrow' ? Math.min(annotation.startY, annotation.endY) : annotation.y;
const right = annotation.type === 'arrow' ? Math.max(annotation.startX, annotation.endX) : annotation.x + annotation.width;
const bottom = annotation.type === 'arrow' ? Math.max(annotation.startY, annotation.endY) : annotation.y + annotation.height;
return left < bounds.x + bounds.width && top < bounds.y + bounds.height && right > bounds.x && bottom > bounds.y;
}
function clampAnnotation(annotation) {
if (annotation.type === 'highlight') {
const x = Math.max(crop.x, annotation.x);
const y = Math.max(crop.y, annotation.y);
const right = Math.min(crop.x + crop.width, annotation.x + annotation.width);
const bottom = Math.min(crop.y + crop.height, annotation.y + annotation.height);
return { type:'highlight', x, y, width:Math.max(1, right - x), height:Math.max(1, bottom - y) };
}
const start = boundedPoint({x:annotation.startX, y:annotation.startY}, crop);
const end = boundedPoint({x:annotation.endX, y:annotation.endY}, crop);
return { type:'arrow', startX:start.x, startY:start.y, endX:end.x, endY:end.y };
}
function setCrop(value) {
remember();
crop = boundedRect(value, crop);
redactions = redactions.filter(rectangle =>
rectangle.x < crop.x + crop.width && rectangle.y < crop.y + crop.height &&
rectangle.x + rectangle.width > crop.x && rectangle.y + rectangle.height > crop.y
).map(rectangle => boundedRect(rectangle, crop));
annotations = annotations.filter(annotation => intersects(annotation, crop)).map(clampAnnotation);
return snapshot();
}
function addRedaction(value) {
remember();
redactions.push(boundedRect(value, crop));
return snapshot();
}
function addHighlight(value) {
remember();
annotations.push({ type:'highlight', ...boundedRect(value, crop) });
return snapshot();
}
function addArrow(value) {
remember();
const start = boundedPoint({x:value.startX, y:value.startY}, crop);
const end = boundedPoint({x:value.endX, y:value.endY}, crop);
annotations.push({ type:'arrow', startX:start.x, startY:start.y, endX:end.x, endY:end.y });
return snapshot();
}
function undo() {
const previous = history.pop();
if (previous) {
crop = previous.crop;
redactions = previous.redactions;
annotations = previous.annotations;
}
return snapshot();
}
function reset() {
remember();
crop = { ...full };
redactions = [];
annotations = [];
return snapshot();
}
function snapshot() {
return {
crop:{...crop}, redactions:redactions.map(value => ({...value})),
annotations:annotations.map(value => ({...value})),
};
}
return { addArrow, addHighlight, addRedaction, reset, setCrop, snapshot, undo };
}
function namedBlob(blob, name) {
if (typeof File === 'function') return new File([blob], name, {type:blob.type});
Object.defineProperty(blob, 'name', {value:name, configurable:true});
return blob;
}
function paint(options) {
const state = options.model.snapshot();
const maxDimension = Number.isFinite(options.maxDimension) ? options.maxDimension : Infinity;
const scale = Math.min(1, maxDimension / Math.max(state.crop.width, state.crop.height));
const width = Math.max(1, Math.round(state.crop.width * scale));
const height = Math.max(1, Math.round(state.crop.height * scale));
options.canvas.width = width;
options.canvas.height = height;
const context = options.canvas.getContext('2d');
if (!context) throw new Error('Screenshot editing is unavailable in this browser.');
context.drawImage(options.source, state.crop.x, state.crop.y, state.crop.width, state.crop.height, 0, 0, width, height);
context.fillStyle = '#000000';
state.redactions.forEach(rectangle => context.fillRect(
Math.round((rectangle.x - state.crop.x) * scale),
Math.round((rectangle.y - state.crop.y) * scale),
Math.round(rectangle.width * scale),
Math.round(rectangle.height * scale),
));
state.annotations.forEach(annotation => {
if (annotation.type === 'highlight') {
context.fillStyle = 'rgba(250, 204, 21, 0.38)';
context.fillRect(
Math.round((annotation.x - state.crop.x) * scale),
Math.round((annotation.y - state.crop.y) * scale),
Math.round(annotation.width * scale),
Math.round(annotation.height * scale),
);
return;
}
const startX = (annotation.startX - state.crop.x) * scale;
const startY = (annotation.startY - state.crop.y) * scale;
const endX = (annotation.endX - state.crop.x) * scale;
const endY = (annotation.endY - state.crop.y) * scale;
const angle = Math.atan2(endY - startY, endX - startX);
const head = Math.max(10, Math.min(24, 16 * scale));
context.save();
context.strokeStyle = '#facc15';
context.lineWidth = Math.max(4, 6 * scale);
context.lineCap = 'round';
context.lineJoin = 'round';
context.beginPath();
context.moveTo(Math.round(startX), Math.round(startY));
context.lineTo(Math.round(endX), Math.round(endY));
context.lineTo(endX - head * Math.cos(angle - Math.PI / 6), endY - head * Math.sin(angle - Math.PI / 6));
context.moveTo(Math.round(endX), Math.round(endY));
context.lineTo(endX - head * Math.cos(angle + Math.PI / 6), endY - head * Math.sin(angle + Math.PI / 6));
context.stroke();
context.restore();
});
return state;
}
async function flatten(options) {
paint(options);
const type = ['image/png', 'image/jpeg', 'image/webp'].includes(options.type) ? options.type : 'image/png';
const blob = await new Promise(resolve => options.canvas.toBlob(resolve, type, type === 'image/png' ? undefined : 0.9));
if (!blob || !blob.size) throw new Error('The edited screenshot could not be saved. Try again.');
return namedBlob(blob, options.name || 'edited-screenshot.png');
}
function attachmentBlob(value) {
if (value.blob) return value.blob;
const binary = atob(String(value.data || ''));
return new Blob([Uint8Array.from(binary, character => character.charCodeAt(0))], {type:value.contentType});
}
function mount(options) {
let source = null;
let model = null;
let mode = 'redact';
let start = null;
let selectedIndex = -1;
let previousFocus = null;
function status(message) { options.status.textContent = message; }
function setMode(value) {
mode = value;
options.crop.setAttribute('aria-pressed', value === 'crop' ? 'true' : 'false');
options.redact.setAttribute('aria-pressed', value === 'redact' ? 'true' : 'false');
options.highlight.setAttribute('aria-pressed', value === 'highlight' ? 'true' : 'false');
options.arrow.setAttribute('aria-pressed', value === 'arrow' ? 'true' : 'false');
status({
crop:'Drag around the part to keep.', redact:'Drag over private information to hide it.',
highlight:'Drag around the detail to highlight.', arrow:'Drag toward the detail you want to point out.',
}[value]);
}
function render() {
if (source && model) paint({source, model, canvas:options.canvas, maxDimension:1600});
}
function close() {
options.dialog.hidden = true;
if (source && typeof source.close === 'function') source.close();
source = null;
model = null;
start = null;
(previousFocus || options.edit).focus();
}
async function open() {
const value = await options.controller.serialize();
const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
selectedIndex = options.getActiveIndex();
const selected = values[selectedIndex];
if (!selected) return;
previousFocus = options.document.activeElement;
status('Opening screenshot editor…');
try {
const decode = options.decode || globalThis.createImageBitmap;
if (typeof decode !== 'function') throw new Error('decode');
source = await decode(attachmentBlob(selected));
model = createModel({width:source.width, height:source.height});
options.dialog.hidden = false;
render();
setMode('redact');
options.redact.focus();
} catch (_error) {
source = null;
status('This browser cannot edit the screenshot. Your original is unchanged.');
}
}
function point(event) {
const box = options.canvas.getBoundingClientRect();
const state = model.snapshot();
return {
x:state.crop.x + Math.round((event.clientX - box.left) * state.crop.width / box.width),
y:state.crop.y + Math.round((event.clientY - box.top) * state.crop.height / box.height),
};
}
options.canvas.addEventListener('pointerdown', event => {
if (!model) return;
start = point(event);
options.canvas.setPointerCapture?.(event.pointerId);
});
options.canvas.addEventListener('pointerup', event => {
if (!model || !start) return;
const end = point(event);
const dragStart = start;
const rectangle = {
x:Math.min(dragStart.x, end.x), y:Math.min(dragStart.y, end.y),
width:Math.abs(end.x - dragStart.x), height:Math.abs(end.y - dragStart.y),
};
start = null;
const tooSmall = mode === 'arrow' ? Math.hypot(end.x - dragStart.x, end.y - dragStart.y) < 8 :
rectangle.width < 3 || rectangle.height < 3;
if (tooSmall) {
status('Drag a larger area on the screenshot.');
return;
}
if (mode === 'crop') model.setCrop(rectangle);
else if (mode === 'redact') model.addRedaction(rectangle);
else if (mode === 'highlight') model.addHighlight(rectangle);
else model.addArrow({startX:dragStart.x, startY:dragStart.y, endX:end.x, endY:end.y});
render();
status({
crop:'Crop applied. Undo or reset if needed.', redact:'Private area hidden with an opaque redaction.',
highlight:'Highlight added.', arrow:'Arrow added.',
}[mode]);
});
options.edit.addEventListener('click', open);
options.crop.addEventListener('click', () => setMode('crop'));
options.redact.addEventListener('click', () => setMode('redact'));
options.highlight.addEventListener('click', () => setMode('highlight'));
options.arrow.addEventListener('click', () => setMode('arrow'));
options.undo.addEventListener('click', () => { if (model) { model.undo(); render(); status('Last edit undone.'); } });
options.reset.addEventListener('click', () => { if (model) { model.reset(); render(); status('Crop, redactions, and annotations reset.'); } });
options.cancel.addEventListener('click', close);
options.dialog.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); close(); } });
options.apply.addEventListener('click', async () => {
if (!model || !source) return;
options.apply.disabled = true;
status('Applying flattened edit…');
try {
const current = await options.controller.serialize();
const values = Array.isArray(current) ? current : [current];
const selected = values[selectedIndex];
let derivative = await flatten({
source, model, canvas:options.exportCanvas, maxDimension:2048,
name:selected.filename, type:selected.contentType,
});
if (options.optimizeImage) derivative = await options.optimizeImage(derivative);
options.controller.replace(selectedIndex, derivative);
await options.onApplied(selectedIndex);
close();
} catch (error) {
status(error.message || 'The edited screenshot could not be applied. Your original is unchanged.');
} finally {
options.apply.disabled = false;
}
});
return { close, open };
}
return { createModel, flatten, mount, paint };
});
+186
View File
@@ -0,0 +1,186 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueEvidenceReview = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
const MAX_PIXELS = 12 * 1024 * 1024;
const MAX_DIMENSION = 8192;
function namedBlob(blob, name) {
if (typeof File === 'function') return new File([blob], name, { type: blob.type });
Object.defineProperty(blob, 'name', { value: name, configurable: true });
return blob;
}
async function optimizeImage(file, environment = {}) {
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
if (typeof decode !== 'function') throw new Error('This browser cannot safely inspect the screenshot. Try cropping it and choose it again.');
let bitmap;
try {
bitmap = await decode(file);
const width = Number(bitmap.width);
const height = Number(bitmap.height);
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
throw new Error('decode');
}
const pixelScale = Math.min(1, Math.sqrt(MAX_PIXELS / (width * height)));
const dimensionScale = Math.min(1, MAX_DIMENSION / Math.max(width, height));
const byteScale = file.size > MAX_BYTES ? Math.sqrt(MAX_BYTES / file.size) * 0.92 : 1;
let scale = Math.min(pixelScale, dimensionScale, byteScale);
if (scale === 1 && file.size <= MAX_BYTES) return file;
const canvas = makeCanvas();
const context = canvas && canvas.getContext && canvas.getContext('2d');
if (!context) throw new Error('decode');
for (let attempt = 0; attempt < 10; attempt += 1) {
canvas.width = Math.max(1, Math.floor(width * scale));
canvas.height = Math.max(1, Math.floor(height * scale));
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise(resolve => canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86));
if (!blob) throw new Error('encode');
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
scale *= 0.8;
}
} catch (_error) {
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
} finally {
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
}
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
}
function create(options) {
const controller = options.controller;
const documentRef = options.document || document;
let activeIndex = 0;
let busy = false;
let previewUrl = '';
let thumbnailUrls = [];
function revoke(url) {
if (url && !url.startsWith('data:')) options.revokeObjectURL(url);
}
function attachmentUrl(attachment) {
return attachment?.blob ? options.createObjectURL(attachment.blob) :
'data:' + attachment.contentType + ';base64,' + attachment.data;
}
function count() {
const value = controller.state();
return Array.isArray(value) ? value.length : (value ? 1 : 0);
}
function setBusy(value) {
busy = Boolean(value);
options.earlier.disabled = busy || activeIndex <= 0;
options.later.disabled = busy || activeIndex >= count() - 1;
if (options.note) options.note.disabled = busy;
if (options.edit) options.edit.disabled = busy || !count();
Array.from(options.tray.children || []).forEach(button => { button.disabled = busy; });
}
function updateNote(values) {
if (!options.note) return;
options.note.value = controller.note(activeIndex);
options.note.disabled = busy;
if (options.noteLabel) {
options.noteLabel.textContent = 'Evidence note for screenshot ' + (activeIndex + 1) + ' of ' +
values.length + ' (optional)';
}
}
function update(values, optimized = false) {
if (!values.length) return;
activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1));
revoke(previewUrl);
previewUrl = attachmentUrl(values[activeIndex]);
options.image.src = previewUrl;
const current = values[activeIndex];
const size = current.blob ? current.blob.size : Math.max(1, Math.floor(String(current.data || '').length * 3 / 4));
options.meta.textContent = 'Screenshot ' + (activeIndex + 1) + ' of ' + values.length + ' · ' +
current.filename + ' · ' + Math.ceil(size / 1024) + ' KB';
options.preview.hidden = false;
if (options.edit) options.edit.disabled = busy;
Array.from(options.tray.children || []).forEach((button, index) =>
button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false'));
options.earlier.disabled = busy || activeIndex === 0;
options.later.disabled = busy || activeIndex === values.length - 1;
updateNote(values);
options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' :
values.length + ' screenshots ready to file in this order.';
}
function render(value, index = activeIndex, optimized = false) {
const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
activeIndex = Math.max(0, Math.min(index, values.length - 1));
thumbnailUrls.forEach(revoke);
thumbnailUrls = [];
options.tray.replaceChildren(...values.map((item, itemIndex) => {
const button = documentRef.createElement('button');
button.type = 'button';
button.className = 'issue-evidence-thumbnail';
button.setAttribute('aria-label', 'Review screenshot ' + (itemIndex + 1) + ' of ' + values.length + ': ' + item.filename);
const thumbnail = documentRef.createElement('img');
thumbnail.alt = '';
thumbnail.src = attachmentUrl(item);
thumbnailUrls.push(thumbnail.src);
button.appendChild(thumbnail);
const position = documentRef.createElement('span');
position.textContent = String(itemIndex + 1);
button.appendChild(position);
button.addEventListener('click', () => { activeIndex = itemIndex; update(values); });
return button;
}));
options.tray.hidden = !values.length;
if (values.length) update(values, optimized);
}
function clear() {
revoke(previewUrl);
thumbnailUrls.forEach(revoke);
previewUrl = '';
thumbnailUrls = [];
activeIndex = 0;
options.tray.replaceChildren();
options.tray.hidden = true;
options.earlier.disabled = true;
options.later.disabled = true;
if (options.note) {
options.note.value = '';
options.note.disabled = true;
}
if (options.edit) options.edit.disabled = true;
if (options.noteLabel) options.noteLabel.textContent = 'Evidence note (optional)';
}
function afterRemoval(removedIndex, previousCount) {
activeIndex = Math.min(removedIndex, previousCount - 2);
}
function move(offset) {
const total = count();
const destination = activeIndex + offset;
if (destination < 0 || destination >= total) return Promise.resolve();
controller.move(activeIndex, destination);
activeIndex = destination;
return controller.serialize().then(value => {
render(value, activeIndex);
options.status.textContent = 'Screenshot moved to position ' + (activeIndex + 1) + ' of ' + total + '.';
});
}
options.earlier.addEventListener('click', () => move(-1));
options.later.addEventListener('click', () => move(1));
options.note?.addEventListener('input', event => {
controller.setNote(activeIndex, event.target.value);
});
clear();
return { activeIndex: () => activeIndex, afterRemoval, clear, render, setBusy };
}
return { create, optimizeImage, MAX_PIXELS };
});
+95
View File
@@ -0,0 +1,95 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createIssueFilingReceipt = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
function createIssueFilingReceipt({
root, heading, key, title, ownership, openLink, shareButton, filedButton,
relatedButton, fileAnotherButton, doneButton, status, navigator = {}, clipboard = navigator.clipboard,
onFileRelated = function () {},
onFileAnother = function () {},
onViewFiled = function () {},
}) {
let active = null;
let relatedPlan = null;
let restoreFocus = null;
function close() {
root.hidden = true;
active = null;
relatedPlan = null;
const target = restoreFocus;
restoreFocus = null;
target?.focus?.();
}
async function share() {
if (!active) return;
const payload = {
title: active.repository + '#' + active.number + ' · ' + active.title,
text: active.title,
url: active.url,
};
if (typeof navigator.share === 'function') {
try {
await navigator.share(payload);
return;
} catch (error) {
if (error?.name === 'AbortError') return;
}
}
if (typeof clipboard?.writeText !== 'function') {
status.textContent = 'Issue link is ready in Open in Gitea.';
return;
}
await clipboard.writeText(active.url);
status.textContent = 'Issue link copied.';
}
function show(issue, returnTarget, reusablePlan = null) {
active = issue;
relatedPlan = reusablePlan;
restoreFocus = returnTarget || null;
key.textContent = issue.repository + '#' + issue.number;
title.textContent = issue.title;
ownership.textContent = issue.assignees?.length
? 'Assigned to @' + issue.assignees.join(', @') + '.'
: 'Created with no owner.';
openLink.href = issue.url;
status.textContent = '';
if (relatedButton) relatedButton.hidden = !relatedPlan;
root.hidden = false;
heading.focus();
}
shareButton.addEventListener('click', share);
filedButton?.addEventListener('click', () => {
if (!active) return;
const issue = active;
close();
onViewFiled(issue);
});
root.addEventListener('keydown', event => {
if (event.key !== 'Escape' || root.hidden) return;
event.preventDefault();
close();
});
doneButton.addEventListener('click', close);
relatedButton?.addEventListener('click', () => {
if (!relatedPlan) return;
const plan = relatedPlan;
close();
onFileRelated(plan);
});
fileAnotherButton.addEventListener('click', () => {
close();
onFileAnother();
});
return { show, close, share };
}
return createIssueFilingReceipt;
});
+181
View File
@@ -0,0 +1,181 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createIssueFilingReview = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const INTENT_LABELS = {
'create-and-assign': 'Create & assign to me',
'create-and-start': 'Create & start',
'follow-up-and-next': 'Create follow-up & next',
};
function clone(value) {
if (typeof structuredClone === 'function') return structuredClone(value);
return JSON.parse(JSON.stringify(value));
}
function create(options) {
let reviewed = null;
let trigger = null;
let busy = false;
let evidenceItems = [];
const generatedUrls = new Set();
function releaseEvidenceUrls() {
generatedUrls.forEach(url => options.revokeObjectURL?.(url));
generatedUrls.clear();
evidenceItems = [];
}
function evidenceUrl(attachment) {
if (attachment?.blob) {
const url = options.createObjectURL(attachment.blob);
generatedUrls.add(url);
return url;
}
if (attachment?.data) {
return 'data:' + String(attachment.contentType || 'application/octet-stream') +
';base64,' + String(attachment.data);
}
return '';
}
function selectEvidence(index) {
const selected = evidenceItems[index];
if (!selected) return;
evidenceItems.forEach((item, position) =>
item.button.setAttribute('aria-pressed', position === index ? 'true' : 'false'));
const filename = selected.attachment.filename || 'Screenshot';
options.evidencePreview.hidden = false;
options.evidenceImage.src = selected.url;
options.evidenceImage.alt = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length + ': ' + filename;
options.evidencePosition.textContent = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length;
options.evidenceFilename.textContent = filename;
options.evidenceNote.textContent = selected.attachment.note || 'No evidence note.';
}
function renderVisualEvidence(attachments) {
releaseEvidenceUrls();
options.evidenceEmpty.hidden = attachments.length > 0;
options.evidencePreview.hidden = true;
options.evidenceImage.src = '';
evidenceItems = attachments.map((attachment, index) => {
const url = evidenceUrl(attachment);
const item = options.document.createElement('li');
const button = options.document.createElement('button');
const image = options.document.createElement('img');
const filename = attachment.filename || 'Screenshot';
button.setAttribute('type', 'button');
button.className = 'issue-filing-evidence-selector';
button.setAttribute('aria-label', 'Preview evidence ' + (index + 1) + ' of ' + attachments.length + ': ' + filename);
button.setAttribute('aria-pressed', 'false');
image.src = url;
image.alt = '';
button.appendChild(image);
item.appendChild(button);
return { attachment, button, item, url };
});
evidenceItems.forEach((entry, index) =>
entry.button.addEventListener('click', () => selectEvidence(index)));
options.evidenceList.replaceChildren(...evidenceItems.map(entry => entry.item));
if (evidenceItems.length) selectEvidence(0);
}
function close() {
options.sheet.hidden = true;
const previousTrigger = trigger;
trigger = null;
reviewed = null;
busy = false;
releaseEvidenceUrls();
options.confirmButton.disabled = false;
previousTrigger?.focus();
}
function render(payload) {
const draft = payload.draft;
options.repository.textContent = draft.repository || 'No repository';
options.intent.textContent = draft.unassigned === true ? 'Create unassigned' :
(INTENT_LABELS[payload.intent] || payload.intent);
if (options.issueType) options.issueType.textContent = draft.templateName || 'Blank issue';
options.title.textContent = draft.title;
options.body.textContent = draft.body || 'No note provided.';
const labels = (draft.labels || []).map(label => typeof label === 'string' ? label : label.name);
const milestone = draft.milestone?.title || draft.milestoneTitle || 'No milestone';
const dueDate = draft.dueDate || draft.due_date || 'No due date';
const owner = draft.unassigned === true ? 'Owner: No owner' : (draft.assignee
? 'Owner: ' + (draft.assigneeName || draft.assignee) + ' (@' + draft.assignee + ')'
: 'Assigned to you');
const planning = [];
if (Number.isInteger(draft.estimateMinutes)) {
planning.push('Estimate: ' + draft.estimateMinutes + ' min');
const projected = draft.todayCapacity?.projectedMinutes;
if (Number.isInteger(projected)) planning.push(projected >= 0
? 'Today after start: ' + projected + ' min free'
: 'Today after start: ' + Math.abs(projected) + ' min over capacity');
}
options.metadata.textContent = [
labels.length ? 'Labels: ' + labels.join(', ') : 'No labels',
'Milestone: ' + milestone,
'Due: ' + dueDate,
owner,
...planning,
].join(' · ');
if (options.blockerList) {
options.blockerList.replaceChildren(...(draft.blockers || []).map(blocker => {
const item = options.document.createElement('li');
item.textContent = blocker.repository + ' #' + blocker.number + ' — ' + blocker.title;
return item;
}));
}
const attachments = (draft.attachments || (draft.attachment ? [draft.attachment] : [])).filter(Boolean);
if (options.evidencePreview) {
renderVisualEvidence(attachments);
return;
}
options.evidenceList.replaceChildren(...attachments.map((attachment, index) => {
const item = options.document.createElement('li');
item.textContent = (index + 1) + '. ' + (attachment.filename || 'Screenshot') +
(attachment.note ? ' — ' + attachment.note : '');
return item;
}));
}
function open(payload, opener) {
reviewed = clone(payload);
trigger = opener || null;
busy = false;
options.confirmButton.disabled = false;
render(reviewed);
options.sheet.hidden = false;
options.backButton.focus();
}
options.backButton.addEventListener('click', close);
options.confirmButton.addEventListener('click', async () => {
if (!reviewed || busy) return;
busy = true;
options.confirmButton.disabled = true;
try {
await options.onConfirm(reviewed);
close();
} catch (error) {
busy = false;
options.confirmButton.disabled = false;
if (options.status) options.status.textContent = error.message;
options.confirmButton.focus();
}
});
options.document.addEventListener('keydown', event => {
if (!options.sheet.hidden && event.key === 'Escape' && !busy) {
event.preventDefault();
close();
}
});
return { close, open };
}
return create;
});
+212 -40
View File
@@ -11,10 +11,72 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const blob = value?.blob;
const data = String(value?.data || '');
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
if (blob) return { filename, contentType, blob };
if (!data && value?.stored === true) return { filename, contentType, stored: true };
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
const noteValue = note ? { note } : {};
if (blob) return { filename, contentType, blob, ...noteValue };
if (!data && value?.stored === true) return { filename, contentType, stored: true, ...noteValue };
if (!data) return undefined;
return { filename, contentType, data };
return { filename, contentType, data, ...noteValue };
}
function captureAttachments(values) {
if (!Array.isArray(values)) return undefined;
if (values.length > 5) throw new Error('You can attach up to 5 screenshots.');
const attachments = values.map(captureAttachment);
if (attachments.some(value => !value)) {
throw new Error('Some screenshots are unavailable. Choose them again before queueing.');
}
return attachments.length ? attachments : undefined;
}
function captureBlockers(values) {
if (!Array.isArray(values)) return undefined;
const seen = new Set();
const blockers = [];
for (const value of values) {
const repository = String(value?.repository || '').trim();
const number = Number(value?.number);
const key = repository + '#' + number;
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
!Number.isInteger(number) || number < 1 || seen.has(key)) continue;
seen.add(key);
blockers.push({
repository, number,
title: String(value?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255),
});
if (blockers.length === 5) break;
}
return blockers.length ? blockers : undefined;
}
function captureEstimate(value) {
const estimate = Number(value);
return Number.isInteger(estimate) && estimate >= 5 && estimate <= 1440 ? estimate : undefined;
}
function captureRelatedDraft(value) {
const repository = String(value?.repository || '');
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return undefined;
const plan = {
repository, title:'', body:String(value?.body || '').slice(0, 9000),
labelIds:Array.from(new Set((Array.isArray(value?.labelIds) ? value.labelIds : [])
.filter(id => Number.isInteger(id) && id > 0))).slice(0, 20),
};
const milestoneId = Number(value?.milestoneId);
if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId;
if (/^\d{4}-\d{2}-\d{2}$/.test(String(value?.dueDate || ''))) plan.dueDate = String(value.dueDate);
if (value?.unassigned === true) plan.unassigned = true;
else if (/^[A-Za-z0-9_.-]+$/.test(String(value?.assignee || ''))) {
plan.assignee = String(value.assignee);
plan.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
const templateId = String(value?.templateId || '').slice(0, 80);
if (templateId) {
plan.templateId = templateId;
plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80);
plan.capturedBody = '';
}
return plan;
}
function read() {
@@ -51,11 +113,27 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
status: 'queued',
queuedAt: Number(now()),
};
const assignee = /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
? String(draft.assignee) : '';
if (assignee) {
item.assignee = assignee;
item.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
if (draft?.unassigned === true) item.unassigned = true;
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
if (draft?.completionIntent === 'create-and-start' && !assignee && !item.unassigned) {
item.completionIntent = 'create-and-start';
item.estimateMinutes = captureEstimate(draft?.estimateMinutes);
}
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
const attachments = captureAttachments(draft?.attachments);
if (attachments) item.attachments = attachments;
const blockers = captureBlockers(draft?.blockers);
if (blockers) item.blockers = blockers;
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
if (relatedDraft) item.relatedDraft = relatedDraft;
item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId);
@@ -65,14 +143,21 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
function localIndexItem(item) {
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
const hasAttachmentBytes = item?.attachment?.data || item?.attachment?.blob;
const hasBundleBytes = Array.isArray(item?.attachments) &&
item.attachments.some(value => value?.data || value?.blob);
if (!hasAttachmentBytes && !hasBundleBytes) return item;
return {
...item,
attachment: {
...(hasAttachmentBytes ? {attachment: {
filename: item.attachment.filename,
contentType: item.attachment.contentType,
stored: true,
},
}} : {}),
...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({
filename:value.filename, contentType:value.contentType, stored:true,
...(value.note ? {note:value.note} : {}),
}))} : {}),
};
}
@@ -120,50 +205,90 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
async function hydrateForEdit(id) {
const item = read().find(candidate => candidate.id === id);
if (!item) return null;
if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) return { ...item };
const storedBundle = Array.isArray(item.attachments) && item.attachments.some(value => value?.stored);
const storedAttachment = item.attachment?.stored && !item.attachment.data && !item.attachment.blob;
if (!storedAttachment && !storedBundle) return { ...item };
if (!backgroundSync?.get) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const durable = await backgroundSync.get(id);
const attachment = captureAttachment(durable?.attachment);
if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
const attachments = captureAttachments(durable?.attachments);
if (durable?.operationId !== item.operationId ||
(storedAttachment && (!attachment?.data && !attachment?.blob)) ||
(storedBundle && (!attachments || attachments.length !== item.attachments.length))) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
return { ...item, attachment };
return {
...item,
...(attachment ? {attachment} : {}),
...(attachments ? {attachments} : {}),
};
}
function prepareUpdate(id, draft) {
let updated = null;
const items = read().map(item => {
if (item.id !== id) return item;
const nextRepository = String(draft?.repository || '');
const nextTitle = String(draft?.title || '');
const nextBody = String(draft?.body || '');
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
const repository = String(draft?.repository || '');
const title = String(draft?.title || '');
const body = String(draft?.body || '');
const labelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
const unassigned = draft?.unassigned === true;
const assignee = !unassigned && /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
? String(draft.assignee) : undefined;
const assigneeName = assignee
? String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255)
: undefined;
const milestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
? Number(draft.milestoneId) : undefined;
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
const dueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
? String(draft.dueDate) : undefined;
const nextAttachment = captureAttachment(draft?.attachment);
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|| attachmentChanged;
const attachment = captureAttachment(draft?.attachment);
const attachments = captureAttachments(draft?.attachments);
const blockers = captureBlockers(draft?.blockers);
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(attachment || null) ||
JSON.stringify(item.attachments || null) !== JSON.stringify(attachments || null);
const changed = item.repository !== repository || item.title !== title || item.body !== body
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(labelIds)
|| Boolean(item.unassigned) !== unassigned
|| item.assignee !== assignee || item.assigneeName !== assigneeName
|| item.milestoneId !== milestoneId || item.dueDate !== dueDate
|| item.estimateMinutes !== captureEstimate(draft?.estimateMinutes)
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(blockers || null)
|| JSON.stringify(item.relatedDraft || null) !== JSON.stringify(relatedDraft || null);
updated = {
...item,
repository: nextRepository, title: nextTitle,
body: nextBody, labelIds: nextLabelIds,
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
repository, title, body, labelIds,
unassigned: unassigned || undefined,
assignee, assigneeName, milestoneId, dueDate,
attachment, attachments, blockers, relatedDraft,
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued',
};
if (draft?.completionIntent === 'create-and-start') updated.completionIntent = 'create-and-start';
else delete updated.completionIntent;
if (nextMilestoneId === undefined) delete updated.milestoneId;
if (nextDueDate === undefined) delete updated.dueDate;
if (nextAttachment === undefined) delete updated.attachment;
if (attachmentChanged) delete updated.attachmentMarkdown;
if (draft?.completionIntent === 'create-and-start' && !assignee && !unassigned) {
updated.completionIntent = 'create-and-start';
updated.estimateMinutes = captureEstimate(draft?.estimateMinutes);
} else {
delete updated.completionIntent;
delete updated.estimateMinutes;
}
if (assignee === undefined) {
delete updated.assignee;
delete updated.assigneeName;
}
if (!unassigned) delete updated.unassigned;
if (milestoneId === undefined) delete updated.milestoneId;
if (dueDate === undefined) delete updated.dueDate;
if (attachment === undefined) delete updated.attachment;
if (attachments === undefined) delete updated.attachments;
if (blockers === undefined) delete updated.blockers;
if (relatedDraft === undefined) delete updated.relatedDraft;
if (attachmentChanged) {
delete updated.attachmentMarkdown;
delete updated.attachmentMarkdowns;
}
delete updated.error;
delete updated.deliveryState;
return updated;
@@ -213,6 +338,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
function evidenceMarkdown(attachment, markdown, index) {
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
if (!note) return markdown;
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
}
function attachmentMultipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
@@ -237,34 +369,68 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.unassigned ? { unassigned: true } : {}),
...(item.assignee ? { assignee: item.assignee } : {}),
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
if (item.attachment || item.attachments || item.blockers?.length) {
persistDeliveryStage(item.id, { deliveredIssue: issue });
}
}
if (!item.attachment) return issue;
let markdown = item.attachmentMarkdown;
if (!markdown) {
const blockers = Array.isArray(item.blockers) ? item.blockers : [];
let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length);
for (let index = deliveredBlockers; index < blockers.length; index += 1) {
const blocker = blockers[index];
await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/blockers',
{
method: 'PATCH',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'blocker-' + index),
},
body: JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}),
},
);
deliveredBlockers = index + 1;
persistDeliveryStage(item.id, { deliveredIssue:issue, deliveredBlockers });
}
const attachments = Array.isArray(item.attachments) ? item.attachments :
(item.attachment ? [item.attachment] : []);
if (!attachments.length) return issue;
const markdowns = Array.isArray(item.attachmentMarkdowns)
? item.attachmentMarkdowns.slice(0, attachments.length)
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
for (let index = markdowns.length; index < attachments.length; index += 1) {
const uploaded = await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
'Idempotency-Key': stageOperationId(
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
),
},
body: attachmentMultipart(item.attachment),
body: attachmentMultipart(attachments[index]),
},
);
markdown = String(uploaded?.markdown || '');
const markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
markdowns.push(markdown);
persistDeliveryStage(item.id, {
deliveredIssue: issue, attachmentMarkdowns:markdowns.slice(),
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
});
}
const markdown = markdowns.map((value, index) =>
evidenceMarkdown(attachments[index], value, index)).join('\n\n');
await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
{
@@ -329,6 +495,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
async function flushQueue(currentLogin) {
const confirmed = [];
const completions = [];
const filings = [];
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
@@ -337,17 +504,19 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const result = await sendItem(item, currentLogin);
if (result.issue) {
confirmed.push(result.issue);
if (result.item?.relatedDraft) filings.push({issue:result.issue, relatedDraft:result.item.relatedDraft});
if (result.item?.completionIntent) completions.push({
id: result.item.id,
intent: result.item.completionIntent,
ownerLogin: result.item.ownerLogin,
operationId: result.item.operationId,
estimateMinutes: result.item.estimateMinutes,
issue: result.issue,
});
}
if (result.transient) break;
}
return { confirmed, completions, remaining: read(), blocked };
return { confirmed, completions, filings, remaining: read(), blocked };
}
async function flush(currentLogin) {
@@ -376,11 +545,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const result = await sendItem(queued, currentLogin);
return {
confirmed: result.issue ? [result.issue] : [],
filings: result.issue && result.item?.relatedDraft ? [{issue:result.issue, relatedDraft:result.item.relatedDraft}] : [],
completions: result.issue && result.item?.completionIntent ? [{
id: result.item.id,
intent: result.item.completionIntent,
ownerLogin: result.item.ownerLogin,
operationId: result.item.operationId,
estimateMinutes: result.item.estimateMinutes,
issue: result.issue,
}] : [],
remaining: read(), blocked: 0,
@@ -425,6 +596,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
intent: item.completionIntent,
ownerLogin: item.ownerLogin,
operationId: item.operationId,
estimateMinutes: item.estimateMinutes,
issue: item.deliveredIssue,
}));
}
+5 -1
View File
@@ -38,8 +38,12 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
return {
readOnly(item) {
return Boolean(item?.is_filed && !item?.is_assigned);
},
load(item) {
return fetchJson(issuePath(item) + '/detail', {
const access = this.readOnly(item) ? '?access=filed' : '';
return fetchJson(issuePath(item) + '/detail' + access, {
headers: { Accept: 'application/json' },
});
},
+2 -1
View File
@@ -90,7 +90,8 @@ function createLaterPicker({
onState({ open:true, message:result.message, value:String(input || '') });
return false;
}
const confirmed = onConfirm(item, result.value, context);
const confirmed = typeof item?.confirm === 'function' ? item.confirm(result.value) :
onConfirm(item, result.value, context);
if (confirmed !== true && typeof confirmed !== 'function') return false;
committed = true;
afterClose = typeof confirmed === 'function' ? confirmed : null;
+1
View File
@@ -30,6 +30,7 @@
if (name === 'today') return options.openToday();
if (name === 'agenda') return options.openAgenda();
if (name === 'update' && options.openUpdates) return options.openUpdates();
if (name === 'filed' && options.openFiled) return options.openFiled();
options.selectFilter(name);
const action = options.firstAction(name);
if (!action) {
+3 -2
View File
@@ -91,9 +91,9 @@
}
function updateQueues(counts) {
const names = 'today agenda attention update later draft'.split(' ');
const names = 'today agenda attention update filed later draft'.split(' ');
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
const actionableNames = ['today', 'attention', 'update', 'later', 'draft'];
const actionableNames = ['today', 'attention', 'update', 'filed', 'later', 'draft'];
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
element.textContent = String(normalized[name] || 0);
@@ -119,6 +119,7 @@
+ ', Agenda ' + normalized.agenda + ' due'
+ ', Attention ' + normalized.attention
+ ', Updates ' + normalized.update
+ ', Filed ' + normalized.filed
+ ', Later ' + normalized.later
+ ', Drafts ' + normalized.draft
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
+12 -1
View File
@@ -70,17 +70,19 @@ 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 due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
const normalized = {
...item,
key: (item.repository || 'unknown') + '#' + item.number,
is_review: isReview,
is_filed: isFiled,
is_assigned: assigned,
has_update: false,
...(due ? { due_label: due.label } : {}),
reason: priorityLabel ? priorityLabel + ' priority' :
(due && due.priority < 4 ? due.label :
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))),
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work')))),
_priority: priorityLabel ? 0 :
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
};
@@ -651,6 +653,7 @@ function createNotificationReplier({
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 === '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);
@@ -958,10 +961,17 @@ function findQueueItems(items, query) {
});
}
function filedFollowUpTarget(items) {
const item = (items || []).find(candidate => candidate?.is_filed);
if (!item) return null;
return { kind:item.has_update ? 'update' : 'issue', item };
}
function countMyWork(items) {
return {
all: items.length,
attention: items.filter(needsAttention).length,
filed: items.filter((item) => item.is_filed).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,
@@ -983,6 +993,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.removeIssue = removeIssue;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.findQueueItems = findQueueItems;
buildMyWork.filedFollowUpTarget = filedFollowUpTarget;
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
+159
View File
@@ -0,0 +1,159 @@
(function (root, factory) {
const createSavedSearches = factory();
if (typeof module === 'object' && module.exports) module.exports = createSavedSearches;
if (root) root.createSavedSearches = createSavedSearches;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function createSavedSearches(options) {
const fetchJson = options.fetchJson;
const createId = options.createId || (() => crypto.randomUUID().replace(/-/g, ''));
const onOpen = options.onOpen;
const onState = options.onState || (() => {});
let current = { revision:0, views:[] };
function snapshot() {
return { revision:current.revision, views:current.views.map(view => ({ ...view })) };
}
function publish(status, message) {
onState({ ...snapshot(), status, ...(message ? { message } : {}) });
}
function normalizeSearch(name, search, id) {
const cleanName = String(name || '').trim();
const query = String(search?.query || '').trim();
if (!cleanName || cleanName.length > 60) throw new Error('Name must be between 1 and 60 characters.');
if (query.length < 2 || query.length > 200) throw new Error('Search query must be between 2 and 200 characters.');
return {
id, name:cleanName, query,
kind:['all', 'issue', 'pull'].includes(search?.kind) ? search.kind : 'all',
state:['all', 'open', 'closed'].includes(search?.state) ? search.state : 'all',
repository:String(search?.repository || '').trim(),
};
}
async function replace(views) {
publish('saving');
try {
current = await fetchJson('api/v1/saved-searches', {
method:'PUT', headers:{ 'Content-Type':'application/json' },
body:JSON.stringify({ revision:current.revision, views }),
});
publish('ready');
return snapshot();
} catch (error) {
const conflict = error?.status === 409 && error?.payload?.detail?.snapshot;
if (conflict) {
current = conflict;
publish('conflict', 'Saved searches changed on another device.');
} else {
publish('error', 'Saved searches could not sync. Search still works.');
}
throw error;
}
}
return {
async load() {
publish('loading');
try {
current = await fetchJson('api/v1/saved-searches');
publish('ready');
} catch (error) {
publish('error', 'Saved searches could not load. Search still works.');
}
return snapshot();
},
save(name, search) {
if (current.views.length >= 20) return Promise.reject(new Error('Saved searches are limited to 20.'));
const created = normalizeSearch(name, search, createId());
return replace(current.views.concat(created));
},
rename(id, name) {
const existing = current.views.find(view => view.id === id);
if (!existing) return Promise.reject(new Error('Saved search no longer exists.'));
const changed = normalizeSearch(name, existing, existing.id);
return replace(current.views.map(view => view.id === id ? changed : view));
},
remove(id) {
if (!current.views.some(view => view.id === id)) return Promise.resolve(snapshot());
return replace(current.views.filter(view => view.id !== id));
},
open(id) {
const selected = current.views.find(view => view.id === id);
if (selected) onOpen({ ...selected });
},
snapshot,
};
}
createSavedSearches.mount = function mountSavedSearches(document, fetch, search, applyScope, history) {
const query = selector => document.querySelector(selector);
const input = query('#cmd-input');
const request = async (url, init) => {
const response = await fetch(url, init);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.detail?.message || payload.detail || 'Saved Search request failed.');
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
};
let controller;
function render(state) {
const list = query('#saved-search-list');
const status = query('#saved-search-status');
list.replaceChildren();
status.textContent = ({loading:'Loading…', saving:'Syncing…', ready:'',
conflict:state.message, error:state.message})[state.status] || '';
state.views.forEach(view => {
const row = document.createElement('div');
row.className = 'saved-search-row';
const open = document.createElement('button');
open.type = 'button'; open.className = 'saved-search-open saved-search-action';
open.textContent = view.name; open.title = view.query;
open.addEventListener('click', () => controller.open(view.id));
const actions = document.createElement('div');
actions.className = 'saved-search-row-actions';
for (const action of ['Rename', 'Delete']) {
const button = document.createElement('button');
button.type = 'button'; button.className = 'saved-search-action'; button.textContent = action;
button.setAttribute('aria-label', action + ' ' + view.name);
button.addEventListener('click', async () => {
if (action === 'Rename') {
const name = globalThis.prompt('Rename saved search', view.name);
if (name !== null && name.trim()) await controller.rename(view.id, name).catch(() => {});
} else if (globalThis.confirm('Delete saved search “' + view.name + '”?')) {
await controller.remove(view.id).catch(() => {});
}
});
actions.append(button);
}
row.append(open, actions); list.append(row);
});
}
controller = createSavedSearches({ fetchJson:request, onState:render, onOpen:view => {
search.setQuery('');
input.value = view.query;
applyScope(view);
search.setQuery(view.query);
history.update({query:view.query, scope:view});
input.focus();
}});
query('#save-current-search').addEventListener('click', async () => {
const name = query('#saved-search-name').value.trim();
const current = {query:input.value.trim(), kind:query('#cmd-search-kind').value,
state:query('#cmd-search-state').value, repository:query('#cmd-search-repository').value.trim()};
if (!name || current.query.length < 2) {
query('#saved-search-status').textContent = 'Enter a name and at least 2 search characters.';
return;
}
await controller.save(name, current).then(() => { query('#saved-search-name').value = ''; })
.catch(error => { query('#saved-search-status').textContent = error.message; });
});
return controller;
};
return createSavedSearches;
});
+197 -6
View File
@@ -57,15 +57,23 @@
if (detail?.claimable) return claim(detail);
throw new Error('This issue is no longer available to assign.');
}
return { identity, eligible, start, cancel, toggle, snapshot, prepare, limit:maximum };
function releaseRepository() {
const repositories = new Set(Array.from(selected.values()).map(item => item.repository));
return repositories.size === 1 ? repositories.values().next().value : null;
}
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
}
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
acceptIssue, lookup, render, escapeHtml, escapeAttribute
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute,
laterWork = null, laterPicker = null
) {
const get = selector => document.querySelector(selector);
let processor;
let laterProcessor;
let releaseProcessor;
let releaseContext = null;
const plan = createSearchBatchPlan({
resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}),
claim:detail => fetchJson(
@@ -77,6 +85,8 @@
get('#search-batch-actions').hidden = !state.active;
get('#select-search-results').hidden = state.active;
get('#queue-selected-search-results').disabled = state.count === 0;
get('#defer-selected-search-results').disabled = state.count === 0;
get('#plan-selected-search-results').disabled = state.count === 0;
get('#search-selection-status').textContent = state.count ?
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
render();
@@ -85,10 +95,24 @@
processor = batchFactory({
capacity:() => Math.max(0, todayWork.limit - todayWork.read().length),
owner:getOwner,
timeBudget:() => {
const planning = todayWork.planning();
return {
capacity_minutes:planning.capacity_minutes,
planned_minutes:Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0),
};
},
journalName:'search-today-batch',
autoMount:false,
claim:item => plan.prepare(item),
queue:confirmed => queueToday(acceptIssue(confirmed)),
persistEstimate:(confirmed, minutes) => {
const planning = todayWork.planning();
planning.estimates[todayWork.identity(confirmed)] = minutes;
todayWork.replacePlanning(planning);
todaySync.enqueueConfiguration(planning.capacity_minutes, planning.estimates);
todaySync.flush();
},
onProgress:progress => {
get('#queue-selected-search-results').disabled = progress.status === 'running';
if (progress.status === 'running') {
@@ -96,19 +120,186 @@
} else if (progress.status === 'full') {
get('#search-selection-status').textContent = 'Today has ' + progress.available + ' remaining slot' +
(progress.available === 1 ? '.' : 's.');
} else if (progress.status === 'estimates-required') {
get('#search-batch-estimate-summary').textContent = 'Estimate every selected issue.';
} else if (progress.status === 'over-budget') {
get('#search-batch-estimate-summary').textContent = progress.requested_minutes +
' min selected · ' + progress.remaining_minutes + ' min remain.';
} else if (progress.status === 'complete') {
get('#cmd-search-action-status').textContent = progress.failed.length ?
progress.queued.length + ' queued · ' + progress.failed.length + ' need retry.' :
progress.queued.length + ' added to Today.';
get('#resume-search-batch').hidden = processor.pending() === 0;
closeEstimateReview();
if (!progress.failed.length) plan.cancel();
}
},
});
laterProcessor = batchFactory({
capacity:() => plan.limit,
owner:getOwner,
journalName:'search-later-batch',
queueFailureReason:'assigned but Later could not be saved',
autoMount:false,
claim:item => plan.prepare(item),
queue:(confirmed, context) => {
const outcome = laterWork?.defer(acceptIssue(confirmed), context?.until);
return outcome === 'deferred' ? 'queued' : outcome;
},
onProgress:progress => {
get('#defer-selected-search-results').disabled = progress.status === 'running';
if (progress.status === 'running') {
get('#search-selection-status').textContent = 'Deferring ' + progress.processed + ' of ' + progress.selected + '…';
} else if (progress.status === 'complete') {
get('#cmd-search-action-status').textContent = progress.failed.length ?
progress.queued.length + ' deferred · ' + progress.failed.length + ' need retry.' :
progress.queued.length + ' added to Later.';
restore();
if (!progress.failed.length) plan.cancel();
}
},
});
releaseProcessor = batchFactory({
capacity:() => plan.limit,
owner:getOwner,
journalName:'search-release-batch',
queueFailureReason:'assigned but release planning could not be confirmed',
autoMount:false,
claim:item => plan.prepare(item),
queue:async (confirmed, context) => {
const issuePath = 'api/v1/repos/' + confirmed.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(confirmed.number);
const releasePlan = await fetchJson(issuePath + '/release-plan', {
method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'},
body:JSON.stringify({milestone_id:context.milestone_id, due_date:context.due_date || null}),
});
if (releasePlan?.number !== confirmed.number ||
releasePlan?.milestone?.id !== context.milestone_id ||
(context.due_date && releasePlan?.due_date !== context.due_date)) {
throw new Error('Issue release plan was not confirmed.');
}
return 'queued';
},
onProgress:progress => {
get('#confirm-search-release').disabled = progress.status === 'running';
if (progress.status === 'running') {
get('#search-release-summary').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
} else if (progress.status === 'complete') {
get('#cmd-search-action-status').textContent = progress.failed.length ?
progress.queued.length + ' planned · ' + progress.failed.length + ' need retry.' :
progress.queued.length + ' planned for ' + (releaseContext?.milestone_title || 'release') + '.';
get('#resume-search-batch').hidden = releaseProcessor.pending() === 0;
closeReleaseReview();
if (!progress.failed.length) plan.cancel();
}
},
});
function estimateValues() {
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
[input.dataset.searchBatchEstimate, Number(input.value)]
));
}
function closeEstimateReview() {
get('#search-batch-estimate-review').hidden = true;
get('#search-batch-actions').hidden = !plan.snapshot().active;
}
function openEstimateReview(items) {
const planning = todayWork.planning();
const planned = Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
const remaining = Math.max(0, planning.capacity_minutes - planned);
get('#search-batch-estimate-summary').textContent = remaining +
' min remain. Estimate before assigning.';
get('#search-batch-estimate-list').innerHTML = items.map(item => {
const id = String(item.repository || '') + '#' + String(item.number || '');
const estimate = planning.estimates?.[id] || '';
return '<div class="search-batch-estimate-row"><div><span class="small">' + escapeHtml(id) +
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div><label class="small">' +
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
escapeAttribute(estimate) + '" data-search-batch-estimate="' + escapeAttribute(id) +
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes" /> min</label></div>';
}).join('');
get('#search-batch-actions').hidden = true;
get('#search-batch-estimate-review').hidden = false;
document.querySelector('[data-search-batch-estimate]')?.focus();
}
function closeReleaseReview() {
get('#search-release-review').hidden = true;
get('#search-batch-actions').hidden = !plan.snapshot().active;
}
async function openReleaseReview() {
const items = plan.snapshot().items;
const repository = plan.releaseRepository();
if (!items.length) return;
if (!repository) {
get('#search-selection-status').textContent = 'Select issues from one repository to plan a release.';
return;
}
get('#plan-selected-search-results').disabled = true;
get('#search-selection-status').textContent = 'Loading release milestones…';
try {
const milestones = await fetchJson(
'api/v1/repos/' + repository.split('/').map(encodeURIComponent).join('/') + '/milestones',
{headers:{Accept:'application/json'}}
);
const open = (Array.isArray(milestones) ? milestones : []).filter(item => item?.state !== 'closed');
get('#search-release-milestone').innerHTML = '<option value="">Choose milestone</option>' + open.map(item =>
'<option value="' + Number(item.id) + '">' + escapeHtml(item.title) + '</option>'
).join('');
get('#search-release-list').innerHTML = items.map(item => '<div class="search-release-row"><span>' +
escapeHtml(item.repository + ' #' + item.number) + '</span><strong>' +
escapeHtml(item.title || 'Untitled work') + '</strong></div>').join('');
get('#search-release-summary').textContent = items.length + ' issues in ' + repository + '. Assignment starts only after confirmation.';
get('#search-batch-actions').hidden = true;
get('#search-release-review').hidden = false;
get('#search-release-milestone').focus();
} catch (error) {
get('#search-selection-status').textContent = error?.message || 'Milestones could not be loaded. Try again.';
} finally {
get('#plan-selected-search-results').disabled = false;
}
}
get('#select-search-results').addEventListener('click', () => plan.start());
get('#cancel-search-selection').addEventListener('click', () => plan.cancel());
get('#queue-selected-search-results').addEventListener('click', () => processor.run(plan.snapshot().items));
get('#resume-search-batch').addEventListener('click', () => processor.resume());
get('#queue-selected-search-results').addEventListener('click', () => {
const items = plan.snapshot().items;
const capacity = todayWork.planning().capacity_minutes;
if (Number.isInteger(capacity) && capacity > 0) openEstimateReview(items);
else processor.run(items);
});
get('#defer-selected-search-results').addEventListener('click', event => {
const items = plan.snapshot().items;
if (!items.length) return;
laterPicker?.open({ items, confirm:until => () => laterProcessor.run(
items, {}, { until:new Date(until).toISOString() }
) }, event.currentTarget, 'search-batch');
});
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
get('#confirm-search-release').addEventListener('click', () => {
const milestoneSelect = get('#search-release-milestone');
const milestoneId = Number(milestoneSelect.value);
if (!Number.isInteger(milestoneId) || milestoneId < 1) {
get('#search-release-summary').textContent = 'Choose an open milestone.';
return;
}
const selectedOption = milestoneSelect.options?.[milestoneSelect.selectedIndex];
const milestoneTitle = selectedOption?.textContent || selectedOption?.text || 'release';
const dueDay = get('#search-release-due-date').value;
releaseContext = {
milestone_id:milestoneId, milestone_title:milestoneTitle,
due_date:dueDay ? dueDay + 'T23:59:59Z' : null,
};
releaseProcessor.run(plan.snapshot().items, {}, releaseContext);
});
get('#cancel-search-batch-estimates').addEventListener('click', closeEstimateReview);
get('#confirm-search-batch-estimates').addEventListener('click', () =>
processor.run(plan.snapshot().items, estimateValues())
);
get('#resume-search-batch').addEventListener('click', () => {
if (processor.pending()) return processor.resume();
if (laterProcessor.pending()) return laterProcessor.resume();
return releaseProcessor.resume();
});
get('#open-palette').addEventListener('click', () => restore());
get('#cmd-results').addEventListener('change', event => {
const index = event.target?.dataset?.searchSelect;
@@ -117,7 +308,7 @@
if (result) plan.toggle(result);
});
function restore() {
const pending = processor.pending();
const pending = processor.pending() + laterProcessor.pending() + releaseProcessor.pending();
get('#resume-search-batch').hidden = pending === 0;
get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
}
@@ -131,7 +322,7 @@
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
}
return { plan, processor, restore, resultHtml };
return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
};
return createSearchBatchPlan;
+46 -7
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v101';
const CACHE = 'stackchain-dashboard-shell-v102';
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;
@@ -14,14 +14,17 @@ const SHELL = [
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
BASE + 'static/feature-loader.js',
BASE + 'static/conversation-action-hydrator.js',
BASE + 'static/security-center.js',
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/saved-searches.js',
BASE + 'static/search-preview.js',
BASE + 'static/search-defer.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
BASE + 'static/unfiled-draft-sync.js',
BASE + 'static/shared-image-capture.js',
BASE + 'static/draft-filing-session.js',
BASE + 'static/draft-capacity-dialog.js',
@@ -63,7 +66,10 @@ const SHELL = [
BASE + 'static/search-batch-plan.js',
BASE + 'static/conversation.js',
BASE + 'static/comment-actions.js',
BASE + 'static/issue-evidence-review.js',
BASE + 'static/issue-evidence-editor.js',
BASE + 'static/issue-attachment.js',
BASE + 'static/issue-filing-review.js',
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-and-start.js',
@@ -94,6 +100,7 @@ const SHELL = [
BASE + 'static/mobile-composer-viewport.js',
BASE + 'static/mention-composer.js',
BASE + 'static/push-notifications.js',
BASE + 'static/issue-filing-receipt.js',
BASE + 'static/background-issue-sync.js',
];
const OPTIONAL_FEATURES = [
@@ -114,17 +121,28 @@ async function acceptSharedContent(request) {
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
let marker = '';
await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {});
if (images.length > 1) marker = 'multiple';
else if (images.length === 1) {
const image = images[0];
if (!SHARED_IMAGE_TYPES.has(String(image.type || '')) || image.size > MAX_SHARED_IMAGE_BYTES) {
if (images.length > 5) marker = 'multiple';
else if (images.length > 0) {
const supported = images.every(image =>
SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES
);
if (!supported) {
marker = 'unsupported';
} else {
} else if (images.length === 1) {
const image = images[0];
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
filename:String(image.name || 'shared-screenshot').slice(0, 255),
contentType:String(image.type), blob:image,
});
marker = 'image';
} else {
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
attachments:images.map(image => ({
filename:String(image.name || 'shared-screenshot').slice(0, 255),
contentType:String(image.type), blob:image,
})),
});
marker = 'images';
}
}
const target = new URL(BASE, self.location.origin);
@@ -315,7 +333,7 @@ async function flushAndNotify() {
body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' :
needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
tag: 'stackchain-delivery-' + receipt.id,
data: { route: receipt.route },
data: receipt.url ? { url: receipt.url } : { route: receipt.route },
});
}
}
@@ -435,8 +453,29 @@ async function openWorkRoute(route) {
return client.focus();
}
async function openCanonicalIssueUrl(rawUrl) {
let target;
try { target = new URL(rawUrl); } catch (_error) { return; }
if (
target.origin !== self.location.origin
|| !/^\/git\/[^/]+\/[^/]+\/issues\/\d+$/.test(target.pathname)
|| target.search || target.hash
) return;
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
if (!client) return self.clients.openWindow(target.href);
if (client.navigate) await client.navigate(target.href);
return client.focus();
}
self.addEventListener('notificationclick', event => {
const route = String(event.notification.data?.route || '');
const issueUrl = String(event.notification.data?.url || '');
if (issueUrl) {
event.notification.close();
event.waitUntil(openCanonicalIssueUrl(issueUrl));
return;
}
if (event.action === 'protect-today') {
const protectRoute = String(event.notification.data?.protectRoute || route);
if (
+13 -1
View File
@@ -10,11 +10,23 @@
async function consume({marker, store, restore, status = () => {}}) {
if (!marker) return false;
if (marker !== 'image') {
if (!['image', 'images'].includes(marker)) {
status('Share one PNG, JPEG, or WebP screenshot.');
return false;
}
const value = await store?.get(RECORD_ID);
const attachments = Array.isArray(value?.attachments) ? value.attachments : null;
if (marker === 'images') {
if (!attachments?.length || attachments.length > 5 || attachments.some(attachment =>
!attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')))) {
status('The shared screenshots are unavailable. Share them again.');
return false;
}
restore(attachments);
await store.delete(RECORD_ID);
status(attachments.length + ' shared screenshots ready to file with this issue.');
return true;
}
if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
status('The shared screenshot is unavailable. Share it again.');
return false;
+280 -7
View File
@@ -1,3 +1,35 @@
function withFilingEstimate(draft, value) {
draft.estimateMinutes = Number(value) || null;
return draft;
}
function unfiledDraftSummary(item) {
const plan = item.filingPlan || {};
const ready = Boolean(plan.repository);
const startsToday = plan.completionIntent === 'create-and-start';
const evidenceCount = Number(item.attachmentCount || (item.hasAttachment ? 1 : 0));
const evidence = evidenceCount ? `${evidenceCount} screenshot${evidenceCount === 1 ? '' : 's'}` : 'No screenshots';
return {
label:ready ? 'Ready to file' : 'Needs filing',
repository:plan.repository || '', ready,
action:ready ? (startsToday ? 'Review & start' : 'Review & file') : 'Choose repository',
details:[startsToday ? 'Create & start' : 'Create issue',
plan.estimateMinutes ? `${plan.estimateMinutes} min` : '', evidence].filter(Boolean).join(' · '),
};
}
function unfiledSavedMessage(item) {
return item.repository ? 'Saved planned Draft. Resume on any signed-in device.' :
'Saved to Drafts. Choose a repository when you’re ready to file it.';
}
function applyIssueFilingMode(qs, enabled) {
qs('#create-issue-filing').hidden = !enabled;
qs('.create-issue-capture-actions').hidden = false;
qs('#file-new-issue').hidden = enabled;
qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work';
}
function createUnfiledCaptures({
storage,
attachmentStore = null,
@@ -8,6 +40,8 @@ function createUnfiledCaptures({
maxItems = 20,
}) {
const storageKey = 'stackchain.unfiled-issues.v1';
const listeners = new Set();
const onChange = (id, removed) => listeners.forEach(listener => listener(id, removed));
function read() {
try {
@@ -35,6 +69,56 @@ function createUnfiledCaptures({
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
}
function filingPlan(value = {}, remote = false) {
const get = (local, wire) => value?.[remote ? wire : local];
const repository = String(get('repository', 'repository') || '').trim();
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return null;
const labelIds = Array.from(new Set((Array.isArray(get('labelIds', 'label_ids')) ?
get('labelIds', 'label_ids') : []).filter(id => Number.isInteger(id) && id > 0))).slice(0, 20);
const plan = {repository, labelIds};
const milestoneId = Number(get('milestoneId', 'milestone_id'));
if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId;
const dueDate = String(get('dueDate', 'due_date') || '');
if (/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) plan.dueDate = dueDate;
const bounded = (local, wire, limit) => {
const text = String(get(local, wire) || '').trim();
if (text) plan[local] = text.slice(0, limit);
};
bounded('templateName', 'template_name', 80);
bounded('templateId', 'template_id', 80);
bounded('capturedBody', 'captured_body', 10000);
if (get('unassigned', 'unassigned') === true) plan.unassigned = true;
const assignee = String(get('assignee', 'assignee') || '').trim();
if (!plan.unassigned && /^[A-Za-z0-9_.-]+$/.test(assignee)) {
plan.assignee = assignee;
bounded('assigneeName', 'assignee_name', 255);
}
const estimateMinutes = Number(get('estimateMinutes', 'estimate_minutes'));
if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) {
plan.estimateMinutes = estimateMinutes;
}
const completionIntent = String(get('completionIntent', 'completion_intent') || '');
if (['create', 'create-and-start'].includes(completionIntent)) plan.completionIntent = completionIntent;
return plan;
}
function exportFilingPlan(plan) {
if (!plan) return null;
return {
repository:plan.repository, label_ids:plan.labelIds,
...(plan.milestoneId ? {milestone_id:plan.milestoneId} : {}),
...(plan.dueDate ? {due_date:plan.dueDate} : {}),
...(plan.templateName ? {template_name:plan.templateName} : {}),
...(plan.templateId ? {template_id:plan.templateId} : {}),
...(plan.capturedBody ? {captured_body:plan.capturedBody} : {}),
...(plan.unassigned ? {unassigned:true} : {}),
...(plan.assignee ? {assignee:plan.assignee} : {}),
...(plan.assigneeName ? {assignee_name:plan.assigneeName} : {}),
...(plan.estimateMinutes ? {estimate_minutes:plan.estimateMinutes} : {}),
...(plan.completionIntent ? {completion_intent:plan.completionIntent} : {}),
};
}
function prepare(note) {
const title = String(note?.title || '').trim().slice(0, 255);
const body = String(note?.body || '').trim().slice(0, 10000);
@@ -42,19 +126,51 @@ function createUnfiledCaptures({
const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
const attachment = note?.attachment;
const attachments = note?.attachments;
if (Array.isArray(attachments) && attachments.length > 5) {
throw new Error('You can attach up to 5 screenshots.');
}
const validAttachments = Array.isArray(attachments) && attachments.length > 0 &&
attachments.every(value => value?.blob && value?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(value?.contentType || '')));
if (attachments && !validAttachments) {
throw new Error('One or more screenshots are unavailable. Choose them again before saving.');
}
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
if (hasAttachment && !attachmentStore) {
if ((hasAttachment || validAttachments) && !attachmentStore) {
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
}
return {title, body, ownerLogin, attachment, hasAttachment};
const seenBlockers = new Set();
const blockers = (Array.isArray(note?.blockers) ? note.blockers : []).reduce((items, blocker) => {
const repository = String(blocker?.repository || '').trim();
const number = Number(blocker?.number);
const key = repository + '#' + number;
if (items.length >= 5 || seenBlockers.has(key) ||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
!Number.isInteger(number) || number < 1) return items;
seenBlockers.add(key);
items.push({repository, number,
title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)});
return items;
}, []);
return {
title, body, ownerLogin, attachment, attachments, blockers,
filingPlan:filingPlan(note),
hasAttachment:hasAttachment || validAttachments,
attachmentCount:validAttachments ? attachments.length : (hasAttachment ? 1 : 0),
};
}
function persist(prepared, existing, removed = null) {
const item = {
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
savedAt:Number(now()), ...(prepared.hasAttachment ? {
hasAttachment:true, attachmentCount:prepared.attachmentCount,
} : {}), ...(prepared.blockers.length ? {
blockers:prepared.blockers, blockerCount:prepared.blockers.length,
} : {}), ...(prepared.filingPlan ? {filingPlan:prepared.filingPlan} : {}),
};
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
const writeItems = () => {
@@ -63,10 +179,17 @@ function createUnfiledCaptures({
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
onChange(item.id, false);
return item;
};
const stage = prepared.hasAttachment
? Promise.resolve(attachmentStore.put(item.id, {
? Promise.resolve(attachmentStore.put(item.id, prepared.attachments ? {
attachments:prepared.attachments.map(value => ({
filename:String(value.filename).slice(0, 255),
contentType:String(value.contentType), blob:value.blob,
...(String(value.note || '').trim() ? {note:String(value.note).trim().slice(0, 240)} : {}),
})),
} : {
filename:String(prepared.attachment.filename).slice(0, 255),
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
}))
@@ -113,6 +236,7 @@ function createUnfiledCaptures({
const removed = items.find(item => item.id === id);
if (!removed?.hasAttachment) {
write(remaining);
onChange(id, true);
return true;
}
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
@@ -122,6 +246,7 @@ function createUnfiledCaptures({
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
throw error;
}
onChange(id, true);
return true;
})
);
@@ -133,10 +258,18 @@ function createUnfiledCaptures({
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
throw new Error('Reconnect with the account that saved this capture.');
}
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
const draft = {...(item.filingPlan || {repository:'', labelIds:[]}), title:item.title, body:item.body,
...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {})};
if (!item.hasAttachment) return draft;
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
if (Array.isArray(attachment?.attachments) && attachment.attachments.length) {
if (attachment.attachments.length > 5 || attachment.attachments.some(value =>
!value?.blob || !value?.filename || !value?.contentType)) {
throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
}
return {...draft, attachments:attachment.attachments};
}
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
}
@@ -146,7 +279,147 @@ function createUnfiledCaptures({
function completeResume(id) { return discard(id); }
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
function encodeBytes(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
function decodeBytes(value, contentType) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return new Blob([bytes], {type:contentType});
}
async function exportOwned(login) {
const owner = String(login || '').trim();
const exported = [];
for (const item of read().filter(candidate => candidate.ownerLogin === owner)) {
let evidence = [];
if (item.hasAttachment) {
const stored = await attachmentStore?.get(item.id);
const attachments = Array.isArray(stored?.attachments) ? stored.attachments :
(stored?.blob ? [stored] : []);
if (attachments.length !== Number(item.attachmentCount || 1)) {
throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
}
evidence = await Promise.all(attachments.map(async attachment => ({
filename:attachment.filename,
content_type:attachment.contentType,
...(attachment.note ? {note:attachment.note} : {}),
data:encodeBytes(await attachment.blob.arrayBuffer()),
})));
}
exported.push({
id:item.id, title:item.title, body:item.body, saved_at:Number(item.savedAt),
...(item.filingPlan ? {filing_plan:exportFilingPlan(item.filingPlan)} : {}),
...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {}),
...(evidence.length ? {evidence} : {}),
});
}
return exported;
}
async function mergeRemote(drafts, login) {
const ownerLogin = String(login || '').trim();
if (!ownerLogin || !Array.isArray(drafts)) return 0;
const existing = read();
const known = new Set(existing.map(item => item.id));
const imported = [];
for (const remote of drafts) {
if (!remote || known.has(remote.id) || imported.length + existing.length >= maxItems) continue;
const evidence = Array.isArray(remote.evidence) ? remote.evidence : [];
if (evidence.length && !attachmentStore) continue;
const plan = filingPlan(remote.filing_plan, true);
const item = {
id:String(remote.id), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''),
savedAt:Number(remote.saved_at),
...(plan ? {filingPlan:plan} : {}),
...(Array.isArray(remote.blockers) && remote.blockers.length ? {
blockers:remote.blockers, blockerCount:remote.blockers.length,
} : {}),
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
};
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
if (evidence.length) {
await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({
filename:String(entry.filename), contentType:String(entry.content_type),
blob:decodeBytes(String(entry.data), String(entry.content_type)),
...(entry.note ? {note:String(entry.note)} : {}),
}))});
}
known.add(item.id);
imported.push(item);
}
if (imported.length) write(imported.concat(existing));
return imported.length;
}
async function reconcileRemote(drafts, login) {
const ownerLogin = String(login || '').trim();
if (!ownerLogin || !Array.isArray(drafts)) return 0;
const existing = read();
const retained = existing.filter(item => item.ownerLogin !== ownerLogin);
const reconciled = [];
const stagedEvidence = new Map();
for (const remote of drafts) {
if (!remote || reconciled.length + retained.length >= maxItems) continue;
const evidence = Array.isArray(remote.evidence) ? remote.evidence : [];
if (evidence.length && !attachmentStore) {
throw new Error('The synchronized screenshots cannot be stored on this device.');
}
const plan = filingPlan(remote.filing_plan, true);
const item = {
id:String(remote.id || ''), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''),
savedAt:Number(remote.saved_at),
...(plan ? {filingPlan:plan} : {}),
...(Array.isArray(remote.blockers) && remote.blockers.length ? {
blockers:remote.blockers, blockerCount:remote.blockers.length,
} : {}),
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
};
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
if (evidence.length) {
stagedEvidence.set(item.id, {attachments:evidence.map(entry => ({
filename:String(entry.filename), contentType:String(entry.content_type),
blob:decodeBytes(String(entry.data), String(entry.content_type)),
...(entry.note ? {note:String(entry.note)} : {}),
}))});
}
reconciled.push(item);
}
const evidenceIds = new Set(reconciled.filter(item => item.hasAttachment).map(item => item.id));
const affectedIds = new Set(stagedEvidence.keys());
for (const item of existing) if (item.ownerLogin === ownerLogin && item.hasAttachment) affectedIds.add(item.id);
const previousEvidence = new Map();
for (const id of affectedIds) previousEvidence.set(id, await attachmentStore?.get(id));
try {
for (const [id, value] of stagedEvidence) await attachmentStore.put(id, value);
for (const item of existing) {
if (item.ownerLogin === ownerLogin && item.hasAttachment && !evidenceIds.has(item.id)) {
await attachmentStore?.delete(item.id);
}
}
write(reconciled.concat(retained));
} catch (error) {
for (const [id, value] of previousEvidence) {
await Promise.resolve(value ? attachmentStore?.put(id, value) : attachmentStore?.delete(id)).catch(() => {});
}
throw error;
}
return reconciled.length;
}
return {list, capacity, save, replaceOldest, discard, resume, completeResume,
exportOwned, mergeRemote, reconcileRemote, currentLogin:getCurrentLogin,
subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createUnfiledCaptures;
module.exports.summary = unfiledDraftSummary;
}
+222
View File
@@ -0,0 +1,222 @@
(function (root, factory) {
const createUnfiledDraftSync = factory();
if (typeof module === 'object' && module.exports) module.exports = createUnfiledDraftSync;
if (root) root.createUnfiledDraftSync = createUnfiledDraftSync;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function createUnfiledDraftSync(options, mountedFetch) {
if (mountedFetch) return createUnfiledDraftSync.mount(options, mountedFetch);
const {
captures, fetchJson, storage, getLogin = () => '', onState = () => {}, maxConflictRetries = 2,
createConflictId = () => 'conflict-' + (globalThis.crypto?.randomUUID?.() || Date.now().toString(36)),
} = options;
const key = 'stackchain.unfiled-draft-sync.v1';
let inFlight = null;
let rerun = false;
let queuedOwner = '';
function read(login) {
try {
const all = JSON.parse(storage?.getItem(key) || '{}');
const state = all?.[login];
return {
all:all && typeof all === 'object' ? all : {},
known:new Set(Array.isArray(state?.known) ? state.known : []),
deleted:new Set(Array.isArray(state?.deleted) ? state.deleted : []),
base:state?.base && typeof state.base === 'object' ? state.base : {},
};
} catch (_error) {
return {all:{}, known:new Set(), deleted:new Set(), base:{}};
}
}
function write(login, state) {
state.all[login] = {known:[...state.known], deleted:[...state.deleted], base:state.base};
storage?.setItem(key, JSON.stringify(state.all));
}
function publish(status, message, details = {}) {
onState({status, ...(message ? {message} : {}), ...details});
const visibleStatus = globalThis.document?.querySelector?.('#my-work-action-status');
if (message && visibleStatus) visibleStatus.textContent = message;
}
function sameDrafts(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
async function fingerprint(draft) {
if (!draft) return null;
const text = JSON.stringify(draft);
if (globalThis.crypto?.subtle && globalThis.TextEncoder) {
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('');
}
let first = 2166136261, second = 3335557771;
for (let index = 0; index < text.length; index += 1) {
first = Math.imul(first ^ text.charCodeAt(index), 16777619);
second = Math.imul(second ^ text.charCodeAt(index), 2246822519);
}
return text.length + ':' + (first >>> 0).toString(16) + (second >>> 0).toString(16);
}
async function reconcile(captures, drafts, owner) {
if (typeof captures.reconcileRemote === 'function') {
await captures.reconcileRemote(drafts, owner);
return;
}
const finalIds = new Set(drafts.map(item => item.id));
for (const item of await captures.exportOwned(owner)) {
if (!finalIds.has(item.id)) await captures.discard(item.id);
}
await captures.mergeRemote(drafts, owner);
}
async function runSync(login) {
const owner = String(login || '').trim();
if (!owner) return false;
publish('syncing', 'Syncing Drafts…');
const state = read(owner);
try {
const remote = await fetchJson('api/v1/unfiled-drafts');
const remoteDrafts = Array.isArray(remote?.drafts) ? remote.drafts : [];
const local = await captures.exportOwned(owner);
const merged = [];
const localById = new Map(local.map(item => [item.id, item]));
const remoteById = new Map(remoteDrafts.map(item => [item.id, item]));
const ids = new Set([...state.known, ...localById.keys(), ...remoteById.keys()]);
let preservedConflict = false;
const usedIds = new Set(ids);
for (const id of ids) {
const localDraft = state.deleted.has(id) ? null : localById.get(id);
const remoteDraft = remoteById.get(id);
const localHash = await fingerprint(localDraft);
const remoteHash = await fingerprint(remoteDraft);
const baseHash = state.base[id] || null;
const localChanged = localHash !== baseHash;
const remoteChanged = remoteHash !== baseHash;
if (localChanged && remoteChanged && localHash !== remoteHash) {
preservedConflict = true;
if (remoteDraft) merged.push(remoteDraft);
if (localDraft) {
let conflictId = String(createConflictId()).slice(0, 100);
for (let suffix = 2; usedIds.has(conflictId); suffix += 1) {
conflictId = (String(createConflictId()).slice(0, 95) + '-' + suffix).slice(0, 100);
}
usedIds.add(conflictId);
const suffix = ' (conflict copy)';
merged.push({...localDraft, id:conflictId,
title:(String(localDraft.title).slice(0, 255 - suffix.length) + suffix)});
}
continue;
}
const selected = localChanged ? localDraft : (remoteChanged ? remoteDraft : (localDraft || remoteDraft));
if (selected) merged.push(selected);
}
merged.sort((left, right) => Number(right.saved_at) - Number(left.saved_at));
let finalSnapshot = remote;
if (!sameDrafts(merged, remoteDrafts)) {
finalSnapshot = await fetchJson('api/v1/unfiled-drafts', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify({revision:Number(remote.revision || 0), drafts:merged}),
});
}
await reconcile(captures, finalSnapshot.drafts || [], owner);
state.known = new Set((finalSnapshot.drafts || []).map(item => item.id));
state.deleted.clear();
state.base = {};
for (const item of finalSnapshot.drafts || []) state.base[item.id] = await fingerprint(item);
write(owner, state);
publish(preservedConflict ? 'conflict-preserved' : 'ready', preservedConflict
? 'Draft changed on two devices · both versions were saved.'
: 'Drafts synced across devices.');
return true;
} catch (error) {
if (error?.status !== 409) {
publish('pending', 'Saved on this device · sync pending');
}
throw error;
}
}
function sync(login) {
const owner = String(login || '').trim();
if (!owner) return Promise.resolve(false);
if (inFlight) {
rerun = true;
queuedOwner = owner;
return inFlight;
}
inFlight = (async () => {
let result = false;
let conflictRetries = 0;
let activeOwner = owner;
do {
rerun = false;
try {
result = await runSync(activeOwner);
} catch (error) {
if (error?.status !== 409) throw error;
if (conflictRetries >= maxConflictRetries) {
publish('conflict', 'Draft sync needs attention. Retry when your devices finish saving.', {retryable:true});
throw error;
}
conflictRetries += 1;
rerun = true;
}
if (queuedOwner) {
if (queuedOwner !== activeOwner) conflictRetries = 0;
activeOwner = queuedOwner;
queuedOwner = '';
rerun = true;
}
} while (rerun);
return result;
})().finally(() => { inFlight = null; });
return inFlight;
}
async function remove(id, login) {
const owner = String(login || '').trim();
if (!owner) return false;
const state = read(owner);
state.deleted.add(String(id));
write(owner, state);
await captures.discard(String(id));
return sync(owner);
}
function retry() {
return sync(getLogin());
}
captures.subscribe?.((id, removed) => {
const login = getLogin();
if (login) void (removed ? remove(id, login) : sync(login)).catch(() => {});
});
return {sync, remove, retry};
}
createUnfiledDraftSync.mount = (captures, fetchJson) => {
const getLogin = captures.currentLogin;
const retryButton = globalThis.document?.querySelector?.('#retry-unfiled-draft-sync');
const controller = createUnfiledDraftSync({
captures, fetchJson, storage:globalThis.localStorage, getLogin,
onState:state => { if (retryButton) retryButton.hidden = !state.retryable; },
});
retryButton?.addEventListener?.('click', async () => {
retryButton.disabled = true;
try { await controller.retry(); } catch (_error) { /* state remains actionable */ }
finally { retryButton.disabled = false; }
});
const connect = () => {
const login = getLogin();
if (login) void controller.sync(login).catch(() => {});
else globalThis.setTimeout(connect, 250);
};
globalThis.setTimeout(connect, 0);
globalThis.addEventListener?.('online', connect);
return controller;
};
return createUnfiledDraftSync;
});
+7 -5
View File
@@ -6,7 +6,7 @@
'use strict';
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
const queueFilters = ['today', 'agenda', 'attention', 'update', 'later', 'draft'];
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
function positiveInteger(value) {
const number = Number(value);
@@ -27,7 +27,7 @@
const notificationId = positiveInteger(parts[3]);
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
}
if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
const number = positiveInteger(parts[5]);
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
@@ -38,11 +38,12 @@
const notificationId = positiveInteger(item.notification_id);
return notificationId ? '#/my-work/update/' + notificationId : '';
}
if (!['issue', 'pull', 'review'].includes(item?.kind)) return '';
const kind = item?.is_filed && !item?.is_assigned ? 'filed' : item?.kind;
if (!['issue', 'filed', 'pull', 'review'].includes(kind)) return '';
const repository = String(item.repository || '').split('/');
const number = positiveInteger(item.number);
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
return '#/my-work/' + item.kind + '/' + repository.join('/') + '/' + number;
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number;
}
function sameRoute(item, route) {
@@ -50,7 +51,8 @@
if (route.kind === 'update') {
return Number(item.notification_id) === route.notification_id;
}
const itemKind = item.is_review ? 'review' : item.kind;
const itemKind = item.is_review ? 'review' :
(item.is_filed && !item.is_assigned ? 'filed' : item.kind);
return itemKind === route.kind && item.repository === route.repository &&
Number(item.number) === route.number;
}
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
playwright==1.55.0
+8 -5
View File
@@ -22,18 +22,21 @@ COMMONJS_BROWSER_BRANCH = re.compile(
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
"issue-capture": ("static/create-issue-sheet.js", "static/update-follow-up.js"),
"issue-capture": (
"static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js",
),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",),
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/commands.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
"static/assign-and-start.js", "static/queue-today.js",
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
"static/search-batch-plan.js",
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
"static/issue-attachment.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
),
}
CACHE_DECLARATION = re.compile(
+143 -8
View File
@@ -367,6 +367,7 @@ async def repository_access(repository: str) -> dict | None:
WORK_SEARCHES = {
"issue": ("assigned=true", "issues", None),
"filed": ("created=true", "issues", "created_by_me"),
"pull": ("assigned=true", "pulls", "assigned_to_me"),
"review": ("review_requested=true", "pulls", "review_requested"),
}
@@ -738,8 +739,20 @@ def _page_metadata(result: dict) -> dict:
async def issues() -> WorkItems:
result = await work_page("issue")
return WorkItems(result["items"], {"issue": _page_metadata(result)})
assigned, filed = await asyncio.gather(work_page("issue"), work_page("filed"))
merged: dict[int, dict] = {}
for result in (assigned, filed):
for issue in result["items"]:
identity = issue.get("id")
if identity not in merged:
merged[identity] = {**issue, "work_reasons": []}
for reason in issue.get("work_reasons", []):
if reason not in merged[identity]["work_reasons"]:
merged[identity]["work_reasons"].append(reason)
return WorkItems(
list(merged.values()),
{"issue": _page_metadata(assigned), "filed": _page_metadata(filed)},
)
def _safe_web_url(value: Any) -> str:
@@ -1400,6 +1413,51 @@ async def repo_labels(repository: str) -> list[dict]:
]
async def repo_issue_templates(repository: str) -> list[dict]:
"""Return a small allowlisted view of repository issue templates."""
response = await _get_client().get(
f"/api/v1/repos/{repository}/issue_templates", headers=_auth()
)
response.raise_for_status()
payload = response.json()
if payload is None:
return []
if not isinstance(payload, list):
raise ValueError("Gitea issue templates response was not a list")
templates = []
for item in payload:
if len(templates) >= 20:
break
if not isinstance(item, dict):
continue
name = item.get("name")
if not isinstance(name, str) or not name.strip():
continue
name = name.strip()[:80]
identifier = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:80]
labels = []
for label in item.get("labels", []) if isinstance(item.get("labels"), list) else []:
if not isinstance(label, str):
continue
clean = label.strip()[:80]
if clean and clean not in labels:
labels.append(clean)
if len(labels) >= 20:
break
scalar = lambda key, limit: (
item.get(key, "")[:limit] if isinstance(item.get(key), str) else ""
)
templates.append({
"id": identifier or f"template-{len(templates) + 1}",
"name": name,
"about": scalar("about", 240).strip(),
"title": scalar("title", 255),
"body": scalar("content", 9000).strip(),
"labels": labels,
})
return templates
async def repo_milestones(repository: str) -> list[dict]:
response = await _get_client().get(
f"/api/v1/repos/{repository}/milestones",
@@ -1424,12 +1482,14 @@ async def create_issue(
repository: str,
title: str,
body: str,
assignee: str,
assignee: str | None,
label_ids: list[int] | None = None,
milestone_id: int | None = None,
due_date: str | None = None,
) -> dict:
payload: dict = {"title": title, "body": body, "assignee": assignee}
payload: dict = {"title": title, "body": body}
if assignee is not None:
payload["assignee"] = assignee
if label_ids:
payload["labels"] = label_ids
if milestone_id is not None:
@@ -1452,8 +1512,11 @@ async def create_issue(
for item in assignees
if isinstance(item, dict) and isinstance(item.get("login"), str)
]
if assignee not in confirmed_assignees:
raise ValueError("Gitea did not confirm issue self-assignment")
expected_assignees = [assignee] if assignee is not None else []
if confirmed_assignees != expected_assignees:
raise ValueError(
"Gitea did not confirm self-assignment or exact issue assignment"
)
labels_value = issue.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
milestone_value = issue.get("milestone")
@@ -1969,6 +2032,58 @@ async def update_assigned_issue_milestone(
}
async def update_assigned_issue_release_plan(
repository: str, number: int, milestone_id: int, due_date: str | None
) -> dict:
path = f"repos/{repository}/issues/{number}"
login, issue = await _current_login_and_target(path)
if (
issue.get("state") != "open"
or isinstance(issue.get("pull_request"), dict)
or not _login_in_users(login, issue.get("assignees"))
):
raise IssueNotAvailableError("assigned issue not found")
selected = next(
(item for item in await repo_milestones(repository) if item["id"] == milestone_id),
None,
)
if selected is None:
raise ValueError("Unknown open repository milestone")
payload: dict = {"milestone": milestone_id}
if due_date is not None:
payload["due_date"] = due_date
response = await _get_client().patch(
f"/api/v1/{path}", headers=_auth(), json=payload
)
response.raise_for_status()
confirmed = response.json()
milestone_value = confirmed.get("milestone") if isinstance(confirmed, dict) else None
normalized = (
{"id": milestone_value["id"], "title": milestone_value["title"]}
if isinstance(milestone_value, dict)
and isinstance(milestone_value.get("id"), int)
and isinstance(milestone_value.get("title"), str)
else None
)
expected_due_date = due_date if due_date is not None else issue.get("due_date")
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or normalized != selected
or confirmed.get("due_date") != expected_due_date
):
raise ValueError("Gitea did not confirm the issue release plan")
return {
"repository": repository,
"number": number,
"state": confirmed.get("state", "open"),
"milestone": normalized,
"due_date": confirmed.get("due_date"),
}
async def issue_conversation_page(
repository: str,
number: int,
@@ -2245,19 +2360,22 @@ async def resolve_work_route(
"title": detail.get("title", ""),
"url": detail.get("url", ""),
}
if repository is None or number is None or kind not in {"issue", "pull", "review"}:
if repository is None or number is None or kind not in {"issue", "filed", "pull", "review"}:
raise WorkRouteUnavailableError("Work identity is missing")
target_path = "issues" if kind == "issue" else "pulls"
target_path = "issues" if kind in {"issue", "filed"} else "pulls"
login, target = await _current_login_and_target(
f"repos/{repository}/{target_path}/{number}"
)
assigned = _login_in_users(login, target.get("assignees"))
requested = _login_in_users(login, target.get("requested_reviewers"))
author = target.get("user") if isinstance(target.get("user"), dict) else {}
authored = author.get("login") == login
eligible = (
target.get("state") == "open"
and (
(kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned)
or (kind == "filed" and not isinstance(target.get("pull_request"), dict) and authored)
or (kind == "pull" and assigned)
or (kind == "review" and requested)
)
@@ -2272,6 +2390,11 @@ async def resolve_work_route(
"state": "open",
"url": _safe_web_url(target.get("html_url")),
**({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}),
**({
"is_filed": True,
"is_assigned": assigned,
"work_reasons": ["created_by_me"],
} if kind == "filed" else {}),
}
@@ -2286,6 +2409,18 @@ async def is_assigned_issue(repository: str, number: int) -> bool:
)
async def is_authored_issue(repository: str, number: int) -> bool:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
)
author = issue.get("user") if isinstance(issue.get("user"), dict) else {}
return (
issue.get("state") == "open"
and not isinstance(issue.get("pull_request"), dict)
and author.get("login") == login
)
async def pull_requests() -> WorkItems:
assigned, review_requested = await asyncio.gather(
work_page("pull"),
+360 -9
View File
@@ -57,6 +57,8 @@ from src.push_notifications import (
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
from src.push_subscription_store import PushSubscriptionStore
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
from src.suggestion_engine import compute
from src.later_store import LaterStore
@@ -617,6 +619,82 @@ class LaterOperationBatch(BaseModel):
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
class SavedSearchView(BaseModel):
id: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
name: str = Field(min_length=1, max_length=60)
query: str = Field(min_length=2, max_length=200)
kind: Literal["all", "issue", "pull"] = "all"
state: Literal["all", "open", "closed"] = "all"
repository: str = Field(default="", max_length=200)
class SavedSearchCollection(BaseModel):
revision: int = Field(ge=0)
views: list[SavedSearchView] = Field(max_length=20)
class UnfiledDraftBlocker(BaseModel):
repository: str = Field(min_length=3, max_length=200)
number: int = Field(ge=1)
title: str = Field(default="", max_length=255)
class UnfiledDraftEvidence(BaseModel):
filename: str = Field(min_length=1, max_length=255)
content_type: Literal["image/png", "image/jpeg", "image/webp"]
note: str = Field(default="", max_length=240)
data: str = Field(max_length=14_000_000)
class UnfiledDraftFilingPlan(BaseModel):
repository: str = Field(
min_length=3,
max_length=200,
pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
)
label_ids: list[PositiveInt] = Field(default_factory=list, max_length=20)
milestone_id: PositiveInt | None = None
due_date: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$", max_length=10)
template_name: str | None = Field(default=None, min_length=1, max_length=80)
template_id: str | None = Field(default=None, min_length=1, max_length=80)
captured_body: str | None = Field(default=None, min_length=1, max_length=10_000)
assignee: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.-]+$", max_length=255)
assignee_name: str | None = Field(default=None, min_length=1, max_length=255)
unassigned: bool = False
estimate_minutes: int | None = Field(default=None, ge=5, le=1440)
completion_intent: Literal["create", "create-and-start"] | None = None
@field_validator("due_date")
@classmethod
def validate_filing_due_date(cls, value: str | None) -> str | None:
if value is not None:
datetime.strptime(value, "%Y-%m-%d")
return value
@model_validator(mode="after")
def require_consistent_owner_intent(self):
if self.unassigned and self.assignee is not None:
raise ValueError("assignee and unassigned cannot be requested together")
if self.assignee is None and self.assignee_name is not None:
raise ValueError("assignee name requires an assignee")
return self
class UnfiledDraft(BaseModel):
id: str = Field(min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
saved_at: int = Field(ge=0)
filing_plan: UnfiledDraftFilingPlan | None = None
blockers: list[UnfiledDraftBlocker] = Field(default_factory=list, max_length=5)
evidence: list[UnfiledDraftEvidence] = Field(default_factory=list, max_length=5)
class UnfiledDraftCollection(BaseModel):
revision: int = Field(ge=0)
drafts: list[UnfiledDraft] = Field(max_length=20)
class NotificationLaterRequest(BaseModel):
wake_at: str = Field(min_length=1, max_length=100)
@@ -699,6 +777,10 @@ def _validate_binary_attachment(filename: str, content_type: str, content: bytes
class IssueCreation(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
unassigned: bool = False
assignee: str | None = Field(
default=None, pattern=r"^[A-Za-z0-9_.-]+$", max_length=255
)
label_ids: list[int] = Field(default_factory=list, max_length=20)
milestone_id: PositiveInt | None = None
due_date: str | None = Field(
@@ -720,6 +802,12 @@ class IssueCreation(BaseModel):
def strip_issue_body(cls, value: str) -> str:
return value.strip()
@model_validator(mode="after")
def require_one_owner_intent(self):
if self.unassigned and self.assignee is not None:
raise ValueError("assignee and unassigned cannot be requested together")
return self
@field_validator("due_date")
@classmethod
def validate_due_date(cls, value: str | None) -> str | None:
@@ -763,6 +851,15 @@ class IssueMilestoneUpdate(BaseModel):
milestone_id: PositiveInt | None = None
class IssueReleasePlanUpdate(BaseModel):
milestone_id: PositiveInt
due_date: str | None = Field(
default=None,
pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$",
max_length=20,
)
class IssueHandoff(BaseModel):
recipient: str = Field(
min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$"
@@ -924,6 +1021,7 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
id=i["id"], number=i["number"], title=i["title"], state=i["state"],
labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)],
assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, dict)],
work_reasons=[reason for reason in (i.get("work_reasons") or []) if reason == "created_by_me"],
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
updated_at=i.get("updated_at") or "",
due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None,
@@ -963,13 +1061,14 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
if stream == "issue":
if stream in {"issue", "filed"}:
return [
Issue(
id=item["id"], number=item["number"], title=item["title"],
state=item["state"],
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
work_reasons=[reason for reason in (item.get("work_reasons") or []) if reason == "created_by_me"],
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
updated_at=item.get("updated_at") or "",
due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None,
@@ -1174,7 +1273,7 @@ async def require_operator_session(request: Request, call_next):
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
path = dashboard_auth.application_path(request)
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@@ -2105,6 +2204,18 @@ def _later_store() -> LaterStore:
)
def _saved_search_store() -> SavedSearchStore:
return SavedSearchStore(
os.getenv("STACKCHAIN_SAVED_SEARCH_DB", str(_state_dir / "saved-searches.sqlite3"))
)
def _unfiled_draft_store() -> UnfiledDraftStore:
return UnfiledDraftStore(
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3"))
)
async def _confirmed_login() -> str:
try:
user = await asyncio.wait_for(
@@ -2120,6 +2231,92 @@ async def _confirmed_login() -> str:
return login.strip().lower()
@app.get("/api/v1/saved-searches")
async def get_saved_searches(response: Response):
login = await _confirmed_login()
try:
snapshot = await asyncio.to_thread(_saved_search_store().get, login)
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Saved Search synchronization is unavailable",
headers={"Retry-After": "1"},
)
response.headers["Cache-Control"] = "no-store"
return snapshot
@app.put("/api/v1/saved-searches")
async def replace_saved_searches(payload: SavedSearchCollection):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_saved_search_store().replace,
login,
payload.revision,
[view.model_dump() for view in payload.views],
)
except SavedSearchConflict as exc:
raise HTTPException(
status_code=409,
detail={
"message": "Saved searches changed on another device.",
"snapshot": exc.snapshot,
},
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Saved Search synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/unfiled-drafts")
async def get_unfiled_drafts(response: Response):
login = await _confirmed_login()
try:
snapshot = await asyncio.to_thread(_unfiled_draft_store().get, login)
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Draft synchronization is unavailable",
headers={"Retry-After": "1"},
)
response.headers["Cache-Control"] = "no-store"
return snapshot
@app.put("/api/v1/unfiled-drafts")
async def replace_unfiled_drafts(payload: UnfiledDraftCollection):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_unfiled_draft_store().replace,
login,
payload.revision,
[draft.model_dump() for draft in payload.drafts],
)
except UnfiledDraftConflict as exc:
raise HTTPException(
status_code=409,
detail={
"message": "Drafts changed on another device.",
"snapshot": exc.snapshot,
},
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Draft synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/today")
async def get_today_plan():
login = await _confirmed_login()
@@ -2858,7 +3055,7 @@ async def comment_on_global_search_preview(
@app.get("/api/v1/work-route")
async def resolve_work_route(
kind: Literal["issue", "pull", "review", "update"] = Query(),
kind: Literal["issue", "filed", "pull", "review", "update"] = Query(),
repository: str | None = Query(default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"),
number: int | None = Query(default=None, gt=0),
notification_id: int | None = Query(default=None, gt=0),
@@ -2885,7 +3082,7 @@ async def resolve_work_route(
@app.get("/api/v1/work/{stream}")
async def paged_work(
stream: Literal["issue", "pull", "review"],
stream: Literal["issue", "filed", "pull", "review"],
page: int = Query(ge=2, le=100),
) -> JSONResponse:
try:
@@ -4190,13 +4387,24 @@ async def handoff_assigned_issue(
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
async def assigned_issue_detail(
owner: str,
repo: str,
number: int = PathParam(gt=0),
access: Literal["assigned", "filed"] = Query(default="assigned"),
):
repository = f"{owner}/{repo}"
async def load_assigned_issue():
if not await gitea_proxy.is_assigned_issue(repository, number):
authorized = await (
gitea_proxy.is_authored_issue(repository, number)
if access == "filed"
else gitea_proxy.is_assigned_issue(repository, number)
)
if not authorized:
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.issue_detail(repository, number)
detail = await gitea_proxy.issue_detail(repository, number)
return {**detail, "read_only": True} if access == "filed" else detail
try:
return await asyncio.wait_for(
@@ -4374,6 +4582,85 @@ async def update_assigned_issue_milestone(
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/release-plan")
async def update_assigned_issue_release_plan(
update: IssueReleasePlanUpdate,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.update_assigned_issue_release_plan(
repository, number, update.milestone_id, update.due_date
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
raise HTTPException(status_code=404, detail="Assigned issue not found")
except ValueError as exc:
if str(exc) == "Unknown open repository milestone":
raise HTTPException(status_code=422, detail=str(exc))
return JSONResponse(
{"error": "The release plan could not be confirmed. Your selection is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The release plan could not be updated. Your selection is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/issue-filing-metadata")
async def repository_issue_filing_metadata(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_metadata():
if await gitea_proxy.repository_access(repository) is None:
raise HTTPException(status_code=404, detail="Repository not found")
names = ("labels", "milestones", "templates")
loaders = (
gitea_proxy.repo_labels(repository),
gitea_proxy.repo_milestones(repository),
gitea_proxy.repo_issue_templates(repository),
)
results = await asyncio.gather(*loaders, return_exceptions=True)
messages = {
"labels": "Labels could not be loaded.",
"milestones": "Milestones could not be loaded.",
"templates": "Issue types could not be loaded.",
}
payload = {}
for name, result in zip(names, results):
if isinstance(result, BaseException):
payload[name] = {
"available": False,
"items": [],
"error": messages[name],
}
else:
payload[name] = {"available": True, "items": result}
return JSONResponse(payload, headers={"Cache-Control": "no-store"})
try:
return await asyncio.wait_for(
load_metadata(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Issue filing details could not be loaded. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1", "Cache-Control": "no-store"},
)
@app.get("/api/v1/repos/{owner}/{repo}/milestones")
async def repository_milestones(owner: str, repo: str):
repository = f"{owner}/{repo}"
@@ -4418,6 +4705,57 @@ async def repository_labels(owner: str, repo: str):
)
@app.get("/api/v1/repos/{owner}/{repo}/issue-templates")
async def repository_issue_templates(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_templates():
if await gitea_proxy.repository_access(repository) is None:
raise HTTPException(status_code=404, detail="Repository not found")
return await gitea_proxy.repo_issue_templates(repository)
try:
return await asyncio.wait_for(load_templates(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Issue types could not be loaded. Blank issue creation is still available."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/repos/{owner}/{repo}/issue-assignees")
async def repository_issue_assignees(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_assignees():
if await gitea_proxy.repository_access(repository) is None:
raise HTTPException(status_code=404, detail="Repository not found")
candidates = [
item
for item in await gitea_proxy.issue_handoff_candidates(repository)
if isinstance(item, dict)
and isinstance(item.get("login"), str)
and re.fullmatch(r"[A-Za-z0-9_.-]+", item["login"])
][:25]
return JSONResponse(candidates, headers={"Cache-Control": "no-store"})
try:
return await asyncio.wait_for(
load_assignees(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Owners could not be loaded. You can still assign the issue to yourself."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201)
async def create_assigned_issue(
creation: IssueCreation,
@@ -4434,6 +4772,17 @@ async def create_assigned_issue(
login = user.get("login") if isinstance(user, dict) else None
if not login or accessible is None:
raise HTTPException(status_code=404, detail="Repository not found")
assignee = None if creation.unassigned else login
if not creation.unassigned and creation.assignee and creation.assignee != login:
eligible = {
item["login"]
for item in await gitea_proxy.issue_handoff_candidates(repository)
}
if creation.assignee not in eligible:
raise HTTPException(
status_code=422, detail="Selected owner is no longer eligible"
)
assignee = creation.assignee
if creation.label_ids:
available_labels = await gitea_proxy.repo_labels(repository)
valid_label_ids = {
@@ -4459,13 +4808,13 @@ async def create_assigned_issue(
repository,
creation.title,
creation.body,
login,
assignee,
creation.label_ids,
creation.milestone_id,
creation.due_date,
)
return await gitea_proxy.create_issue(
repository, creation.title, creation.body, login, creation.label_ids
repository, creation.title, creation.body, assignee, creation.label_ids
)
try:
@@ -4477,6 +4826,8 @@ async def create_assigned_issue(
repository,
creation.title,
creation.body,
creation.assignee,
creation.unassigned,
tuple(creation.label_ids),
creation.milestone_id,
creation.due_date,
+1
View File
@@ -29,6 +29,7 @@ class Issue(BaseModel):
state: str
labels: list[str] = []
assignees: list[str] = []
work_reasons: list[str] = []
repository: str = ""
updated_at: str = ""
due_date: str | None = None
+3
View File
@@ -7,6 +7,7 @@ SESSION_BODY_LIMIT = 16 * 1024
API_MUTATION_BODY_LIMIT = 64 * 1024
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
UNFILED_DRAFT_SYNC_BODY_LIMIT = 17 * 1024 * 1024
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
@@ -15,6 +16,8 @@ def request_body_limit(method: str, path: str) -> int | None:
normalized_method = method.upper()
if normalized_method == "POST" and path == "/api/v1/session":
return SESSION_BODY_LIMIT
if normalized_method == "PUT" and path == "/api/v1/unfiled-drafts":
return UNFILED_DRAFT_SYNC_BODY_LIMIT
if (
normalized_method == "POST"
and (
+130
View File
@@ -0,0 +1,130 @@
"""Durable, account-scoped saved Search views."""
import json
import re
import sqlite3
from pathlib import Path
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_VIEW_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
class SavedSearchConflict(ValueError):
"""Raised when a client attempts to replace a stale collection."""
def __init__(self, snapshot: dict):
super().__init__("saved searches changed on another device")
self.snapshot = snapshot
class SavedSearchStore:
def __init__(self, path: str | Path, *, limit: int = 20, timeout: float = 1.0):
self.path = Path(path)
self.limit = limit
self.timeout = timeout
self._initialize()
def _initialize(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(self.path, timeout=self.timeout) as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS saved_searches (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
views TEXT NOT NULL
)
"""
)
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(self.path, timeout=self.timeout)
@staticmethod
def _login(login: str) -> str:
normalized = login.strip().lower()
if not normalized:
raise ValueError("login is required")
return normalized
@staticmethod
def _snapshot(row) -> dict:
return {"revision": 0, "views": []} if row is None else {
"revision": int(row[0]), "views": json.loads(row[1])
}
def get(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT revision, views FROM saved_searches WHERE login = ?",
(self._login(login),),
).fetchone()
return self._snapshot(row)
def _normalize(self, views: list[dict]) -> list[dict]:
if not isinstance(views, list):
raise ValueError("views must be a list")
if len(views) > self.limit:
raise ValueError(f"saved searches are limited to {self.limit}")
normalized = []
seen = set()
for raw in views:
if not isinstance(raw, dict):
raise ValueError("saved search must be an object")
view_id = raw.get("id")
if not isinstance(view_id, str) or not _VIEW_ID.fullmatch(view_id):
raise ValueError("id is invalid")
if view_id in seen:
raise ValueError("saved search ids must be unique")
name = raw.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError("name is required")
name = name.strip()
if len(name) > 60:
raise ValueError("name must be at most 60 characters")
query = raw.get("query")
if not isinstance(query, str) or not 2 <= len(query.strip()) <= 200:
raise ValueError("query must be between 2 and 200 characters")
kind = raw.get("kind", "all")
state = raw.get("state", "all")
if kind not in {"all", "issue", "pull"}:
raise ValueError("kind is invalid")
if state not in {"all", "open", "closed"}:
raise ValueError("state is invalid")
repository = raw.get("repository", "")
if not isinstance(repository, str) or (repository and not _REPOSITORY.fullmatch(repository)):
raise ValueError("repository is invalid")
seen.add(view_id)
normalized.append({
"id": view_id,
"name": name,
"query": query.strip(),
"kind": kind,
"state": state,
"repository": repository,
})
return normalized
def replace(self, login: str, expected_revision: int, views: list[dict]) -> dict:
login = self._login(login)
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
raise ValueError("revision is invalid")
normalized = self._normalize(views)
serialized = json.dumps(normalized, separators=(",", ":"))
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, views FROM saved_searches WHERE login = ?", (login,)
).fetchone()
current = self._snapshot(row)
if current["revision"] != expected_revision:
raise SavedSearchConflict(current)
revision = expected_revision + 1
connection.execute(
"INSERT INTO saved_searches(login, revision, views) VALUES (?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, views=excluded.views",
(login, revision, serialized),
)
return {"revision": revision, "views": normalized}
+248
View File
@@ -0,0 +1,248 @@
"""Durable, account-scoped unfiled issue drafts and ordered evidence."""
import base64
import binascii
import json
import re
import sqlite3
from datetime import datetime
from pathlib import Path
_DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"}
class UnfiledDraftConflict(ValueError):
"""Raised when a client attempts to replace a stale draft collection."""
def __init__(self, snapshot: dict):
super().__init__("unfiled drafts changed on another device")
self.snapshot = snapshot
class UnfiledDraftStore:
def __init__(
self,
path: str | Path,
*,
limit: int = 20,
max_total_bytes: int = 12 * 1024 * 1024,
timeout: float = 1.0,
):
self.path = Path(path)
self.limit = limit
self.max_total_bytes = max_total_bytes
self.timeout = timeout
self._initialize()
def _initialize(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(self.path, timeout=self.timeout) as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""CREATE TABLE IF NOT EXISTS unfiled_drafts (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
drafts TEXT NOT NULL
)"""
)
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(self.path, timeout=self.timeout)
@staticmethod
def _login(login: str) -> str:
normalized = login.strip().lower()
if not normalized:
raise ValueError("login is required")
return normalized
@staticmethod
def _snapshot(row) -> dict:
return {"revision": 0, "drafts": []} if row is None else {
"revision": int(row[0]), "drafts": json.loads(row[1])
}
def get(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?",
(self._login(login),),
).fetchone()
return self._snapshot(row)
@staticmethod
def _filing_plan(raw: object) -> dict | None:
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError("filing plan is invalid")
repository = raw.get("repository")
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
raise ValueError("filing repository is invalid")
label_ids = raw.get("label_ids", [])
if not isinstance(label_ids, list) or len(label_ids) > 20:
raise ValueError("filing labels are invalid")
if any(not isinstance(item, int) or isinstance(item, bool) or item < 1 for item in label_ids):
raise ValueError("filing labels are invalid")
if len(set(label_ids)) != len(label_ids):
raise ValueError("filing labels are invalid")
plan = {"repository": repository, "label_ids": label_ids}
for key in ("milestone_id", "estimate_minutes"):
value = raw.get(key)
if value is None:
continue
upper = 1440 if key == "estimate_minutes" else None
lower = 5 if key == "estimate_minutes" else 1
if (
not isinstance(value, int)
or isinstance(value, bool)
or value < lower
or (upper is not None and value > upper)
):
raise ValueError(f"filing {key.replace('_', ' ')} is invalid")
plan[key] = value
due_date = raw.get("due_date")
if due_date is not None:
try:
datetime.strptime(due_date, "%Y-%m-%d")
except (TypeError, ValueError) as error:
raise ValueError("filing due date is invalid") from error
plan["due_date"] = due_date
for key, limit in {
"template_name": 80,
"template_id": 80,
"captured_body": 10_000,
"assignee_name": 255,
}.items():
value = raw.get(key)
if value is not None:
if not isinstance(value, str) or not value.strip() or len(value) > limit:
raise ValueError(f"filing {key.replace('_', ' ')} is invalid")
plan[key] = value
unassigned = raw.get("unassigned", False)
if not isinstance(unassigned, bool):
raise ValueError("filing owner intent is invalid")
assignee = raw.get("assignee")
if unassigned and assignee is not None:
raise ValueError("filing owner intent is invalid")
if unassigned:
plan["unassigned"] = True
elif assignee is not None:
if not isinstance(assignee, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+", assignee):
raise ValueError("filing assignee is invalid")
plan["assignee"] = assignee
completion_intent = raw.get("completion_intent")
if completion_intent is not None:
if completion_intent not in {"create", "create-and-start"}:
raise ValueError("filing completion intent is invalid")
plan["completion_intent"] = completion_intent
return plan
def _normalize(self, drafts: list[dict]) -> list[dict]:
if not isinstance(drafts, list):
raise ValueError("drafts must be a list")
if len(drafts) > self.limit:
raise ValueError(f"unfiled drafts are limited to {self.limit}")
normalized = []
seen = set()
decoded_total = 0
for raw in drafts:
if not isinstance(raw, dict):
raise ValueError("draft must be an object")
draft_id = raw.get("id")
if not isinstance(draft_id, str) or not _DRAFT_ID.fullmatch(draft_id):
raise ValueError("draft id is invalid")
if draft_id in seen:
raise ValueError("draft ids must be unique")
title = raw.get("title")
body = raw.get("body", "")
saved_at = raw.get("saved_at")
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 255:
raise ValueError("title is invalid")
if not isinstance(body, str) or len(body) > 10_000:
raise ValueError("body is invalid")
if not isinstance(saved_at, int) or isinstance(saved_at, bool) or saved_at < 0:
raise ValueError("saved_at is invalid")
filing_plan = self._filing_plan(raw.get("filing_plan"))
blockers = raw.get("blockers", [])
if not isinstance(blockers, list) or len(blockers) > 5:
raise ValueError("blockers are invalid")
clean_blockers = []
for blocker in blockers:
repository = blocker.get("repository") if isinstance(blocker, dict) else None
number = blocker.get("number") if isinstance(blocker, dict) else None
blocker_title = blocker.get("title", "") if isinstance(blocker, dict) else ""
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
raise ValueError("blocker repository is invalid")
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
raise ValueError("blocker number is invalid")
if not isinstance(blocker_title, str) or len(blocker_title) > 255:
raise ValueError("blocker title is invalid")
clean_blockers.append({"repository": repository, "number": number, "title": blocker_title})
evidence = raw.get("evidence", [])
if not isinstance(evidence, list) or len(evidence) > 5:
raise ValueError("evidence is invalid")
clean_evidence = []
for item in evidence:
if not isinstance(item, dict):
raise ValueError("evidence is invalid")
filename = item.get("filename")
content_type = item.get("content_type")
note = item.get("note", "")
data = item.get("data")
if not isinstance(filename, str) or not filename or len(filename) > 255:
raise ValueError("evidence filename is invalid")
if content_type not in _CONTENT_TYPES:
raise ValueError("evidence content type is invalid")
if not isinstance(note, str) or len(note) > 240:
raise ValueError("evidence note is invalid")
if not isinstance(data, str):
raise ValueError("evidence data is invalid")
try:
decoded_total += len(base64.b64decode(data, validate=True))
except (binascii.Error, ValueError) as error:
raise ValueError("evidence data must be valid base64") from error
if decoded_total > self.max_total_bytes:
raise ValueError("synchronized evidence is too large")
clean_evidence.append({
"filename": filename,
"content_type": content_type,
**({"note": note} if note else {}),
"data": data,
})
seen.add(draft_id)
normalized.append({
"id": draft_id,
"title": title.strip(),
"body": body,
"saved_at": saved_at,
**({"filing_plan": filing_plan} if filing_plan else {}),
**({"blockers": clean_blockers} if clean_blockers else {}),
**({"evidence": clean_evidence} if clean_evidence else {}),
})
return normalized
def replace(self, login: str, expected_revision: int, drafts: list[dict]) -> dict:
login = self._login(login)
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
raise ValueError("revision is invalid")
normalized = self._normalize(drafts)
serialized = json.dumps(normalized, separators=(",", ":"))
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,)
).fetchone()
current = self._snapshot(row)
if current["revision"] != expected_revision:
raise UnfiledDraftConflict(current)
revision = expected_revision + 1
connection.execute(
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts",
(login, revision, serialized),
)
return {"revision": revision, "drafts": normalized}
+82
View File
@@ -0,0 +1,82 @@
"""Deterministic Gitea double used by the release-artifact browser journey."""
from __future__ import annotations
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
REPOSITORY = {
"id": 7,
"name": "mobile",
"full_name": "acme/mobile",
"description": "Mobile filing acceptance repository",
"html_url": "http://127.0.0.1/acme/mobile",
"owner": {"login": "acme"},
}
USER = {"id": 1, "login": "timmy", "full_name": "Timmy"}
class FakeGiteaServer(ThreadingHTTPServer):
def __init__(self, address: tuple[str, int]):
super().__init__(address, FakeGiteaHandler)
self.created_issues: list[dict] = []
class FakeGiteaHandler(BaseHTTPRequestHandler):
server: FakeGiteaServer
def log_message(self, _format: str, *_args: object) -> None:
return
def _json(self, status: int, payload: object, **headers: str) -> None:
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
for name, value in headers.items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = urlsplit(self.path).path
if path == "/api/v1/user":
self._json(200, USER)
elif path == "/api/v1/user/repos":
self._json(200, [REPOSITORY], **{"X-Total-Count": "1"})
elif path in {"/api/v1/repos/acme/mobile", "/api/v1/repos/acme/mobile/"}:
self._json(200, REPOSITORY)
elif path == "/api/v1/repos/search":
self._json(200, {"data": [REPOSITORY], "ok": True})
elif path.startswith("/api/v1/"):
self._json(200, [], **{"X-Total-Count": "0"})
else:
self._json(404, {"message": "not found"})
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
path = urlsplit(self.path).path
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length) or b"{}")
if path != "/api/v1/repos/acme/mobile/issues":
self._json(404, {"message": "not found"})
return
self.server.created_issues.append(payload)
assignee = payload.get("assignee")
self._json(
201,
{
"id": 101,
"number": 41,
"title": payload.get("title", ""),
"body": payload.get("body", ""),
"state": "open",
"html_url": "http://127.0.0.1/acme/mobile/issues/41",
"assignees": [{"login": assignee}] if assignee else [],
"labels": [],
"milestone": None,
"due_date": payload.get("due_date"),
"updated_at": "2026-08-15T12:00:00Z",
},
)
@@ -0,0 +1,253 @@
from __future__ import annotations
import json
import os
import socket
import ssl
import subprocess
import sys
import tarfile
import threading
import time
import urllib.request
from contextlib import contextmanager
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("release artifact browser journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import Page, expect, sync_playwright
from fake_gitea import FakeGiteaServer
ROOT = Path(__file__).resolve().parents[2]
TITLE = "Offline artifact journey 863"
BODY = "Captured on a phone, retained offline, delivered exactly once."
ACCESS_TOKEN = "artifact-browser-access-token-863"
def free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def wait_for_health(url: str, process: subprocess.Popen[str]) -> None:
deadline = time.monotonic() + 20
tls = ssl._create_unverified_context()
while time.monotonic() < deadline:
if process.poll() is not None:
stdout, stderr = process.communicate()
raise AssertionError(f"release server exited early\nstdout:\n{stdout}\nstderr:\n{stderr}")
try:
with urllib.request.urlopen(url, timeout=0.5, context=tls) as response:
if response.status == 200:
return
except OSError:
time.sleep(0.05)
raise AssertionError("release server did not become healthy")
@contextmanager
def release_server(archive: Path, tmp_path: Path, gitea_url: str):
release_root = tmp_path / "release"
release_root.mkdir()
with tarfile.open(archive, "r:gz") as bundle:
bundle.extractall(release_root, filter="data")
assert (release_root / "release-manifest.json").is_file()
assert not (release_root / "tests").exists(), "journey must run the packaged artifact, not checkout code"
port = free_port()
state_dir = tmp_path / "state"
state_dir.mkdir()
key_file = tmp_path / "localhost.key"
certificate_file = tmp_path / "localhost.crt"
subprocess.run(
[
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", str(key_file), "-out", str(certificate_file), "-days", "1",
"-subj", "/CN=127.0.0.1", "-addext", "subjectAltName=IP:127.0.0.1",
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
origin = f"https://127.0.0.1:{port}"
env = {
**os.environ,
"PYTHONPATH": str(release_root),
"GITEA_URL": gitea_url,
"GITEA_TOKEN": "deterministic-e2e-token",
"STACKCHAIN_DASHBOARD_AUTH_MODE": "operator",
"STACKCHAIN_DASHBOARD_ACCESS_TOKEN": ACCESS_TOKEN,
"STACKCHAIN_DASHBOARD_SESSION_SECRET": "artifact-browser-independent-session-secret-863",
"STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN": origin,
"STACKCHAIN_STATE_DIR": str(tmp_path / "state"),
}
process = subprocess.Popen(
[
sys.executable, "-m", "uvicorn", "src.main:app",
"--host", "127.0.0.1", "--port", str(port),
"--ssl-keyfile", str(key_file), "--ssl-certfile", str(certificate_file),
],
cwd=release_root,
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
try:
wait_for_health(origin + "/healthz", process)
yield origin
finally:
process.terminate()
try:
process.communicate(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
def indexed_issue_records(page: Page) -> list[dict]:
return page.evaluate(
"""async () => {
const db = await new Promise((resolve, reject) => {
const request = indexedDB.open('stackchain-background-outbox-v1', 1);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return await new Promise((resolve, reject) => {
const request = db.transaction('issues', 'readonly').objectStore('issues').getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}"""
)
def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_reload(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
failed_responses: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": 390, "height": 844},
service_workers="allow",
ignore_https_errors=True,
)
page = context.new_page()
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
page.on("response", lambda response: failed_responses.append(
f"{response.status} {response.url}"
) if response.status >= 400 else None)
page.goto(origin + "/", wait_until="networkidle")
expect(page.locator("#sign-in")).to_be_visible()
page.locator('input[name="device_label"]').fill("Release journey phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
new_action = page.locator('[data-mobile-task="new"]')
expect(new_action).to_be_visible()
service_worker_ready = page.evaluate(
"""() => Promise.race([
navigator.serviceWorker.ready.then(() => true),
new Promise(resolve => setTimeout(() => resolve(false), 10000)),
])"""
)
assert service_worker_ready, (
"release artifact did not register its service worker; browser errors=" + repr(browser_errors)
+ "; failed responses=" + repr(failed_responses)
)
if not page.evaluate("() => navigator.serviceWorker.controller !== null"):
page.reload(wait_until="networkidle")
new_action = page.locator('[data-mobile-task="new"]')
assert page.evaluate("() => navigator.serviceWorker.controller !== null")
new_action.click()
page.locator("#create-issue-title").fill(TITLE)
page.locator("#create-issue-body").fill(BODY)
page.locator("#file-new-issue").click()
expect(page.locator("#create-issue-filing")).to_be_visible()
page.locator("#create-issue-repository").select_option("acme/mobile")
expect(page.locator("#submit-new-issue")).to_be_enabled()
context.set_offline(True)
page.locator("#submit-new-issue").click()
expect(page.locator("#issue-filing-review")).to_be_visible()
page.locator("#confirm-issue-filing").click()
page.wait_for_timeout(1_000)
filing_statuses = page.locator(
"#my-work-action-status, #create-issue-status, #issue-filing-review-status"
).all_inner_texts()
assert any(
marker in " ".join(filing_statuses).lower()
for marker in ("queued", "saved for", "background delivery")
), (filing_statuses, browser_errors, failed_responses)
local_record = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
assert [(item["title"], item["body"], item["repository"]) for item in local_record["items"]] == [
(TITLE, BODY, "acme/mobile")
]
durable = indexed_issue_records(page)
assert [(item["title"], item["body"], item["repository"]) for item in durable] == [
(TITLE, BODY, "acme/mobile")
]
operation_id = durable[0]["operationId"]
assert operation_id and operation_id == local_record["items"][0]["operationId"]
assert fake.created_issues == []
page.reload(wait_until="domcontentloaded")
expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
durable_after_reload = indexed_issue_records(page)
assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)]
assert durable_after_reload[0]["operationId"] == operation_id
assert fake.created_issues == []
browser_errors.clear() # Chromium reports expected network errors while the context is offline.
failed_responses.clear()
context.set_offline(False)
page.evaluate("window.dispatchEvent(new Event('online'))")
for _ in range(80):
if fake.created_issues:
break
page.wait_for_timeout(250)
assert fake.created_issues == [{"title": TITLE, "body": BODY, "assignee": "timmy"}]
for _ in range(40):
durable_completion = indexed_issue_records(page)
if durable_completion and all(item.get("status") == "sent" for item in durable_completion):
break
page.wait_for_timeout(100)
assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
page.reload(wait_until="networkidle")
page.evaluate("window.dispatchEvent(new Event('online'))")
for _ in range(40):
completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
if completed_local["items"] == []:
break
page.wait_for_timeout(100)
assert completed_local["items"] == []
page.wait_for_timeout(500)
assert len(fake.created_issues) == 1
assert browser_errors == []
assert failed_responses == []
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
+110 -3
View File
@@ -64,6 +64,38 @@ const fetchJson = async (url, options = {{}}) => {{
}
def test_closed_app_sync_preserves_explicit_no_owner():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'unowned-1',operationId:'unowned-1',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Backlog capture',body:'',labelIds:[],unassigned:true}};
const calls=[];const store={{claimNext:async()=>item,complete:async()=>{{item=null;}},release:async()=>{{}},fail:async()=>{{}}}};
const fetchJson=async(url,options={{}})=>{{calls.push({{url,body:options.body||''}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:19,assignees:[]}};}};
(async()=>{{await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(calls));}})();
"""
calls = run_node(script)
assert json.loads(calls[1]["body"]) == {
"title": "Backlog capture", "body": "", "label_ids": [], "unassigned": True
}
def test_closed_app_issue_receipt_uses_the_confirmed_canonical_gitea_url():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'unowned-link',operationId:'unowned-link',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Backlog capture',body:'',labelIds:[],unassigned:true}};
const store={{claimNext:async()=>item,complete:async()=>{{item=null;}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:19,title:'Backlog capture',assignees:[],url:'https://forge.example/git/o/r/issues/19'}};
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(result.receipts[0]));}})();
"""
receipt = run_node(script)
assert receipt == {
"id": "unowned-link",
"status": "confirmed",
"kind": "issue",
"url": "https://forge.example/git/o/r/issues/19",
}
def test_closed_app_sync_delivers_desired_blocker_state():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -164,6 +196,71 @@ const fetchJson=async(url,options={{}})=>{{
assert output["second"]["confirmed"][0]["number"] == 469
def test_closed_app_capture_retries_only_unfinished_blockers_after_creation():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'blocked',operationId:'blocked',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Blocked',body:'',labelIds:[],blockers:[
{{repository:'o/api',number:7,title:'API'}},{{repository:'o/web',number:8,title:'Web'}}
]}};
const calls=[];let secondAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
const body=options.body?JSON.parse(options.body):null;calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
if(url.endsWith('/issues'))return{{number:42,repository:'o/r'}};
if(body.number===8 && secondAttempts++===0){{const e=new Error('offline');e.status=503;throw e;}}
return{{number:42,dependencies_available:true,dependencies:[body]}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';try{{await sync.flush();}}catch(e){{first=e.message;}}const checkpoint={{...item}};const result=await sync.flush();process.stdout.write(JSON.stringify({{first,checkpoint,calls,result}}));}})();
"""
output = run_node(script)
assert output["first"] == "offline"
assert output["checkpoint"]["deliveredIssue"]["number"] == 42
assert output["checkpoint"]["deliveredBlockers"] == 1
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
blockers = [call for call in output["calls"] if call["url"].endswith("/blockers")]
assert [call["body"]["number"] for call in blockers] == [7, 8, 8]
assert output["result"]["confirmed"][0]["number"] == 42
def test_evidence_bundle_retry_resumes_at_failed_image_and_posts_one_ordered_comment():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Journey',body:'',labelIds:[],attachments:[
{{filename:'one.png',contentType:'image/png',data:'b25l',note:'First state'}},
{{filename:'two.png',contentType:'image/png',data:'dHdv',note:'Tap [Submit](unsafe)'}},
{{filename:'three.png',contentType:'image/png',data:'dGhyZWU='}},
]}};
const calls=[];let twoAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
let filename=null;if(options.body instanceof FormData)filename=options.body.get('file').name;
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename,body:options.body instanceof FormData?null:JSON.parse(options.body)}});
if(url.endsWith('/issues'))return{{number:823}};
if(url.endsWith('/attachments')){{if(filename==='two.png' && twoAttempts++===0){{const e=new Error('offline');e.status=503;throw e;}}return{{markdown:'!['+filename+'](url/'+filename+')'}};}}
return{{id:9}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';try{{await sync.flush();}}catch(e){{first=e.message;}}const checkpoint={{...item}};await sync.flush();process.stdout.write(JSON.stringify({{first,checkpoint,calls}}));}})();
"""
output = run_node(script)
assert output["first"] == "offline"
uploads = [call for call in output["calls"] if call["filename"]]
assert [call["filename"] for call in uploads] == ["one.png", "two.png", "two.png", "three.png"]
assert [call["key"] for call in uploads] == [
"bundle:attachment-0", "bundle:attachment-1", "bundle:attachment-1", "bundle:attachment-2"
]
assert output["checkpoint"]["attachmentMarkdowns"] == ["![one.png](url/one.png)"]
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
assert len(comments) == 1
assert comments[0]["body"]["body"].split("\n\n") == [
"**Screenshot 1 — First state**", "![one.png](url/one.png)",
"**Screenshot 2 — Tap \\[Submit\\]\\(unsafe\\)**", "![two.png](url/two.png)",
"![three.png](url/three.png)"
]
def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
@@ -633,22 +730,32 @@ const transaction=work=>{{const run=tail.then(()=>work({{
await store.reconcile([{{
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'one.png',contentType:'image/png',blob:new Blob(['first-image-bytes'],{{type:'image/png'}})}},
}}]);
}},{{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
{{filename:'a.png',contentType:'image/png',blob:new Blob(['a-bytes'],{{type:'image/png'}})}},
{{filename:'b.png',contentType:'image/png',blob:new Blob(['b-bytes'],{{type:'image/png'}})}},
]}}]);
await store.reconcile([
{{id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'one.png',contentType:'image/png',stored:true}}}},
{{id:'second',operationId:'second',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'two.png',contentType:'image/png',data:'c2Vjb25kLWltYWdlLWJ5dGVz'}}}},
{{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
{{filename:'a.png',contentType:'image/png',stored:true}},
{{filename:'b.png',contentType:'image/png',stored:true}},
]}},
]);
const snapshot=await store.snapshot();
process.stdout.write(JSON.stringify({{first:{{isBlob:snapshot[0].attachment.blob instanceof Blob,
text:await snapshot[0].attachment.blob?.text()}},second:snapshot[1].attachment.data}}));
const byId=Object.fromEntries(snapshot.map(value=>[value.id,value]));
process.stdout.write(JSON.stringify({{first:{{isBlob:byId.first.attachment.blob instanceof Blob,
text:await byId.first.attachment.blob?.text()}},second:byId.second.attachment.data,
bundle:await Promise.all(byId.bundle.attachments.map(value=>value.blob?.text()))}}));
}})();
"""
output = run_node(script)
assert output["first"] == {"isBlob": True, "text": "first-image-bytes"}
assert output["second"] == "c2Vjb25kLWltYWdlLWJ5dGVz"
assert output["bundle"] == ["a-bytes", "b-bytes"]
def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():
+49
View File
@@ -155,6 +155,55 @@ first.run([item]).then(firstResult=>{{
}
def test_interrupted_batch_restores_operation_context_for_idempotent_recovery():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const values={{}};
const storage={{
getItem:key=>values[key] || null,
setItem:(key,value)=>{{values[key]=value;}},
removeItem:key=>{{delete values[key];}},
}};
const calls=[];
const options={{
capacity:()=>2, storage, owner:()=> 'timmy', journalName:'search-later-batch',
queueFailureReason:'assigned but Later could not be saved',
claim:item=>Promise.resolve({{...item,assigned_to_me:true}}),
}};
const first=createBatchFindWork({{...options, queue:(item,context)=>{{
calls.push(['first',item.number,context?.until]);
return item.number===813 ? 'queued' : 'unavailable';
}}}});
const items=[813,814].map(number=>({{repository:'stackchain/dashboard',number}}));
first.run(items, {{}}, {{until:'2026-08-15T09:00:00.000Z'}}).then(firstResult=>{{
const second=createBatchFindWork({{...options, queue:(item,context)=>{{
calls.push(['resume',item.number,context?.until]);
return 'queued';
}}}});
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
firstResult,resumed,calls,keys:Object.keys(values),
}})));
}});
"""
result = run_node(script)
assert result["firstResult"]["queued"] == ["stackchain/dashboard#813"]
assert result["firstResult"]["failed"] == [{
"key": "stackchain/dashboard#814",
"reason": "assigned but Later could not be saved",
"assigned": True,
}]
assert result["resumed"]["queued"] == [
"stackchain/dashboard#813", "stackchain/dashboard#814",
]
assert result["calls"] == [
["first", 813, "2026-08-15T09:00:00.000Z"],
["first", 814, "2026-08-15T09:00:00.000Z"],
["resume", 814, "2026-08-15T09:00:00.000Z"],
]
assert result["keys"] == []
def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
+15 -1
View File
@@ -21,8 +21,22 @@ def test_release_promotion_waits_for_tests_and_bundle():
text = WORKFLOW.read_text()
release = text[text.index(" release-candidate:") :]
assert "needs: [lint, build-release]" in release
assert "needs: [lint, build-release, browser-journey]" in release
assert "github.event_name == 'push'" in release
assert "actions/download-artifact@v3" in release
assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
def test_release_promotion_waits_for_artifact_mobile_offline_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
release = text[text.index(" release-candidate:") :]
assert "needs: build-release" in browser
assert "actions/download-artifact@v3" in browser
assert "pip install -r requirements-e2e.txt" in browser
assert "python3 -m playwright install --with-deps chromium" in browser
assert 'STACKCHAIN_RUN_RELEASE_E2E: "1"' in browser
assert "python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q" in browser
assert "needs: [lint, build-release, browser-journey]" in release
+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-v101" in worker
assert "stackchain-dashboard-shell-v102" in worker
+120
View File
@@ -0,0 +1,120 @@
import json
import subprocess
from pathlib import Path
FRONTEND = Path(__file__).parents[1] / "frontend"
HYDRATOR = FRONTEND / "conversation-action-hydrator.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_conversation_paints_before_optional_actions_load_and_hydrates_once():
script = f"""
const createConversationActionHydrator = require({json.dumps(str(HYDRATOR))});
const events = [];
let finishLoad;
const hydrator = createConversationActionHydrator({{
load: () => {{
events.push('load');
return new Promise(resolve => {{ finishLoad = resolve; }});
}},
activate: () => ({{name:'actions'}}),
}});
const root = {{}};
const retry = {{hidden:false}};
const status = {{textContent:'2 messages loaded.'}};
const state = {{comments:[1,2]}};
const pending = hydrator.show({{
root, state, retry, status,
paint: (_state, actions) => events.push(actions ? 'paint-actions' : 'paint-core'),
wire: () => events.push('wire'),
}});
events.push('returned');
finishLoad();
pending.then(() => {{
hydrator.show({{
root, state, retry, status,
paint: (_state, actions) => events.push(actions ? 'paint-actions-again' : 'paint-core-again'),
wire: () => events.push('wire-again'),
}}).then(() => process.stdout.write(JSON.stringify({{events,retry,status:status.textContent}})));
}});
"""
output = run_node(script)
assert output == {
"events": [
"paint-core", "load", "returned", "wire", "paint-actions",
"paint-actions-again",
],
"retry": {"hidden": True},
"status": "2 messages loaded.",
}
def test_failed_comment_actions_keep_conversation_usable_and_retry_in_place():
script = f"""
const createConversationActionHydrator = require({json.dumps(str(HYDRATOR))});
const events = [];
let attempt = 0;
const hydrator = createConversationActionHydrator({{
load: async () => {{
attempt += 1;
if (attempt === 1) throw new Error('chunk unavailable');
}},
activate: () => ({{name:'actions'}}),
}});
const root = {{}};
const retry = {{hidden:true}};
const status = {{textContent:'2 messages loaded.'}};
const options = {{
root, retry, status, state:{{comments:[1,2]}},
paint: (_state, actions) => events.push(actions ? 'actions' : 'core'),
wire: () => events.push('wire'),
}};
(async () => {{
const failed = await hydrator.show(options);
events.push('reply-still-usable');
const recovered = await hydrator.show(options);
process.stdout.write(JSON.stringify({{
failed, recovered, attempt, events, retry, status:status.textContent,
}}));
}})();
"""
output = run_node(script)
assert output == {
"failed": False,
"recovered": True,
"attempt": 2,
"events": ["core", "reply-still-usable", "core", "wire", "actions"],
"retry": {"hidden": True},
"status": "2 messages loaded.",
}
def test_issue_pull_and_update_conversations_trigger_optional_actions_not_startup():
html = (FRONTEND / "index.html").read_text()
javascript = (FRONTEND / "dashboard.js").read_text()
assert '<script src="static/conversation-action-hydrator.js"></script>' in html
assert html.index("static/conversation-action-hydrator.js") < html.index("static/dashboard.js")
assert "await commentActionFeatures.run('comment-actions'" not in javascript
assert "load: () => commentActionFeatures.load('comment-actions')" in javascript
assert "createConversationActionHydrator" in javascript
for kind in ("issue", "pull", "update"):
assert f"retry-{kind}-comment-actions" in html
assert f"showConversationWithActions('{kind}'" in javascript
assert f"qs('#retry-{kind}-comment-actions').addEventListener" in javascript
def test_comment_action_retry_is_a_phone_sized_inline_control():
css = (FRONTEND / "dashboard.css").read_text()
assert ".conversation-actions-retry" in css
rule = css.split(".conversation-actions-retry", 1)[1].split("}", 1)[0]
assert "min-height:44px" in rule
assert "max-width:100%" in rule
+82 -7
View File
@@ -16,7 +16,7 @@ def run_node(script):
).stdout)
def test_confirmed_issue_is_planned_synced_warmed_and_started_once():
def test_confirmed_issue_persists_and_syncs_estimate_before_starting_once():
script = f"""
const fs = require('fs');
if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
@@ -26,13 +26,22 @@ if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
const calls = [];
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:387}};
const flow = createCreateAndStart({{
todayWork: {{limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':', add:()=>{{calls.push('add');return 'added';}}}},
todaySync: {{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}}, flush:()=>calls.push('flush')}},
todayWork: {{
limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':',
add:()=>{{calls.push('add');return 'added';}},
planning:()=>({{capacity_minutes:120,estimates:{{}}}}),
replacePlanning:plan=>{{calls.push('plan:'+JSON.stringify(plan));return true;}},
}},
todaySync: {{
enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}},
enqueueConfiguration:(capacity,estimates)=>{{calls.push('configure:'+capacity+':'+JSON.stringify(estimates));return true;}},
flush:()=>calls.push('flush')
}},
refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:item=>calls.push('start:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
const result = flow.complete(issue);
flow.complete(issue);
const result = flow.complete(issue, 45);
flow.complete(issue, 45);
process.stdout.write(JSON.stringify({{available:true,result,calls}}));
}}
"""
@@ -42,7 +51,9 @@ if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
"result": "started",
"calls": [
"add",
'plan:{"capacity_minutes":120,"estimates":{"issue:stackchain/dashboard:387:":45}}',
"add:issue:stackchain/dashboard:387:",
'configure:120:{"issue:stackchain/dashboard:387:":45}',
"refresh",
"flush",
"warm",
@@ -61,8 +72,14 @@ def test_mobile_capture_wires_distinct_create_and_start_intent_into_the_offline_
assert '<button id="create-and-start-issue" type="submit">Create &amp; start</button>' in html
assert '<script src="static/create-and-start.js"></script>' in html
assert "const createAndStart = createCreateAndStart({" in dashboard
assert "event.submitter?.id === 'create-and-start-issue'" in dashboard
assert "createAndStart.complete(created)" in dashboard
assert "event.submitter.id === 'create-and-start-issue'" in dashboard
assert "review.intent === 'create-and-start'" in dashboard
assert 'id="create-issue-estimate"' in html
assert 'min="5" max="1440" step="5"' in html
assert "createAndStart.capacity(qs('#create-issue-estimate').value)" in dashboard
assert "estimateMinutes: startCapacity.minutes" in dashboard
assert "createAndStart.complete(created, completion.estimateMinutes)" in dashboard
assert ".create-issue-estimate" in css
assert ".create-issue-actions" in css and "grid-template-columns" in css
assert "BASE + 'static/create-and-start.js'" in worker
@@ -125,3 +142,61 @@ process.stdout.write(JSON.stringify({{first,second,calls}}));
"refresh", "flush", "warm", "start",
"Created, added to Today, and ready to work.",
]
def test_create_and_start_reports_validation_and_remaining_today_capacity():
script = f"""
const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});
const flow=createCreateAndStart({{
todayWork:{{limit:5,read:()=>['first','second'],planning:()=>({{
capacity_minutes:120,estimates:{{first:30,second:40}}
}})}},
}});
process.stdout.write(JSON.stringify({{
empty:flow.capacity(''),tooSmall:flow.capacity('4'),fits:flow.capacity('45'),over:flow.capacity('55')
}}));
"""
assert run_node(script) == {
"empty": {"valid": False, "reason": "required"},
"tooSmall": {"valid": False, "reason": "range"},
"fits": {"valid": True, "minutes": 45, "capacityMinutes": 120, "plannedMinutes": 70,
"remainingMinutes": 50, "projectedMinutes": 5, "fits": True},
"over": {"valid": True, "minutes": 55, "capacityMinutes": 120, "plannedMinutes": 70,
"remainingMinutes": 50, "projectedMinutes": -5, "fits": False},
}
def test_create_and_start_does_not_start_when_estimate_cannot_be_saved():
script = f"""
const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});const calls=[];
const flow=createCreateAndStart({{
todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:o/r:849:',add:()=> 'added',
planning:()=>({{capacity_minutes:60,estimates:{{}}}}),replacePlanning:()=>false}},
todaySync:{{enqueue:()=>true,enqueueConfiguration:()=>true,flush:()=>calls.push('flush')}},
refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'),
announce:message=>calls.push(message),
}});
process.stdout.write(JSON.stringify({{result:flow.complete({{repository:'o/r',number:849}},30),calls}}));
"""
assert run_node(script) == {
"result": "estimate-unavailable",
"calls": ["Created and added to Today, but its estimate could not be saved on this device."],
}
def test_create_and_start_does_not_start_when_estimate_sync_cannot_be_queued():
script = f"""
const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});const calls=[];
const flow=createCreateAndStart({{
todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:o/r:849:',add:()=> 'added',
planning:()=>({{capacity_minutes:60,estimates:{{}}}}),replacePlanning:()=>true}},
todaySync:{{enqueue:()=>true,enqueueConfiguration:()=>false,flush:()=>calls.push('flush')}},
refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'),
announce:message=>calls.push(message),
}});
process.stdout.write(JSON.stringify({{result:flow.complete({{repository:'o/r',number:849}},30),calls}}));
"""
assert run_node(script) == {
"result": "sync-unavailable",
"calls": ["Created and estimated on this device, but Today plan sync is unavailable."],
}
+9 -4
View File
@@ -270,13 +270,17 @@ const values = new Map([
]);
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const inbox = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
process.stdout.write(JSON.stringify(inbox.partition()));
const partition=inbox.partition();
process.stdout.write(JSON.stringify({{...partition,labels:partition.deliveries.map(inbox.deliveryLabel)}}));
"""
output = run_node(script)
assert len(output["drafts"]) == 1
assert len(output["deliveries"]) == 5
assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2, "authorization": 0}
assert output["labels"] == [
"Needs attention", "Identity protected", "Needs attention", "Sending", "Queued for sync"
]
assert [item["outbox_id"] for item in output["retryable"]] == ["waiting"]
waiting = next(item for item in output["deliveries"] if item["outbox_id"] == "waiting")
assert waiting["last_attempt_at"] == 90
@@ -293,7 +297,7 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
assert 'class="draft-resume"' in html
assert 'class="draft-discard"' in html
assert 'Discard this unfinished draft?' in html
assert '.draft-actions button { min-height:44px;' in html
assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px;' in html
assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
assert "Verified not posted — retry" in html
@@ -316,7 +320,8 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
assert "const deliveryCenter = draftInbox.partition(displayedDrafts);" in html
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
assert "deliveryCenter.retryable.length" in html
assert "item.status === 'sending' ? 'Sending'" in html
assert "draftInbox.deliveryLabel(item)" in html
assert "if (item.status === 'sending') return 'Sending'" in DRAFTS.read_text()
assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html
assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html
assert '.delivery-center button { min-height:44px;' in html
@@ -330,7 +335,7 @@ async def test_mobile_dashboard_requires_an_explicit_authorize_and_close_gesture
assert '>Authorize &amp; close</button>' in html
assert "list.querySelectorAll('.draft-authorize')" in html
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
assert "item.status === 'authorization' ? 'Awaiting authorization'" in html
assert "if (item.status === 'authorization') return 'Awaiting authorization'" in DRAFTS.read_text()
@pytest.mark.anyio
+2 -2
View File
@@ -74,8 +74,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.
assert len(first.runtime_gzip_bytes) <= 98 * 1024
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
assert len(first.runtime_gzip_bytes) <= 99 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
+62 -2
View File
@@ -66,6 +66,64 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages(
assert result["items"][0]["work_reasons"] == ["review_requested"]
@pytest.mark.anyio
async def test_filed_work_page_loads_open_authored_issues_with_a_distinct_reason():
requests = []
def upstream(request):
requests.append(str(request.url))
return httpx.Response(200, headers={"X-Total-Count": "61"}, json=[{
"id": 870, "number": 870, "title": "Delegated filing", "state": "open",
"repository": {"full_name": "stackchain/dashboard"},
"html_url": "https://forge.example/stackchain/dashboard/issues/870",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.work_page("filed", page=2)
finally:
await gitea_proxy.stop_client()
assert requests == [
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&created=true&type=issues&limit=50&page=2"
]
assert result == {
"stream": "filed", "page": 2, "total": 61, "has_more": False,
"items": [{
"id": 870, "number": 870, "title": "Delegated filing", "state": "open",
"repository": {"full_name": "stackchain/dashboard"},
"html_url": "https://forge.example/stackchain/dashboard/issues/870",
"work_reasons": ["created_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):
del page, limit
common = {
"id": 7, "number": 7, "title": "My own filing", "state": "open",
"repository": {"full_name": "stackchain/api"}, "html_url": "https://forge.example/issues/7",
}
if stream == "issue":
return {"items": [{**common, "assignees": [{"login": "timmy"}]}], "page": 1, "total": 1, "has_more": False}
return {"items": [{**common, "work_reasons": ["created_by_me"]}, {
**common, "id": 8, "number": 8, "title": "Delegated",
"work_reasons": ["created_by_me"],
}], "page": 1, "total": 2, "has_more": False}
monkeypatch.setattr(gitea_proxy, "work_page", page)
result = await gitea_proxy.issues()
assert [item["number"] for item in result] == [7, 8]
assert result[0]["work_reasons"] == ["created_by_me"]
assert result.pagination == {
"issue": {"page": 1, "total": 1, "has_more": False},
"filed": {"page": 1, "total": 2, "has_more": False},
}
@pytest.mark.anyio
async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_priority():
requests = []
@@ -568,7 +626,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, "pull": 61, "review": 73}
totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73}
return {
"items": [], "page": 1, "total": totals[stream],
"has_more": True, "stream": stream,
@@ -580,7 +638,8 @@ async def test_initial_work_collections_expose_independent_pagination(monkeypatc
pulls = await gitea_proxy.pull_requests()
assert assigned_issues.pagination == {
"issue": {"page": 1, "total": 84, "has_more": True}
"issue": {"page": 1, "total": 84, "has_more": True},
"filed": {"page": 1, "total": 62, "has_more": True},
}
assert pulls.pagination == {
"pull": {"page": 1, "total": 61, "has_more": True},
@@ -602,6 +661,7 @@ async def test_work_collections_include_supported_review_request_search(monkeypa
assert await gitea_proxy.pull_requests() == []
assert requested_streams == [
("issue", 1, 50),
("filed", 1, 50),
("pull", 1, 50),
("review", 1, 50),
]
+367
View File
@@ -8,6 +8,121 @@ from src import gitea_proxy, main
from src.idempotency import IdempotencyLedger
@pytest.mark.anyio
async def test_repository_issue_templates_are_bounded_and_normalized():
async def handler(request):
assert request.url.path == "/api/v1/repos/stackchain/api/issue_templates"
return httpx.Response(200, json=[
{
"name": " Bug report ", "about": " Reproduce a defect ",
"title": "[Bug] ", "content": "## Steps\n1. ",
"labels": ["bug", " needs-triage ", "bug", 42],
},
{"name": "", "content": "ignored"},
"malformed",
] + [{"name": f"Template {index}", "content": "x"} for index in range(30)])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
templates = await gitea_proxy.repo_issue_templates("stackchain/api")
finally:
await gitea_proxy.stop_client()
assert templates[0] == {
"id": "bug-report",
"name": "Bug report",
"about": "Reproduce a defect",
"title": "[Bug] ",
"body": "## Steps\n1.",
"labels": ["bug", "needs-triage"],
}
assert len(templates) == 20
@pytest.mark.anyio
async def test_repository_issue_templates_route_checks_access_and_returns_blank_fallback(monkeypatch):
calls = []
async def access(repository):
calls.append(("access", repository))
return {"full_name": repository}
async def templates(repository):
calls.append(("templates", repository))
return []
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
monkeypatch.setattr(main.gitea_proxy, "repo_issue_templates", templates, 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/issue-templates")
assert response.status_code == 200
assert response.json() == []
assert calls == [("access", "stackchain/api"), ("templates", "stackchain/api")]
@pytest.mark.anyio
async def test_issue_filing_metadata_authorizes_once_and_isolates_concurrent_section_failures(monkeypatch):
calls = []
started = set()
all_started = asyncio.Event()
async def access(repository):
calls.append(("access", repository))
return {"full_name": repository}
async def section(name, result=None, error=None):
calls.append((name, "stackchain/api"))
started.add(name)
if len(started) == 3:
all_started.set()
await asyncio.wait_for(all_started.wait(), timeout=0.2)
if error:
raise error
return result
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
monkeypatch.setattr(
main.gitea_proxy, "repo_labels",
lambda repository: section("labels", [{"id": 3, "name": "P0"}]),
)
monkeypatch.setattr(
main.gitea_proxy, "repo_milestones",
lambda repository: section("milestones", [{"id": 9, "title": "August RC"}]),
)
monkeypatch.setattr(
main.gitea_proxy, "repo_issue_templates",
lambda repository: section("templates", error=RuntimeError("templates unavailable")),
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/repos/stackchain/api/issue-filing-metadata"
)
assert response.status_code == 200
assert response.json() == {
"labels": {"available": True, "items": [{"id": 3, "name": "P0"}]},
"milestones": {
"available": True,
"items": [{"id": 9, "title": "August RC"}],
},
"templates": {
"available": False,
"items": [],
"error": "Issue types could not be loaded.",
},
}
assert calls.count(("access", "stackchain/api")) == 1
assert set(calls[1:]) == {
("labels", "stackchain/api"),
("milestones", "stackchain/api"),
("templates", "stackchain/api"),
}
@pytest.mark.anyio
async def test_owned_comment_mutations_verify_thread_and_author_before_writing():
requests = []
@@ -215,6 +330,81 @@ async def test_gitea_milestone_update_revalidates_assignment_and_open_repository
}
@pytest.mark.anyio
@pytest.mark.parametrize("due_date", ["2026-08-31T23:59:59Z", None])
async def test_release_plan_applies_milestone_and_optional_deadline_in_one_patch(due_date):
requests = []
existing_due_date = "2026-08-20T23:59:59Z"
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.method == "GET" and request.url.path.endswith("/issues/17"):
return httpx.Response(200, json={
"number": 17, "state": "open", "pull_request": None,
"assignees": [{"login": "timmy"}], "due_date": existing_due_date,
})
if request.method == "GET" and request.url.path.endswith("/milestones"):
return httpx.Response(200, json=[
{"id": 9, "title": "August RC", "state": "open"},
])
return httpx.Response(200, json={
"number": 17, "state": "open",
"milestone": {"id": 9, "title": "August RC"},
"due_date": due_date or existing_due_date,
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.update_assigned_issue_release_plan(
"stackchain/api", 17, 9, due_date
)
finally:
await gitea_proxy.stop_client()
patch_requests = [request for request in requests if request.method == "PATCH"]
assert len(patch_requests) == 1
expected_payload = {"milestone": 9}
if due_date:
expected_payload["due_date"] = due_date
assert json.loads(patch_requests[0].content) == expected_payload
assert result == {
"repository": "stackchain/api", "number": 17, "state": "open",
"milestone": {"id": 9, "title": "August RC"},
"due_date": due_date or existing_due_date,
}
@pytest.mark.anyio
async def test_release_plan_endpoint_is_bounded_and_returns_confirmed_plan(monkeypatch):
calls = []
async def update(repository, number, milestone_id, due_date):
calls.append((repository, number, milestone_id, due_date))
return {
"repository": repository, "number": number, "state": "open",
"milestone": {"id": milestone_id, "title": "August RC"},
"due_date": due_date,
}
monkeypatch.setattr(
main.gitea_proxy, "update_assigned_issue_release_plan", update, raising=False
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/release-plan",
json={"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z"},
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["milestone"] == {"id": 9, "title": "August RC"}
assert response.json()["due_date"] == "2026-08-31T23:59:59Z"
assert calls == [("stackchain/api", 17, 9, "2026-08-31T23:59:59Z")]
@pytest.mark.anyio
async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch):
calls = []
@@ -545,6 +735,106 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
@pytest.mark.anyio
async def test_create_issue_endpoint_validates_and_assigns_selected_initial_owner(monkeypatch):
calls = []
async def user():
return {"login": "timmy"}
async def access(repository):
return {"full_name": repository}
async def candidates(repository):
assert repository == "stackchain/api"
return [{"login": "alex", "name": "Alex"}]
async def create(repository, title, body, assignee, label_ids):
calls.append((repository, title, assignee))
return {"number": 18, "repository": repository, "assignees": [assignee]}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={"title": "Delegate at capture", "assignee": "alex"},
)
assert response.status_code == 201
assert response.json()["assignees"] == ["alex"]
assert calls == [("stackchain/api", "Delegate at capture", "alex")]
@pytest.mark.anyio
async def test_create_issue_endpoint_preserves_explicit_no_owner(monkeypatch):
calls = []
async def user():
return {"login": "timmy"}
async def access(repository):
return {"full_name": repository}
async def create(repository, title, body, assignee, label_ids):
calls.append((repository, title, assignee))
return {"number": 19, "repository": repository, "assignees": []}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={"title": "Backlog capture", "unassigned": True},
)
assert response.status_code == 201
assert response.json()["assignees"] == []
assert calls == [("stackchain/api", "Backlog capture", None)]
@pytest.mark.anyio
async def test_create_issue_endpoint_rejects_named_and_no_owner_together():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={"title": "Contradictory owner", "unassigned": True, "assignee": "alex"},
)
assert response.status_code == 422
assert "assignee" in response.text
assert "unassigned" in response.text
@pytest.mark.anyio
async def test_initial_owner_candidates_require_repository_access_and_are_bounded(monkeypatch):
async def access(repository):
return {"full_name": repository} if repository == "stackchain/api" else None
async def candidates(repository):
return ([{"login": "bad user", "name": "Unsafe"}] +
[{"login": f"user-{index}", "name": f"User {index}"} for index in range(40)])
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
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/issue-assignees")
missing = await client.get("/api/v1/repos/stackchain/private/issue-assignees")
assert listed.status_code == 200
assert listed.headers["cache-control"] == "no-store"
assert len(listed.json()) == 25
assert all(item["login"] != "bad user" for item in listed.json())
assert missing.status_code == 404
@pytest.mark.anyio
async def test_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
calls = []
@@ -1043,6 +1333,49 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
assert result["labels"] == ["P0"]
@pytest.mark.anyio
async def test_gitea_create_issue_omits_assignment_and_confirms_no_owner():
requests = []
async def handler(request):
requests.append(request)
return httpx.Response(201, json={
"id": 83, "number": 19, "title": "Backlog capture", "state": "open",
"updated_at": "2026-08-07T03:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/19",
"assignees": [], "labels": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.create_issue(
"stackchain/api", "Backlog capture", "Context", None, []
)
finally:
await gitea_proxy.stop_client()
assert json.loads(requests[0].content) == {
"title": "Backlog capture", "body": "Context"
}
assert result["assignees"] == []
@pytest.mark.anyio
async def test_gitea_create_issue_requires_exact_selected_initial_owner():
async def handler(_request):
return httpx.Response(201, json={
"id": 82, "number": 18, "title": "Delegate", "state": "open",
"assignees": [{"login": "alex"}, {"login": "timmy"}], "labels": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(ValueError, match="exact issue assignment"):
await gitea_proxy.create_issue("stackchain/api", "Delegate", "", "alex", [])
finally:
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_gitea_create_issue_requires_confirmed_release_plan():
async def handler(_request):
@@ -1672,6 +2005,40 @@ async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkey
}
@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_authored_filing_in_read_only_mode(monkeypatch):
calls = []
async def authored(repository, number):
calls.append(("authored", repository, number))
return (repository, number) == ("stackchain/api", 8)
async def detail(repository, number):
calls.append(("detail", repository, number))
return {"repository": repository, "number": number, "title": "Delegated filing"}
monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored, raising=False)
monkeypatch.setattr(main.gitea_proxy, "issue_detail", detail)
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/8/detail?access=filed"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"repository": "stackchain/api",
"number": 8,
"title": "Delegated filing",
"read_only": True,
}
assert calls == [
("authored", "stackchain/api", 8),
("detail", "stackchain/api", 8),
]
@pytest.mark.anyio
async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no_store(monkeypatch):
calls = []
+336 -15
View File
@@ -58,6 +58,95 @@ controller.select(file);
}
def test_mobile_evidence_bundle_keeps_order_limits_selection_and_uploads_each_image_once():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
let sequence=0; const calls=[];
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
const controller=attachment.create({{
maxFiles:5,
createOperationId:()=> 'evidence-' + (++sequence),
upload:async payload=>{{calls.push([payload.filename,payload.operation_id]);return {{markdown:'!['+payload.filename+'](url/'+payload.filename+')'}};}},
}});
for(const name of ['one.png','two.png','three.png','four.png','five.png']) controller.select(image(name));
let limit='';try{{controller.select(image('six.png'));}}catch(error){{limit=error.message;}}
controller.remove(1);
controller.select(image('replacement.png'));
(async()=>{{
const serialized=await controller.serialize();
const comment=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
const second=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
process.stdout.write(JSON.stringify({{limit,state:controller.state(),names:serialized.map(x=>x.filename),comment,second,calls}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output["limit"] == "Up to 5 screenshots. Remove one before adding another."
assert output["names"] == ["one.png", "three.png", "four.png", "five.png", "replacement.png"]
assert [item["name"] for item in output["state"]] == output["names"]
assert output["comment"] == output["second"]
assert output["comment"].startswith("Evidence sequence\n\n![one.png]")
assert len(output["calls"]) == 5
assert len({key for _, key in output["calls"]}) == 5
def test_mobile_evidence_bundle_reorders_selected_image_for_serialization_and_upload():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const uploaded=[];
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
const controller=attachment.create({{
maxFiles:5,
upload:async payload=>{{uploaded.push(payload.filename);return {{markdown:'!['+payload.filename+'](url)'}};}},
}});
['one.png','two.png','three.png'].forEach(name=>controller.select(image(name)));
controller.move(2, 0);
controller.move(0, 99);
controller.move(0, 1);
(async()=>{{
const serialized=await controller.serialize();
const comment=await controller.prepareComment({{repository:'o/r',number:825}},'Reviewed');
process.stdout.write(JSON.stringify({{names:serialized.map(x=>x.filename),uploaded,comment}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output["names"] == ["one.png", "three.png", "two.png"]
assert output["uploaded"] == output["names"]
assert output["comment"].endswith(
"![one.png](url)\n\n![three.png](url)\n\n![two.png](url)"
)
def test_mobile_evidence_notes_follow_images_and_render_as_safe_ordered_captions():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
const controller=attachment.create({{
maxFiles:5,
upload:async payload=>({{markdown:'!['+payload.filename+'](url/'+payload.filename+')'}}),
}});
['one.png','two.png','three.png'].forEach(name=>controller.select(image(name)));
controller.setNote(0, ' Login *token* is visible ');
controller.setNote(1, 'Keyboard hides [Submit](bad)');
controller.setNote(2, '');
controller.move(1, 0);
controller.remove(1);
(async()=>{{
const serialized=await controller.serialize();
const comment=await controller.prepareComment({{repository:'o/r',number:827}},'Evidence');
process.stdout.write(JSON.stringify({{serialized:serialized.map(x=>({{filename:x.filename,note:x.note||''}})),comment}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output["serialized"] == [
{"filename": "two.png", "note": "Keyboard hides [Submit](bad)"},
{"filename": "three.png", "note": ""},
]
assert output["comment"] == (
"Evidence\n\n**Screenshot 1 — Keyboard hides \\[Submit\\]\\(bad\\)**\n\n"
"![two.png](url/two.png)\n\n![three.png](url/three.png)"
)
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
@@ -191,6 +280,74 @@ const canvas = {{
assert output["name"] == "camera.jpg"
def test_browser_optimizer_downscales_small_file_when_decoded_pixels_exceed_budget():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const original = new Blob(['compressed'], {{type:'image/jpeg'}});
original.name = 'panorama.jpg';
let closed = false;
const attempts = [];
const canvas = {{
width: 0, height: 0,
getContext: () => ({{drawImage: () => {{}}}}),
toBlob: callback => {{
attempts.push([canvas.width, canvas.height]);
callback(new Blob(['bounded'], {{type:'image/jpeg'}}));
}},
}};
(async()=>{{
const result = await attachment.optimizeImage(original, {{
createImageBitmap: async () => ({{width:6000,height:4000,close:()=>{{closed=true;}}}}),
createCanvas: () => canvas,
}});
process.stdout.write(JSON.stringify({{
attempts,closed,size:result.size,type:result.type,name:result.name,
maxPixels:attachment.MAX_PIXELS,
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert len(output["attempts"]) == 1
width, height = output["attempts"][0]
assert width * height <= output["maxPixels"]
assert width < 6000
assert height < 4000
assert output["closed"] is True
assert output["size"] == 7
assert output["type"] == "image/jpeg"
assert output["name"] == "panorama.jpg"
def test_mobile_preview_inspects_small_screenshot_before_rendering_it():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
class Element{{constructor(){{this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;}}addEventListener(t,f){{this.listeners[t]=f;}}dispatch(t){{return this.listeners[t]({{target:this}});}}}}
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
const original=new Blob(['compressed'],{{type:'image/jpeg'}}); original.name='panorama.jpg';
const bounded=new Blob(['bounded'],{{type:'image/jpeg'}}); bounded.name='panorama.jpg';
const inspected=[];
attachment.mount({{
input,preview,image,meta,remove,status,
optimizeImage:async file=>{{inspected.push(file.name);return bounded;}},
createObjectURL:blob=>blob===bounded?'blob:bounded':'blob:unsafe',revokeObjectURL:()=>{{}},upload:async()=>{{}},
}});
input.files=[original];
(async()=>{{
await input.dispatch('change');
process.stdout.write(JSON.stringify({{inspected,src:image.src,hidden:preview.hidden,status:status.textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output == {
"inspected": ["panorama.jpg"],
"src": "blob:bounded",
"hidden": False,
"status": "Screenshot optimized and ready to upload.",
}
def test_binary_screenshot_builds_multipart_body_with_original_bytes():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
@@ -224,6 +381,122 @@ controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0K
assert output["removed"] is None
def test_attachment_view_restores_ordered_evidence_bundle_preview():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element{{constructor(){{this.listeners={{}};this.hidden=true;this.value='';this.textContent='';this.src='';this.disabled=false;}}addEventListener(t,f){{this.listeners[t]=f;}}}}
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();input.multiple=true;
const controller=attachment.mount({{input,preview,image,meta,remove,status,createObjectURL:blob=>'blob:'+blob.size,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
const value=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
const restored=controller.restore([value('one.png'),value('two.png')]);
process.stdout.write(JSON.stringify({{restored,src:image.src,meta:meta.textContent,hidden:preview.hidden}}));
"""
output = json.loads(run_node(script))
assert [item["name"] for item in output["restored"]] == ["one.png", "two.png"]
assert output["src"] == "blob:7"
assert output["meta"] == "2 screenshots ready · latest: two.png"
assert output["hidden"] is False
def test_evidence_review_tray_selects_reorders_and_removes_any_screenshot():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element{{
constructor(tag='div'){{this.tag=tag;this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}};this.dataset={{}};}}
addEventListener(type,fn){{this.listeners[type]=fn;}}
dispatch(type){{return this.listeners[type]({{target:this}});}}
appendChild(child){{this.children.push(child);return child;}}
replaceChildren(...children){{this.children=children;}}
setAttribute(name,value){{this.attributes[name]=String(value);}}
}}
const document={{createElement:tag=>new Element(tag)}};
const input=new Element('input'),preview=new Element(),image=new Element('img'),meta=new Element(),remove=new Element('button'),status=new Element(),tray=new Element(),earlier=new Element('button'),later=new Element('button');
input.multiple=true;
const urls=[];
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document,
removedMessage:'Screenshot removed. Your issue draft is unchanged.',
optimizeImage:async file=>file,
createObjectURL:blob=>{{const url='blob:'+blob.name;urls.push(url);return url;}},revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
const file=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
input.files=['one.png','two.png','three.png'].map(file);
(async()=>{{
await input.dispatch('change');
await tray.children[1].dispatch('click');
await earlier.dispatch('click');
const reordered=(await controller.serialize()).map(item=>item.filename);
await remove.dispatch('click');
const remaining=(await controller.serialize()).map(item=>item.filename);
process.stdout.write(JSON.stringify({{reordered,remaining,preview:image.src,meta:meta.textContent,
selected:tray.children.map(button=>button.attributes['aria-pressed']),
labels:tray.children.map(button=>button.attributes['aria-label']),
earlierDisabled:earlier.disabled,laterDisabled:later.disabled,status:status.textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output["reordered"] == ["two.png", "one.png", "three.png"]
assert output["remaining"] == ["one.png", "three.png"]
assert output["preview"] == "blob:one.png"
assert output["meta"] == "Screenshot 1 of 2 · one.png · 1 KB"
assert output["selected"] == ["true", "false"]
assert output["labels"] == [
"Review screenshot 1 of 2: one.png", "Review screenshot 2 of 2: three.png"
]
assert output["earlierDisabled"] is True
assert output["laterDisabled"] is False
assert output["status"] == "Screenshot removed. Your issue draft is unchanged."
def test_evidence_review_edits_the_active_note_and_keeps_it_visible_after_reorder():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element{{
constructor(tag='div'){{this.tag=tag;this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}};}}
addEventListener(type,fn){{this.listeners[type]=fn;}}
dispatch(type){{return this.listeners[type]({{target:this}});}}
appendChild(child){{this.children.push(child);return child;}}
replaceChildren(...children){{this.children=children;}}
setAttribute(name,value){{this.attributes[name]=String(value);}}
}}
const document={{createElement:tag=>new Element(tag)}};
const input=new Element('input'),preview=new Element(),image=new Element('img'),meta=new Element(),remove=new Element('button'),status=new Element(),tray=new Element(),earlier=new Element('button'),later=new Element('button'),note=new Element('textarea'),noteLabel=new Element('label');
input.multiple=true;
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,note,noteLabel,document,
optimizeImage:async file=>file,
createObjectURL:blob=>'blob:'+blob.name,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
const file=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
input.files=['one.png','two.png','three.png'].map(file);
(async()=>{{
await input.dispatch('change');
await tray.children[1].dispatch('click');
note.value='Keyboard hides Submit';
await note.dispatch('input');
await earlier.dispatch('click');
const afterMove={{value:note.value,label:noteLabel.textContent,disabled:note.disabled}};
await tray.children[1].dispatch('click');
const other={{value:note.value,label:noteLabel.textContent}};
process.stdout.write(JSON.stringify({{afterMove,other,serialized:(await controller.serialize()).map(x=>[x.filename,x.note||''])}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output == {
"afterMove": {
"value": "Keyboard hides Submit",
"label": "Evidence note for screenshot 1 of 3 (optional)",
"disabled": False,
},
"other": {
"value": "",
"label": "Evidence note for screenshot 2 of 3 (optional)",
},
"serialized": [
["two.png", "Keyboard hides Submit"],
["one.png", ""],
["three.png", ""],
],
}
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
@@ -264,13 +537,36 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
assert 'id="create-issue-attachment"' in html
assert 'accept="image/png,image/jpeg,image/webp"' in html
assert 'multiple' in html
assert 'Up to 5' in html
assert 'id="create-issue-attachment-preview"' in html
assert 'id="remove-create-issue-attachment"' in html
assert 'id="create-issue-evidence-tray"' in html
assert 'role="toolbar"' in html
assert 'aria-label="Evidence screenshots"' in html
assert 'id="move-create-issue-attachment-earlier"' in html
assert 'id="move-create-issue-attachment-later"' in html
assert 'id="create-issue-evidence-note-label"' in html
assert 'id="create-issue-evidence-note"' in html
assert 'maxlength="240"' in html
assert "tray: qs('#create-issue-evidence-tray')" in source
assert "earlier: qs('#move-create-issue-attachment-earlier')" in source
assert "later: qs('#move-create-issue-attachment-later')" in source
assert "note: qs('#create-issue-evidence-note')" in source
assert "noteLabel: qs('#create-issue-evidence-note-label')" in source
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
assert "attachment: await createIssueAttachmentController.serialize()" in source
assert "createIssueAttachmentController.restore(hydrated.attachment);" in source
assert "attachments:evidence" in source
assert "hydrated.attachments || hydrated.attachment" in source
assert "createIssueAttachmentController.clear();" in source
assert ".create-issue-attachment" in css
assert ".issue-evidence-tray" in css
assert ".issue-evidence-review-actions" in css
assert ".issue-evidence-note" in css
assert "resize:vertical" in css
assert "#create-issue-attachment-preview { grid-template-columns:1fr;" in css
assert "#create-issue-attachment-image { grid-row:auto; width:100%;" in css
assert "overflow-x:auto" in css
assert "min-width:44px" in css
assert "overflow-x:hidden" in css
@@ -303,15 +599,18 @@ const input=new Element(), preview=new Element(), image=new Element(), meta=new
const revoked=[];
const controller=attachment.mount({{
input,preview,image,meta,remove,status,
optimizeImage:async file=>file,
createObjectURL:()=> 'blob:preview', revokeObjectURL:url=>revoked.push(url),
readDataUrl:async()=>'', upload:async()=>{{}},
}});
input.files=[{{name:'payload.svg',type:'image/svg+xml',size:20}}]; input.dispatch('change');
const invalid={{message:status.textContent,hidden:preview.hidden}};
input.files=[{{name:'screen.png',type:'image/png',size:2048}}]; input.dispatch('change');
const selected={{src:image.src,meta:meta.textContent,hidden:preview.hidden,state:controller.state()}};
remove.dispatch('click');
process.stdout.write(JSON.stringify({{invalid,selected,removed:{{hidden:preview.hidden,src:image.src,revoked,state:controller.state()}}}}));
;(async()=>{{
input.files=[{{name:'payload.svg',type:'image/svg+xml',size:20}}]; await input.dispatch('change');
const invalid={{message:status.textContent,hidden:preview.hidden}};
input.files=[{{name:'screen.png',type:'image/png',size:2048}}]; await input.dispatch('change');
const selected={{src:image.src,meta:meta.textContent,hidden:preview.hidden,state:controller.state()}};
await remove.dispatch('click');
process.stdout.write(JSON.stringify({{invalid,selected,removed:{{hidden:preview.hidden,src:image.src,revoked,state:controller.state()}}}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output["invalid"] == {
@@ -410,21 +709,38 @@ def test_pending_comment_admission_locks_attachment_replacement_and_remove_contr
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element {{
constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
constructor() {{ this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}}; }}
addEventListener(type,fn) {{ this.listeners[type]=fn; }}
appendChild(child) {{ this.children.push(child); }}
replaceChildren(...children) {{ this.children=children; }}
setAttribute(name,value) {{ this.attributes[name]=String(value); }}
}}
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
const controller=attachment.mount({{input,preview,image,meta,remove,status,
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element(),tray=new Element(),earlier=new Element(),later=new Element();
input.multiple=true;
const document={{createElement:()=>new Element()}};
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document,
createObjectURL:()=> 'blob:preview',revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
controller.restore([
{{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}},
{{filename:'two.png',contentType:'image/png',blob:new Blob(['two'],{{type:'image/png'}})}},
]);
controller.setBusy(true);
const pending={{input:input.disabled,remove:remove.disabled}};
const pending={{input:input.disabled,remove:remove.disabled,earlier:earlier.disabled,later:later.disabled,
thumbnails:tray.children.map(button=>button.disabled)}};
controller.setBusy(false);
process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled}}}}));
process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled,
earlier:earlier.disabled,later:later.disabled,thumbnails:tray.children.map(button=>button.disabled)}}}}));
"""
assert json.loads(run_node(script)) == {
"pending": {"input": True, "remove": True},
"released": {"input": False, "remove": False},
"pending": {
"input": True, "remove": True, "earlier": True, "later": True,
"thumbnails": [True, True],
},
"released": {
"input": False, "remove": False, "earlier": False, "later": True,
"thumbnails": [False, False],
},
}
@@ -462,6 +778,11 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
assert "uploads before the comment is posted" in readme
assert "New issue" in readme
assert "retry resumes with the confirmed issue" in readme
assert "thumbnail tray" in readme
assert "Move earlier" in readme
assert "Remove selected" in readme
assert "Evidence note" in readme
assert "caption" in readme
def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_advancing():
+153
View File
@@ -0,0 +1,153 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
EDITOR = ROOT / "frontend" / "issue-evidence-editor.js"
ATTACHMENT = ROOT / "frontend" / "issue-attachment.js"
INDEX = ROOT / "frontend" / "index.html"
CSS = ROOT / "frontend" / "dashboard.css"
SERVICE_WORKER = ROOT / "frontend" / "service-worker.js"
def run_node(script: str) -> dict:
return json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
def test_editor_crops_redacts_undoes_and_flattens_derivative():
script = f"""
const editor = require({json.dumps(str(EDITOR))});
const model = editor.createModel({{width:400,height:300}});
model.setCrop({{x:40,y:30,width:240,height:180}});
model.addRedaction({{x:70,y:60,width:80,height:40}});
model.addRedaction({{x:180,y:120,width:50,height:30}});
model.undo();
const calls=[];
const canvas={{width:0,height:0,getContext:()=>({{
drawImage:(...args)=>calls.push(['draw',...args.slice(1)]),
fillRect:(...args)=>calls.push(['fill',...args]),
set fillStyle(value){{calls.push(['color',value]);}}
}}),toBlob:(callback,type)=>callback(new Blob(['flattened'],{{type}}))}};
(async()=>{{
const result=await editor.flatten({{source:{{}},model,canvas,name:'private.png',type:'image/png'}});
process.stdout.write(JSON.stringify({{snapshot:model.snapshot(),calls,width:canvas.width,height:canvas.height,
name:result.name,type:result.type,text:await result.text()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["snapshot"] == {
"crop": {"x": 40, "y": 30, "width": 240, "height": 180},
"redactions": [{"x": 70, "y": 60, "width": 80, "height": 40}],
"annotations": [],
}
assert output["width"] == 240
assert output["height"] == 180
assert output["calls"] == [
["draw", 40, 30, 240, 180, 0, 0, 240, 180],
["color", "#000000"],
["fill", 30, 30, 80, 40],
]
assert output["name"] == "private.png"
assert output["type"] == "image/png"
assert output["text"] == "flattened"
def test_applying_edit_replaces_only_selected_blob_and_preserves_note_and_order():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
let id=0;
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
const controller=attachment.create({{maxFiles:5,createOperationId:()=>`edit-${{++id}}`,upload:async()=>({{markdown:'ok'}})}});
['one.png','private.png','three.png'].forEach(name=>controller.select(image(name)));
controller.setNote(1,'Token hidden here');
controller.replace(1,image('private-edited.png'));
(async()=>{{
const values=await controller.serialize();
process.stdout.write(JSON.stringify({{values:await Promise.all(values.map(async value=>({{name:value.filename,note:value.note||'',text:await value.blob.text()}}))),state:controller.state()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["values"] == [
{"name": "one.png", "note": "", "text": "one.png"},
{"name": "private-edited.png", "note": "Token hidden here", "text": "private-edited.png"},
{"name": "three.png", "note": "", "text": "three.png"},
]
assert [item["uploaded"] for item in output["state"]] == [False, False, False]
def test_editor_adds_undoes_and_flattens_highlight_and_arrow_annotations():
script = f"""
const editor = require({json.dumps(str(EDITOR))});
const model = editor.createModel({{width:400,height:300}});
model.setCrop({{x:40,y:30,width:240,height:180}});
model.addHighlight({{x:60,y:50,width:100,height:60}});
model.addArrow({{startX:80,startY:150,endX:220,endY:80}});
const beforeUndo=model.snapshot();
model.undo();
const afterUndo=model.snapshot();
model.addArrow({{startX:80,startY:150,endX:220,endY:80}});
const calls=[];
const context={{
drawImage:()=>calls.push(['draw']), fillRect:(...args)=>calls.push(['fill',...args]),
save:()=>calls.push(['save']), restore:()=>calls.push(['restore']),
beginPath:()=>calls.push(['begin']), moveTo:(...args)=>calls.push(['move',...args]),
lineTo:(...args)=>calls.push(['line',...args]), stroke:()=>calls.push(['stroke']),
set fillStyle(value){{calls.push(['fillStyle',value]);}},
set strokeStyle(value){{calls.push(['strokeStyle',value]);}},
set lineWidth(value){{calls.push(['lineWidth',value]);}},
set lineCap(value){{calls.push(['lineCap',value]);}},
set lineJoin(value){{calls.push(['lineJoin',value]);}},
}};
const canvas={{width:0,height:0,getContext:()=>context}};
editor.paint({{source:{{}},model,canvas}});
process.stdout.write(JSON.stringify({{beforeUndo,afterUndo,calls}}));
"""
output = run_node(script)
assert [item["type"] for item in output["beforeUndo"]["annotations"]] == ["highlight", "arrow"]
assert [item["type"] for item in output["afterUndo"]["annotations"]] == ["highlight"]
assert ["fillStyle", "rgba(250, 204, 21, 0.38)"] in output["calls"]
assert ["fill", 20, 20, 100, 60] in output["calls"]
assert ["strokeStyle", "#facc15"] in output["calls"]
assert ["move", 40, 120] in output["calls"]
assert ["line", 180, 50] in output["calls"]
assert output["calls"].count(["stroke"]) == 1
def test_crop_clips_annotations_to_the_retained_image_area():
script = f"""
const editor = require({json.dumps(str(EDITOR))});
const model = editor.createModel({{width:300,height:200}});
model.addHighlight({{x:20,y:30,width:100,height:80}});
model.addArrow({{startX:10,startY:90,endX:120,endY:90}});
model.setCrop({{x:50,y:20,width:100,height:120}});
process.stdout.write(JSON.stringify(model.snapshot()));
"""
output = run_node(script)
assert output["annotations"] == [
{"type": "highlight", "x": 50, "y": 30, "width": 70, "height": 80},
{"type": "arrow", "startX": 50, "startY": 90, "endX": 120, "endY": 90},
]
def test_mobile_editor_dialog_is_accessible_touch_sized_and_available_offline():
html = INDEX.read_text()
css = CSS.read_text()
worker = SERVICE_WORKER.read_text()
assert 'id="edit-create-issue-attachment"' in html
assert 'id="issue-evidence-editor"' in html
assert 'role="dialog"' in html
assert 'aria-labelledby="issue-evidence-editor-heading"' in html
assert 'id="issue-evidence-editor-canvas"' in html
assert 'id="highlight-issue-evidence"' in html
assert 'id="arrow-issue-evidence"' in html
assert 'aria-label="Screenshot editing canvas. Drag to crop, redact, highlight, or point with an arrow."' in html
assert 'aria-pressed="true"' in html
assert "issue-evidence-editor.js" in worker
assert ".issue-evidence-editor" in css
assert "min-height:44px" in css
assert "max-width:100%" in css
assert "overflow-x:hidden" in css
+136
View File
@@ -0,0 +1,136 @@
import json
import subprocess
from pathlib import Path
RECEIPT = Path(__file__).parents[1] / "frontend" / "issue-filing-receipt.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
def run_node(script: str) -> dict:
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_confirmed_delegated_issue_opens_an_accessible_shareable_receipt():
script = f"""
const createReceipt = require({json.dumps(str(RECEIPT))});
const element = () => ({{hidden:true,textContent:'',href:'',focused:false,focus(){{this.focused=true;}},addEventListener(name, fn){{this[name]=fn;}}}});
const elements = {{root:element(),heading:element(),key:element(),title:element(),ownership:element(),openLink:element(),shareButton:element(),fileAnotherButton:element(),doneButton:element(),status:element()}};
const controller = createReceipt({{...elements,navigator:{{share:async payload=>{{elements.shared=payload;}}}}}});
controller.show({{repository:'stackchain/dashboard',number:866,title:'Finish the filing journey',assignees:['alex'],url:'https://forge.example/git/stackchain/dashboard/issues/866'}}, elements.doneButton);
(async()=>{{await elements.shareButton.click();process.stdout.write(JSON.stringify({{
hidden:elements.root.hidden,key:elements.key.textContent,title:elements.title.textContent,
ownership:elements.ownership.textContent,href:elements.openLink.href,focused:elements.heading.focused,
shared:elements.shared,
}}));}})();
"""
output = run_node(script)
assert output == {
"hidden": False,
"key": "stackchain/dashboard#866",
"title": "Finish the filing journey",
"ownership": "Assigned to @alex.",
"href": "https://forge.example/git/stackchain/dashboard/issues/866",
"focused": True,
"shared": {
"title": "stackchain/dashboard#866 · Finish the filing journey",
"text": "Finish the filing journey",
"url": "https://forge.example/git/stackchain/dashboard/issues/866",
},
}
def test_unassigned_receipt_copies_link_and_escape_restores_launch_focus():
script = f"""
const createReceipt = require({json.dumps(str(RECEIPT))});
const element = () => ({{hidden:true,textContent:'',href:'',focusCount:0,focus(){{this.focusCount++;}},addEventListener(name, fn){{this[name]=fn;}}}});
const elements = {{root:element(),heading:element(),key:element(),title:element(),ownership:element(),openLink:element(),shareButton:element(),fileAnotherButton:element(),doneButton:element(),status:element()}};
const copied=[];const launcher=element();
const controller=createReceipt({{...elements,navigator:{{}},clipboard:{{writeText:async value=>copied.push(value)}}}});
controller.show({{repository:'stackchain/dashboard',number:867,title:'Unowned follow-up',assignees:[],url:'https://forge.example/git/stackchain/dashboard/issues/867'}},launcher);
(async()=>{{await elements.shareButton.click();elements.root.keydown({{key:'Escape',preventDefault(){{elements.prevented=true;}}}});process.stdout.write(JSON.stringify({{
ownership:elements.ownership.textContent,copied,status:elements.status.textContent,hidden:elements.root.hidden,
restored:launcher.focusCount,prevented:elements.prevented,
}}));}})();
"""
output = run_node(script)
assert output == {
"ownership": "Created with no owner.",
"copied": ["https://forge.example/git/stackchain/dashboard/issues/867"],
"status": "Issue link copied.",
"hidden": True,
"restored": 1,
"prevented": True,
}
def test_related_action_returns_only_the_confirmed_reusable_plan():
script = f"""
const createReceipt = require({json.dumps(str(RECEIPT))});
const element = () => ({{hidden:true,focusCount:0,focus(){{this.focusCount++;}},addEventListener(name, fn){{this[name]=fn;}}}});
const elements = {{root:element(),heading:element(),key:element(),title:element(),ownership:element(),openLink:element(),shareButton:element(),relatedButton:element(),fileAnotherButton:element(),doneButton:element(),status:element()}};
const plans=[];
const controller=createReceipt({{...elements,onFileRelated:plan=>plans.push(plan)}});
const plan={{repository:'stackchain/dashboard',title:'',body:'## Bug',labelIds:[7],templateId:'bug.yml'}};
controller.show({{repository:'stackchain/dashboard',number:868,title:'Filed',assignees:[],url:'https://forge.example/issues/868'}},elements.doneButton,plan);
elements.relatedButton.click();
process.stdout.write(JSON.stringify({{plans,hidden:elements.root.hidden,restored:elements.doneButton.focusCount}}));
"""
output = run_node(script)
assert output == {"plans": [{
"repository": "stackchain/dashboard", "title": "", "body": "## Bug",
"labelIds": [7], "templateId": "bug.yml",
}], "hidden": True, "restored": 1}
def test_view_in_filed_closes_receipt_and_routes_the_exact_confirmed_issue():
script = f"""
const createReceipt = require({json.dumps(str(RECEIPT))});
const element = () => ({{hidden:true,focusCount:0,focus(){{this.focusCount++;}},addEventListener(name, fn){{this[name]=fn;}}}});
const elements = {{root:element(),heading:element(),key:element(),title:element(),ownership:element(),openLink:element(),shareButton:element(),filedButton:element(),fileAnotherButton:element(),doneButton:element(),status:element()}};
const filed=[];
const controller=createReceipt({{...elements,onViewFiled:issue=>filed.push(issue)}});
controller.show({{repository:'stackchain/dashboard',number:870,title:'Delegated',assignees:['alex'],url:'https://forge.example/issues/870'}},elements.doneButton);
elements.filedButton.click();
process.stdout.write(JSON.stringify({{filed,hidden:elements.root.hidden,restored:elements.doneButton.focusCount}}));
"""
output = run_node(script)
assert output == {
"filed": [{"repository": "stackchain/dashboard", "number": 870, "title": "Delegated", "assignees": ["alex"], "url": "https://forge.example/issues/870"}],
"hidden": True, "restored": 1,
}
def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt():
index = INDEX.read_text()
css = CSS.read_text()
dashboard = DASHBOARD.read_text()
assert 'id="issue-filing-receipt" role="dialog" aria-modal="true"' in index
assert 'id="issue-filing-receipt-heading"' in index
assert 'id="issue-filing-receipt-open"' in index
assert 'id="issue-filing-receipt-filed"' in index
assert 'id="issue-filing-receipt-share"' in index
assert 'id="issue-filing-receipt-related"' in index
assert 'id="issue-filing-receipt-another"' in index
assert 'id="issue-filing-receipt-done"' in index
assert '<script src="static/issue-filing-receipt.js"></script>' in index
assert '.issue-filing-receipt-panel' in css
assert 'padding-bottom:calc(18px + env(safe-area-inset-bottom))' in css
assert '.issue-filing-receipt-actions' in css
assert 'min-height:44px' in css
assert 'createIssueFilingReceipt({' in dashboard
assert "filedButton:qs('#issue-filing-receipt-filed')" in dashboard
assert 'onViewFiled:issue =>' in dashboard
assert "is_filed:true, is_assigned:(issue.assignees || []).includes(confirmedOwnerLogin)" in dashboard
assert "confirmed.work_reasons = ['created_by_me']" in dashboard
assert "relatedButton:qs('#issue-filing-receipt-related')" in dashboard
assert 'relatedDraft:issueCapture.buildRelatedDraft(durableDraft)' in dashboard
assert "issueCapture.saveDraft(plan)" in dashboard
assert "issueFilingReceipt.show(confirmed, qs('#new-issue'), filing?.relatedDraft)" in dashboard
+350
View File
@@ -0,0 +1,350 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "issue-filing-review.js"
def run_node(script: str) -> dict:
completed = subprocess.run(
["node", "-e", script], capture_output=True, text=True, check=True
)
return json.loads(completed.stdout)
def test_review_requires_one_confirmation_and_preserves_the_reviewed_payload():
script = f"""
const createReview = require({json.dumps(str(MODULE))});
function target() {{
const listeners = {{}};
return {{
disabled:false, hidden:true, textContent:'', children:[],
addEventListener:(name, fn)=>listeners[name]=fn,
dispatch:(name, event={{}})=>listeners[name]?.({{preventDefault(){{}}, ...event}}),
focus(){{this.focused=true;}}, replaceChildren(...items){{this.children=items;}},
setAttribute(){{}}, appendChild(item){{this.children.push(item);}},
}};
}}
const sheet=target(), confirm=target(), back=target(), trigger=target(), evidence=target();
const documentRef={{
createElement:tag=>({{tag, textContent:'', children:[], appendChild(item){{this.children.push(item);}}}}),
addEventListener:()=>{{}},
}};
const admitted=[];
const review=createReview({{
sheet, confirmButton:confirm, backButton:back, evidenceList:evidence,
repository:target(), intent:target(), title:target(), body:target(), metadata:target(),
document:documentRef,
onConfirm:async payload=>{{ admitted.push(payload); return {{ok:true}}; }},
}});
const payload={{
draft:{{repository:'stackchain/dashboard',title:'Ship it',body:'Full note',labels:['P1'],
milestone:{{title:'Sprint'}},dueDate:'2026-08-20',attachments:[
{{filename:'first.png',note:'Before'}},{{filename:'second.png',note:'After'}}
]}},
intent:'create-and-start',
}};
review.open(payload, trigger);
payload.draft.title='Mutated outside';
confirm.dispatch('click');
confirm.dispatch('click');
setImmediate(()=>process.stdout.write(JSON.stringify({{
admissions:admitted.length,
reviewedTitle:admitted[0].draft.title,
evidence:evidence.children.map(item=>item.textContent),
hidden:sheet.hidden,
triggerFocused:trigger.focused || false,
}})));
"""
result = run_node(script)
assert result == {
"admissions": 1,
"reviewedTitle": "Ship it",
"evidence": ["1. first.png — Before", "2. second.png — After"],
"hidden": True,
"triggerFocused": True,
}
def test_review_lists_selected_blockers_in_filing_order():
script = f"""
const createReview=require({json.dumps(str(MODULE))});
function target(){{const listeners={{}};return{{hidden:true,disabled:false,textContent:'',children:[],addEventListener:(n,f)=>listeners[n]=f,replaceChildren(...items){{this.children=items;}},focus(){{}}}};}}
const blockerList=target();
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),blockerList,
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
review.open({{draft:{{repository:'o/r',title:'Blocked',blockers:[
{{repository:'o/api',number:7,title:'API ready'}},{{repository:'o/web',number:8,title:'Web ready'}}
]}},intent:'create-and-assign'}},target());
process.stdout.write(JSON.stringify(blockerList.children.map(item=>item.textContent)));
"""
assert run_node(script) == ["o/api #7 — API ready", "o/web #8 — Web ready"]
def test_review_names_the_selected_initial_owner():
script = f"""
const createReview=require({json.dumps(str(MODULE))});
function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}}
const metadata=target();
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),
repository:target(),intent:target(),title:target(),body:target(),metadata,
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
review.open({{draft:{{repository:'o/r',title:'Delegate',assignee:'alex',assigneeName:'Alex'}},intent:'create-and-assign'}},target());
process.stdout.write(JSON.stringify({{metadata:metadata.textContent}}));
"""
assert run_node(script)["metadata"].endswith("Owner: Alex (@alex)")
def test_review_names_explicit_no_owner():
script = f"""
const createReview=require({json.dumps(str(MODULE))});
function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}}
const metadata=target(),intent=target();
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),
repository:target(),intent,title:target(),body:target(),metadata,
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
review.open({{draft:{{repository:'o/r',title:'Backlog capture',unassigned:true}},intent:'create-and-assign'}},target());
process.stdout.write(JSON.stringify({{metadata:metadata.textContent,intent:intent.textContent}}));
"""
output = run_node(script)
assert output["metadata"].endswith("Owner: No owner")
assert output["intent"] == "Create unassigned"
def test_review_names_the_repository_issue_type():
script = f"""
const createReview=require({json.dumps(str(MODULE))});
function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}}
const issueType=target();
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),issueType,
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
review.open({{draft:{{repository:'o/r',title:'Crash',templateName:'Bug report'}},intent:'create-and-assign'}},target());
process.stdout.write(JSON.stringify({{issueType:issueType.textContent}}));
"""
assert run_node(script)["issueType"] == "Bug report"
def test_create_and_start_review_shows_estimate_and_projected_capacity():
script = f"""
const createReview=require({json.dumps(str(MODULE))});
function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}}
const metadata=target();
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),
repository:target(),intent:target(),title:target(),body:target(),metadata,
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
review.open({{draft:{{repository:'o/r',title:'Sized work',estimateMinutes:45,
todayCapacity:{{remainingMinutes:60,projectedMinutes:15}}}},intent:'create-and-start'}},target());
process.stdout.write(JSON.stringify({{metadata:metadata.textContent}}));
"""
metadata = run_node(script)["metadata"]
assert "Estimate: 45 min" in metadata
assert "Today after start: 15 min free" in metadata
def test_escape_returns_to_the_unchanged_issue_form():
script = f"""
const createReview = require({json.dumps(str(MODULE))});
function target() {{
const listeners={{}};
return {{hidden:true,disabled:false,textContent:'',children:[],
addEventListener:(name,fn)=>listeners[name]=fn,
replaceChildren(...items){{this.children=items;}},
focus(){{this.focused=true;}}, dispatch:(name,event)=>listeners[name]?.(event)}};
}}
const listeners={{}}, documentRef={{
createElement:()=>({{textContent:''}}),
addEventListener:(name,fn)=>listeners[name]=fn,
dispatch:(name,event)=>listeners[name]?.(event),
}};
const sheet=target(), trigger=target();
const review=createReview({{
sheet,confirmButton:target(),backButton:target(),evidenceList:target(),
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),
document:documentRef,onConfirm:async()=>{{}},
}});
const payload={{draft:{{repository:'o/r',title:'Original',body:'Note'}},intent:'create-and-assign'}};
review.open(payload,trigger);
documentRef.dispatch('keydown',{{key:'Escape',preventDefault(){{this.prevented=true;}}}});
process.stdout.write(JSON.stringify({{hidden:sheet.hidden,focused:trigger.focused||false,title:payload.draft.title}}));
"""
assert run_node(script) == {"hidden": True, "focused": True, "title": "Original"}
def test_review_renders_exact_evidence_bytes_and_selects_the_preview():
script = f"""
const createReview = require({json.dumps(str(MODULE))});
function target(tag='div') {{
const listeners={{}}, attributes={{}};
return {{tag,hidden:true,disabled:false,textContent:'',children:[],src:'',alt:'',
addEventListener:(name,fn)=>listeners[name]=fn,
dispatch:(name,event={{}})=>listeners[name]?.({{preventDefault(){{}},currentTarget:this,...event}}),
replaceChildren(...items){{this.children=items;}}, appendChild(item){{this.children.push(item);}},
setAttribute(name,value){{attributes[name]=String(value);}}, getAttribute:name=>attributes[name],
focus(){{this.focused=true;}}}};
}}
const documentRef={{createElement:tag=>target(tag),addEventListener:()=>{{}}}};
const evidence=target(), preview=target(), image=target('img');
const position=target(), filename=target(), note=target(), empty=target();
const created=[];
const review=createReview({{
sheet:target(),confirmButton:target(),backButton:target(),evidenceList:evidence,
evidencePreview:preview,evidenceImage:image,evidencePosition:position,
evidenceFilename:filename,evidenceNote:note,evidenceEmpty:empty,
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),
document:documentRef,createObjectURL:blob=>{{const url='blob:review-'+blob.size;created.push(url);return url;}},
revokeObjectURL:()=>{{}},onConfirm:async()=>{{}},
}});
review.open({{draft:{{repository:'o/r',title:'Privacy check',attachments:[
{{filename:'redacted.png',contentType:'image/png',blob:new Blob(['flattened-redaction']),note:'Secrets hidden'}},
{{filename:'legacy.webp',contentType:'image/webp',data:'bGVnYWN5',note:'Expected result'}}
]}},intent:'create-and-assign'}},target());
const firstButton=evidence.children[0].children[0];
const secondButton=evidence.children[1].children[0];
secondButton.dispatch('click');
process.stdout.write(JSON.stringify({{
created,thumbnailSources:[firstButton.children[0].src,secondButton.children[0].src],
labels:[firstButton.getAttribute('aria-label'),secondButton.getAttribute('aria-label')],
selected:[firstButton.getAttribute('aria-pressed'),secondButton.getAttribute('aria-pressed')],
preview:{{hidden:preview.hidden,src:image.src,alt:image.alt,position:position.textContent,
filename:filename.textContent,note:note.textContent}},emptyHidden:empty.hidden,
}}));
"""
result = run_node(script)
assert result == {
"created": ["blob:review-19"],
"thumbnailSources": [
"blob:review-19",
"data:image/webp;base64,bGVnYWN5",
],
"labels": [
"Preview evidence 1 of 2: redacted.png",
"Preview evidence 2 of 2: legacy.webp",
],
"selected": ["false", "true"],
"preview": {
"hidden": False,
"src": "data:image/webp;base64,bGVnYWN5",
"alt": "Evidence 2 of 2: legacy.webp",
"position": "Evidence 2 of 2",
"filename": "legacy.webp",
"note": "Expected result",
},
"emptyHidden": True,
}
def test_failed_filing_keeps_preview_and_success_releases_blob_url_once():
script = f"""
const createReview = require({json.dumps(str(MODULE))});
function target() {{
const listeners={{}};
return {{hidden:true,disabled:false,textContent:'',children:[],src:'',alt:'',
addEventListener:(name,fn)=>listeners[name]=fn,
dispatch:(name,event={{}})=>listeners[name]?.({{preventDefault(){{}},...event}}),
replaceChildren(...items){{this.children=items;}},appendChild(item){{this.children.push(item);}},
setAttribute(){{}},focus(){{this.focused=true;}}}};
}}
const documentRef={{createElement:()=>target(),addEventListener:()=>{{}}}};
const sheet=target(), confirm=target(), trigger=target(), image=target(), preview=target();
const revoked=[];
let attempts=0;
const review=createReview({{
sheet,confirmButton:confirm,backButton:target(),evidenceList:target(),evidencePreview:preview,
evidenceImage:image,evidencePosition:target(),evidenceFilename:target(),evidenceNote:target(),evidenceEmpty:target(),
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),status:target(),
document:documentRef,createObjectURL:blob=>'blob:attempt-'+blob.size,
revokeObjectURL:url=>revoked.push(url),
onConfirm:async()=>{{attempts+=1;if(attempts===1)throw new Error('Network unavailable');}},
}});
review.open({{draft:{{repository:'o/r',title:'Verify',attachment:{{filename:'one.png',blob:new Blob(['one'])}}}},intent:'create-and-assign'}},trigger);
confirm.dispatch('click');
setImmediate(()=>{{
const afterFailure={{hidden:sheet.hidden,previewHidden:preview.hidden,src:image.src,disabled:confirm.disabled,revoked:[...revoked]}};
confirm.dispatch('click');
setImmediate(()=>process.stdout.write(JSON.stringify({{
attempts,afterFailure,afterSuccess:{{hidden:sheet.hidden,triggerFocused:trigger.focused||false,revoked}}
}})));
}});
"""
assert run_node(script) == {
"attempts": 2,
"afterFailure": {
"hidden": False,
"previewHidden": False,
"src": "blob:attempt-3",
"disabled": False,
"revoked": [],
},
"afterSuccess": {
"hidden": True,
"triggerFocused": True,
"revoked": ["blob:attempt-3"],
},
}
def test_create_actions_review_the_complete_payload_before_durable_admission():
root = Path(__file__).parents[1]
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()
assert 'id="issue-filing-review" role="dialog" aria-modal="true"' in html
assert 'id="issue-filing-review-repository"' in html
assert 'id="issue-filing-review-intent"' in html
assert 'id="issue-filing-review-title"' in html
assert 'id="issue-filing-review-body"' in html
assert 'id="issue-filing-review-evidence"' in html
assert 'id="issue-filing-review-evidence-empty"' in html
assert 'id="issue-filing-review-evidence-preview"' in html
assert 'id="issue-filing-review-evidence-image"' in html
assert 'id="issue-filing-review-evidence-position"' in html
assert 'id="issue-filing-review-evidence-filename"' in html
assert 'id="issue-filing-review-evidence-note"' in html
assert 'id="back-to-issue-edit"' in html
assert 'id="confirm-issue-filing"' in html
assert '<script src="static/issue-filing-review.js"></script>' in html
submit_handler = dashboard.split(
"qs('#create-issue-form').addEventListener('submit'", 1
)[1].split("qs('#close-issue-sheet').addEventListener", 1)[0]
assert "filingReview.open" in submit_handler
assert "issueOutbox.enqueueDurably" not in submit_handler
assert "async function admitReviewedIssue" in dashboard
assert "evidencePreview: qs('#issue-filing-review-evidence-preview')" in dashboard
assert "evidenceImage: qs('#issue-filing-review-evidence-image')" in dashboard
assert "createObjectURL: blob => URL.createObjectURL(blob)" in dashboard
assert "revokeObjectURL: url => URL.revokeObjectURL(url)" in dashboard
assert "issueOutbox.enqueueDurably" in dashboard.split(
"async function admitReviewedIssue", 1
)[1].split("qs('#create-issue-form').addEventListener", 1)[0]
assert ".issue-filing-review" in css
assert "max-height:100dvh" in css
assert "overflow-x:hidden" in css
assert "env(safe-area-inset-bottom)" in css
assert ".issue-filing-evidence-preview" in css
assert ".issue-filing-evidence-selector" in css
assert "min-height:44px" in css.split(".issue-filing-evidence-selector", 1)[1]
assert '"static/issue-filing-review.js"' in bundle
def test_duplicate_acknowledgement_preserves_the_selected_filing_intent():
dashboard = (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
assert "let pendingIssueFilingIntent = 'create-and-assign';" in dashboard
assert "if (event.submitter) pendingIssueFilingIntent" in dashboard
assert "const intent = pendingIssueFilingIntent;" in dashboard
assert "pendingIssueFilingIntent = 'create-and-assign';" in dashboard.split(
"async function admitReviewedIssue", 1
)[1].split("qs('#create-issue-form').addEventListener", 1)[0]
+294 -1
View File
@@ -44,6 +44,61 @@ confirmedLogin = 'alexander';
assert len(output["calls"]) == 1
def test_confirmed_delivery_returns_the_safe_related_plan_without_sending_it_to_gitea():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
let sentBody;
const outbox=createIssueOutbox({{
storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'first-operation',
fetchJson:async (_url,options)=>{{sentBody=JSON.parse(options.body);return {{repository:'stackchain/dashboard',number:869,title:'Related source',assignees:[]}};}},
}});
outbox.enqueue({{
repository:'stackchain/dashboard',title:'Related source',body:'Filled report',labelIds:[7],
relatedDraft:{{repository:'stackchain/dashboard',title:'',body:'## Bug',labelIds:[7],templateId:'bug.yml',templateName:'Bug report',capturedBody:''}},
}});
outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result,sentBody}})));
"""
output = run_node(script)
assert output["result"]["filings"] == [{
"issue": {"repository": "stackchain/dashboard", "number": 869,
"title": "Related source", "assignees": []},
"relatedDraft": {"repository": "stackchain/dashboard", "title": "",
"body": "## Bug", "labelIds": [7], "templateId": "bug.yml",
"templateName": "Bug report", "capturedBody": ""},
}]
assert "relatedDraft" not in output["sentBody"]
def test_editing_a_queued_issue_refreshes_its_related_plan():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
let sequence=0;
const outbox=createIssueOutbox({{
storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'operation-' + (++sequence),
fetchJson:async ()=>({{repository:'stackchain/dashboard',number:870,title:'Edited',assignees:[]}}),
}});
const queued=outbox.enqueue({{
repository:'stackchain/dashboard',title:'Original',body:'One',
relatedDraft:{{repository:'stackchain/dashboard',title:'',body:'Old scaffold',labelIds:[1]}},
}});
outbox.update(queued.id,{{
repository:'stackchain/dashboard',title:'Edited',body:'Two',
relatedDraft:{{repository:'stackchain/dashboard',title:'',body:'New scaffold',labelIds:[2]}},
}});
outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify(result.filings[0].relatedDraft)));
"""
assert run_node(script) == {
"repository": "stackchain/dashboard", "title": "", "body": "New scaffold",
"labelIds": [2],
}
def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
@@ -71,6 +126,180 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
assert output["stored"]["version"] == 3
def test_issue_outbox_preserves_initial_owner_and_disables_start_for_teammate():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'owner-outbox',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:18,assignees:['alex']}};}}}});
const queued=outbox.enqueue({{repository:'o/r',title:'Delegate',assignee:'alex',assigneeName:'Alex',completionIntent:'create-and-start'}});
(async()=>{{await outbox.flush('timmy');process.stdout.write(JSON.stringify({{queued,calls}}));}})();
"""
output = run_node(script)
assert output["queued"]["assignee"] == "alex"
assert output["queued"]["assigneeName"] == "Alex"
assert "completionIntent" not in output["queued"]
assert output["calls"] == [{
"title": "Delegate", "body": "", "label_ids": [], "assignee": "alex"
}]
def test_issue_outbox_preserves_no_owner_and_delivers_unassigned_intent():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'unowned-outbox',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:19,assignees:[]}};}}}});
const queued=outbox.enqueue({{repository:'o/r',title:'Backlog capture',unassigned:true,completionIntent:'create-and-start'}});
(async()=>{{await outbox.flush('timmy');process.stdout.write(JSON.stringify({{queued,calls}}));}})();
"""
output = run_node(script)
assert output["queued"]["unassigned"] is True
assert "completionIntent" not in output["queued"]
assert output["calls"] == [{
"title": "Backlog capture", "body": "", "label_ids": [], "unassigned": True
}]
def test_editing_queued_issue_can_return_initial_owner_to_self():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let id=0;const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>String(++id)}});
const queued=outbox.enqueue({{repository:'o/r',title:'Delegate',assignee:'alex',assigneeName:'Alex'}});
const updated=outbox.update(queued.id,{{...queued,assignee:'',assigneeName:'',completionIntent:'create-and-start'}});
process.stdout.write(JSON.stringify(updated));
"""
output = run_node(script)
assert "assignee" not in output
assert "assigneeName" not in output
assert output["completionIntent"] == "create-and-start"
assert output["operationId"] != "1"
def test_editing_queued_no_owner_to_self_rotates_delivery_and_allows_start():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let id=0;const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>String(++id)}});
const queued=outbox.enqueue({{repository:'o/r',title:'Backlog',unassigned:true}});
const updated=outbox.update(queued.id,{{...queued,unassigned:false,completionIntent:'create-and-start',estimateMinutes:30}});
process.stdout.write(JSON.stringify({{queued,updated}}));
"""
output = run_node(script)
assert output["queued"]["unassigned"] is True
assert "unassigned" not in output["updated"]
assert output["updated"]["completionIntent"] == "create-and-start"
assert output["updated"]["estimateMinutes"] == 30
assert output["updated"]["operationId"] != output["queued"]["operationId"]
def test_create_and_start_estimate_survives_outbox_delivery_and_completion():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox=createIssueOutbox({{
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'estimated-start',
fetchJson:async()=>({{number:849,title:'Estimated work'}}),
}});
const queued=outbox.enqueue({{repository:'stackchain/dashboard',title:'Estimated work',
completionIntent:'create-and-start',estimateMinutes:45}});
(async()=>{{
const delivered=await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{queued,completion:delivered.completions[0]}}));
}})();
"""
output = run_node(script)
assert output["queued"]["estimateMinutes"] == 45
assert output["completion"]["estimateMinutes"] == 45
def test_editing_queued_start_estimate_rotates_the_delivery_operation():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});const values=new Map();let sequence=0;
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-'+(++sequence)}});
const queued=outbox.enqueue({{repository:'o/r',title:'Sized',completionIntent:'create-and-start',estimateMinutes:30}});
const updated=outbox.update(queued.id,{{...queued,estimateMinutes:60}});
process.stdout.write(JSON.stringify({{queued,updated}}));
"""
output = run_node(script)
assert output["updated"]["estimateMinutes"] == 60
assert output["updated"]["operationId"] != output["queued"]["operationId"]
def test_issue_outbox_preserves_bounded_ordered_evidence_bundle():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const image=name=>({{filename:name,contentType:'image/png',data:name,note:'Explain '+name}});
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'bundle'}});
const queued=outbox.enqueue({{repository:'o/r',title:'Journey',attachments:['one','two','three','four','five'].map(image)}});
let limit='';try{{outbox.enqueue({{repository:'o/r',title:'Too many',attachments:['1','2','3','4','5','6'].map(image)}});}}catch(error){{limit=error.message;}}
process.stdout.write(JSON.stringify({{queued,list:outbox.list(),limit}}));
"""
output = run_node(script)
assert [item["filename"] for item in output["queued"]["attachments"]] == [
"one", "two", "three", "four", "five"
]
assert output["list"][0]["attachments"] == output["queued"]["attachments"]
assert [item["note"] for item in output["queued"]["attachments"]] == [
"Explain one", "Explain two", "Explain three", "Explain four", "Explain five"
]
assert output["limit"] == "You can attach up to 5 screenshots."
def test_foreground_issue_delivery_posts_the_same_captioned_evidence_bundle():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map(),calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const fetchJson=async(url,options={{}})=>{{
let body=null;
if(options.body instanceof FormData)body={{filename:options.body.get('file').name}};
else if(options.body)body=JSON.parse(options.body);
calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
if(url.endsWith('/issues'))return{{number:827}};
if(url.endsWith('/attachments'))return{{markdown:'!['+body.filename+'](url/'+body.filename+')'}};
return{{id:9}};
}};
let id=0;const outbox=createIssueOutbox({{storage,fetchJson,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-'+(++id)}});
const image=(name,note)=>({{filename:name,contentType:'image/png',data:'eA==',note}});
outbox.enqueue({{repository:'o/r',title:'Journey',attachments:[image('one.png','First'),image('two.png','Tap *Submit*')]}});
(async()=>{{const result=await outbox.flush('timmy');process.stdout.write(JSON.stringify({{result,calls}}));}})();
"""
output = run_node(script)
assert output["result"]["confirmed"][0]["number"] == 827
uploads = [call for call in output["calls"] if call["url"].endswith("/attachments")]
assert [call["body"]["filename"] for call in uploads] == ["one.png", "two.png"]
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
assert len(comments) == 1
assert comments[0]["body"]["body"] == (
"**Screenshot 1 — First**\n\n![one.png](url/one.png)\n\n"
"**Screenshot 2 — Tap \\*Submit\\***\n\n![two.png](url/two.png)"
)
def test_issue_outbox_replaces_evidence_bundle_when_editing_a_queued_issue():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});const values=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};const image=name=>({{filename:name,contentType:'image/png',data:name}});
let sequence=0;const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>String(++sequence)}});
const queued=outbox.enqueue({{repository:'o/r',title:'Journey',attachments:[image('old-one'),image('old-two')]}});
const updated=outbox.update(queued.id,{{...queued,attachments:[image('new-one'),image('new-two'),image('new-three')]}});
process.stdout.write(JSON.stringify(updated));
"""
output = run_node(script)
assert [item["filename"] for item in output["attachments"]] == ["new-one", "new-two", "new-three"]
assert output["operationId"] != output["id"]
def test_issue_outbox_persists_a_validated_screenshot_with_the_capture():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
@@ -94,6 +323,27 @@ process.stdout.write(JSON.stringify(createIssueOutbox({{storage}}).list()[0]));
}
def test_issue_outbox_hydrates_durable_evidence_bundle_without_copying_blobs_to_localstorage():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
let durable=null;
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'bundle',backgroundSync:{{
reconcile:async items=>{{durable=items[0];}},requestSync:async()=>{{}},get:async()=>durable,
}}}});
(async()=>{{const admitted=await outbox.enqueueDurably({{repository:'o/r',title:'Journey',attachments:['one','two'].map(image)}});
const raw=values.get('stackchain.issue-outbox.v1');const hydrated=await outbox.hydrateForEdit(admitted.item.id);
process.stdout.write(JSON.stringify({{raw,names:hydrated.attachments.map(x=>x.filename),sizes:hydrated.attachments.map(x=>x.blob.size)}}));}})();
"""
output = run_node(script)
assert '"stored":true' in output["raw"]
assert '"blob"' not in output["raw"]
assert output["names"] == ["one", "two"]
assert output["sizes"] == [3, 3]
def test_issue_outbox_hydrates_a_metadata_only_screenshot_for_edit_without_copying_bytes_to_localstorage():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
@@ -260,6 +510,49 @@ const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{f
assert output["remaining"] == []
def test_foreground_blocker_retry_checkpoints_each_relationship_without_recreating_issue():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const calls=[];let secondAttempts=0;
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createIssueOutbox({{
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'blocked-op',
fetchJson:async(url,options={{}})=>{{
const body=options.body ? JSON.parse(options.body) : null;
calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
if(url.endsWith('/issues'))return{{repository:'o/r',number:42,title:'Blocked work'}};
if(body?.number===8 && secondAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
return{{number:42,dependencies_available:true,dependencies:[body]}};
}},
}});
const blockers=[
{{repository:'o/api',number:7,title:'API ready'}},
{{repository:'o/web',number:8,title:'Web ready'}},
];
const queued=outbox.enqueue({{repository:'o/r',title:'Blocked work',body:'',blockers}});
(async()=>{{
await outbox.flush('timmy');const partial=outbox.list()[0];
const result=await outbox.retry(queued.id,'timmy');
process.stdout.write(JSON.stringify({{queued,partial,result,calls,remaining:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output["queued"]["blockers"] == [
{"repository": "o/api", "number": 7, "title": "API ready"},
{"repository": "o/web", "number": 8, "title": "Web ready"},
]
assert output["partial"]["deliveredIssue"]["number"] == 42
assert output["partial"]["deliveredBlockers"] == 1
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
blocker_calls = [call for call in output["calls"] if call["url"].endswith("/blockers")]
assert [call["body"]["number"] for call in blocker_calls] == [7, 8, 8]
assert blocker_calls[0]["key"] == "blocked-op:blocker-0"
assert blocker_calls[-1]["key"] == "blocked-op:blocker-1"
assert output["result"]["confirmed"][0]["number"] == 42
assert output["remaining"] == []
def test_replacing_a_partial_capture_screenshot_keeps_issue_and_restarts_upload_stage():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
@@ -730,7 +1023,7 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio
assert '<script src="static/issue-outbox.js"></script>' in html
assert "const issueOutbox = createIssueOutbox({" in html
assert "await issueOutbox.enqueueDurably(durableDraft)" in html
assert "await issueOutbox.enqueueDurably(deliveryDraft)" in html
assert "Saving for background delivery…" in html
assert "Saved for next launch; background delivery unavailable." in html
assert "throw new Error('Background Sync unavailable')" in html
+19
View File
@@ -132,3 +132,22 @@ process.stdout.write(JSON.stringify({{first, second, confirmations}}));
output = run_node(script)
assert output == {"first": True, "second": False, "confirmations": 1}
def test_picker_item_can_own_batch_confirmation_and_run_after_close():
script = f"""
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
const order=[];
const picker=createLaterPicker({{
now:() => new Date(2026,7,14,8,0),
onConfirm:()=>{{order.push('global');return true;}},
}});
picker.open({{confirm:until=>() => order.push(until.toISOString())}},null,'search-batch');
const saved=picker.submit('2026-08-15T09:00');
process.stdout.write(JSON.stringify({{saved,order}}));
"""
assert run_node(script) == {
"saved": True,
"order": ["2026-08-15T09:00:00.000Z"],
}
+1 -1
View File
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v101" in source
assert "stackchain-dashboard-shell-v102" in source
assert "BASE + 'static/later-sync.js'" in source
+3 -6
View File
@@ -325,11 +325,13 @@ async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch):
@pytest.mark.anyio
async def test_live_snapshot_reuses_completed_snapshot_inside_freshness_window(monkeypatch):
user_calls = 0
user_started = asyncio.Event()
release = asyncio.Event()
async def user():
nonlocal user_calls
user_calls += 1
user_started.set()
return {"id": 1, "login": "timmy"}
async def blocked_repos():
@@ -350,13 +352,8 @@ async def test_live_snapshot_reuses_completed_snapshot_inside_freshness_window(m
monkeypatch.setattr(main, "notifications", empty)
first = asyncio.create_task(main.live_snapshot())
await asyncio.sleep(0)
await user_started.wait()
second = asyncio.create_task(main.live_snapshot())
for _ in range(100):
if user_calls:
break
await asyncio.to_thread(lambda: None)
assert user_calls == 1
release.set()
await asyncio.gather(first, second)
+1 -1
View File
@@ -137,4 +137,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-v101" in worker
assert "stackchain-dashboard-shell-v102" in worker
+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-v101" in worker
assert "stackchain-dashboard-shell-v102" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v101" in worker
assert "stackchain-dashboard-shell-v102" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+33 -6
View File
@@ -331,7 +331,7 @@ sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
const close = new FakeElement();
const badge = new FakeElement();
const deadline = new FakeElement();
const rows = Object.fromEntries(['today','agenda','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()]));
const rows = Object.fromEntries(['today','agenda','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
const selected = [];
const utilities = [];
@@ -342,7 +342,7 @@ const dock = createDock({{
observe() {{}},
}});
dock.start();
dock.updateQueues({{today:2, agenda:5, attention:1, update:5, later:3, draft:4}});
dock.updateQueues({{today:2, agenda:5, attention:1, update:5, filed:2, later:3, draft:4}});
queues.click();
const opened = sheet.open;
rows.update.click();
@@ -387,9 +387,9 @@ process.stdout.write(JSON.stringify({{
"utilities": ["recaps:trigger"],
"badge": "0 active",
"populated": {
"badge": "5 active",
"badge": "6 active",
"badgeHidden": False,
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Later 3, Drafts 4; 5 active queues",
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 6 active queues",
"deadline": "5 due",
"deadlineHidden": False,
"agendaDue": "true",
@@ -398,13 +398,13 @@ process.stdout.write(JSON.stringify({{
"badge": "1 active",
"badgeHidden": False,
"deadlineHidden": True,
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Later 0, Drafts 0; 1 active queue",
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
},
"clearedBadgeHidden": True,
"clearedDeadlineHidden": True,
"clearedAgendaDue": None,
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "later": "0", "draft": "0", "recaps": "0"},
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
"updateLabel": "Updates, 0 unread conversations",
"queueFocuses": 3,
"columnState": None,
@@ -439,6 +439,28 @@ process.stdout.write(JSON.stringify({{opened, empty, calls}}));
}
def test_mobile_queue_launcher_uses_dedicated_filed_follow_up_flow():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
const launcher = createLauncher({{
openFiled: () => {{ calls.push('filed-follow-up'); return 'opened-update'; }},
selectFilter: name => calls.push('generic-filter:' + name),
firstAction: () => {{ throw new Error('generic card launch must not run'); }},
announce: message => calls.push('announce:' + message),
}});
const result = launcher.open('filed');
process.stdout.write(JSON.stringify({{result, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"result": "opened-update",
"calls": ["filed-follow-up"],
}
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
@@ -556,6 +578,11 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
assert 'data-mobile-queue="attention"' in html
assert 'data-mobile-queue="update"' in html
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
assert 'data-mobile-queue="filed"' in html
assert '<strong>Filed</strong><small>Issues you delegated</small>' in html
assert 'data-mobile-queue-count="filed"' in html
assert "openFiled: openFiledFollowUp" in html
assert "filedFollowUpTarget(lastMyWork)" in html
assert 'data-mobile-queue="later"' in html
assert 'data-mobile-queue="draft"' in html
assert 'data-mobile-queue="recaps"' in html
+470 -12
View File
@@ -44,6 +44,34 @@ AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-sessi
UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js"
def test_filed_follow_up_target_preserves_queue_order_and_selects_update_reader():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const choose = buildMyWork.filedFollowUpTarget;
const available = typeof choose === 'function';
const quiet = {{key:'repo#1', is_filed:true, has_update:false}};
const unread = {{key:'repo#2', is_filed:true, has_update:true, notification_id:22}};
const unrelated = {{key:'repo#3', is_filed:false, has_update:true}};
const results = available ? {{
first:choose([unrelated, unread, quiet]),
fallback:choose([unrelated, quiet, unread]),
empty:choose([unrelated]),
}} : null;
process.stdout.write(JSON.stringify({{available, results}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"available": True,
"results": {
"first": {"kind": "update", "item": {"key": "repo#2", "is_filed": True, "has_update": True, "notification_id": 22}},
"fallback": {"kind": "issue", "item": {"key": "repo#1", "is_filed": True, "has_update": False}},
"empty": None,
},
}
def test_updates_rank_actionable_work_before_newer_routine_activity():
payload = {
"user": {"login": "timmy"},
@@ -1033,6 +1061,8 @@ def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
const routes = require({json.dumps(str(WORK_ROUTE))});
const inputs = [
{{kind:'issue',repository:'stackchain/api',number:17}},
{{kind:'filed',repository:'stackchain/api',number:18}},
{{kind:'issue',repository:'stackchain/api',number:19,is_filed:true,is_assigned:false}},
{{kind:'pull',repository:'stackchain/dashboard',number:42}},
{{kind:'review',repository:'stackchain/dashboard',number:42}},
{{kind:'update',notification_id:913}},
@@ -1055,12 +1085,16 @@ process.stdout.write(JSON.stringify({{
assert json.loads(result.stdout) == {
"paths": [
"#/my-work/issue/stackchain/api/17",
"#/my-work/filed/stackchain/api/18",
"#/my-work/filed/stackchain/api/19",
"#/my-work/pull/stackchain/dashboard/42",
"#/my-work/review/stackchain/dashboard/42",
"#/my-work/update/913",
],
"parsed": [
{"kind": "issue", "repository": "stackchain/api", "number": 17},
{"kind": "filed", "repository": "stackchain/api", "number": 18},
{"kind": "filed", "repository": "stackchain/api", "number": 19},
{"kind": "pull", "repository": "stackchain/dashboard", "number": 42},
{"kind": "review", "repository": "stackchain/dashboard", "number": 42},
{"kind": "update", "notification_id": 913},
@@ -1158,7 +1192,7 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.p
def test_every_mobile_queue_route_survives_hydration_and_detail_back_navigation():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const canonical = ['today', 'agenda', 'attention', 'updates', 'later', 'drafts'];
const canonical = ['today', 'agenda', 'attention', 'filed', 'updates', 'later', 'drafts'];
const parsed = canonical.map(name => [name, routes.parse('#/my-work/' + name)]);
const listeners = {{}};
const location = {{hash:'#/my-work/agenda'}};
@@ -1194,6 +1228,7 @@ process.stdout.write(JSON.stringify({{parsed, calls, hash:location.hash}}));
["today", {"kind": "queue", "filter": "today"}],
["agenda", {"kind": "queue", "filter": "agenda"}],
["attention", {"kind": "queue", "filter": "attention"}],
["filed", {"kind": "queue", "filter": "filed"}],
["updates", {"kind": "queue", "filter": "update"}],
["later", {"kind": "queue", "filter": "later"}],
["drafts", {"kind": "queue", "filter": "draft"}],
@@ -1320,6 +1355,24 @@ const calls = [];
}
@pytest.mark.anyio
async def test_dashboard_opens_delegated_filings_as_read_only_issue_sheets():
html = await dashboard()
assert "else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);" in html
assert "const readOnly = issueController.readOnly(item);" in html
assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html
assert "#issue-sheet.read-only .issue-comment-composer" in html
assert "#issue-sheet.read-only #issue-planning" in html
assert "#issue-sheet.read-only #issue-handoff" in html
assert "#issue-sheet.read-only #release-issue" in html
assert "#issue-sheet.read-only #close-issue" in html
assert "#issue-sheet.read-only .detail-defer" in html
assert "if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);" in html
assert "qs('#load-older-issue-comments').hidden = true;" in html
assert "readOnly ? 'Filed issue ready · read-only'" in html
@pytest.mark.anyio
async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share():
html = await dashboard()
@@ -1549,6 +1602,43 @@ process.stdout.write(JSON.stringify({{updated:updated.issues[0],original:origina
}
def test_issue_sheet_identifies_delegated_filings_as_read_only():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const controller = createIssueSheet({{fetchJson:async () => ({{}}), storage:null}});
process.stdout.write(JSON.stringify([
controller.readOnly({{kind:'filed',is_filed:true,is_assigned:false}}),
controller.readOnly({{kind:'issue',is_filed:true,is_assigned:true}}),
controller.readOnly({{kind:'issue',is_assigned:true}}),
]));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == [True, False, False]
def test_issue_sheet_loads_filed_items_through_read_only_access():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const urls = [];
const controller = createIssueSheet({{
fetchJson: async url => {{ urls.push(url); return {{}}; }}, storage:null,
}});
Promise.all([
controller.load({{kind:'issue',repository:'stackchain/api',number:17,is_assigned:true}}),
controller.load({{kind:'filed',repository:'stackchain/api',number:18,is_filed:true,is_assigned:false}}),
]).then(() => process.stdout.write(JSON.stringify(urls)));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == [
"api/v1/repos/stackchain/api/issues/17/detail",
"api/v1/repos/stackchain/api/issues/18/detail?access=filed",
]
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -1578,6 +1668,40 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
assert output["results"][0]["available"] is True
def test_related_issue_draft_reuses_plan_but_resets_issue_specific_work():
script = f"""
const createIssueSheet = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const related = createIssueSheet.buildRelatedDraft({{
repository:'stackchain/dashboard', title:'Completed discovery', body:'Filled private report',
labelIds:[7, 7, 9], milestoneId:4, dueDate:'2026-08-20',
assignee:'alex', assigneeName:'Alexander', unassigned:false,
templateId:'bug.yml', templateName:'Bug report', capturedBody:'Original notes',
blockers:[{{repository:'stackchain/api',number:2}}], estimateMinutes:45,
completionIntent:'create-and-start', operationId:'already-delivered',
attachment:{{name:'secret.png'}}, duplicateAcknowledged:true,
}}, {{id:'bug.yml', name:'Bug report', body:'## What happened?\\n\\n## Expected'}});
process.stdout.write(JSON.stringify(related));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
output = json.loads(result.stdout)
assert output == {
"repository": "stackchain/dashboard",
"title": "",
"body": "## What happened?\n\n## Expected",
"labelIds": [7, 9],
"milestoneId": 4,
"dueDate": "2026-08-20",
"assignee": "alex",
"assigneeName": "Alexander",
"templateId": "bug.yml",
"templateName": "Bug report",
"capturedBody": "",
}
def test_issue_release_rejects_response_that_still_assigns_current_user():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -1844,9 +1968,8 @@ async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
assert 'id="create-issue-due-date" type="date"' in html
assert 'id="create-issue-milestone-status" aria-live="polite"' in html
assert '.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px;' in html
assert 'issueCapture.loadMilestones(repository)' in html
assert "milestoneId: Number(qs('#create-issue-milestone').value) || null" in html
assert "dueDate: qs('#create-issue-due-date').value" in html
assert 'issueFilingMetadata.load(repository, selected)' in html
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
@pytest.mark.anyio
@@ -3734,7 +3857,7 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
)
assert json.loads(result.stdout) == {
"all": 3, "attention": 1, "issue": 1, "pull": 1, "review": 1, "update": 0
"all": 3, "attention": 1, "filed": 0, "issue": 1, "pull": 1, "review": 1, "update": 0
}
@@ -4018,11 +4141,318 @@ async def test_mobile_issue_capture_requires_explicit_searchable_repository_sele
assert "if (query.length < 2) {\n issueCapture.searchRepositories(query);" in html
assert "renderIssueRepositoryResults(state.items)" in html
assert "selectIssueCaptureRepository(repository)" in html
assert "const hasRepository = Boolean(qs('#create-issue-repository').value);" in html
assert "issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value)" in html
assert '.create-issue-repository-result { min-height:44px;' in html
assert '.create-issue-repository-picker { min-width:0;' in html
def test_issue_capture_persists_bounded_blockers_and_searches_open_issues():
script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values=new Map();const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const capture=createIssueCapture({{storage,fetchJson:async url=>{{calls.push(url);return{{items:[
{{kind:'issue',state:'open',repository:'o/api',number:7,title:'API ready'}},
{{kind:'pull',state:'open',repository:'o/web',number:8,title:'Not an issue'}},
{{kind:'issue',state:'closed',repository:'o/old',number:9,title:'Closed'}}
]}};}}}});
const blockers=[
{{repository:'o/api',number:7,title:'API ready'}},
{{repository:'o/api',number:7,title:'duplicate'}},
{{repository:'bad',number:2,title:'invalid'}},
];
capture.saveDraft({{repository:'o/r',title:'Blocked work',body:'',blockers}});
(async()=>{{const found=await capture.searchBlockers('api');process.stdout.write(JSON.stringify({{draft:capture.loadDraft(),found,calls}}));}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["draft"]["blockers"] == [
{"repository": "o/api", "number": 7, "title": "API ready"}
]
assert output["found"]["items"] == [
{"kind": "issue", "state": "open", "repository": "o/api", "number": 7, "title": "API ready"}
]
assert output["calls"] == ["api/v1/search?q=api&limit=20"]
def test_issue_capture_persists_selected_owner_and_sends_it_on_creation():
script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values=new Map(); const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const fetchJson=async (url,options={{}})=>{{calls.push({{url,body:options.body||''}});if(url.endsWith('/issue-assignees'))return [{{login:'alex',name:'Alex'}}];return {{number:18,assignees:['alex']}};}};
const capture=createIssueCapture({{storage,fetchJson,createOperationId:()=>'owner-op'}});
capture.saveDraft({{repository:'o/r',title:'Delegate',body:'Context',labelIds:[],assignee:'alex',assigneeName:'Alex'}});
(async()=>{{const owners=await capture.loadOwners('o/r');const draft=capture.loadDraft();await capture.submit(draft);process.stdout.write(JSON.stringify({{owners,draft,calls}}));}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["owners"] == [{"login": "alex", "name": "Alex"}]
assert output["draft"]["assignee"] == "alex"
assert output["draft"]["assigneeName"] == "Alex"
assert json.loads(output["calls"][-1]["body"])["assignee"] == "alex"
def test_issue_capture_persists_and_sends_explicit_no_owner():
script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values=new Map();const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const capture=createIssueCapture({{storage,createOperationId:()=>'unowned-op',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:19,assignees:[]}};}}}});
capture.saveDraft({{repository:'o/r',title:'Backlog capture',body:'Context',labelIds:[],unassigned:true}});
(async()=>{{const draft=capture.loadDraft();await capture.submit(draft);process.stdout.write(JSON.stringify({{draft,calls}}));}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["draft"]["unassigned"] is True
assert output["calls"] == [{
"title": "Backlog capture", "body": "Context", "label_ids": [], "unassigned": True
}]
def test_issue_capture_persists_estimate_and_completion_intent_for_resumed_drafts():
script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
const capture=createIssueCapture({{storage,fetchJson:async()=>{{}}}});
capture.saveDraft({{
repository:'o/r',title:'Planned work',body:'Context',labelIds:[3],
estimateMinutes:45,completionIntent:'create-and-start',
}});
process.stdout.write(JSON.stringify(capture.loadDraft()));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["estimateMinutes"] == 45
assert output["completionIntent"] == "create-and-start"
def test_issue_capture_applies_and_switches_repository_templates_without_losing_authored_work():
script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values=new Map(); const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const fetchJson=async url=>{{calls.push(url);return [{{id:'bug-report',name:'Bug report',about:'Report a defect',title:'[Bug] ',body:'## Steps\\n1.',labels:['bug','unknown']}}];}};
const capture=createIssueCapture({{storage,fetchJson}});
(async()=>{{
const templates=await capture.loadTemplates('o/r');
const labels=[{{id:3,name:'bug'}},{{id:4,name:'P1'}}];
const first=capture.applyTemplate({{repository:'o/r',title:'Camera crashes',body:'Captured on Android',labelIds:[4]}},templates[0],labels);
const second=capture.applyTemplate(first,{{id:'feature',name:'Feature',body:'## Outcome',labels:[]}},labels);
capture.saveDraft(second);
process.stdout.write(JSON.stringify({{templates,first,second,reloaded:capture.loadDraft(),calls}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["calls"] == ["api/v1/repos/o/r/issue-templates"]
assert output["first"]["title"] == "Camera crashes"
assert output["first"]["body"] == "Captured on Android\n\n---\n\n## Steps\n1."
assert output["first"]["labelIds"] == [4, 3]
assert output["second"]["body"] == "Captured on Android\n\n---\n\n## Outcome"
assert output["second"]["templateName"] == "Feature"
assert output["reloaded"]["templateName"] == "Feature"
assert output["reloaded"]["capturedBody"] == "Captured on Android"
def test_initial_owner_picker_discards_an_older_same_repository_response():
script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
let repository='o/a'; const pending=[];
function select(){{const listeners={{}};return{{value:'',dataset:{{}},children:[],selectedOptions:[],
replaceChildren(){{this.children=[];this.value='';this.selectedOptions=[];}},appendChild(option){{this.children.push(option);if(option.value===this.value)this.selectedOptions=[option];}},
addEventListener:(name,fn)=>listeners[name]=fn}};}}
const owner=select(),status={{textContent:''}},repo={{get value(){{return repository;}}}};
const documentRef={{querySelector:id=>id==='#create-issue-assignee'?owner:id==='#create-issue-assignee-status'?status:repo,
createElement:()=>({{value:'',textContent:'',dataset:{{}}}})}};
const capture={{loadOwners:()=>new Promise(resolve=>pending.push(resolve))}};
const picker=createIssueCapture.createOwnerPicker(capture,documentRef,()=>{{}});
(async()=>{{picker.reset('o/a');const first=picker.load('o/a');repository='o/b';picker.reset('o/b');
repository='o/a';picker.reset('o/a');const latest=picker.load('o/a');pending[1]([{{login:'new',name:'New'}}]);await latest;
pending[0]([{{login:'old',name:'Old'}}]);await first;
process.stdout.write(JSON.stringify(owner.children.map(option=>option.value)));}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output == ["", "__unassigned__", "new"]
def test_initial_owner_picker_exposes_no_owner_and_disables_start():
script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
function select(){{return{{value:'',dataset:{{}},children:[],selectedOptions:[],
replaceChildren(){{this.children=[];this.value='';this.selectedOptions=[];}},
appendChild(option){{this.children.push(option);}},addEventListener:()=>{{}}}};}}
const owner=select(),status={{textContent:''}},repo={{value:'o/r'}};
const submit={{disabled:false,textContent:''}},start={{disabled:false,title:''}};
const documentRef={{querySelector:id=>({{
'#create-issue-assignee':owner,'#create-issue-assignee-status':status,
'#create-issue-repository':repo,'#submit-new-issue':submit,'#create-and-start-issue':start,
}}[id]),createElement:()=>({{value:'',textContent:'',dataset:{{}}}})}};
const picker=createIssueCapture.createOwnerPicker({{loadOwners:async()=>[]}},documentRef,()=>{{}});
picker.reset('o/r',{{unassigned:true}});owner.value='__unassigned__';
picker.updateActions(true,false,true);
process.stdout.write(JSON.stringify({{options:owner.children.map(option=>[option.value,option.textContent]),fields:picker.fields(),submit,start}}));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["options"] == [["", "Me"], ["__unassigned__", "No owner"]]
assert output["fields"] == {"assignee": "", "assigneeName": "", "unassigned": True}
assert output["submit"]["textContent"] == "Create unassigned"
assert output["start"]["disabled"] is True
assert "No owner" in output["start"]["title"]
@pytest.mark.anyio
async def test_mobile_issue_capture_selects_blockers_without_starting_blocked_work():
html = await dashboard()
assert 'id="create-issue-blocker-search" type="search"' in html
assert 'id="create-issue-blocker-results" role="listbox"' in html
assert 'id="create-issue-blocker-selected"' in html
assert 'id="create-issue-blocker-status" class="small" aria-live="polite"' in html
assert "issueCapture.searchBlockers(query)" in html
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
assert "renderIssueCaptureBlockers(captureDraft.blockers || [])" in html
assert "issueCaptureBlockers.length > 0, createAndStart.available()" in html
assert ".create-issue-blocker-result" in html
assert "min-height:44px" in html.split(".create-issue-blocker-result", 1)[1]
assert "@media(max-width:320px)" in html
@pytest.mark.anyio
async def test_mobile_issue_capture_lazily_selects_an_initial_owner():
html = await dashboard()
assert 'id="create-issue-assignee"' in html
assert '<option value="">Me</option>' in html
assert '<option value="__unassigned__">No owner</option>' in html
assert 'id="create-issue-assignee-status" class="small" aria-live="polite"' in html
assert "createIssueCapture.createOwnerPicker(issueCapture, document," in html
assert "() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });" in html
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
assert "issueOwnerPicker.updateActions(" in html
assert '.create-issue-owner select { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_issue_filing_exposes_repository_issue_types_without_blocking_blank_filing():
html = await dashboard()
source = CREATE_ISSUE_SHEET.read_text()
assert 'id="create-issue-template"' in html
assert '<option value="">Blank issue</option>' in html
assert 'id="create-issue-template-status" class="small" aria-live="polite"' in html
assert 'templatePicker.render(repository, metadata.templates' in source
assert 'issueCapture.bindTemplatePicker(' in html
assert 'id="issue-filing-review-template"' in html
@pytest.mark.anyio
async def test_every_mobile_repository_selection_uses_the_resilient_metadata_loader():
html = await dashboard()
source = CREATE_ISSUE_SHEET.read_text()
assert "async function loadIssueFilingMetadata(repository" in html
assert "issueCapture.bindFilingMetadata(" in html
assert "issueFilingMetadata.load(repository, selected)" in html
assert "const metadata = await loadFilingMetadata(repository)" in source
assert "renderLabels(repository, metadata.labels" in source
assert "renderMilestones(repository, metadata.milestones" in source
assert "templatePicker.render(repository, metadata.templates" in source
assert html.count("loadIssueFilingMetadata(") == 5
assert "loadIssueLabels(repository);\n loadIssueMilestones(repository);" not in html
assert "loadIssueLabels(event.target.value).then" not in html
assert "loadIssueLabels(qs('#create-issue-repository').value" not in html
def test_issue_capture_loads_all_filing_metadata_in_one_section_isolated_request():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const calls = [];
const capture = createIssueCapture({{
storage:{{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
fetchJson:url=>{{
calls.push(url);
return Promise.resolve({{
labels:{{available:true,items:[{{id:3,name:'P0'}}]}},
milestones:{{available:false,items:[],error:'Milestones could not be loaded.'}},
templates:{{available:true,items:[{{id:'bug',name:'Bug report'}}]}},
}});
}},
}});
capture.loadFilingMetadata('stackchain/api').then(result=>
process.stdout.write(JSON.stringify({{calls,result}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output == {
"calls": ["api/v1/repos/stackchain/api/issue-filing-metadata"],
"result": {
"status": "ready",
"repository": "stackchain/api",
"labels": {"available": True, "items": [{"id": 3, "name": "P0"}]},
"milestones": {
"available": False,
"items": [],
"error": "Milestones could not be loaded.",
},
"templates": {
"available": True,
"items": [{"id": "bug", "name": "Bug report"}],
},
},
}
def test_issue_capture_rejects_filing_metadata_from_a_stale_repository_request():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const releases = {{}};
const capture = createIssueCapture({{
storage:{{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
fetchJson:url=>new Promise(resolve=>{{ releases[url] = resolve; }}),
}});
const first = capture.loadFilingMetadata('stackchain/old');
const second = capture.loadFilingMetadata('stackchain/current');
const payload = {{
labels:{{available:true,items:[]}}, milestones:{{available:true,items:[]}},
templates:{{available:true,items:[]}},
}};
releases['api/v1/repos/stackchain/current/issue-filing-metadata'](payload);
Promise.resolve().then(()=>{{
releases['api/v1/repos/stackchain/old/issue-filing-metadata'](payload);
return Promise.all([first, second]);
}}).then(results=>process.stdout.write(JSON.stringify(results)));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == [
{"status": "stale", "repository": "stackchain/old"},
{
"status": "ready", "repository": "stackchain/current",
"labels": {"available": True, "items": []},
"milestones": {"available": True, "items": []},
"templates": {"available": True, "items": []},
},
]
@pytest.mark.anyio
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
html = await dashboard()
@@ -4587,7 +5017,7 @@ process.stdout.write(JSON.stringify({{
assert output["updates"][1]["kind"] == "update"
assert output["updates"][1]["update_reason"] == ""
assert output["counts"] == {
"all": 2, "attention": 2, "issue": 1, "pull": 0, "review": 0, "update": 2
"all": 2, "attention": 2, "filed": 0, "issue": 1, "pull": 0, "review": 0, "update": 2
}
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
@@ -6309,7 +6739,7 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
assert 'openRoutedWork(created' in html
assert 'issueCapture.loadLabels(repository)' in html
assert 'issueCapture.bindFilingMetadata({' in html
assert "input[name=\"create-issue-label\"]:checked" in html
assert '.create-issue-label-option' in html and 'min-height:44px' in html
@@ -6333,9 +6763,12 @@ async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
assert "issueCapture.findDuplicates(draft)" in html
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html
assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html
assert html.index("issueCapture.needsDuplicateAcknowledgement(captureDraft)") < html.index(
"issueOutbox.enqueueDurably(durableDraft)"
)
submit_handler = html.split("qs('#create-issue-form').addEventListener('submit'", 1)[1].split(
"qs('#close-issue-sheet').addEventListener", 1
)[0]
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in submit_handler
assert "filingReview.open" in submit_handler
assert "issueOutbox.enqueueDurably" not in submit_handler
assert ".create-issue-duplicate-card" in html
assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html
assert "overflow-wrap:anywhere" in html
@@ -6553,7 +6986,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', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert "['all', 'today', 'agenda', 'attention', 'filed', '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
@@ -7304,3 +7737,28 @@ first.submit(item, payload).catch(() => {{
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {"calls": ["review-op-185", "review-op-185"], "keys": []}
def test_filed_queue_contains_authored_issues_and_deduplicates_self_assigned_filings():
payload = {
"user": {"login": "timmy"},
"issues": [
{"id": 1, "repository": "stackchain/api", "number": 1, "title": "Delegated", "assignees": ["alex"], "work_reasons": ["created_by_me"]},
{"id": 2, "repository": "stackchain/api", "number": 2, "title": "Mine", "assignees": ["timmy"], "work_reasons": ["created_by_me"]},
{"id": 3, "repository": "stackchain/api", "number": 3, "title": "Assigned only", "assignees": ["timmy"]},
],
"pull_requests": [],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = buildMyWork({json.dumps(payload)});
process.stdout.write(JSON.stringify({{
filed: buildMyWork.filterMyWork(items, 'filed').map(item => item.number).sort((a,b) => a-b),
count: buildMyWork.countMyWork(items).filed,
identities: items.map(item => item.key).sort(),
}}));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {
"filed": [1, 2], "count": 2,
"identities": ["stackchain/api#1", "stackchain/api#2", "stackchain/api#3"],
}
+1 -1
View File
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v101" in source
assert "stackchain-dashboard-shell-v102" 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
@@ -112,6 +112,7 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
== 64 * 1024
)
assert main.request_body_limit("GET", "/api/v1/context") is None
assert main.request_body_limit("PUT", "/api/v1/unfiled-drafts") == 17 * 1024 * 1024
assert (
main.request_body_limit(
"POST", "/api/v1/repos/stackchain/project/issues/17/attachments"
+95
View File
@@ -0,0 +1,95 @@
import httpx
import pytest
from src import main
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
def view(view_id="release", name="Release queue", query="mobile", **scope):
return {
"id": view_id,
"name": name,
"query": query,
"kind": scope.get("kind", "issue"),
"state": scope.get("state", "open"),
"repository": scope.get("repository", "stackchain/stackchain-dashboard"),
}
def test_saved_searches_are_revisioned_ordered_and_account_scoped(tmp_path):
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3")
created = store.replace(" Timmy ", 0, [view(), view("reviews", "My reviews", "review", kind="pull")])
assert created == {"revision": 1, "views": [view(), view("reviews", "My reviews", "review", kind="pull")]}
assert store.get("timmy") == created
assert store.get("alexander") == {"revision": 0, "views": []}
with pytest.raises(SavedSearchConflict) as conflict:
store.replace("timmy", 0, [view(name="Stale overwrite")])
assert conflict.value.snapshot == created
def test_saved_searches_validate_and_bound_the_synced_collection(tmp_path):
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3", limit=2)
with pytest.raises(ValueError, match="limited to 2"):
store.replace("timmy", 0, [view("one"), view("two"), view("three")])
with pytest.raises(ValueError, match="unique"):
store.replace("timmy", 0, [view("same"), view("same")])
invalid = [
({**view(), "name": " "}, "name is required"),
({**view(), "query": "x"}, "query must be between"),
({**view(), "kind": "commit"}, "kind is invalid"),
({**view(), "state": "merged"}, "state is invalid"),
({**view(), "repository": "not-a-repository"}, "repository is invalid"),
]
for candidate, message in invalid:
with pytest.raises(ValueError, match=message):
store.replace("timmy", 0, [candidate])
@pytest.mark.anyio
async def test_saved_search_api_is_authenticated_csrf_protected_no_store_and_conflict_safe(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv(
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
"a-separate-session-signing-secret-with-enough-entropy",
)
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_SAVED_SEARCH_DB", str(tmp_path / "searches.sqlite3"))
async def user():
return {"id": 1, "login": "Timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
forbidden = await client.put(
"/api/v1/saved-searches", json={"revision": 0, "views": [view()]}
)
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
saved = await client.put(
"/api/v1/saved-searches", json={"revision": 0, "views": [view()]}, headers=headers
)
stale = await client.put(
"/api/v1/saved-searches",
json={"revision": 0, "views": [view(name="Overwrite")]},
headers=headers,
)
fetched = await client.get("/api/v1/saved-searches")
assert forbidden.status_code == 403
assert saved.status_code == 200
assert saved.json() == {"revision": 1, "views": [view()]}
assert stale.status_code == 409
assert stale.json()["detail"] == {
"message": "Saved searches changed on another device.",
"snapshot": saved.json(),
}
assert fetched.json() == saved.json()
assert fetched.headers["cache-control"] == "no-store"
+105
View File
@@ -0,0 +1,105 @@
import json
import subprocess
from pathlib import Path
SAVED_SEARCHES = Path(__file__).parents[1] / "frontend" / "saved-searches.js"
def run_node(script):
return json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
def test_saved_search_controller_loads_saves_opens_renames_and_deletes():
script = f"""
const createSavedSearches=require({json.dumps(str(SAVED_SEARCHES))});
const requests=[]; const opened=[]; const states=[];
let remote={{revision:0,views:[]}}; let sequence=0;
const controller=createSavedSearches({{
fetchJson:async (_url,options={{}})=>{{
requests.push(options.method||'GET');
if(!options.method)return remote;
const body=JSON.parse(options.body);remote={{revision:remote.revision+1,views:body.views}};return remote;
}},
createId:()=> 'view-'+(++sequence),
onOpen:view=>opened.push(view),
onState:state=>states.push(state),
}});
(async()=>{{
await controller.load();
await controller.save('Release queue',{{query:'mobile',kind:'issue',state:'open',repository:'stackchain/dashboard'}});
controller.open('view-1');
await controller.rename('view-1','Morning release queue');
await controller.remove('view-1');
process.stdout.write(JSON.stringify({{requests,opened,snapshot:controller.snapshot(),statuses:states.map(x=>x.status)}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
assert run_node(script) == {
"requests": ["GET", "PUT", "PUT", "PUT"],
"opened": [{
"id": "view-1",
"name": "Release queue",
"query": "mobile",
"kind": "issue",
"state": "open",
"repository": "stackchain/dashboard",
}],
"snapshot": {"revision": 3, "views": []},
"statuses": ["loading", "ready", "saving", "ready", "saving", "ready", "saving", "ready"],
}
def test_saved_search_controller_adopts_conflict_and_keeps_search_usable_on_failure():
script = f"""
const createSavedSearches=require({json.dumps(str(SAVED_SEARCHES))});
const states=[];let mode='conflict';
const server={{revision:4,views:[{{id:'remote',name:'Remote',query:'review',kind:'pull',state:'open',repository:''}}]}};
const controller=createSavedSearches({{
fetchJson:async (_url,options={{}})=>{{
if(!options.method)return {{revision:3,views:[]}};
if(mode==='conflict'){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:server}}}};throw error;}}
throw new Error('offline');
}},createId:()=> 'local',onOpen:()=>{{}},onState:state=>states.push([state.status,state.message||'']),
}});
(async()=>{{
await controller.load();
try{{await controller.save('Local',{{query:'mobile',kind:'all',state:'all'}})}}catch(_error){{}}
mode='offline';
try{{await controller.remove('remote')}}catch(_error){{}}
process.stdout.write(JSON.stringify({{snapshot:controller.snapshot(),states}}));
}})();
"""
result = run_node(script)
assert result["snapshot"] == {
"revision": 4,
"views": [{
"id": "remote", "name": "Remote", "query": "review",
"kind": "pull", "state": "open", "repository": "",
}],
}
assert result["states"][-1] == ["error", "Saved searches could not sync. Search still works."]
assert ["conflict", "Saved searches changed on another device."] in result["states"]
def test_mobile_search_renders_synced_saved_view_controls_and_runtime_wiring():
from tests.dashboard_bundle import dashboard_bundle_text
html = dashboard_bundle_text()
css = (SAVED_SEARCHES.parent / "dashboard.css").read_text()
dashboard = (SAVED_SEARCHES.parent / "dashboard.js").read_text()
assert '<section class="saved-searches" aria-labelledby="saved-searches-heading">' in html
assert 'id="saved-search-name"' in html
assert 'id="save-current-search"' in html
assert 'id="saved-search-list"' in html
assert 'id="saved-search-status"' in html
assert "createSavedSearches.mount(" in dashboard
saved_searches = SAVED_SEARCHES.read_text()
assert "search.setQuery('')" in saved_searches
assert "applyScope(view)" in saved_searches
assert "search.setQuery(view.query)" in saved_searches
assert ").load();" in dashboard
assert "static/saved-searches.js" in (SAVED_SEARCHES.parent.parent / "src" / "frontend_bundle.py").read_text()
assert ".saved-search-action { min-height:44px;" in css
+263 -1
View File
@@ -69,6 +69,20 @@ Promise.all([
assert result["results"][2] == "This issue is no longer available to assign."
def test_release_planning_requires_selected_issues_from_one_repository():
script = f"""
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
const flow=createSearchBatchPlan();
flow.start();
flow.toggle({{kind:'issue',state:'open',repository:'stackchain/dashboard',number:815}});
const one=flow.releaseRepository();
flow.toggle({{kind:'issue',state:'open',repository:'stackchain/api',number:17}});
process.stdout.write(JSON.stringify({{one,mixed:flow.releaseRepository()}}));
"""
assert run_node(script) == {"one": "stackchain/dashboard", "mixed": None}
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
@@ -79,13 +93,25 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
assert 'id="select-search-results"' in html
assert 'id="search-batch-actions"' in html
assert 'id="queue-selected-search-results"' in html
assert 'id="defer-selected-search-results"' in html
assert 'id="plan-selected-search-results"' in html
assert 'id="search-release-review"' in html
assert 'id="search-release-milestone"' in html
assert 'id="search-release-due-date"' in html
assert 'id="resume-search-batch"' in html
assert 'src="static/search-batch-plan.js"' in html
assert "mountSearchBatchPlanning(" in dashboard
assert "escapeHtml, escAttr, laterWork, laterPicker" in dashboard
assert "journalName:'search-today-batch'" in controller
assert "journalName:'search-later-batch'" in controller
assert "journalName:'search-release-batch'" in controller
assert "autoMount:false" in controller
assert "plan.toggle(result)" in controller
assert "processor.run(plan.snapshot().items)" in controller
assert "processor.run(plan.snapshot().items, estimateValues())" in controller
assert "timeBudget:()" in controller
assert "persistEstimate" in controller
assert 'id="search-batch-estimate-review"' in html
assert 'data-search-batch-estimate' in controller
assert "processor.resume()" in controller
assert 'aria-label="Select ' in controller
assert "result.kind === 'issue' && result.state === 'open'" in controller
@@ -93,4 +119,240 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
assert "position:sticky" in css
assert "env(safe-area-inset-bottom)" in css
assert ".cmd-select-result" in css and "min-height:44px" in css
assert ".search-release-review" in css and "100dvh" in css
assert ".search-release-actions" in css and "env(safe-area-inset-bottom)" in css
assert "BASE + 'static/search-batch-plan.js'" in worker
def test_selected_search_issues_choose_one_future_time_before_durable_batch_defer():
script = f"""
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
const listeners={{}};
const elements={{}};
for (const id of [
'search-batch-actions','select-search-results','queue-selected-search-results',
'defer-selected-search-results','plan-selected-search-results','search-selection-status','cmd-search-action-status',
'resume-search-batch','open-palette','cmd-results','cancel-search-selection',
'search-batch-estimate-review','search-batch-estimate-summary','search-batch-estimate-list',
'cancel-search-batch-estimates','confirm-search-batch-estimates','search-release-review',
'search-release-summary','search-release-milestone','search-release-due-date','search-release-list',
'cancel-search-release','confirm-search-release'
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
const document={{querySelector:selector=>elements[selector] || null,querySelectorAll:()=>[]}};
const processorOptions=[];
const runs=[];
const processors=[];
const batchFactory=options=>{{
processorOptions.push(options);
const processor={{
run:(items,estimates,context)=>{{runs.push({{items,estimates,context}});return Promise.resolve();}},
resume:()=>Promise.resolve(),pending:()=>0,
}};
processors.push(processor);
return processor;
}};
const planning={{capacity_minutes:null,estimates:{{}}}};
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,
replacePlanning:()=>{{}}}};
const laterCalls=[];
const laterWork={{defer:(item,until)=>{{laterCalls.push([item.number,until]);return 'deferred';}}}};
let pickerRequest=null;
const laterPicker={{open:(payload,trigger,context)=>{{pickerRequest={{payload,trigger,context}};return true;}}}};
const item={{kind:'issue',state:'open',repository:'stackchain/dashboard',number:813,title:'Batch defer'}};
const mounted=createSearchBatchPlan.mount(
document,batchFactory,todayWork,()=> 'timmy',()=>Promise.resolve(),()=>'',()=>Promise.resolve('queued'),
{{enqueueConfiguration:()=>{{}},flush:()=>{{}}}},value=>({{...value,accepted:true}}),()=>item,()=>{{}},
value=>value,value=>value,laterWork,laterPicker
);
mounted.plan.start();
mounted.plan.toggle(item);
listeners['defer-selected-search-results:click']({{currentTarget:elements['#defer-selected-search-results']}});
const before={{runs:runs.slice(),count:pickerRequest.payload.items.length,context:pickerRequest.context,journals:processorOptions.map(value=>value.journalName)}};
pickerRequest.payload.confirm(new Date('2026-08-15T09:00:00.000Z'))();
const laterResult=processorOptions[1].queue({{...item,assigned_to_me:true}},{{until:'2026-08-15T09:00:00.000Z'}});
process.stdout.write(JSON.stringify({{before,runs,laterResult,laterCalls}}));
"""
result = run_node(script)
assert result["before"] == {
"runs": [],
"count": 1,
"context": "search-batch",
"journals": ["search-today-batch", "search-later-batch", "search-release-batch"],
}
assert result["runs"] == [{
"items": [{
"kind": "issue", "state": "open", "repository": "stackchain/dashboard",
"number": 813, "title": "Batch defer",
}],
"estimates": {},
"context": {"until": "2026-08-15T09:00:00.000Z"},
}]
assert result["laterResult"] == "queued"
assert result["laterCalls"] == [[813, "2026-08-15T09:00:00.000Z"]]
def test_search_batch_reviews_time_estimates_before_any_assignment_and_persists_queued_estimates():
script = f"""
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
const listeners={{}};
const elements={{}};
for (const id of [
'search-batch-actions','select-search-results','queue-selected-search-results',
'defer-selected-search-results','plan-selected-search-results',
'search-selection-status','cmd-search-action-status','resume-search-batch',
'open-palette','cmd-results','cancel-search-selection','search-batch-estimate-review',
'search-batch-estimate-summary','search-batch-estimate-list',
'cancel-search-batch-estimates','confirm-search-batch-estimates','search-release-review',
'search-release-summary','search-release-milestone','search-release-due-date','search-release-list',
'cancel-search-release','confirm-search-release'
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
const estimateInputs=[
{{dataset:{{searchBatchEstimate:'stackchain/dashboard#811'}},value:'25'}},
{{dataset:{{searchBatchEstimate:'stackchain/api#12'}},value:'30'}},
];
const document={{
querySelector:selector=>elements[selector] || null,
querySelectorAll:selector=>selector==='[data-search-batch-estimate]' ? estimateInputs : [],
}};
const calls=[];
const processorOptions=[];
const processor={{
run:(items,estimates)=>{{calls.push({{type:'run',items,estimates}});return Promise.resolve();}},
resume:()=>Promise.resolve(),pending:()=>0,
}};
const batchFactory=options=>{{processorOptions.push(options);return processor;}};
const planning={{capacity_minutes:90,estimates:{{'stackchain/existing#1':20}}}};
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,
replacePlanning:value=>Object.assign(planning,value)}};
const todaySync={{enqueueConfiguration:()=>{{}},flush:()=>{{}}}};
const items=[
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:811,title:'Capacity'}},
{{kind:'issue',state:'open',repository:'stackchain/api',number:12,title:'API'}},
];
let selected=items;
const mounted=createSearchBatchPlan.mount(
document,batchFactory,todayWork,()=> 'timmy',()=>Promise.resolve(),()=>'',()=>Promise.resolve('queued'),
todaySync,item=>item,index=>selected[index],()=>{{}},value=>value,value=>value
);
mounted.plan.start();
items.forEach(item=>mounted.plan.toggle(item));
listeners['queue-selected-search-results:click']();
const before={{
calls:calls.slice(),
reviewHidden:elements['#search-batch-estimate-review'].hidden,
actionsHidden:elements['#search-batch-actions'].hidden,
markup:elements['#search-batch-estimate-list'].innerHTML,
budget:processorOptions[0].timeBudget(),
}};
listeners['confirm-search-batch-estimates:click']();
processorOptions[0].persistEstimate(items[0],25);
processorOptions[0].onProgress({{status:'complete',queued:['stackchain/dashboard#811','stackchain/api#12'],failed:[]}});
const afterCompleteHidden=elements['#search-batch-estimate-review'].hidden;
process.stdout.write(JSON.stringify({{before,calls,afterCompleteHidden}}));
"""
result = run_node(script)
assert result["before"]["calls"] == []
assert result["before"]["reviewHidden"] is False
assert result["before"]["actionsHidden"] is True
assert "Capacity" in result["before"]["markup"]
assert "data-search-batch-estimate" in result["before"]["markup"]
assert result["before"]["budget"] == {"capacity_minutes": 90, "planned_minutes": 20}
assert result["calls"] == [{
"type": "run",
"items": [
{"kind": "issue", "state": "open", "repository": "stackchain/dashboard", "number": 811, "title": "Capacity"},
{"kind": "issue", "state": "open", "repository": "stackchain/api", "number": 12, "title": "API"},
],
"estimates": {"stackchain/dashboard#811": 25, "stackchain/api#12": 30},
}]
assert result["afterCompleteHidden"] is True
def test_search_batch_reviews_and_applies_one_repository_release_plan_before_assignment():
script = f"""
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
const listeners={{}};
const elements={{}};
for (const id of [
'search-batch-actions','select-search-results','queue-selected-search-results',
'defer-selected-search-results','plan-selected-search-results','search-selection-status',
'cmd-search-action-status','resume-search-batch','open-palette','cmd-results',
'cancel-search-selection','search-batch-estimate-review','search-batch-estimate-summary',
'search-batch-estimate-list','cancel-search-batch-estimates','confirm-search-batch-estimates',
'search-release-review','search-release-summary','search-release-milestone',
'search-release-due-date','search-release-list','cancel-search-release','confirm-search-release'
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
const document={{querySelector:selector=>elements[selector] || null,querySelectorAll:()=>[]}};
const calls=[];
const fetchJson=(url,options={{}})=>{{
calls.push({{url,method:options.method || 'GET',body:options.body ? JSON.parse(options.body) : null}});
if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}},{{id:7,title:'Old',state:'closed'}}]);
const number=Number(url.split('/issues/')[1].split('/')[0]);
if (url.endsWith('/release-plan')) return Promise.resolve({{
number,milestone:{{id:9,title:'August RC'}},due_date:'2026-08-31T23:59:59Z'
}});
return Promise.resolve();
}};
const processorOptions=[];
const processors=[];
const batchFactory=options=>{{processorOptions.push(options);const processor={{
run:(items,estimates,context)=>{{calls.push({{run:items.map(item=>item.number),context}});return Promise.resolve();}},
resume:()=>Promise.resolve(),pending:()=>0,
}};processors.push(processor);return processor;}};
const planning={{capacity_minutes:null,estimates:{{}}}};
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,replacePlanning:()=>{{}}}};
const items=[
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:815,title:'Release plan'}},
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:816,title:'Second issue'}},
];
const mounted=createSearchBatchPlan.mount(
document,batchFactory,todayWork,()=> 'timmy',fetchJson,()=>'',()=>Promise.resolve('queued'),
{{enqueueConfiguration:()=>{{}},flush:()=>{{}}}},item=>item,index=>items[index],()=>{{}},
value=>value,value=>value,null,null
);
mounted.plan.start();items.forEach(item=>mounted.plan.toggle(item));
listeners['plan-selected-search-results:click']().then(async()=>{{
const before={{calls:calls.slice(),reviewHidden:elements['#search-release-review'].hidden,
actionsHidden:elements['#search-batch-actions'].hidden,options:elements['#search-release-milestone'].innerHTML,
list:elements['#search-release-list'].innerHTML}};
elements['#search-release-milestone'].value='9';
elements['#search-release-milestone'].selectedIndex=0;
elements['#search-release-milestone'].options=[{{textContent:'August RC'}}];
elements['#search-release-due-date'].value='2026-08-31';
listeners['confirm-search-release:click']();
const queued=await processorOptions[2].queue({{...items[0],assigned_to_me:true}},
{{milestone_id:9,milestone_title:'August RC',due_date:'2026-08-31T23:59:59Z'}});
process.stdout.write(JSON.stringify({{before,calls,queued,journals:processorOptions.map(option=>option.journalName)}}));
}});
"""
result = run_node(script)
assert result["before"]["calls"] == [{
"url": "api/v1/repos/stackchain/dashboard/milestones", "method": "GET", "body": None,
}]
assert result["before"]["reviewHidden"] is False
assert result["before"]["actionsHidden"] is True
assert "August RC" in result["before"]["options"]
assert "Old" not in result["before"]["options"]
assert "Release plan" in result["before"]["list"]
assert result["calls"][1] == {
"run": [815, 816],
"context": {
"milestone_id": 9, "milestone_title": "August RC",
"due_date": "2026-08-31T23:59:59Z",
},
}
assert result["calls"][2:] == [{
"url": "api/v1/repos/stackchain/dashboard/issues/815/release-plan",
"method": "PATCH",
"body": {"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z"},
}]
assert result["queued"] == "queued"
assert result["journals"] == [
"search-today-batch", "search-later-batch", "search-release-batch",
]
+56 -16
View File
@@ -120,11 +120,11 @@ async function dispatchMessage(data, ports = []) {{
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null) {{
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null, url = null) {{
let pending;
listeners.notificationclick({{
action,
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId}}, close: () => {{ state.notificationClosed = true; }}}},
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId, ...(url ? {{url}} : {{}})}}, close: () => {{ state.notificationClosed = true; }}}},
waitUntil: promise => {{ pending = promise; }},
}});
if (pending) await pending;
@@ -150,7 +150,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v101" in source
assert "stackchain-dashboard-shell-v102" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -159,14 +159,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v101" in source
assert "stackchain-dashboard-shell-v102" 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-v101" in source
assert "stackchain-dashboard-shell-v102" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -174,7 +174,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-v101" in source
assert "stackchain-dashboard-shell-v102" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -182,14 +182,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v101" in source
assert "stackchain-dashboard-shell-v102" 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-v101" in source
assert "stackchain-dashboard-shell-v102" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -198,21 +198,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-v101" in source
assert "stackchain-dashboard-shell-v102" 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-v101" in source
assert "stackchain-dashboard-shell-v102" 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-v101" in source
assert "stackchain-dashboard-shell-v102" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -247,7 +247,7 @@ def test_image_share_target_stages_one_supported_image_and_redirects_to_capture(
}
def test_image_share_target_rejects_multiple_images_without_staging_private_data():
def test_image_share_target_stages_ordered_evidence_bundle():
result = run_worker_scenario(
"""
const form = new FormData();
@@ -256,16 +256,19 @@ def test_image_share_target_rejects_multiple_images_without_staging_private_data
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
Object.defineProperty(request, 'mode', {value:'navigate'});
const response = await dispatch('fetch', request);
const record=state.sharedRecords['shared-image'];
process.stdout.write(JSON.stringify({
status:response.status, location:response.headers.get('Location'), records:Object.keys(state.sharedRecords),
status:response.status, location:response.headers.get('Location'),
names:record.attachments.map(value=>value.filename),sizes:record.attachments.map(value=>value.blob.size),
}));
"""
)
assert result == {
"status": 303,
"location": "/dashboard/?launch=new&shared=multiple",
"records": [],
"location": "/dashboard/?launch=new&shared=images",
"names": ["one.png", "two.jpg"],
"sizes": [3, 3],
}
@@ -457,6 +460,36 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
assert result["notificationClosed"] is True
def test_created_issue_receipt_tap_opens_the_confirmed_canonical_gitea_url():
result = run_worker_scenario(
"""
const issueUrl = 'https://forge.example/git/stackchain/api/issues/44';
state.receiptLogin = 'timmy';
state.flushResult = {login:'timmy', receipts:[
{id:'capture-1',status:'confirmed',kind:'issue',url:issueUrl},
]};
state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}];
await dispatchSync('stackchain-issue-outbox-v1');
await dispatchNotificationClick('', '', null, 'stackchain-delivery-capture-1', issueUrl);
process.stdout.write(JSON.stringify(state));
"""
)
assert result["notifications"] == [{
"title": "Queued issue created",
"options": {
"body": "Tap to open it in Stackchain.",
"tag": "stackchain-delivery-capture-1",
"data": {"url": "https://forge.example/git/stackchain/api/issues/44"},
},
}]
assert result["focused"] == [
"https://forge.example/git/stackchain/api/issues/44"
]
assert result["opened"] == []
assert result["notificationClosed"] is True
def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
result = run_worker_scenario(
"""
@@ -761,7 +794,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-v101" in source
assert "stackchain-dashboard-shell-v102" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -784,14 +817,17 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js",
"/dashboard/static/feature-loader.js",
"/dashboard/static/conversation-action-hydrator.js",
"/dashboard/static/security-center.js",
"/dashboard/static/markdown.js",
"/dashboard/static/commands.js",
"/dashboard/static/saved-searches.js",
"/dashboard/static/search-preview.js",
"/dashboard/static/search-defer.js",
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js",
"/dashboard/static/unfiled-draft-sync.js",
"/dashboard/static/shared-image-capture.js",
"/dashboard/static/draft-filing-session.js",
"/dashboard/static/draft-capacity-dialog.js",
@@ -833,7 +869,10 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/search-batch-plan.js",
"/dashboard/static/conversation.js",
"/dashboard/static/comment-actions.js",
"/dashboard/static/issue-evidence-review.js",
"/dashboard/static/issue-evidence-editor.js",
"/dashboard/static/issue-attachment.js",
"/dashboard/static/issue-filing-review.js",
"/dashboard/static/issue-sheet.js",
"/dashboard/static/create-issue-sheet.js",
"/dashboard/static/create-and-start.js",
@@ -864,6 +903,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-composer-viewport.js",
"/dashboard/static/mention-composer.js",
"/dashboard/static/push-notifications.js",
"/dashboard/static/issue-filing-receipt.js",
"/dashboard/static/background-issue-sync.js",
}
+17
View File
@@ -36,6 +36,23 @@ shared.consume({{
}
def test_shared_evidence_bundle_is_consumed_once_in_order():
script = f"""
const shared=require({json.dumps(str(MODULE))});const calls=[];
const store={{get:async()=>({{attachments:[
{{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}},
{{filename:'two.jpg',contentType:'image/jpeg',blob:new Blob(['two'],{{type:'image/jpeg'}})}},
]}}),delete:async id=>calls.push(['delete',id])}};
(async()=>{{const result=await shared.consume({{marker:'images',store,restore:value=>calls.push(['restore',value.map(x=>x.filename)]),status:value=>calls.push(['status',value])}});process.stdout.write(JSON.stringify({{result,calls}}));}})();
"""
output = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
assert output == {"result": True, "calls": [
["restore", ["one.png", "two.jpg"]], ["delete", "shared-image"],
["status", "2 shared screenshots ready to file with this issue."],
]}
def test_invalid_shared_image_reports_error_without_touching_open_capture():
script = f"""
const shared = require({json.dumps(str(MODULE))});
+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-v101';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v102';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v101" in source
assert "stackchain-dashboard-shell-v102" in source
assert "BASE + 'static/today-sync.js'" in source
+348 -7
View File
@@ -38,6 +38,100 @@ process.stdout.write(JSON.stringify({{opened,skipped,afterFiled,previous}}));
}
def test_draft_filing_session_restores_all_saved_evidence():
script = f"""
const createDraftFilingSession=require({json.dumps(str(DRAFT_SESSION))});
const evidence=[{{filename:'one.png'}},{{filename:'two.png'}}];
const restored=[];
const elements=new Map();
const qs=selector=>{{
if(!elements.has(selector)) elements.set(selector,{{hidden:false,disabled:false,textContent:'',addEventListener:()=>{{}}}});
return elements.get(selector);
}};
const session=createDraftFilingSession({{list:()=>[{{id:'planned'}}]}});
session.attach(qs,{{
captures:{{resume:async()=>({{repository:'o/r',title:'Planned',body:'',labelIds:[],attachments:evidence}})}},
issueCapture:{{saveDraft:()=>{{}}}},attachment:{{restore:value=>restored.push(value),clear:()=>restored.push(null)}},
getLogin:()=>'timmy',setResumedId:()=>{{}},openSheet:async()=>{{}},setFilingMode:()=>{{}},
}});
session.start('planned');
(async()=>{{await session.nextCapture();process.stdout.write(JSON.stringify(restored));}})();
"""
assert run_node(script) == [[{"filename": "one.png"}, {"filename": "two.png"}]]
def test_ready_draft_summary_exposes_truthful_mobile_action_and_plan_details():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const planned=createUnfiledCaptures.summary({{
filingPlan:{{repository:'stackchain/dashboard',completionIntent:'create-and-start',estimateMinutes:45}},
attachmentCount:2,
}});
const minimal=createUnfiledCaptures.summary({{}});
process.stdout.write(JSON.stringify({{planned,minimal}}));
"""
assert run_node(script) == {
"planned": {
"label": "Ready to file",
"repository": "stackchain/dashboard",
"ready": True,
"action": "Review & start",
"details": "Create & start · 45 min · 2 screenshots",
},
"minimal": {
"label": "Needs filing",
"repository": "",
"ready": False,
"action": "Choose repository",
"details": "Create issue · No screenshots",
},
}
def test_ready_draft_opens_final_review_with_restored_intent_and_evidence():
script = f"""
const createDraftFilingSession=require({json.dumps(str(DRAFT_SESSION))});
const events=[];
const trigger={{id:'ready-action'}};
const resumed={{repository:'o/r',title:'Planned',body:'Evidence',labelIds:[7],
completionIntent:'create-and-start',estimateMinutes:30,
attachments:[{{filename:'one.png'}},{{filename:'two.png'}}]}};
const elements=new Map();
const qs=selector=>{{
if(!elements.has(selector)) elements.set(selector,{{id:selector.slice(1),hidden:false,disabled:false,textContent:'',addEventListener:()=>{{}},
requestSubmit:button=>events.push(['review',resumed,button.id])}});
return elements.get(selector);
}};
const session=createDraftFilingSession({{list:()=>[{{id:'planned'}}]}});
session.attach(qs,{{
captures:{{resume:async()=>resumed}},
capture:()=>({{saveDraft:value=>events.push(['save',value])}}),
attachment:{{restore:value=>events.push(['evidence',value]),clear:()=>{{}}}},
getLogin:()=>'timmy',setResumedId:id=>events.push(['source',id]),
openSheet:async()=>events.push(['sheet']),setFilingMode:()=>events.push(['filing']),
review:async()=>{{events.push(['metadata']);events.push(['duplicates']);}},
}});
session.start('planned');
(async()=>{{await session.reviewCapture('planned',trigger);process.stdout.write(JSON.stringify({{
events,origin:session.takeTrigger().id
}}));}})();
"""
output = run_node(script)
events = output["events"]
assert [event[0] for event in events] == [
"save", "source", "sheet", "evidence", "filing", "metadata",
"duplicates", "review"
]
assert events[-1][1]["labelIds"] == [7]
assert events[-1][1]["attachments"] == [
{"filename": "one.png"}, {"filename": "two.png"}
]
assert events[-1][2] == "create-and-start-issue"
assert output["origin"] == "ready-action"
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
@@ -85,6 +179,218 @@ process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement
assert output["restored"][0]["ownerLogin"] == "timmy"
def test_unfiled_capture_restores_ordered_evidence_bundle():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const values=new Map(),blobs=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore={{put:async(id,value)=>blobs.set(id,value),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}};
const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}}),note:'Note '+name}});
(async()=>{{const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'bundle'}});
const saved=await captures.save({{title:'Journey',body:'Steps',attachments:['one','two','three'].map(image)}});
const resumed=await captures.resume(saved.id,'timmy');
process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.attachments.map(x=>x.filename),notes:resumed.attachments.map(x=>x.note),stored:blobs.get('bundle').attachments.map(x=>x.note)}}));}})();
"""
output = run_node(script)
assert output["listed"]["attachmentCount"] == 3
assert output["names"] == ["one", "two", "three"]
assert output["notes"] == ["Note one", "Note two", "Note three"]
assert output["stored"] == output["notes"]
def test_unfiled_captures_export_and_import_ordered_evidence_between_devices():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
function device() {{
const values=new Map(), blobs=new Map();
return {{
captures:createUnfiledCaptures({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}},
attachmentStore:{{put:async(id,v)=>blobs.set(id,v),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}},
getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'phone-draft',now:()=>42,
}}), blobs,
}};
}}
(async()=>{{
const phone=device(), desktop=device();
const image=(name,note)=>({{filename:name,contentType:'image/png',note,blob:new Blob([name],{{type:'image/png'}})}});
await phone.captures.save({{title:'Field failure',body:'Steps',attachments:[image('one.png','First'),image('two.png','Second')],blockers:[{{repository:'o/api',number:7,title:'API'}}]}});
const exported=await phone.captures.exportOwned('timmy');
const imported=await desktop.captures.mergeRemote(exported,'timmy');
const resumed=await desktop.captures.resume('phone-draft','timmy');
process.stdout.write(JSON.stringify({{
exported:exported[0], imported,
names:resumed.attachments.map(item=>item.filename),
notes:resumed.attachments.map(item=>item.note),
bytes:await Promise.all(resumed.attachments.map(async item=>Buffer.from(await item.blob.arrayBuffer()).toString())),
blockers:resumed.blockers,
}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output["exported"]["id"] == "phone-draft"
assert output["exported"]["saved_at"] == 42
assert [item["filename"] for item in output["exported"]["evidence"]] == ["one.png", "two.png"]
assert output["imported"] == 1
assert output["names"] == ["one.png", "two.png"]
assert output["notes"] == ["First", "Second"]
assert output["bytes"] == ["one.png", "two.png"]
assert output["blockers"] == [{"repository": "o/api", "number": 7, "title": "API"}]
def test_unfiled_captures_resume_complete_filing_plan_on_another_device():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
function device(id) {{
const values=new Map();
return createUnfiledCaptures({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>id,now:()=>42,
}});
}}
(async()=>{{
const phone=device('planned-draft'), desktop=device('unused');
await phone.save({{
repository:'stackchain/dashboard',title:'Restore my plan',body:'Do not make me plan twice',
labelIds:[7,3,7],milestoneId:12,dueDate:'2026-08-21',
templateName:'Bug report',templateId:'bug.yml',capturedBody:'Original field notes',
assignee:'alexander',assigneeName:'Alexander',unassigned:false,
estimateMinutes:45,completionIntent:'create-and-start',
blockers:[{{repository:'stackchain/api',number:9,title:'API rollout'}}],
}});
const exported=await phone.exportOwned('timmy');
await desktop.mergeRemote(exported,'timmy');
const resumed=await desktop.resume('planned-draft','timmy');
process.stdout.write(JSON.stringify({{exported:exported[0],resumed}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output["exported"]["filing_plan"] == {
"repository": "stackchain/dashboard",
"label_ids": [7, 3],
"milestone_id": 12,
"due_date": "2026-08-21",
"template_name": "Bug report",
"template_id": "bug.yml",
"captured_body": "Original field notes",
"assignee": "alexander",
"assignee_name": "Alexander",
"estimate_minutes": 45,
"completion_intent": "create-and-start",
}
assert output["resumed"] == {
"repository": "stackchain/dashboard",
"title": "Restore my plan",
"body": "Do not make me plan twice",
"labelIds": [7, 3],
"milestoneId": 12,
"dueDate": "2026-08-21",
"templateName": "Bug report",
"templateId": "bug.yml",
"capturedBody": "Original field notes",
"assignee": "alexander",
"assigneeName": "Alexander",
"estimateMinutes": 45,
"completionIntent": "create-and-start",
"blockers": [{"repository": "stackchain/api", "number": 9, "title": "API rollout"}],
}
def test_unfiled_captures_reconcile_remote_replaces_stale_content_and_evidence():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const values=new Map(),blobs=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore={{get:id=>blobs.get(id),put:(id,value)=>blobs.set(id,value),delete:id=>blobs.delete(id)}};
const captures=createUnfiledCaptures({{
storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'field',now:()=>1,
}});
(async()=>{{
await captures.save({{title:'Field failure',body:'phone edit',attachment:{{
filename:'phone.png',contentType:'image/png',blob:new Blob(['phone'])
}}}});
await captures.reconcileRemote([{{
id:'field',title:'Field failure',body:'desktop edit',saved_at:2,evidence:[{{
filename:'desktop.png',content_type:'image/png',note:'newer',data:'ZGVza3RvcA=='
}}]
}}], 'timmy');
const resumed=await captures.resume('field','timmy');
process.stdout.write(JSON.stringify({{
body:resumed.body,filename:resumed.attachments[0].filename,note:resumed.attachments[0].note,
bytes:await resumed.attachments[0].blob.text(),count:captures.list().length,
}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output == {
"body": "desktop edit",
"filename": "desktop.png",
"note": "newer",
"bytes": "desktop",
"count": 1,
}
def test_unfiled_captures_reconcile_rolls_back_evidence_when_metadata_write_fails():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const values=new Map(),blobs=new Map();let failWrite=false;
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>{{if(failWrite)throw new Error('quota');values.set(k,v)}}}};
const attachmentStore={{get:id=>blobs.get(id),put:(id,value)=>blobs.set(id,value),delete:id=>blobs.delete(id)}};
const captures=createUnfiledCaptures({{
storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'field',now:()=>1,
}});
(async()=>{{
await captures.save({{title:'Field failure',body:'phone edit',attachment:{{
filename:'phone.png',contentType:'image/png',blob:new Blob(['phone'])
}}}});
failWrite=true;let error='';
try {{
await captures.reconcileRemote([{{
id:'field',title:'Field failure',body:'desktop edit',saved_at:2,evidence:[{{
filename:'desktop.png',content_type:'image/png',data:'ZGVza3RvcA=='
}}]
}}], 'timmy');
}} catch (caught) {{ error=caught.message; }}
const resumed=await captures.resume('field','timmy');
const proof=resumed.attachments?.[0] || resumed.attachment;
process.stdout.write(JSON.stringify({{
error,body:resumed.body,filename:proof.filename,
bytes:await proof.blob.text(),
}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output == {
"error": "quota",
"body": "phone edit",
"filename": "phone.png",
"bytes": "phone",
}
def test_unfiled_capture_restores_selected_blockers():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const captures=createUnfiledCaptures({{storage,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'blocked'}});
const blockers=[{{repository:'o/api',number:7,title:'API ready'}},{{repository:'o/web',number:8,title:'Web ready'}}];
const saved=captures.save({{title:'Blocked work',body:'Context',blockers}});
const resumed=captures.resume(saved.id,'timmy');
process.stdout.write(JSON.stringify({{listed:captures.list()[0],resumed}}));
"""
output = run_node(script)
assert output["listed"]["blockerCount"] == 2
assert output["resumed"]["blockers"] == [
{"repository": "o/api", "number": 7, "title": "API ready"},
{"repository": "o/web", "number": 8, "title": "Web ready"},
]
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
@@ -357,15 +663,15 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert "data-capture-id=\"' + escapeHtml(item.capture_id) + '\"" in html
assert "requestAnimationFrame(() =>" in html
assert "savedCard?.scrollIntoView({block:'nearest'})" in html
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in html
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in UNFILED.read_text()
assert "await dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text()
assert "await createIssueAttachmentController.serialize()" in html
assert "createUnfiledAttachmentStore()" in html
assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text()
assert "dependencies.attachment[evidence ? 'restore' : 'clear'](evidence)" in DRAFT_SESSION.read_text()
assert "await unfiledCaptures.completeResume(rUC)" in html
assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
assert "issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
assert "item.kind === 'unfiled-issue'" in html
assert '.create-issue-actions button { min-height:44px;' in html
assert '.draft-card:focus-visible' in html
@@ -373,6 +679,39 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert '@media(max-width:320px)' in html
@pytest.mark.anyio
async def test_mobile_planned_draft_can_be_saved_and_resumed_without_replanning():
html = await dashboard()
feature = UNFILED.read_text()
assert "const captureDraft = currentIssueCaptureDraft();" in html
assert "return withFilingEstimate(issueTemplatePicker.fields(" in html
assert "draft.estimateMinutes = Number(value) || null" in feature
assert "qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';" in html
assert "qs('#file-new-issue').hidden = enabled" in feature
assert "qs('.create-issue-capture-actions').hidden = false" in feature
assert "label:ready ? 'Ready to file' : 'Needs filing'" in feature
assert "repository:plan.repository || ''" in feature
assert "Saved planned Draft" in feature
@pytest.mark.anyio
async def test_mobile_ready_draft_card_opens_validated_final_review_in_one_tap():
html = await dashboard()
assert "isUnfiled ? item.action" in html
assert "[item.label, item.repository, item.details].filter(Boolean).join(' · ')" in html
assert "escapeHtml([item.label" in html
assert "await dFS.open(item.capture_id, item.ready ? button : null)" in html
assert "capture:()=>issueCapture" in html
assert "review:r=>loadIssueFilingMetadata(r.repository,r)" in html
assert ".then(()=>issueCapture.findDuplicates(currentIssueCaptureDraft())).then(renderIssueDuplicates)" in html
assert "qs('#create-issue-form').requestSubmit(submitter)" in DRAFT_SESSION.read_text()
assert "dFS.takeTrigger() || event.submitter || qs('#submit-new-issue')" in html
assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px; width:100%; }' in html
assert '@media(max-width:320px)' in html
@pytest.mark.anyio
async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
html = await dashboard()
@@ -382,13 +721,12 @@ async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
assert 'id="draft-filing-progress"' in html
assert 'id="previous-draft"' in html
assert 'id="skip-draft"' in html
assert "dFS.start(item.capture_id)" in html
assert "await dFS.nextCapture()" in html
assert "await dFS.open(item.capture_id, item.ready ? button : null)" in html
assert "await dFS.advance(fS.id)" in html
feature = DRAFT_SESSION.read_text()
assert "Draft ${state.position} of ${state.total}" in feature
assert "qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft'" in feature
assert '.draft-filing-session-actions button { min-height:44px;' in html
assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
@@ -401,8 +739,11 @@ async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision(
assert 'id="review-full-drafts"' in html
assert 'id="replace-oldest-draft"' in html
assert 'id="keep-editing-draft"' in html
assert "showDraftCapacityDialog(unfiledCaptures)" in html
assert "showDraftCapacityDialog(unfiledCaptures, qs)" in html
assert "bindDraftCapacityDialog({" in html
feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text()
assert "const draft = currentIssueCaptureDraft();" in feature
assert "Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}" in feature
assert "unfiledCaptures.replaceOldest(draft, oldest.id)" in feature
assert '.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh;' in html
assert '.draft-capacity-actions button { min-height:44px;' in html
+21
View File
@@ -0,0 +1,21 @@
from pathlib import Path
from src.frontend_bundle import FEATURE_SOURCES, build_frontend
ROOT = Path(__file__).parents[1]
def test_cross_device_unfiled_draft_sync_is_shipped_and_wired_to_user_flow():
html = (ROOT / "frontend" / "index.html").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
assert '<script src="static/unfiled-draft-sync.js"></script>' in html
assert "static/unfiled-draft-sync.js" in FEATURE_SOURCES["today-timer"]
assert "createUnfiledDraftSync(" in dashboard
assert "unfiledCaptures, fetchReviewJson" in dashboard
assert "Saved on this device · sync pending" in (ROOT / "frontend" / "unfiled-draft-sync.js").read_text()
built = build_frontend(ROOT / "frontend")
assert "unfiled-draft-sync.js" not in built.dashboard_html
assert b"stackchain.unfiled-draft-sync.v1" in built.feature_bundles["today-timer"].runtime_bytes
+132
View File
@@ -0,0 +1,132 @@
import httpx
import pytest
from src import main
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None):
return {
"id": draft_id,
"title": title,
"body": "Steps from the field",
"saved_at": 1_723_600_000_000,
"blockers": [{"repository": "stackchain/api", "number": 7, "title": "API rollout"}],
"evidence": (evidence if evidence is not None else [
{
"filename": "checkout.png",
"content_type": "image/png",
"note": "Error after tapping Pay",
"data": "cG5nLWJ5dGVz",
}
]),
}
def test_unfiled_drafts_are_revisioned_ordered_and_account_scoped(tmp_path):
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
created = store.replace(" Timmy ", 0, [draft(), draft("second", title="Second")])
assert created == {"revision": 1, "drafts": [draft(), draft("second", title="Second")]}
assert store.get("timmy") == created
assert store.get("alexander") == {"revision": 0, "drafts": []}
with pytest.raises(UnfiledDraftConflict) as conflict:
store.replace("timmy", 0, [draft(title="Stale overwrite")])
assert conflict.value.snapshot == created
def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path):
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3", limit=2, max_total_bytes=12)
with pytest.raises(ValueError, match="limited to 2"):
store.replace("timmy", 0, [draft("one", evidence=[]), draft("two", evidence=[]), draft("three", evidence=[])])
with pytest.raises(ValueError, match="evidence is too large"):
store.replace("timmy", 0, [draft(evidence=[{
"filename": "large.png", "content_type": "image/png", "data": "eHh4eHh4eHh4eHh4eHh4eHh4eHg=",
}])])
with pytest.raises(ValueError, match="valid base64"):
store.replace("timmy", 0, [draft(evidence=[{
"filename": "bad.png", "content_type": "image/png", "data": "not base64!",
}])])
def test_unfiled_drafts_validate_and_round_trip_complete_filing_plan(tmp_path):
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
planned = draft(evidence=[])
planned.pop("evidence")
planned["filing_plan"] = {
"repository": "stackchain/dashboard",
"label_ids": [7, 3],
"milestone_id": 12,
"due_date": "2026-08-21",
"template_name": "Bug report",
"template_id": "bug.yml",
"captured_body": "Original field notes",
"assignee": "alexander",
"assignee_name": "Alexander",
"estimate_minutes": 45,
"completion_intent": "create-and-start",
}
assert store.replace("timmy", 0, [planned])["drafts"] == [planned]
invalid = draft(evidence=[])
invalid["filing_plan"] = {**planned["filing_plan"], "repository": "not-a-repository"}
with pytest.raises(ValueError, match="filing repository is invalid"):
store.replace("timmy", 1, [invalid])
@pytest.mark.anyio
async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_safe(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv(
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
"a-separate-session-signing-secret-with-enough-entropy",
)
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_DB", str(tmp_path / "drafts.sqlite3"))
identity = {"login": "Timmy"}
async def user():
return {"id": 1, "login": identity["login"]}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
planned = draft(evidence=[])
planned["filing_plan"] = {
"repository": "stackchain/dashboard",
"label_ids": [7, 3],
"milestone_id": 12,
"due_date": "2026-08-21",
"template_name": "Bug report",
"template_id": "bug.yml",
"captured_body": "Original field notes",
"assignee": "alexander",
"assignee_name": "Alexander",
"estimate_minutes": 45,
"completion_intent": "create-and-start",
}
payload = {"revision": 0, "drafts": [planned]}
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
forbidden = await client.put("/api/v1/unfiled-drafts", json=payload)
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
saved = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers)
stale = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers)
fetched = await client.get("/api/v1/unfiled-drafts")
identity["login"] = "Alexander"
isolated = await client.get("/api/v1/unfiled-drafts")
assert forbidden.status_code == 403
assert saved.status_code == 200
expected = planned.copy()
expected.pop("evidence")
assert saved.json() == {"revision": 1, "drafts": [expected]}
assert stale.status_code == 409
assert stale.json()["detail"]["snapshot"] == saved.json()
assert fetched.json() == saved.json()
assert fetched.headers["cache-control"] == "no-store"
assert isolated.json() == {"revision": 0, "drafts": []}
+305
View File
@@ -0,0 +1,305 @@
import json
import subprocess
from pathlib import Path
SYNC = Path(__file__).parents[1] / "frontend" / "unfiled-draft-sync.js"
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_unfiled_draft_sync_moves_capture_between_devices_and_propagates_delete():
script = f"""
const createSync=require({json.dumps(str(SYNC))});
let remote={{revision:0,drafts:[]}};
function api(url,init={{}}) {{
if (!init.method) return Promise.resolve(JSON.parse(JSON.stringify(remote)));
const body=JSON.parse(init.body);
if (body.revision!==remote.revision) {{
const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};return Promise.reject(error);
}}
remote={{revision:remote.revision+1,drafts:body.drafts}};
return Promise.resolve(JSON.parse(JSON.stringify(remote)));
}}
function device(initial=[]) {{
let drafts=initial.map(item=>({{...item}})); const values=new Map(); const states=[];
const captures={{
exportOwned:async()=>drafts.map(item=>({{...item}})),
mergeRemote:async(items)=>{{let count=0;for(const item of items)if(!drafts.some(x=>x.id===item.id)){{drafts.push({{...item}});count++}}return count}},
discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}},
}};
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
const sync=createSync({{captures,fetchJson:api,storage,onState:state=>states.push(state)}});
return {{sync,states,drafts:()=>drafts}};
}}
(async()=>{{
const phone=device([{{id:'field',title:'Field failure',body:'Steps',saved_at:42,evidence:[{{filename:'proof.png',content_type:'image/png',data:'cHJvb2Y='}}]}}]);
await phone.sync.sync('timmy');
const desktop=device();
await desktop.sync.sync('timmy');
await desktop.sync.remove('field','timmy');
await phone.sync.sync('timmy');
process.stdout.write(JSON.stringify({{
remote,desktop:desktop.drafts(),phone:phone.drafts(),
phoneStates:phone.states.map(x=>x.status),desktopStates:desktop.states.map(x=>x.status),
}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output["remote"]["revision"] == 2
assert output["remote"]["drafts"] == []
assert output["desktop"] == []
assert output["phone"] == []
assert output["phoneStates"][-1] == "ready"
assert output["desktopStates"][-1] == "ready"
def test_unfiled_draft_sync_rebases_and_retries_a_revision_race_without_user_action():
script = f"""
const createSync=require({json.dumps(str(SYNC))});
let calls=0, puts=0;const states=[];
let drafts=[{{id:'local',title:'Local',body:'',saved_at:1}}];
let remote={{revision:1,drafts:[]}};
const sync=createSync({{
captures:{{
exportOwned:async()=>drafts,
reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}},
discard:async()=>true,
}},
storage:{{getItem:()=>null,setItem:()=>{{}}}},
fetchJson:async(_url,init)=>{{
calls++;
if(!init)return JSON.parse(JSON.stringify(remote));
puts++;
const body=JSON.parse(init.body);
if(puts===1){{
remote={{revision:2,drafts:[{{id:'remote',title:'Remote',body:'',saved_at:2}}]}};
const error=new Error('conflict');error.status=409;
error.payload={{detail:{{snapshot:JSON.parse(JSON.stringify(remote))}}}};
throw error;
}}
if(body.revision!==remote.revision)throw new Error('stale retry');
remote={{revision:remote.revision+1,drafts:body.drafts}};
return JSON.parse(JSON.stringify(remote));
}},
onState:state=>states.push(state),
}});
(async()=>{{
const result=await sync.sync('timmy');
process.stdout.write(JSON.stringify({{calls,puts,states,result,drafts,remote}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output["result"] is True
assert output["puts"] == 2
assert output["calls"] == 4
assert [item["id"] for item in output["remote"]["drafts"]] == ["remote", "local"]
assert output["drafts"] == output["remote"]["drafts"]
assert output["states"][-1]["status"] == "ready"
def test_unfiled_draft_sync_bounds_conflicts_and_exposes_a_retry_path():
script = f"""
const createSync=require({json.dumps(str(SYNC))});
let contended=true, puts=0;
let drafts=[{{id:'local',title:'Local',body:'kept',saved_at:1}}];
let remote={{revision:1,drafts:[]}};const states=[];
const controller=createSync({{
captures:{{
exportOwned:async()=>JSON.parse(JSON.stringify(drafts)),
reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}},
discard:async()=>true,
}},
storage:{{getItem:()=>null,setItem:()=>{{}}}},getLogin:()=> 'timmy',maxConflictRetries:1,
fetchJson:async(_url,init)=>{{
if(!init)return JSON.parse(JSON.stringify(remote));
puts++;const body=JSON.parse(init.body);
if(contended){{
remote={{revision:remote.revision+1,drafts:remote.drafts}};
const error=new Error('conflict');error.status=409;throw error;
}}
remote={{revision:remote.revision+1,drafts:body.drafts}};
return JSON.parse(JSON.stringify(remote));
}},
onState:state=>states.push(state),
}});
(async()=>{{
let rejected=false;
try{{await controller.sync('timmy')}}catch(error){{rejected=error.status===409}}
const afterExhaustion={{puts,rejected,state:states.at(-1),drafts:JSON.parse(JSON.stringify(drafts))}};
contended=false;
const retried=await controller.retry();
process.stdout.write(JSON.stringify({{afterExhaustion,retried,puts,state:states.at(-1),drafts,remote}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output["afterExhaustion"]["puts"] == 2
assert output["afterExhaustion"]["rejected"] is True
assert output["afterExhaustion"]["state"] == {
"status": "conflict",
"message": "Draft sync needs attention. Retry when your devices finish saving.",
"retryable": True,
}
assert output["afterExhaustion"]["drafts"][0]["body"] == "kept"
assert output["retried"] is True
assert output["state"]["status"] == "ready"
assert output["drafts"] == output["remote"]["drafts"]
def test_mounted_unfiled_draft_sync_shows_a_working_retry_action_after_exhaustion():
assert 'id="retry-unfiled-draft-sync"' in HTML.read_text()
script = f"""
let clickHandler;const status={{textContent:''}};
const button={{hidden:true,disabled:false,textContent:'Retry Draft sync',addEventListener:(_name,handler)=>clickHandler=handler}};
global.document={{querySelector:selector=>selector==='#retry-unfiled-draft-sync'?button:(selector==='#my-work-action-status'?status:null)}};
global.localStorage={{getItem:()=>null,setItem:()=>{{}}}};
global.setTimeout=()=>0;global.addEventListener=()=>{{}};
const createSync=require({json.dumps(str(SYNC))});
let contended=true;let remote={{revision:0,drafts:[]}};
const captures={{
currentLogin:()=> 'timmy',exportOwned:async()=>[{{id:'local',title:'Local',body:'',saved_at:1}}],
reconcileRemote:async()=>{{}},discard:async()=>true,
}};
const api=async(_url,init)=>{{
if(!init)return remote;
if(contended){{remote={{revision:remote.revision+1,drafts:[]}};const error=new Error('conflict');error.status=409;throw error}}
const body=JSON.parse(init.body);remote={{revision:remote.revision+1,drafts:body.drafts}};return remote;
}};
(async()=>{{
const controller=createSync.mount(captures,api);
try{{await controller.sync('timmy')}}catch(_error){{}}
const exhausted={{hidden:button.hidden,label:button.textContent,status:status.textContent}};
contended=false;
await clickHandler();
process.stdout.write(JSON.stringify({{exhausted,after:{{hidden:button.hidden,disabled:button.disabled,status:status.textContent}},remote}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output["exhausted"] == {
"hidden": False,
"label": "Retry Draft sync",
"status": "Draft sync needs attention. Retry when your devices finish saving.",
}
assert output["after"]["hidden"] is True
assert output["after"]["disabled"] is False
assert [draft["id"] for draft in output["remote"]["drafts"]] == ["local"]
def test_unfiled_draft_sync_coalesces_overlapping_reconnects_without_self_conflict():
script = f"""
const createSync=require({json.dumps(str(SYNC))});
let remote={{revision:0,drafts:[]}}, gets=0, puts=0, release;
const gate=new Promise(resolve=>release=resolve);
const api=async(_url,init)=>{{
if(!init){{gets++;if(gets===1)await gate;return JSON.parse(JSON.stringify(remote))}}
puts++;const body=JSON.parse(init.body);
if(body.revision!==remote.revision){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error}}
remote={{revision:remote.revision+1,drafts:body.drafts}};return remote;
}};
const draft={{id:'local',title:'Local',body:'',saved_at:1}};
const sync=createSync({{
captures:{{exportOwned:async()=>[draft],mergeRemote:async()=>0,discard:async()=>true}},
storage:{{getItem:()=>null,setItem:()=>{{}}}},fetchJson:api,
}});
(async()=>{{const first=sync.sync('timmy'),second=sync.sync('timmy');release();const results=await Promise.all([first,second]);process.stdout.write(JSON.stringify({{gets,puts,results,remote}}))}})();
"""
output = run_node(script)
assert output["puts"] == 1
assert output["remote"]["drafts"][0]["id"] == "local"
assert output["results"] == [True, True]
def test_unfiled_draft_sync_does_not_drop_an_account_transition_during_an_active_sync():
script = f"""
const createSync=require({json.dumps(str(SYNC))});
let remote={{revision:0,drafts:[]}}, release;
const gate=new Promise(resolve=>release=resolve);let firstGet=true;const exported=[];
const api=async(_url,init)=>{{
if(!init){{if(firstGet){{firstGet=false;await gate}}return JSON.parse(JSON.stringify(remote))}}
const body=JSON.parse(init.body);remote={{revision:remote.revision+1,drafts:body.drafts}};return JSON.parse(JSON.stringify(remote));
}};
const captures={{
exportOwned:async owner=>{{exported.push(owner);return [{{id:owner,title:owner,body:'',saved_at:owner==='alice'?1:2}}]}},
reconcileRemote:async()=>{{}},discard:async()=>true,
}};
const sync=createSync({{captures,fetchJson:api,storage:{{getItem:()=>null,setItem:()=>{{}}}}}});
(async()=>{{
const alice=sync.sync('alice');const bob=sync.sync('bob');release();
const results=await Promise.all([alice,bob]);
process.stdout.write(JSON.stringify({{exported,results,remote}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output["exported"] == ["alice", "bob"]
assert output["results"] == [True, True]
assert [draft["id"] for draft in output["remote"]["drafts"]] == ["bob", "alice"]
def test_unfiled_draft_sync_preserves_divergent_same_draft_edits_as_a_conflict_copy():
script = f"""
const createSync=require({json.dumps(str(SYNC))});
let remote={{revision:0,drafts:[]}};
async function api(_url,init) {{
if(!init) return JSON.parse(JSON.stringify(remote));
const body=JSON.parse(init.body);
if(body.revision!==remote.revision) {{
const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error;
}}
remote={{revision:remote.revision+1,drafts:body.drafts}};
return JSON.parse(JSON.stringify(remote));
}}
function device(initial=[]) {{
let drafts=JSON.parse(JSON.stringify(initial));
const values=new Map(), states=[];
const captures={{
exportOwned:async()=>JSON.parse(JSON.stringify(drafts)),
mergeRemote:async items=>{{for(const item of items)if(!drafts.some(x=>x.id===item.id))drafts.push(JSON.parse(JSON.stringify(item))) }},
reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}},
discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}},
}};
const sync=createSync({{
captures,fetchJson:api,
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
createConflictId:()=> 'field-conflict-phone',onState:state=>states.push(state),
}});
return {{sync,states,edit(body,saved_at){{drafts[0].body=body;drafts[0].saved_at=saved_at}},drafts:()=>drafts}};
}}
(async()=>{{
const original={{id:'field',title:'Field failure',body:'original',saved_at:1}};
const phone=device([original]);
await phone.sync.sync('timmy');
const desktop=device();
await desktop.sync.sync('timmy');
desktop.edit('desktop edit',2);
await desktop.sync.sync('timmy');
await phone.sync.sync('timmy');
phone.edit('phone edit',3);
desktop.edit('desktop second edit',4);
await desktop.sync.sync('timmy');
await phone.sync.sync('timmy');
process.stdout.write(JSON.stringify({{remote,phone:phone.drafts(),states:phone.states}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output["remote"]["revision"] == 4
assert [(item["id"], item["body"]) for item in output["remote"]["drafts"]] == [
("field", "desktop second edit"),
("field-conflict-phone", "phone edit"),
]
assert output["remote"]["drafts"][1]["title"] == "Field failure (conflict copy)"
assert output["phone"] == output["remote"]["drafts"]
assert output["states"][-1] == {
"status": "conflict-preserved",
"message": "Draft changed on two devices · both versions were saved.",
}
+2 -1
View File
@@ -192,7 +192,8 @@ async def test_follow_up_capture_offers_durable_create_and_next_flow():
assert 'id="create-follow-up-next"' in html
assert '>Create follow-up &amp; next</button>' in html
assert "updateFollowUp.stageSource(source.item)" in html
assert "event.submitter?.id === 'create-follow-up-next'" in html
assert "event.submitter.id === 'create-follow-up-next'" in html
assert "review.intent === 'follow-up-and-next'" in html
assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html
assert "advance: source => notificationReader.acceptReadAndNext(lastMyWork, source)" in html
assert ".create-issue-actions button { min-height:44px;" in html
+24
View File
@@ -41,6 +41,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
"items": [{
"id": 51, "number": 51, "title": "Older issue", "state": "open",
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
"work_reasons": [],
"updated_at": "2026-08-07T10:00:00Z",
"due_date": "2026-08-09T23:59:59Z",
"milestone": {"id": 9, "title": "August RC"},
@@ -66,6 +67,29 @@ async def test_work_page_endpoint_rejects_unknown_stream_before_upstream_io(monk
assert called is False
@pytest.mark.anyio
async def test_filed_work_page_endpoint_preserves_authored_reason(monkeypatch):
async def page_loader(stream, page):
return {
"stream": stream, "page": page, "total": 1, "has_more": False,
"items": [{
"id": 870, "number": 870, "title": "Delegated", "state": "open",
"labels": [], "assignees": [{"login": "alex"}],
"work_reasons": ["created_by_me"],
"repository": {"full_name": "stackchain/dashboard"},
"html_url": "https://forge.example/issues/870",
}],
}
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/filed?page=2")
assert response.status_code == 200
assert response.json()["items"][0]["work_reasons"] == ["created_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)
+67
View File
@@ -39,6 +39,33 @@ async def test_work_route_endpoint_resolves_one_authorized_item(monkeypatch):
}
@pytest.mark.anyio
async def test_work_route_endpoint_accepts_filed_issue_identity(monkeypatch):
requested = []
async def resolve(kind, repository, number, notification_id):
requested.append((kind, repository, number, notification_id))
return {
"kind": "filed",
"repository": repository,
"number": number,
"title": "Delegated filing",
"is_filed": True,
"is_assigned": False,
}
monkeypatch.setattr(main.gitea_proxy, "resolve_work_route", resolve)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/work-route?kind=filed&repository=stackchain/dashboard&number=88"
)
assert response.status_code == 200
assert requested == [("filed", "stackchain/dashboard", 88, None)]
assert response.json()["kind"] == "filed"
@pytest.mark.anyio
async def test_resolve_work_route_returns_only_a_requested_review():
requests = []
@@ -80,6 +107,46 @@ async def test_resolve_work_route_returns_only_a_requested_review():
}
@pytest.mark.anyio
async def test_resolve_work_route_returns_an_open_issue_filed_by_the_current_user():
requests = []
async def upstream(request):
requests.append(request.url.path)
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 701,
"number": 88,
"title": "Delegated filing",
"state": "open",
"html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/88",
"assignees": [{"login": "alex"}],
"user": {"login": "timmy"},
})
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.resolve_work_route(
"filed", "stackchain/dashboard", 88, None
)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 2
assert result == {
"kind": "filed",
"repository": "stackchain/dashboard",
"number": 88,
"title": "Delegated filing",
"state": "open",
"url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/88",
"is_filed": True,
"is_assigned": False,
"work_reasons": ["created_by_me"],
}
@pytest.mark.anyio
async def test_resolve_work_route_rejects_a_notification_that_is_already_read():
async def upstream(request):