Compare commits
254
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd637d0141 | ||
|
|
785fb990b4 | ||
|
|
f8c2c080f9 | ||
|
|
47220929bf | ||
|
|
3b4bb618dd | ||
|
|
9adfcd01b6 | ||
|
|
64e764b996 | ||
|
|
76eed47487 | ||
|
|
05b2f769e4 | ||
|
|
40b124bc2c | ||
|
|
4ab0ffde58 | ||
|
|
ab77beb3fe | ||
|
|
170828b308 | ||
|
|
08139bc3ee | ||
|
|
4589c46f16 | ||
|
|
6b924a4757 | ||
|
|
1602528e47 | ||
|
|
d28140bbd1 | ||
|
|
3f559a50ea | ||
|
|
e7a4eb3331 | ||
|
|
7b27f85b39 | ||
|
|
7204a426af | ||
|
|
2aad1a9bef | ||
|
|
dbadc73a57 | ||
|
|
59bc13c5b0 | ||
|
|
5c370cbe33 | ||
|
|
475739328c | ||
|
|
411e40d117 | ||
|
|
88e5f3f33b | ||
|
|
72dcdc6c52 | ||
|
|
d8f2b803c7 | ||
|
|
fdd3cf03b4 | ||
|
|
06184f1cb2 | ||
|
|
c26f4bdeeb | ||
|
|
04ae7480cf | ||
|
|
21705a0859 | ||
|
|
a2076cf9a9 | ||
|
|
d4aafbf351 | ||
|
|
7351363982 | ||
|
|
c86a63c54b | ||
|
|
3c5c58fc38 | ||
|
|
498d7e772a | ||
|
|
c594cc20fc | ||
|
|
5105fb40d3 | ||
|
|
b13eeb9cc0 | ||
|
|
49ef3dad78 | ||
|
|
a60f2cfac3 | ||
|
|
2eea42859e | ||
|
|
64f549848d | ||
|
|
5201b18cae | ||
|
|
8f92286cce | ||
|
|
27be5d43a2 | ||
|
|
e64541463c | ||
|
|
6a03dfd7ac | ||
|
|
f01dfa5f93 | ||
|
|
a57aa879e2 | ||
|
|
403190e888 | ||
|
|
6acc8866ed | ||
|
|
8e7a21f316 | ||
|
|
8411e907af | ||
|
|
eb88355229 | ||
|
|
1b68223798 | ||
|
|
33d7f4b884 | ||
|
|
05dd6c1e9c | ||
|
|
42f2a077b2 | ||
|
|
0bb76bdc8b | ||
|
|
efafaaddd2 | ||
|
|
a9ffc2d879 | ||
|
|
1fdbba1ca1 | ||
|
|
4b88e1a533 | ||
|
|
7a31ea5cba | ||
|
|
6633ba5244 | ||
|
|
5b954a7a53 | ||
|
|
c30206bfb2 | ||
|
|
cf9a02ae38 | ||
|
|
9f5aa33e95 | ||
|
|
ae3749bab9 | ||
|
|
c1c4ff8e31 | ||
|
|
ff41eb1d57 | ||
|
|
f23839f2b9 | ||
|
|
7cab4e8749 | ||
|
|
8a3316a5c4 | ||
|
|
631eb2e584 | ||
|
|
ed246ce6dd | ||
|
|
fc35b17d24 | ||
|
|
cee3d93864 | ||
|
|
fca99fee86 | ||
|
|
3dd0ba2024 | ||
|
|
871e35c10b | ||
|
|
5c9420ed9c | ||
|
|
8edc83893f | ||
|
|
a226addcc4 | ||
|
|
2402af9e82 | ||
|
|
31f95589cb | ||
|
|
2ac6b1f56b | ||
|
|
6449ead83d | ||
|
|
322f025794 | ||
|
|
9ab22b97f8 | ||
|
|
35747cf9fb | ||
|
|
f094438dab | ||
|
|
04f06042b8 | ||
|
|
a476170fca | ||
|
|
5a579362ff | ||
|
|
d797179844 | ||
|
|
a500915223 | ||
|
|
59a2e4f616 | ||
|
|
e125019f57 | ||
|
|
6ba823abd2 | ||
|
|
f44fa6e579 | ||
|
|
87ff83bd2d | ||
|
|
7dd396154d | ||
|
|
af5fe9b69d | ||
|
|
807364c63b | ||
|
|
6315323a48 | ||
|
|
6a9271cabb | ||
|
|
a1c56bd644 | ||
|
|
ebc3b9ee4d | ||
|
|
19772b5797 | ||
|
|
6314efb82b | ||
|
|
5cda95eeb2 | ||
|
|
0c71dea00c | ||
|
|
53539e0cdf | ||
|
|
9a24aff537 | ||
|
|
43769460fc | ||
|
|
2e62eb09f5 | ||
|
|
1ef3e291c2 | ||
|
|
3694f2501b | ||
|
|
c3d58d7111 | ||
|
|
da45cc36a3 | ||
|
|
c04cba29d9 | ||
|
|
cacecac865 | ||
|
|
10f7cc832a | ||
|
|
e5f37a866c | ||
|
|
5a4fbd1564 | ||
|
|
7bcab03b8a | ||
|
|
51fe9e5af2 | ||
|
|
2406d24cb8 | ||
|
|
745d2529e5 | ||
|
|
af9699e362 | ||
|
|
71b38687c4 | ||
|
|
0a8f33dc83 | ||
|
|
0f45b7aead | ||
|
|
a44bf46bef | ||
|
|
ec86bf55a8 | ||
|
|
7636e30482 | ||
|
|
bd054998b2 | ||
|
|
3c65bc1b31 | ||
|
|
bc082f2bd9 | ||
|
|
9ae0a89970 | ||
|
|
43163ed926 | ||
|
|
66a2b78860 | ||
|
|
20d4f1ef7e | ||
|
|
70281bb56a | ||
|
|
7b736bc4c6 | ||
|
|
fa3e5e3bf0 | ||
|
|
bc0d47e8e7 | ||
|
|
741c27eacf | ||
|
|
3896f17a58 | ||
|
|
9d14fa5ffb | ||
|
|
83be8fcdbe | ||
|
|
35f3999787 | ||
|
|
378291d3c8 | ||
|
|
584752ed24 | ||
|
|
d54f58bf36 | ||
|
|
84302d6f1e | ||
|
|
2e0d64907b | ||
|
|
80042ce561 | ||
|
|
931f656f78 | ||
|
|
18f56583ba | ||
|
|
6e2f5d1120 | ||
|
|
9bd755f521 | ||
|
|
fd2f801f3e | ||
|
|
e21cc58cca | ||
|
|
d6212b2812 | ||
|
|
6528b2e73f | ||
|
|
a9d586e689 | ||
|
|
7c86f28346 | ||
|
|
707ee4c1bc | ||
|
|
82982e5741 | ||
|
|
db08c7e75f | ||
|
|
1a3df5d7a7 | ||
|
|
fdba0554df | ||
|
|
d2a85cd5ea | ||
|
|
1f034a6f64 | ||
|
|
de6d6d0e61 | ||
|
|
8dc3880167 | ||
|
|
b93e63ab70 | ||
|
|
8c82644515 | ||
|
|
6c6cd3c71b | ||
|
|
4f6cebae7c | ||
|
|
341a3283ea | ||
|
|
9c276fa251 | ||
|
|
940f7c05d2 | ||
|
|
d34c082da2 | ||
|
|
1c086be09b | ||
|
|
4cfcb92eec | ||
|
|
b484bbef03 | ||
|
|
623817eb7d | ||
|
|
11347e5fe0 | ||
|
|
bb1712279c | ||
|
|
e7d5196987 | ||
|
|
64ff5127d8 | ||
|
|
4ac5a0f499 | ||
|
|
715df380e0 | ||
|
|
1fc70f6890 | ||
|
|
523968f5a3 | ||
|
|
af27bb06e0 | ||
|
|
4410af09a6 | ||
|
|
880f60831a | ||
|
|
e860d16d29 | ||
|
|
abf8e23685 | ||
|
|
02a472f841 | ||
|
|
c5e6e17fc1 | ||
|
|
8a12524595 | ||
|
|
cbe4017bdc | ||
|
|
fcc9ead33e | ||
|
|
0d29b29bef | ||
|
|
dce9ae04c9 | ||
|
|
9ec88179c3 | ||
|
|
93890e72de | ||
|
|
8f0df3d393 | ||
|
|
fc79e4523d | ||
|
|
741d545199 | ||
|
|
9320016b50 | ||
|
|
b069379df0 | ||
|
|
8387c57ceb | ||
|
|
72842ecbcd | ||
|
|
c503994be1 | ||
|
|
5e6328a8d0 | ||
|
|
4209834d6d | ||
|
|
46888d2876 | ||
|
|
7d7abefb0b | ||
|
|
2796808759 | ||
|
|
a2f83b0955 | ||
|
|
6b56188d30 | ||
|
|
467c8c665d | ||
|
|
1b311c9cd4 | ||
|
|
71d06347fd | ||
|
|
1de6d73e75 | ||
|
|
46c75f0b22 | ||
|
|
5cdb3f7b3e | ||
|
|
15d197327a | ||
|
|
df8385b325 | ||
|
|
47c6412a12 | ||
|
|
6cac778acd | ||
|
|
a8a93b2f3b | ||
|
|
ef37a66fd7 | ||
|
|
52e2c26304 | ||
|
|
3817d2c67b | ||
|
|
ad6c1edf80 | ||
|
|
d2b2c7bada | ||
|
|
9ba03613d4 | ||
|
|
1132075318 | ||
|
|
16b1d8670f |
No files matched your search
+22
-1
@@ -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
|
||||
|
||||
@@ -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,13 +62,50 @@ 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
|
||||
another worker replays a confirmed result instead of posting duplicate content. The ordered,
|
||||
another worker replays a confirmed result instead of posting duplicate content. Mobile Search previews
|
||||
let operators assign an eligible issue directly into Later at an exact local return time without filling
|
||||
Today or interrupting active work. Cancel and browser Back preserve the Search preview without assigning;
|
||||
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.
|
||||
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
|
||||
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and
|
||||
Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day
|
||||
checkpoint: finishing Agenda, Updates, or the final Filed review returns to a focused handoff using fresh queue
|
||||
counts, while reopening the queue sheet resumes the next live phase. **Finish for now** removes only that local
|
||||
checkpoint. Once urgent review is clear the pass continues the existing Today plan, or opens Find Work when Today
|
||||
is empty; the briefing and checkpoint never change Gitea state.
|
||||
Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the
|
||||
queue without erasing the delegated-work record. Reviewed cards reopen the existing read-only issue detail and
|
||||
conversation, while the Filed badge continues to count actionable outcomes only. A later Gitea update moves that
|
||||
issue back to Needs review automatically. Acknowledgements synchronize the exact Gitea `updated_at` revision in
|
||||
bounded batches to the confirmed account and preserve the same Reviewed state on other signed-in devices. Offline
|
||||
or failed synchronization keeps the local acknowledgement and retries on the next healthy dashboard refresh
|
||||
without blocking **Acknowledge & next**. Set
|
||||
`STACKCHAIN_COMPLETED_FILED_REVIEW_DB` to override the default
|
||||
`.stackchain-state/completed-filed-reviews.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
|
||||
planning, reports an existing Today item without duplicating it, and uses the same capacity, sync, and
|
||||
offline-warm path as other Today admission flows. The ordered,
|
||||
five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
|
||||
and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
|
||||
time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,
|
||||
@@ -72,7 +115,12 @@ explicit **Add blocked item anyway** override, while an unavailable dependency l
|
||||
as unknown rather than unblocked. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
After a reload or installed-app
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open
|
||||
assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps
|
||||
**Add step** directly beside the plan. A new step is normalized, checked for duplicates, and appended without
|
||||
exposing or replacing the full issue description. Offline additions enter the same account-bound durable
|
||||
issue-content queue as checklist toggles; reconnect conflict review applies unique local additions to the
|
||||
latest remote body while preserving remote prose and task-state changes.
|
||||
**Comment & next** on that current issue or pull request posts the handoff online or admits it
|
||||
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||
one without closing or merging it. **Reply & next** provides the same one-action continuation
|
||||
@@ -81,7 +129,11 @@ an open, unassigned issue update also offers **Take ownership & start**, which c
|
||||
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
|
||||
the session, and opens the issue. The adjacent **Take ownership** action remains available for
|
||||
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
|
||||
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
|
||||
**Mark read & next** remains the explicit acknowledgement path. During an online Updates pass,
|
||||
Stackchain preloads at most the next surviving conversation from the fixed snapshot while the
|
||||
current one is being read. Advancing consumes that account-bound result without another detail
|
||||
request; failures fall back to the normal foreground retry path, and offline triage never speculates.
|
||||
Delivery or local-admission
|
||||
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||
@@ -125,7 +177,12 @@ and runs up to three deliveries concurrently, but claims a record only when a de
|
||||
is ready. Claims have unique fencing tokens and are renewed before every network stage;
|
||||
completion, release, failure, and delivery checkpoints are token-fenced so an expired worker
|
||||
cannot alter a newer crash-recovery claim. Device purge cancels an active drain before closing
|
||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. All private SQLite stores enforce a
|
||||
filesystem boundary independently of the service umask: the database directory is repaired to
|
||||
owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database
|
||||
paths are rejected before access. This protects state from unrelated local accounts, but it is not
|
||||
encryption at rest; secure host access, encrypted volumes, and private backups are still required.
|
||||
Set `STACKCHAIN_STATE_DIR` to a
|
||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
||||
writes run outside the request event loop, and lock admission is bounded to 100 ms by
|
||||
@@ -210,6 +267,10 @@ export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
||||
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
|
||||
# within each device; one slow device does not delay healthy devices behind it.
|
||||
export STACKCHAIN_PUSH_POLL_SECONDS=30
|
||||
# Unread updates and deadline reminders run on independent, fixed-cadence
|
||||
# workers, so a slow channel cannot delay the other or add drift to its ticks.
|
||||
# On each phone, Device Setup can enable deadline reminders and choose any local
|
||||
# reminder hour from 00:00 through 23:00; the server-confirmed choice is restored.
|
||||
export STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS=10
|
||||
export STACKCHAIN_PUSH_MAX_CONCURRENCY=8
|
||||
# Maximum individual alerts per device and poll before one digest covers the rest.
|
||||
@@ -281,8 +342,9 @@ remain compatible. The bounded **Passkey counter anomaly** Security activity ent
|
||||
only the device label and attempted action; repeated alerts should prompt removal and
|
||||
re-enrollment of that passkey.
|
||||
|
||||
High-impact actions—merging a pull request, closing an assigned issue, revoking a
|
||||
remote device, or signing out every device—require a passkey assertion or the
|
||||
High-impact actions—merging a pull request, closing an assigned issue, permanently
|
||||
deleting an authored comment, revoking a remote device, or signing out every device—
|
||||
require a passkey assertion or the
|
||||
operator access token again.
|
||||
The server issues a random 90-second grant bound to the active session, exact action,
|
||||
and exact target. Only its digest is stored, and the grant is consumed atomically on
|
||||
@@ -373,7 +435,10 @@ account and never contain the Gitea token. Find Work uses the same worker-shared
|
||||
`STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB` overrides
|
||||
`STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3`. One expiring lease bounds each
|
||||
catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry
|
||||
bursts, and confirmed claims are removed from every worker's retained catalog. Releasing
|
||||
bursts, and confirmed claims are removed from every worker's retained catalog. On a cold
|
||||
catalog, non-owner workers wait within the bounded foreground deadline for the lease owner
|
||||
to publish; if that owner releases or abandons the lease, a waiter can take over the scan
|
||||
within its remaining request budget. Releasing
|
||||
an assignment invalidates the shared catalog so the newly available issue can be discovered
|
||||
by the next authoritative scan. Each bounded opaque revision token includes the
|
||||
store generation, so a token from a different deployment or before replacement of the store
|
||||
@@ -396,6 +461,14 @@ filter, preserves the selected release lane, shows the current draft count, resp
|
||||
the device safe area, and moves out of the way while a full-screen task is open.
|
||||
Desktop layout is unchanged.
|
||||
|
||||
Mobile **Search** provides touch-sized **Repository**, **Type**, and **Status** controls.
|
||||
Operators can find and select a repository visible to their Gitea account, then scope results
|
||||
to that exact repository, issues, pull requests, open work, closed work, or all accessible work;
|
||||
the server applies every scope before pagination. Clearing Repository returns to organization-wide
|
||||
Search. The selected scope is bounded and addressable, survives preview/back, reload, sharing,
|
||||
and sign-in continuation, and a scope change cancels obsolete requests before restarting at the
|
||||
first page. Repository lookup failure leaves organization-wide Search usable.
|
||||
|
||||
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
|
||||
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
|
||||
accepts a valid future local date and time and returns the item at that exact instant; the picker
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
function createAgendaReplan({ now = () => new Date(), update }) {
|
||||
let items = [];
|
||||
let index = 0;
|
||||
let pending = false;
|
||||
let active = false;
|
||||
|
||||
const dayKey = date => [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
const dueDay = value => {
|
||||
const match = String(value || '').match(/^(\d{4}-\d{2}-\d{2})(?:$|T)/);
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
const current = () => active ? items[index] || null : null;
|
||||
const snapshot = () => ({
|
||||
active,
|
||||
index,
|
||||
total: items.length,
|
||||
current: current()?.key || null,
|
||||
pending,
|
||||
});
|
||||
const advance = () => {
|
||||
index += 1;
|
||||
if (index >= items.length) active = false;
|
||||
return { ok:true, done:!active };
|
||||
};
|
||||
const change = async dueDate => {
|
||||
const item = current();
|
||||
if (!item) return { ok:false, error:'No overdue deadline selected.' };
|
||||
if (pending) return { ok:false, error:'Deadline update already in progress.' };
|
||||
pending = true;
|
||||
try {
|
||||
await update(item, dueDate);
|
||||
return advance();
|
||||
} catch (error) {
|
||||
return { ok:false, error:error?.message || 'Deadline could not be updated.' };
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start(overdue) {
|
||||
items = (overdue || []).slice().sort((left, right) =>
|
||||
dueDay(left.due_date).localeCompare(dueDay(right.due_date)) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
);
|
||||
index = 0;
|
||||
pending = false;
|
||||
active = items.length > 0;
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
current,
|
||||
keep() {
|
||||
if (pending) return Promise.resolve({ ok:false, error:'Deadline update already in progress.' });
|
||||
if (!current()) return Promise.resolve({ ok:false, error:'No overdue deadline selected.' });
|
||||
return Promise.resolve(advance());
|
||||
},
|
||||
tomorrow() {
|
||||
const date = new Date(now());
|
||||
date.setDate(date.getDate() + 1);
|
||||
return change(dayKey(date) + 'T23:59:59Z');
|
||||
},
|
||||
choose(value) {
|
||||
const chosen = String(value || '');
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(chosen) || chosen <= dayKey(now())) {
|
||||
return Promise.resolve({ ok:false, error:'Choose a future date.' });
|
||||
}
|
||||
return change(chosen + 'T23:59:59Z');
|
||||
},
|
||||
cancel() {
|
||||
active = false;
|
||||
pending = false;
|
||||
return snapshot();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAgendaReplan;
|
||||
@@ -0,0 +1,33 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createAgendaSessionLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createAgendaSessionLauncher(options) {
|
||||
let pending = null;
|
||||
|
||||
function open() {
|
||||
if (pending) return pending;
|
||||
options.selectAgenda();
|
||||
pending = Promise.resolve()
|
||||
.then(() => options.discover())
|
||||
.then(complete => {
|
||||
if (!options.isAgendaSelected()) return 'cancelled';
|
||||
if (complete === false || options.hasMore()) {
|
||||
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
|
||||
return 'incomplete';
|
||||
}
|
||||
const opened = options.hasCheckpoint() ? options.resume() : options.start();
|
||||
if (!opened) options.announce('No deadlines are ready in Agenda.');
|
||||
return opened ? 'opened' : 'empty';
|
||||
})
|
||||
.catch(() => {
|
||||
if (options.isAgendaSelected()) {
|
||||
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
|
||||
}
|
||||
return 'incomplete';
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {open};
|
||||
});
|
||||
@@ -1,10 +1,20 @@
|
||||
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
||||
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, mergeChecklistConflict, now = () => Date.now(), maxItems = 50 }) {
|
||||
const storageKey = 'stackchain.authored-outbox.v1';
|
||||
const makeId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
|
||||
|
||||
function checklistOperation(value) {
|
||||
const action = String(value?.action || '');
|
||||
if (!['rename', 'remove', 'move-earlier', 'move-later'].includes(action)) return null;
|
||||
return {
|
||||
action,
|
||||
index:Number(value.index),
|
||||
...(action === 'rename' ? { label:String(value.label || '') } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
@@ -61,6 +71,23 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
|
||||
}
|
||||
}
|
||||
if (message.kind === 'issue-content') {
|
||||
const queuedContent = items.find(item => item.kind === 'issue-content' && item.status === 'queued' &&
|
||||
item.ownerLogin === ownerLogin && item.repository === String(message.repository || '') &&
|
||||
item.number === Number(message.number || 0));
|
||||
if (queuedContent) {
|
||||
const operation = checklistOperation(message.checklistOperation);
|
||||
const replacement = {
|
||||
...queuedContent,
|
||||
operationId: String(requestedOperationId || makeId()).slice(0, 128),
|
||||
title: String(message.title || ''),
|
||||
body: String(message.body || ''),
|
||||
...(operation ? { checklistOperations:[...(queuedContent.checklistOperations || []), operation] } : {}),
|
||||
};
|
||||
write(items.map(item => item.id === queuedContent.id ? replacement : item), mirror);
|
||||
return replacement;
|
||||
}
|
||||
}
|
||||
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
|
||||
const id = String(requestedOperationId || makeId()).slice(0, 128);
|
||||
const item = {
|
||||
@@ -96,6 +123,14 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
blockerNumber: Number(message.blockerNumber || 0),
|
||||
present: message.present === true,
|
||||
} : {}),
|
||||
...(message.kind === 'issue-content' ? {
|
||||
title: String(message.title || ''),
|
||||
baseBody: String(message.baseBody ?? message.body ?? ''),
|
||||
expectedUpdatedAt: String(message.expectedUpdatedAt || ''),
|
||||
...(checklistOperation(message.checklistOperation) ? {
|
||||
checklistOperations:[checklistOperation(message.checklistOperation)],
|
||||
} : {}),
|
||||
} : {}),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
@@ -103,6 +138,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
}
|
||||
|
||||
async function enqueueDurably(message) {
|
||||
const previousItems = read();
|
||||
const item = enqueue(message, false);
|
||||
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
|
||||
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
|
||||
@@ -124,7 +160,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
try {
|
||||
await backgroundSync.reconcile(durableItems, 'authored');
|
||||
} catch (error) {
|
||||
write(read().filter(candidate => candidate.id !== item.id), false);
|
||||
write(previousItems, false);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
@@ -171,6 +207,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
if (item.kind === 'issue-blocker') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
|
||||
}
|
||||
if (item.kind === 'issue-content') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content';
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
@@ -246,9 +285,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
decision: item.decision,
|
||||
expected_head_sha: item.expectedHeadSha,
|
||||
comments: item.comments,
|
||||
} : item.kind === 'issue-content' ? {
|
||||
title:item.title, body:item.body, expected_updated_at:item.expectedUpdatedAt,
|
||||
} : { body: item.body };
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
method: item.kind === 'issue-content' ? 'PATCH' : 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
@@ -256,6 +297,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (item.kind === 'issue-content' &&
|
||||
(result?.number !== item.number || result?.title !== item.title || result?.body !== item.body)) {
|
||||
throw new Error('Checklist update was not confirmed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!result) return { blocked: true };
|
||||
@@ -334,6 +379,51 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
return retryItem(id, currentLogin);
|
||||
}
|
||||
|
||||
async function resolveIssueContentConflict(id, latest, currentLogin) {
|
||||
const previousItems = read();
|
||||
const item = previousItems.find(candidate => candidate.id === id);
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
if (!item || item.kind !== 'issue-content' || item.status !== 'attention') {
|
||||
throw new Error('This checklist conflict is no longer available.');
|
||||
}
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
throw new Error('Confirm the account that queued this checklist update.');
|
||||
}
|
||||
if (typeof mergeChecklistConflict !== 'function') {
|
||||
throw new Error('Checklist conflict review is unavailable.');
|
||||
}
|
||||
const merged = mergeChecklistConflict({
|
||||
baseBody: String(item.baseBody ?? item.body ?? ''),
|
||||
localBody: String(item.body || ''),
|
||||
remoteBody: String(latest?.body || ''),
|
||||
});
|
||||
if (merged.conflicts?.length) return merged;
|
||||
if (!String(latest?.updated_at || '')) throw new Error('Latest issue revision is unavailable.');
|
||||
const rebased = {
|
||||
...item,
|
||||
operationId: String(makeId()).slice(0, 128),
|
||||
title: String(latest?.title || ''),
|
||||
baseBody: String(latest?.body || ''),
|
||||
body: merged.body,
|
||||
expectedUpdatedAt: String(latest.updated_at),
|
||||
status: 'queued',
|
||||
};
|
||||
delete rebased.error;
|
||||
delete rebased.deliveryState;
|
||||
const nextItems = previousItems.map(candidate => candidate.id === id ? rebased : candidate);
|
||||
write(nextItems, false);
|
||||
if (backgroundSync?.reconcile) {
|
||||
try { await backgroundSync.reconcile(nextItems, 'authored'); }
|
||||
catch (error) {
|
||||
write(previousItems, false);
|
||||
throw error;
|
||||
}
|
||||
try { await backgroundSync.requestSync?.(); }
|
||||
catch (_error) { /* Foreground retry remains available. */ }
|
||||
}
|
||||
return { ...merged, item: rebased };
|
||||
}
|
||||
|
||||
function reconcileBackground(records) {
|
||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
@@ -359,7 +449,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
return { enqueue, enqueueDurably, update, discard, flush, retry, resolveIssueContentConflict,
|
||||
reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
|
||||
@@ -91,13 +91,25 @@ 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.kind === 'issue-content' && replacement.kind === 'issue-content' &&
|
||||
current.operationId !== replacement.operationId && current.status === 'sending' &&
|
||||
Number(current.claimUntil) > Number(now())) {
|
||||
throw new Error('Checklist sync is already delivering. Retry this change.');
|
||||
}
|
||||
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(['attention', 'authorization'].includes(current.status) &&
|
||||
replacement.status === current.status) ||
|
||||
@@ -353,6 +365,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) };
|
||||
}
|
||||
|
||||
@@ -400,6 +415,24 @@ function createBackgroundIssueSync({
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-content') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content',
|
||||
options: {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title,
|
||||
body: item.body,
|
||||
expected_updated_at: item.expectedUpdatedAt,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||
@@ -439,6 +472,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 +501,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 +567,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 +599,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 +711,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;
|
||||
@@ -654,6 +729,12 @@ function createBackgroundIssueSync({
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (item.kind === 'issue-content' &&
|
||||
(delivered?.number !== item.number || delivered?.title !== item.title || delivered?.body !== item.body)) {
|
||||
const error = new Error('Checklist update was not confirmed.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await completeClaim(item, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
|
||||
@@ -7,9 +7,13 @@ function createBatchFindWork({
|
||||
onProgress = () => {},
|
||||
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
||||
owner = () => '',
|
||||
journalName = 'find-work-batch',
|
||||
queueFailureReason = 'assigned but Today sync is unavailable',
|
||||
autoMount = true,
|
||||
}) {
|
||||
let request = null;
|
||||
const journalKey = () => 'stackchain.find-work-batch.v1.' + encodeURIComponent(String(owner() || ''));
|
||||
const journalKey = () => 'stackchain.' + journalName + '.v1.' +
|
||||
encodeURIComponent(String(owner() || ''));
|
||||
|
||||
function readJournal() {
|
||||
if (!storage || !String(owner() || '')) return null;
|
||||
@@ -55,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';
|
||||
@@ -64,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' });
|
||||
@@ -78,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);
|
||||
@@ -109,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);
|
||||
@@ -125,6 +129,11 @@ function createBatchFindWork({
|
||||
return request;
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const journal = readJournal();
|
||||
return journal ? journal.items.filter(item => item.state !== 'queued').length : 0;
|
||||
}
|
||||
|
||||
function mountRecovery(button, opener) {
|
||||
const show = () => {
|
||||
const journal = readJournal();
|
||||
@@ -144,10 +153,10 @@ function createBatchFindWork({
|
||||
opener.addEventListener('click', show);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') mountRecovery(
|
||||
if (autoMount && typeof document !== 'undefined') mountRecovery(
|
||||
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
|
||||
);
|
||||
return { run, resume, mountRecovery };
|
||||
return { run, resume, pending, mountRecovery };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|
||||
@@ -0,0 +1,141 @@
|
||||
function mergeChecklistConflict({ baseBody, localBody, remoteBody, operations = [] }) {
|
||||
const taskPattern = /^(\s*[-*+]\s+\[)([ xX])(\]\s+)(.*)$/;
|
||||
|
||||
function tasks(body) {
|
||||
const entries = [];
|
||||
String(body || '').split('\n').forEach((line, lineIndex) => {
|
||||
const match = line.match(taskPattern);
|
||||
if (!match) return;
|
||||
const label = match[4].trim();
|
||||
const key = label.replace(/\s+/g, ' ').toLocaleLowerCase();
|
||||
if (!key) return;
|
||||
entries.push({ key, label, checked: match[2].toLowerCase() === 'x', lineIndex, match });
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
function grouped(entries) {
|
||||
const result = new Map();
|
||||
entries.forEach(entry => result.set(entry.key, [...(result.get(entry.key) || []), entry]));
|
||||
return result;
|
||||
}
|
||||
|
||||
function replayOperations(body, requested, reportChanges) {
|
||||
let lines = String(body || '').split('\n');
|
||||
const visibleTasks = value => tasks(value).filter(entry => /^[-*+]/.test(entry.match[1]));
|
||||
const logical = visibleTasks(baseBody).map(entry => ({ key:entry.key, label:entry.label }));
|
||||
const replayed = [];
|
||||
for (const operation of requested) {
|
||||
const index = Number(operation?.index);
|
||||
const target = logical[index];
|
||||
if (!target) return { body:null, changes:replayed, conflict:{ label:'Checklist step', reason:'missing' } };
|
||||
let entries = visibleTasks(lines.join('\n'));
|
||||
const matches = entries.filter(entry => entry.key === target.key);
|
||||
if (matches.length !== 1) {
|
||||
return { body:null, changes:replayed, conflict:{
|
||||
label:target.label, reason:matches.length ? 'ambiguous' : 'missing',
|
||||
} };
|
||||
}
|
||||
const match = matches[0];
|
||||
if (operation.action === 'rename') {
|
||||
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
|
||||
const key = label.toLocaleLowerCase();
|
||||
if (!label || entries.some(entry => entry.key === key && entry.lineIndex !== match.lineIndex)) {
|
||||
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'ambiguous' } };
|
||||
}
|
||||
lines[match.lineIndex] = match.match[1] + match.match[2] + match.match[3] + label;
|
||||
if (reportChanges) replayed.push({ label:target.label, renamed:label });
|
||||
target.label = label;
|
||||
target.key = key;
|
||||
} else if (operation.action === 'remove') {
|
||||
lines.splice(match.lineIndex, 1);
|
||||
logical.splice(index, 1);
|
||||
if (reportChanges) replayed.push({ label:target.label, removed:true });
|
||||
} else if (operation.action === 'move-earlier' || operation.action === 'move-later') {
|
||||
const neighborIndex = operation.action === 'move-earlier' ? index - 1 : index + 1;
|
||||
const neighbor = logical[neighborIndex];
|
||||
const neighborMatches = neighbor ? entries.filter(entry => entry.key === neighbor.key) : [];
|
||||
if (neighborMatches.length !== 1 || Math.abs(neighborMatches[0].lineIndex - match.lineIndex) !== 1) {
|
||||
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'order-changed' } };
|
||||
}
|
||||
const neighborLine = neighborMatches[0].lineIndex;
|
||||
[lines[match.lineIndex], lines[neighborLine]] = [lines[neighborLine], lines[match.lineIndex]];
|
||||
[logical[index], logical[neighborIndex]] = [logical[neighborIndex], logical[index]];
|
||||
if (reportChanges) replayed.push({
|
||||
label:target.label, moved:operation.action === 'move-earlier' ? 'earlier' : 'later',
|
||||
});
|
||||
}
|
||||
}
|
||||
return { body:lines.join('\n'), changes:replayed, conflict:null };
|
||||
}
|
||||
|
||||
if (Array.isArray(operations) && operations.length) {
|
||||
const baseReplay = replayOperations(baseBody, operations, false);
|
||||
const remoteReplay = replayOperations(remoteBody, operations, true);
|
||||
const conflict = baseReplay.conflict || remoteReplay.conflict;
|
||||
if (conflict) return { body:null, changes:remoteReplay.changes || [], conflicts:[conflict] };
|
||||
const residual = mergeChecklistConflict({
|
||||
baseBody:baseReplay.body, localBody, remoteBody:remoteReplay.body,
|
||||
});
|
||||
return {
|
||||
body:residual.body,
|
||||
changes:[...remoteReplay.changes, ...residual.changes],
|
||||
conflicts:residual.conflicts,
|
||||
};
|
||||
}
|
||||
|
||||
const base = grouped(tasks(baseBody));
|
||||
const local = grouped(tasks(localBody));
|
||||
const remoteEntries = tasks(remoteBody);
|
||||
const remote = grouped(remoteEntries);
|
||||
const changes = [];
|
||||
const conflicts = [];
|
||||
|
||||
for (const [key, baseMatches] of base) {
|
||||
const localMatches = local.get(key) || [];
|
||||
if (baseMatches.length !== 1 || localMatches.length !== 1) continue;
|
||||
if (baseMatches[0].checked === localMatches[0].checked) continue;
|
||||
const remoteMatches = remote.get(key) || [];
|
||||
if (remoteMatches.length !== 1) {
|
||||
conflicts.push({
|
||||
label: baseMatches[0].label,
|
||||
reason: remoteMatches.length ? 'ambiguous' : 'missing',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
changes.push({ label: remoteMatches[0].label, checked: localMatches[0].checked });
|
||||
}
|
||||
|
||||
for (const [key, localMatches] of local) {
|
||||
if (base.has(key)) continue;
|
||||
if (localMatches.length !== 1) {
|
||||
conflicts.push({ label:localMatches[0].label, reason:'ambiguous' });
|
||||
continue;
|
||||
}
|
||||
const remoteMatches = remote.get(key) || [];
|
||||
if (remoteMatches.length > 1) {
|
||||
conflicts.push({ label: localMatches[0].label, reason: 'ambiguous' });
|
||||
continue;
|
||||
}
|
||||
if (remoteMatches.length === 0) {
|
||||
changes.push({ label: localMatches[0].label, checked:localMatches[0].checked, added:true });
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length) return { body: null, changes, conflicts };
|
||||
const desired = new Map(changes.map(change => [
|
||||
change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked,
|
||||
]));
|
||||
const lines = String(remoteBody || '').split('\n');
|
||||
remoteEntries.forEach(entry => {
|
||||
if (!desired.has(entry.key)) return;
|
||||
const marker = desired.get(entry.key) ? 'x' : ' ';
|
||||
lines[entry.lineIndex] = entry.match[1] + marker + entry.match[3] + entry.match[4];
|
||||
});
|
||||
changes.filter(change => change.added).forEach(change => {
|
||||
lines.push('- [' + (change.checked ? 'x' : ' ') + '] ' + change.label);
|
||||
});
|
||||
return { body: lines.join('\n'), changes, conflicts: [] };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = mergeChecklistConflict;
|
||||
+73
-17
@@ -15,6 +15,15 @@
|
||||
return current;
|
||||
};
|
||||
|
||||
filterCommands.searchUrl = function searchUrl(query, page, scope, continuation) {
|
||||
const streamPages = continuation ?
|
||||
(continuation.issues ? '&issues_page=' + encodeURIComponent(continuation.issues) : '') +
|
||||
(continuation.pulls ? '&pulls_page=' + encodeURIComponent(continuation.pulls) : '') : '';
|
||||
return 'api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page +
|
||||
'&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state) +
|
||||
(scope.repository ? '&repository=' + encodeURIComponent(scope.repository) : '') + streamPages;
|
||||
};
|
||||
|
||||
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
|
||||
const search = options.search;
|
||||
const onState = options.onState;
|
||||
@@ -22,6 +31,46 @@
|
||||
let timer = null;
|
||||
let generation = 0;
|
||||
let activeController = null;
|
||||
let scope = { kind:'all', state:'all' };
|
||||
let state = { status: 'idle', query: '', items: [], more: false, next: 1, scope };
|
||||
|
||||
function publish(next) {
|
||||
state = next;
|
||||
onState(next);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function requestPage(query, page, current, append, continuation) {
|
||||
const requestController = new AbortController();
|
||||
activeController = requestController;
|
||||
try {
|
||||
const requestScope = { ...scope };
|
||||
const result = await search(
|
||||
query, requestController.signal, page, requestScope, continuation
|
||||
);
|
||||
const partial = result.partial === true;
|
||||
const incoming = result.items || result;
|
||||
const combined = append ? state.items.concat(incoming) : incoming;
|
||||
const items = [...new Map((combined || []).map(item =>
|
||||
[`${item.kind}:${item.repository}:${item.number}`, item]
|
||||
)).values()];
|
||||
if (current === generation) return publish({
|
||||
status: 'ready', query, items, partial,
|
||||
more: !!result.has_more,
|
||||
next: result.next_page,
|
||||
continuation:result.continuation,
|
||||
failedStreams:result.failed_streams || [],
|
||||
scope:requestScope,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (current === generation) return publish(append
|
||||
? { ...state, status: 'ready' }
|
||||
: { status: 'error', query, items: [], error, scope:{ ...scope } });
|
||||
} finally {
|
||||
if (activeController === requestController) activeController = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setQuery(value) {
|
||||
@@ -32,25 +81,32 @@
|
||||
if (activeController !== null) activeController.abort();
|
||||
activeController = null;
|
||||
if (query.length < 2) {
|
||||
onState({ status: 'idle', query, items: [] });
|
||||
publish({ status: 'idle', query, items: [], more: false, next: 1, scope:{ ...scope } });
|
||||
return;
|
||||
}
|
||||
onState({ status: 'loading', query, items: [] });
|
||||
timer = setTimeout(async () => {
|
||||
const requestController = new AbortController();
|
||||
activeController = requestController;
|
||||
try {
|
||||
const result = await search(query, requestController.signal);
|
||||
const items = Array.isArray(result) ? result : result.items;
|
||||
const partial = !Array.isArray(result) && result.partial === true;
|
||||
if (current === generation) onState({ status: 'ready', query, items, partial });
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (current === generation) onState({ status: 'error', query, items: [], error });
|
||||
} finally {
|
||||
if (activeController === requestController) activeController = null;
|
||||
}
|
||||
}, delay);
|
||||
publish({ status: 'loading', query, items: [], more: false, next: 1, scope:{ ...scope } });
|
||||
timer = setTimeout(() => requestPage(query, 1, current, false), delay);
|
||||
},
|
||||
setScope(value) {
|
||||
const repository = typeof value?.repository === 'string' &&
|
||||
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
|
||||
? value.repository.trim() : '';
|
||||
const next = {
|
||||
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
|
||||
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
|
||||
...(repository ? { repository } : {}),
|
||||
};
|
||||
if (next.kind === scope.kind && next.state === scope.state &&
|
||||
(next.repository || '') === (scope.repository || '')) return;
|
||||
const query = state.query;
|
||||
scope = next;
|
||||
this.setQuery(query);
|
||||
},
|
||||
loadMore() {
|
||||
if (state.status !== 'ready' || !state.more || activeController) return Promise.resolve(state);
|
||||
const current = generation;
|
||||
const page = state.next;
|
||||
return requestPage(state.query, page, current, true, state.continuation);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,6 +37,8 @@ function createContextPoller({
|
||||
let retainedSnapshot = null;
|
||||
let nextDelayMs = intervalMs;
|
||||
let failureStreak = 0;
|
||||
let nextRetryAt = null;
|
||||
let lastSuccessAt = null;
|
||||
|
||||
function snapshotDelay(snapshot) {
|
||||
const freshness = snapshot && snapshot.freshness;
|
||||
@@ -69,8 +71,10 @@ function createContextPoller({
|
||||
function schedule(delayMs = intervalMs) {
|
||||
cancelTimer();
|
||||
if (stopped || isHidden()) return;
|
||||
nextRetryAt = Date.now() + delayMs;
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
nextRetryAt = null;
|
||||
refresh();
|
||||
}, delayMs);
|
||||
}
|
||||
@@ -125,6 +129,7 @@ function createContextPoller({
|
||||
.then((snapshot) => {
|
||||
if (activeRequest !== requestState) return retainedSnapshot;
|
||||
failureStreak = 0;
|
||||
lastSuccessAt = Date.now();
|
||||
nextDelayMs = snapshotDelay(snapshot);
|
||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||
@@ -165,6 +170,14 @@ function createContextPoller({
|
||||
start: refresh,
|
||||
refresh,
|
||||
setVisible,
|
||||
getState() {
|
||||
return {
|
||||
refreshing: Boolean(activeRequest),
|
||||
nextRetryAt,
|
||||
lastSuccessAt,
|
||||
failureStreak,
|
||||
};
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelTimer();
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -19,22 +19,244 @@ 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 relatedChecklistDraft(item, label) {
|
||||
const title = String(label || '').trim().replace(/\s+/g, ' ').slice(0, 240);
|
||||
if (!title) throw new Error('Choose a checklist step to file.');
|
||||
const repository = String(item?.repository || '');
|
||||
const number = Number(item?.number);
|
||||
const url = String(item?.url || '');
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
||||
!Number.isInteger(number) || number < 1 || !/^https:\/\//.test(url)) {
|
||||
throw new Error('The parent issue link is unavailable.');
|
||||
}
|
||||
return {repository, title,
|
||||
body:'Related to [' + repository + '#' + number + '](' + url + ').', labelIds:[]};
|
||||
}
|
||||
|
||||
function linkChecklistTask(raw, targetIndex, child) {
|
||||
const url = String(child?.url || '');
|
||||
if (!/^https:\/\//.test(url)) throw new Error('The related issue link is unavailable.');
|
||||
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let taskIndex = 0;
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const line = parts[index];
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^(\s*[-*+]\s+\[[ xX]\]\s+)(.*)$/);
|
||||
if (!task) continue;
|
||||
if (taskIndex === Number(targetIndex)) {
|
||||
if (/^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(task[2])) throw new Error('That checklist step is already linked.');
|
||||
const label = task[2].trim().replace(/\s+/g, ' ');
|
||||
parts[index] = task[1] + '[' + label + '](' + url + ')';
|
||||
return parts.join('');
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
throw new Error('The checklist step is no longer available.');
|
||||
}
|
||||
|
||||
function createChecklistPromotion({ issueController, issueCapture, clearAttachments, onLinked, onStatus }) {
|
||||
let pending = null;
|
||||
const persistedPromotion = value => ({
|
||||
item:{repository:value.repository, number:value.number, url:value.url},
|
||||
detail:{title:value.title, body:value.body, updated_at:value.updatedAt}, taskIndex:value.taskIndex,
|
||||
});
|
||||
return {
|
||||
pending:() => Boolean(pending),
|
||||
start(context) {
|
||||
pending = {item:{...context.item}, detail:{...context.detail}, taskIndex:Number(context.taskIndex)};
|
||||
const draft = issueController.relatedTaskDraft(context.item, context.detail, context.taskIndex, context.label);
|
||||
issueCapture.saveDraft(draft);
|
||||
clearAttachments();
|
||||
return draft;
|
||||
},
|
||||
cancel() { if (!pending) return false; pending = null; issueCapture.clearDraft(); return true; },
|
||||
deliveryContext() {
|
||||
if (!pending) return null;
|
||||
return {repository:pending.item.repository, number:pending.item.number, url:pending.item.url,
|
||||
title:pending.detail.title, body:pending.detail.body, updatedAt:pending.detail.updated_at,
|
||||
taskIndex:pending.taskIndex};
|
||||
},
|
||||
async finish(child, persisted = null) {
|
||||
const promotion = pending || (persisted ? persistedPromotion(persisted) : null);
|
||||
if (!promotion || !child) return false;
|
||||
pending = null;
|
||||
try {
|
||||
const confirmed = await issueController.linkRelatedTask(
|
||||
promotion.item, promotion.detail, promotion.taskIndex, child
|
||||
);
|
||||
onLinked(promotion, confirmed);
|
||||
onStatus('Related issue created and linked from its parent checklist.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
onStatus('Related issue created, but the parent link needs attention.' +
|
||||
(child.url ? ' ' + child.url : '') + ' ' + error.message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 +269,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 +310,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 +442,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 +736,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 +811,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 +824,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 +835,10 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
||||
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
|
||||
createIssueCapture.buildRelatedDraft = buildRelatedDraft;
|
||||
createIssueCapture.relatedChecklistDraft = relatedChecklistDraft;
|
||||
createIssueCapture.linkChecklistTask = linkChecklistTask;
|
||||
createIssueCapture.createChecklistPromotion = createChecklistPromotion;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
+246
-6
@@ -5,7 +5,24 @@ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text);
|
||||
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
|
||||
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.live-data-status-sheet[hidden] { display:none; }
|
||||
.live-data-status-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 #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.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; }
|
||||
.live-data-status-actions { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||
.app-menu { margin-left:auto; }
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
@@ -38,6 +55,9 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; }
|
||||
.device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.device-setup-step p { margin:5px 0 0; }
|
||||
.device-setup-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; }
|
||||
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
|
||||
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
|
||||
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
|
||||
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
|
||||
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
|
||||
@@ -77,6 +97,14 @@ main { position: relative; z-index: 1; display: grid; grid-template-columns: 300
|
||||
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
||||
a { color: #60a5fa; text-decoration: none; }
|
||||
.muted { color: #94a3b8; }
|
||||
.empty-work-start { display:grid; gap:12px; margin:12px 0; padding:16px; border:1px solid #31577f; border-radius:14px; background:#0b1b2f; }
|
||||
.empty-work-start[hidden] { display:none; }
|
||||
.empty-work-start p { margin:6px 0 0; }
|
||||
.empty-work-start-actions { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); gap:8px; }
|
||||
.empty-work-start-actions button { min-width:0; min-height:44px; white-space:normal; }
|
||||
@media (max-width:420px) {
|
||||
.empty-work-start-actions { grid-template-columns:1fr; }
|
||||
}
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
||||
.kv .label { color: #94a3b8; }
|
||||
.kv .value { color: #e5e7eb; }
|
||||
@@ -93,11 +121,52 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||||
#cmd-palette.open { display: block; }
|
||||
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.cmd-palette-header-actions { display:flex; gap:8px; }
|
||||
.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
|
||||
.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; }
|
||||
.cmd-search-scope label { font-size:12px; color:#cbd5e1; }
|
||||
.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; }
|
||||
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
|
||||
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
|
||||
.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: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); }
|
||||
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
|
||||
@@ -216,11 +285,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.queue-finder button { padding-inline:14px; }
|
||||
#search-older-work { width:100%; }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.filed-history-tabs { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin:10px 0; }
|
||||
.filed-history-tabs[hidden] { display:none; }
|
||||
.filed-history-tabs button { min-height:44px; min-width:0; }
|
||||
.filed-history-tabs button[aria-pressed="true"] { border-color:#60a5fa; background:#17365d; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
|
||||
.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; }
|
||||
@@ -228,13 +301,26 @@ 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 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; }
|
||||
.protect-today p { margin:4px 0; }
|
||||
.protect-today button { min-height:44px; flex:0 0 auto; }
|
||||
@media(max-width:360px) { .agenda-replan-launch { align-items:stretch; flex-direction:column; } .agenda-replan-actions { grid-template-columns:1fr; } .agenda-replan-actions label { grid-column:auto; } }
|
||||
@media(max-width:430px) { .protect-today { align-items:stretch; flex-direction:column; } .protect-today button { width:100%; } }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
@@ -350,7 +436,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.update-sheet-header button { min-height:44px; }
|
||||
.update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.update-new-activity { scroll-margin-block:18px 96px; border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||
.update-new-activity::before { content:'New activity'; display:inline-block; margin-bottom:6px; padding:2px 7px; border-radius:999px; background:#1d4ed8; color:#fff; font-size:12px; font-weight:700; }
|
||||
.update-new-activity-jump { position:sticky; bottom:92px; z-index:4; min-height:44px; width:100%; margin:10px 0; border-color:#60a5fa; background:#17345a; }
|
||||
.update-reply { display:grid; gap:8px; margin-top:16px; }
|
||||
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
|
||||
.update-reply button { min-height:44px; width:100%; }
|
||||
@@ -367,14 +457,45 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-ownership-actions button { min-width:0; width:100%; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions #mute-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-more-actions { margin-top:14px; }
|
||||
.update-more-actions > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-decision-bar { display:grid; grid-template-columns:repeat(auto-fit,minmax(76px,1fr)); gap:6px; margin-top:14px; padding:8px; background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.update-decision-bar button, .update-decision-bar summary { min-height:44px; min-width:0; padding-inline:6px; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.issue-sheet.open { display:flex; }
|
||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr); align-items:center; gap:8px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
|
||||
.completed-filed-actions[hidden] { display:none; }
|
||||
.completed-filed-actions button { min-height:44px; min-width:0; }
|
||||
.completed-filed-buttons { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.completed-filed-buttons button { flex:1 1 180px; }
|
||||
#issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(170px + env(safe-area-inset-bottom)); }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.checklist-step-editor { display:grid; gap:8px; margin:10px 0 16px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1526; }
|
||||
.checklist-step-editor[hidden] { display:none; }
|
||||
.checklist-step-editor input { width:100%; min-width:0; box-sizing:border-box; }
|
||||
.checklist-step-editor-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.checklist-step-editor button, .checklist-step-editor input { min-height:44px; }
|
||||
#remove-checklist-step { border-color:#b45309; }
|
||||
#issue-sheet.read-only .checklist-step-editor { display:none; }
|
||||
.checklist-add { display:grid; gap:8px; margin:10px 0 16px; }
|
||||
.checklist-add form { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; }
|
||||
.checklist-add form[hidden] { display:none; }
|
||||
.checklist-add input { min-width:0; width:100%; box-sizing:border-box; }
|
||||
.checklist-add button, .checklist-add input { min-height:44px; }
|
||||
#issue-sheet.read-only .checklist-add { display:none; }
|
||||
@media(max-width:340px) { .checklist-add form { grid-template-columns:1fr 1fr; } .checklist-add input { grid-column:1 / -1; } }
|
||||
.checklist-completion { position:fixed; right:0; bottom:0; z-index:58; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr) repeat(2,minmax(0,auto)); align-items:center; gap:8px; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border-top:1px solid #4ade80; background:rgba(11,21,38,.98); overflow-wrap:anywhere; }
|
||||
.checklist-completion[hidden] { display:none; }
|
||||
.checklist-completion button { min-height:44px; min-width:0; }
|
||||
#issue-sheet:has(.checklist-completion:not([hidden])) .issue-sheet-panel { padding-bottom:calc(118px + env(safe-area-inset-bottom)); }
|
||||
#issue-sheet:has(.checklist-completion:not([hidden])) .work-session-nav { display:none; }
|
||||
@media(max-width:420px) { .checklist-completion { grid-template-columns:1fr 1fr; } .checklist-completion span { grid-column:1 / -1; } }
|
||||
.issue-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; }
|
||||
.issue-blockers h2 { margin-top:0; }
|
||||
.issue-blocker-list { display:grid; gap:8px; }
|
||||
@@ -389,6 +510,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-blocker-manager [role="option"] { text-align:left; overflow-wrap:anywhere; }
|
||||
#manage-issue-blockers, #start-unblocked-issue { min-height:44px; width:100%; margin-top:10px; }
|
||||
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
|
||||
.issue-content-editor { max-width:100%; overflow-x:hidden; margin-top:16px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
|
||||
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
.issue-planning-retry { min-height:44px; width:100%; }
|
||||
@@ -401,6 +523,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.issue-comment-composer textarea { min-height:96px; }
|
||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
.issue-attachment-controls { display:flex; max-width:100%; }
|
||||
@@ -430,6 +553,18 @@ 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-attachment-controls,
|
||||
#issue-sheet.read-only .issue-attachment-preview,
|
||||
#issue-sheet.read-only #issue-planning,
|
||||
#issue-sheet.read-only #issue-handoff,
|
||||
#issue-sheet.read-only #release-issue,
|
||||
#issue-sheet.read-only .detail-defer,
|
||||
#issue-sheet.read-only #issue-blockers,
|
||||
#issue-sheet.read-only #retry-issue-comment-actions { display:none; }
|
||||
#issue-sheet.read-only.filed-reassignable #issue-handoff { display:block; }
|
||||
.filed-claim-actions { position:sticky; bottom:0; z-index:4; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:14px; padding:10px 4px calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #4ade80; }
|
||||
.filed-claim-actions[hidden] { display:none; }
|
||||
.filed-claim-actions button { min-width:0; min-height:44px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
@@ -497,8 +632,23 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
|
||||
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
|
||||
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.search-preview-conversation { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-conversation h2 { margin:0; font-size:1rem; }
|
||||
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||
.search-preview-conversation button { min-height:44px; width:100%; }
|
||||
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-reply[hidden] { display:none; }
|
||||
.search-preview-reply h2 { margin:0; font-size:1rem; }
|
||||
.search-preview-reply textarea { box-sizing:border-box; width:100%; min-height:110px; resize:vertical; scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom)); }
|
||||
.search-preview-reply-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.search-preview-reply-actions button { min-height:44px; }
|
||||
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
|
||||
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
@media (max-width:420px) {
|
||||
.search-preview-primary-actions { grid-template-columns:1fr; }
|
||||
}
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||
.markdown-content > :first-child { margin-top:0; }
|
||||
@@ -511,6 +661,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.markdown-content .task-list { padding-left:0; list-style:none; }
|
||||
.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; }
|
||||
.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; }
|
||||
.markdown-content .task-list-toggle { min-width:44px; min-height:44px; margin:-9px 0 -9px -9px; cursor:pointer; accent-color:#60a5fa; }
|
||||
.markdown-content .task-list-manage { min-width:44px; min-height:44px; margin:-9px -9px -9px auto; padding:6px 8px; flex:0 0 auto; }
|
||||
.markdown-content.checklist-pending .task-list-toggle { opacity:.65; cursor:wait; }
|
||||
.markdown-content.checklist-pending .task-list-manage { opacity:.65; cursor:wait; }
|
||||
.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; }
|
||||
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
@@ -519,11 +673,60 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.voice-issue-capture { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#101d31; }
|
||||
.voice-issue-capture[hidden], .voice-issue-review[hidden], .voice-issue-controls button[hidden] { display:none; }
|
||||
.voice-issue-heading, .voice-issue-review { display:grid; gap:6px; }
|
||||
.voice-issue-controls, .voice-issue-review-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.voice-issue-controls button, .voice-issue-review-actions button { min-width:0; min-height:44px; }
|
||||
#voice-issue-transcript { box-sizing:border-box; width:100%; min-height:96px; resize:vertical; }
|
||||
.create-issue-capture-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.create-issue-capture-actions[hidden] { display:none; }
|
||||
.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; }
|
||||
@@ -531,8 +734,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; }
|
||||
@@ -555,6 +768,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; }
|
||||
@@ -582,7 +796,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
.mobile-task-action[hidden] { display:none; }
|
||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; max-width:100%; padding:1px 5px; border-radius:999px; background:#31577f; font-size:10px; line-height:16px; white-space:nowrap; }
|
||||
.mobile-task-deadline { padding:1px 5px; border-radius:999px; color:#fef3c7; background:#92400e; font-size:10px; line-height:14px; }
|
||||
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
|
||||
.attention-interruption button { min-height:44px; flex:0 0 auto; }
|
||||
@media (max-width: 600px) {
|
||||
@@ -625,6 +840,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
|
||||
.update-sheet-panel { padding-bottom:calc(82px + env(safe-area-inset-bottom)); }
|
||||
.update-gesture-status { display:block; padding:6px 10px; border-radius:999px; text-align:center; background:#102641; }
|
||||
.update-sheet-panel[data-triage-gesture="keep"] { box-shadow:inset 8px 0 #60a5fa; }
|
||||
.update-sheet-panel[data-triage-gesture="read"] { box-shadow:inset -8px 0 #34d399; }
|
||||
.update-decision-bar { position:fixed; inset:auto 0 0; z-index:57; margin:0; padding-bottom:calc(8px + env(safe-area-inset-bottom)); }
|
||||
.update-more-actions > summary { display:none; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-blocker-row { grid-template-columns:1fr; }
|
||||
.issue-blocker-remove { width:100%; }
|
||||
@@ -632,8 +853,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
|
||||
#cmd-palette.open { display:flex; flex-direction:column; }
|
||||
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
|
||||
#close-command-palette { min-height:44px; }
|
||||
#close-command-palette, #cmd-load-more { min-height:44px; }
|
||||
#cmd-input { flex:0 0 auto; min-height:44px; }
|
||||
.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; }
|
||||
@@ -655,12 +882,21 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.mobile-queue-panel h2 { margin:0; }
|
||||
.mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); }
|
||||
.mobile-start-day h3, .mobile-start-day p { margin:0; }
|
||||
.mobile-start-day p + p { margin-top:4px; }
|
||||
.mobile-start-day-action { width:100%; min-height:48px; text-align:center; }
|
||||
.mobile-start-day-finish { min-height:44px; width:100%; background:transparent; }
|
||||
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
|
||||
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||
.mobile-queue-list small { color:var(--muted); }
|
||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
|
||||
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
||||
.mobile-update-outcome { margin-top:12px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
|
||||
.mobile-update-outcome p { margin:0 0 10px; }
|
||||
.mobile-update-outcome button { width:100%; min-height:44px; }
|
||||
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
|
||||
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
|
||||
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
@@ -669,6 +905,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-today-hud button { min-height:44px; max-width:100%; }
|
||||
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
|
||||
}
|
||||
@media (min-width:701px) { .update-gesture-status { display:none; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.update-sheet-panel[data-triage-gesture] { transition:none; }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
+1736
-322
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+14
-2
@@ -155,6 +155,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
.filter(Boolean).join('\n\n');
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
const checklistConflict = !quarantined && item.kind === 'issue-content' &&
|
||||
item.status === 'attention' && typeof item.baseBody === 'string';
|
||||
return {
|
||||
id: 'stackchain.authored-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
@@ -162,7 +164,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
|
||||
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
label: checklistConflict ? 'Checklist conflict' : item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
|
||||
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
||||
@@ -172,6 +174,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
title: target,
|
||||
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: copyText,
|
||||
checklist_conflict: checklistConflict,
|
||||
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
@@ -251,7 +254,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;
|
||||
@@ -0,0 +1,9 @@
|
||||
function filedClaimEligible(item, detail) {
|
||||
return Boolean(
|
||||
item?.is_filed && !item?.is_assigned && !item?.is_completed &&
|
||||
item?.state === 'open' && detail?.state === 'open' &&
|
||||
Array.isArray(detail?.assignees) && detail.assignees.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = filedClaimEligible;
|
||||
+419
-24
@@ -12,7 +12,7 @@
|
||||
<canvas id="bg"></canvas>
|
||||
<header>
|
||||
<div class="app-brand"><span class="obi"></span><strong>Stackchain</strong><span class="muted">Dashboard</span></div>
|
||||
<div class="app-live-status"><span class="dot"></span><span class="small" id="status">Live</span></div>
|
||||
<button class="app-live-status" id="open-live-data-status" type="button" aria-controls="live-data-status-sheet" aria-expanded="false"><span class="dot"></span><span class="small" id="status">Live</span></button>
|
||||
<details class="app-menu">
|
||||
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
|
||||
<div class="app-menu-panel">
|
||||
@@ -26,6 +26,20 @@
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
|
||||
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
|
||||
<div class="live-data-status-header">
|
||||
<div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div>
|
||||
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
|
||||
</div>
|
||||
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
|
||||
<p id="live-data-status-retry" class="small muted"></p>
|
||||
<div class="live-data-status-actions">
|
||||
<button id="refresh-live-data" type="button">Refresh now</button>
|
||||
<span id="live-data-status-result" class="small" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="offline-status" id="offline-status" role="status" aria-live="polite" hidden>
|
||||
Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
|
||||
</div>
|
||||
@@ -82,6 +96,16 @@
|
||||
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
|
||||
<div class="device-setup-deadline-controls">
|
||||
<label for="device-setup-deadline-hour">Local hour</label>
|
||||
<select id="device-setup-deadline-hour" aria-label="Deadline reminder local hour"></select>
|
||||
<label for="device-setup-deadline-days">Warn me</label>
|
||||
<select id="device-setup-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select>
|
||||
<button class="device-setup-action" id="device-setup-deadline" type="button">Enable</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
@@ -109,7 +133,9 @@
|
||||
<div class="work-filters" aria-label="Filter My Work">
|
||||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
||||
<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>
|
||||
@@ -128,6 +154,10 @@
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
|
||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
|
||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||
@@ -182,12 +212,52 @@
|
||||
<div class="small" id="queue-find-status" role="status" aria-live="polite"></div>
|
||||
<button id="search-older-work" type="button" hidden>Search older work</button>
|
||||
</form>
|
||||
<section class="empty-work-start" id="empty-work-start" aria-labelledby="empty-work-start-heading" hidden>
|
||||
<div>
|
||||
<strong id="empty-work-start-heading">Ready for something new?</strong>
|
||||
<p class="small muted">Find an available issue to own, or create the work that needs doing.</p>
|
||||
</div>
|
||||
<div class="empty-work-start-actions">
|
||||
<button id="empty-work-find" type="button">Find and start work</button>
|
||||
<button id="empty-work-create" type="button">Create an issue</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="agenda-replan" id="agenda-replan" aria-labelledby="agenda-replan-heading" hidden>
|
||||
<div class="agenda-replan-launch">
|
||||
<div><strong id="agenda-replan-heading">Overdue deadlines</strong><p class="small muted">Make the Agenda credible without leaving the queue.</p></div>
|
||||
<button id="start-agenda-replan" type="button">Replan overdue</button>
|
||||
</div>
|
||||
<div id="agenda-replan-controls" hidden>
|
||||
<div class="small" id="agenda-replan-progress" role="status" aria-live="assertive"></div>
|
||||
<div class="agenda-replan-actions">
|
||||
<button id="agenda-replan-keep" type="button">Keep date & next</button>
|
||||
<button id="agenda-replan-tomorrow" type="button">Tomorrow & next</button>
|
||||
<label for="agenda-replan-date">Choose a future date</label>
|
||||
<input id="agenda-replan-date" type="date" />
|
||||
<button id="agenda-replan-choose" type="button">Choose date & next</button>
|
||||
<button id="agenda-replan-cancel" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="protect-today" id="protect-today-panel" aria-labelledby="protect-today-heading" hidden>
|
||||
<div>
|
||||
<strong id="protect-today-heading">Deadlines need a plan</strong>
|
||||
<p class="small muted">Put overdue and due-today work first, then review what fits.</p>
|
||||
<div class="small" id="protect-today-status" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<button id="protect-today" type="button">Protect Today</button>
|
||||
</section>
|
||||
<div class="filed-history-tabs" id="filed-history-tabs" role="group" aria-label="Filed outcome view" hidden>
|
||||
<button type="button" data-filed-view="needs-review" aria-pressed="true">Needs review <span id="filed-needs-review-count">0</span></button>
|
||||
<button type="button" data-filed-view="reviewed" aria-pressed="false">Reviewed <span id="filed-reviewed-count">0</span></button>
|
||||
</div>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
||||
<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>
|
||||
@@ -359,13 +429,99 @@
|
||||
<button id="add-plan-preview" type="button">Add to Today & 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>
|
||||
<button id="close-command-palette" type="button">Close</button>
|
||||
<div class="cmd-palette-header-actions">
|
||||
<button id="select-search-results" type="button">Select</button>
|
||||
<button id="close-command-palette" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
|
||||
<fieldset class="cmd-search-scope">
|
||||
<legend>Filter search results</legend>
|
||||
<label for="cmd-search-kind">Type</label>
|
||||
<select id="cmd-search-kind">
|
||||
<option value="all">All work</option>
|
||||
<option value="issue">Issues</option>
|
||||
<option value="pull">Pull requests</option>
|
||||
</select>
|
||||
<label for="cmd-search-state">Status</label>
|
||||
<select id="cmd-search-state">
|
||||
<option value="all">Any status</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
<label for="cmd-search-repository">Repository</label>
|
||||
<input id="cmd-search-repository" type="search" list="cmd-search-repositories" autocomplete="off" placeholder="All repositories" aria-describedby="cmd-repository-status" />
|
||||
<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>
|
||||
<button id="cmd-load-more" hidden>More results</button>
|
||||
<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 & 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 & 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 & plan</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
|
||||
@@ -380,12 +536,36 @@
|
||||
<div id="search-preview-status" class="small" aria-live="polite">Choose a search result.</div>
|
||||
<div id="search-preview-meta" class="row"></div>
|
||||
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
|
||||
<section id="search-preview-conversation" class="search-preview-conversation" aria-labelledby="search-preview-conversation-title">
|
||||
<h2 id="search-preview-conversation-title">Current conversation</h2>
|
||||
<div id="search-preview-comments"></div>
|
||||
<div id="search-preview-conversation-status" class="small" aria-live="polite"></div>
|
||||
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
|
||||
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
|
||||
</section>
|
||||
<section class="search-preview-reply" aria-labelledby="search-preview-reply-title" hidden>
|
||||
<h2 id="search-preview-reply-title">Reply</h2>
|
||||
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
<div id="search-preview-reply-status" class="small" aria-live="polite"></div>
|
||||
<div class="search-preview-reply-actions">
|
||||
<button id="send-search-preview-reply" type="button">Send</button>
|
||||
<button id="send-search-preview-reply-next" type="button">Send & next</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="search-preview-actions">
|
||||
<nav class="search-preview-navigation" aria-label="Search result navigation" hidden>
|
||||
<button id="previous-search-result" type="button">Previous</button>
|
||||
<span id="search-preview-position" aria-live="polite"></span>
|
||||
<button id="next-search-result" type="button">Next</button>
|
||||
</nav>
|
||||
<div class="search-preview-primary-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<button id="defer-search-result" type="button" hidden>Assign & defer</button>
|
||||
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
|
||||
</div>
|
||||
<button id="share-search-result" type="button" disabled>Share result</button>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -418,9 +598,44 @@
|
||||
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||||
</div>
|
||||
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
||||
<section class="completed-filed-actions" id="completed-filed-actions" aria-label="Completed Filed review" hidden>
|
||||
<span id="completed-filed-progress" role="status" aria-live="polite">Completed Filed issue ready for review.</span>
|
||||
<div class="completed-filed-buttons">
|
||||
<button id="complete-parent-and-acknowledge" type="button" hidden>Complete parent step & acknowledge</button>
|
||||
<button id="acknowledge-completed-filed" type="button">Acknowledge & next</button>
|
||||
</div>
|
||||
</section>
|
||||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
|
||||
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
||||
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
|
||||
<form class="checklist-step-editor" id="checklist-step-editor" aria-label="Manage checklist step" hidden>
|
||||
<label for="checklist-step-label">Checklist step</label>
|
||||
<input id="checklist-step-label" type="text" maxlength="240" autocomplete="off" />
|
||||
<div class="checklist-step-editor-actions">
|
||||
<button id="save-checklist-step-edit" type="submit">Rename</button>
|
||||
<button id="move-checklist-step-earlier" type="button">Move earlier</button>
|
||||
<button id="move-checklist-step-later" type="button">Move later</button>
|
||||
<button id="file-checklist-step-related" type="button">File as related issue</button>
|
||||
<button id="remove-checklist-step" type="button">Remove</button>
|
||||
<button id="cancel-checklist-step-edit" type="button">Cancel</button>
|
||||
</div>
|
||||
<div id="checklist-step-edit-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
<section class="checklist-add" aria-label="Add checklist step">
|
||||
<button id="open-add-checklist-step" type="button" disabled>Add step</button>
|
||||
<form id="add-checklist-step-form" hidden>
|
||||
<label class="visually-hidden" for="add-checklist-step">Checklist step</label>
|
||||
<input id="add-checklist-step" type="text" maxlength="240" autocomplete="off" placeholder="New checklist step" />
|
||||
<button id="save-checklist-step" type="submit">Add</button>
|
||||
<button id="cancel-checklist-step" type="button">Cancel</button>
|
||||
</form>
|
||||
<div id="add-checklist-step-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="checklist-completion" id="checklist-completion" aria-label="Completed checklist" hidden>
|
||||
<span id="checklist-completion-status" role="status" aria-live="polite">Checklist complete</span>
|
||||
<button id="complete-checklist-issue" type="button">Close issue</button>
|
||||
<button id="keep-checklist-issue-open" type="button">Keep open</button>
|
||||
</section>
|
||||
<section class="issue-blockers" id="issue-blockers" aria-labelledby="issue-blockers-title" hidden>
|
||||
<h2 id="issue-blockers-title">Blocked by</h2>
|
||||
<div class="issue-blocker-list" id="issue-blocker-list"></div>
|
||||
@@ -440,6 +655,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>
|
||||
@@ -460,9 +676,7 @@
|
||||
</div>
|
||||
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<details class="issue-planning" id="issue-planning">
|
||||
<summary>Plan & edit</summary>
|
||||
<button class="issue-planning-retry" id="retry-issue-planning" type="button" hidden>Retry planning controls</button>
|
||||
<section class="issue-content-editor" aria-label="Issue content">
|
||||
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
|
||||
<form class="issue-edit-form" id="issue-edit-form" hidden>
|
||||
<label for="issue-edit-title">Title
|
||||
@@ -477,6 +691,10 @@
|
||||
</div>
|
||||
<div id="issue-edit-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
</section>
|
||||
<details class="issue-planning" id="issue-planning">
|
||||
<summary>Plan & edit</summary>
|
||||
<button class="issue-planning-retry" id="retry-issue-planning" type="button" hidden>Retry planning controls</button>
|
||||
<fieldset class="issue-label-editor" id="issue-label-editor" aria-describedby="issue-label-status">
|
||||
<legend>Labels</legend>
|
||||
<div class="issue-label-list" id="issue-label-list"></div>
|
||||
@@ -502,7 +720,7 @@
|
||||
</fieldset>
|
||||
</details>
|
||||
<details class="issue-handoff" id="issue-handoff">
|
||||
<summary>Hand off to teammate</summary>
|
||||
<summary id="issue-handoff-summary">Hand off to teammate</summary>
|
||||
<div>
|
||||
<button id="load-issue-handoff" type="button">Choose teammate</button>
|
||||
<label for="issue-handoff-recipient" class="small">Eligible repository assignee</label>
|
||||
@@ -511,6 +729,10 @@
|
||||
<div id="issue-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
|
||||
</div>
|
||||
</details>
|
||||
<section class="filed-claim-actions" id="filed-claim-actions" aria-label="Take ownership of filed issue" hidden>
|
||||
<button id="queue-filed-issue" type="button">Queue Today</button>
|
||||
<button id="start-filed-issue" type="button">Claim & start</button>
|
||||
</section>
|
||||
<div class="issue-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="release-issue" type="button">Release assignment</button>
|
||||
@@ -594,20 +816,51 @@
|
||||
<label for="create-issue-body">Note <span class="small">Optional</span>
|
||||
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<section class="voice-issue-capture" id="voice-issue-capture" aria-labelledby="voice-issue-heading" hidden>
|
||||
<div class="voice-issue-heading">
|
||||
<strong id="voice-issue-heading">Speak capture</strong>
|
||||
<span class="small">Your browser transcribes speech. Stackchain does not retain audio.</span>
|
||||
</div>
|
||||
<div class="voice-issue-controls">
|
||||
<button id="start-voice-issue-capture" type="button">Speak capture</button>
|
||||
<button id="stop-voice-issue-capture" type="button" hidden>Stop listening</button>
|
||||
</div>
|
||||
<div id="voice-issue-status" class="small" aria-live="polite"></div>
|
||||
<div class="voice-issue-review" id="voice-issue-review" hidden>
|
||||
<label for="voice-issue-transcript">Review transcript
|
||||
<textarea id="voice-issue-transcript" maxlength="10255"></textarea>
|
||||
</label>
|
||||
<div class="voice-issue-review-actions">
|
||||
<button id="append-voice-issue-transcript" type="button">Append to draft</button>
|
||||
<button id="replace-with-voice-issue-transcript" type="button">Replace draft</button>
|
||||
<button id="discard-voice-issue-transcript" type="button" class="secondary">Discard transcript</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="create-issue-capture-actions">
|
||||
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
|
||||
<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>
|
||||
@@ -631,6 +884,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>
|
||||
@@ -640,6 +906,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>
|
||||
@@ -651,6 +926,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 & 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 & assign to me</button>
|
||||
@@ -662,8 +941,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>
|
||||
@@ -679,14 +1016,15 @@
|
||||
</div>
|
||||
|
||||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||||
<section class="update-sheet-panel">
|
||||
<section class="update-sheet-panel" data-triage-gesture="">
|
||||
<div class="update-sheet-header">
|
||||
<div>
|
||||
<div class="small" id="update-sheet-key"></div>
|
||||
<h3 id="update-sheet-title">Unread update</h3>
|
||||
</div>
|
||||
<button id="keep-update-unread" type="button">Keep unread</button>
|
||||
</div>
|
||||
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
||||
<div id="update-gesture-status" class="update-gesture-status small" aria-live="polite">Swipe right to keep unread or left to mark read. Buttons remain available.</div>
|
||||
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
|
||||
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
|
||||
<div class="row">
|
||||
@@ -695,8 +1033,11 @@
|
||||
</div>
|
||||
<h2>Full conversation</h2>
|
||||
<div id="update-comments"></div>
|
||||
<button class="update-new-activity-jump" id="jump-update-new-activity" type="button" hidden>Jump to new activity</button>
|
||||
<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>
|
||||
@@ -722,18 +1063,21 @@
|
||||
</div>
|
||||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="update-sheet-actions">
|
||||
<div class="update-ownership-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership & start</button>
|
||||
<details class="update-more-actions">
|
||||
<summary>More actions</summary>
|
||||
<div class="update-sheet-actions">
|
||||
<div class="update-ownership-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership & start</button>
|
||||
</div>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||
<button id="mute-update-next" type="button" hidden>Mute future updates & next</button>
|
||||
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
</details>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
@@ -741,6 +1085,13 @@
|
||||
<button type="button" data-work-session-complete="update" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
<div class="update-decision-bar" aria-label="Update decisions">
|
||||
<button id="keep-update-unread" type="button" aria-label="Keep unread and open next update">Keep unread</button>
|
||||
<button id="mark-update-read-next" type="button" aria-label="Mark read and open next update">Mark read</button>
|
||||
<button id="review-update-now" type="button" hidden>Review now</button>
|
||||
<button id="focus-update-reply" type="button">Reply</button>
|
||||
<button id="toggle-update-more" type="button" aria-expanded="false">More</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -761,6 +1112,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>
|
||||
@@ -918,11 +1270,26 @@
|
||||
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
|
||||
<section class="mobile-queue-panel">
|
||||
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
|
||||
<div id="mobile-update-outcome" class="mobile-update-outcome" role="status" aria-live="polite" hidden>
|
||||
<p id="mobile-update-outcome-summary"></p>
|
||||
<button id="review-kept-updates" type="button" hidden>Review kept unread</button>
|
||||
</div>
|
||||
<p class="small muted">Choose what to work through next.</p>
|
||||
<section class="mobile-start-day" aria-labelledby="mobile-start-day-heading">
|
||||
<div>
|
||||
<h3 id="mobile-start-day-heading">Prepare Today</h3>
|
||||
<p id="mobile-start-day-summary" class="small" aria-live="polite">Reviewing urgent queues…</p>
|
||||
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, and Filed</p>
|
||||
</div>
|
||||
<button class="mobile-start-day-action" id="mobile-start-day-action" type="button">Prepare Today</button>
|
||||
<button class="mobile-start-day-finish" id="finish-mobile-start-day" type="button" hidden>Finish for now</button>
|
||||
</section>
|
||||
<div class="mobile-queue-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<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>
|
||||
@@ -936,25 +1303,32 @@
|
||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
|
||||
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
||||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues, 0 items">Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues: no active queues; no upcoming deadlines"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0 active</span></span><span class="mobile-task-deadline" id="mobile-deadline-count" hidden>0 due</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/private-data-registry.js"></script>
|
||||
<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>
|
||||
<script src="static/outbox-coordinator.js"></script>
|
||||
<script src="static/background-issue-sync.js"></script>
|
||||
<script src="static/issue-outbox.js"></script>
|
||||
<script src="static/reconnect-outboxes.js"></script>
|
||||
<script src="static/authored-outbox.js"></script>
|
||||
<script src="static/offline-issue-close.js"></script>
|
||||
<script src="static/offline-issue-blocker.js"></script>
|
||||
@@ -962,6 +1336,8 @@
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/agenda-replan.js"></script>
|
||||
<script src="static/protect-today.js"></script>
|
||||
<script src="static/notification-undo.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/work-selection.js"></script>
|
||||
@@ -986,29 +1362,48 @@
|
||||
<script src="static/later-picker.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
<script src="static/batch-find-work.js"></script>
|
||||
<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/voice-transcript-store.js"></script>
|
||||
<script src="static/voice-issue-capture.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
<script src="static/assign-and-start.js"></script>
|
||||
<script src="static/filed-claim.js"></script>
|
||||
<script src="static/queue-today.js"></script>
|
||||
<script src="static/pull-sheet.js"></script>
|
||||
<script src="static/review-sheet.js"></script>
|
||||
<script src="static/work-route.js"></script>
|
||||
<script src="static/task-overlay-history.js"></script>
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script src="static/live-data-status.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/mobile-start-day.js"></script>
|
||||
<script src="static/update-triage-session.js"></script>
|
||||
<script src="static/update-review-handoff.js"></script>
|
||||
<script src="static/update-read-position.js"></script>
|
||||
<script src="static/work-detail-position.js"></script>
|
||||
<script src="static/update-triage-launcher.js"></script>
|
||||
<script src="static/update-triage-gesture.js"></script>
|
||||
<script src="static/update-decision-transaction.js"></script>
|
||||
<script src="static/agenda-session-launcher.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
<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
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
+224
-40
@@ -11,10 +11,80 @@ 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 = '';
|
||||
}
|
||||
const promotion = value?.checklistPromotion;
|
||||
if (promotion?.repository && promotion?.number && promotion?.url && promotion?.updatedAt) {
|
||||
plan.checklistPromotion = {
|
||||
repository:String(promotion.repository), number:Number(promotion.number), url:String(promotion.url),
|
||||
title:String(promotion.title || ''), body:String(promotion.body || '').slice(0, 10000),
|
||||
updatedAt:String(promotion.updatedAt), taskIndex:Number(promotion.taskIndex),
|
||||
};
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function read() {
|
||||
@@ -51,11 +121,28 @@ 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 +152,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 +214,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 +347,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 +378,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 +504,8 @@ 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 +514,20 @@ 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 +556,14 @@ 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 +608,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
intent: item.completionIntent,
|
||||
ownerLogin: item.ownerLogin,
|
||||
operationId: item.operationId,
|
||||
estimateMinutes: item.estimateMinutes,
|
||||
issue: item.deliveredIssue,
|
||||
}));
|
||||
}
|
||||
|
||||
+396
-9
@@ -18,11 +18,146 @@ function createPlanningLoader({ loadLabels, loadMilestones }) {
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
function escapeOptionHtml(value) {
|
||||
return String(value || '').replace(/[&<>"']/g, character => ({
|
||||
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||
})[character]);
|
||||
}
|
||||
|
||||
function appendChecklistTask(body, label) {
|
||||
const normalized = String(label || '').trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) throw new Error('Enter a checklist step.');
|
||||
const key = normalized.toLocaleLowerCase();
|
||||
const duplicate = String(body || '').split('\n').some(line => {
|
||||
const match = line.match(/^\s*[-*+]\s+\[[ xX]\]\s+(.*)$/);
|
||||
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
|
||||
});
|
||||
if (duplicate) throw new Error('That checklist step already exists.');
|
||||
const prefix = body ? String(body).replace(/\s+$/, '') + '\n' : '';
|
||||
return prefix + '- [ ] ' + normalized;
|
||||
}
|
||||
|
||||
function manageChecklistTask(raw, targetIndex, operation = {}) {
|
||||
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let taskIndex = 0;
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const line = parts[index];
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^([-*+]\s+\[[ xX]\]\s+)(.*)$/);
|
||||
if (!task) continue;
|
||||
if (taskIndex === Number(targetIndex)) {
|
||||
if (operation.action === 'rename') {
|
||||
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
|
||||
if (!label) throw new Error('Enter a checklist step.');
|
||||
const key = label.toLocaleLowerCase();
|
||||
let inFence = false;
|
||||
const duplicate = parts.some((candidate, candidateIndex) => {
|
||||
if (candidateIndex % 2) return false;
|
||||
if (/^\s*```/.test(candidate)) { inFence = !inFence; return false; }
|
||||
if (inFence || candidateIndex === index) return false;
|
||||
const match = candidate.match(/^[-*+]\s+\[[ xX]\]\s+(.*)$/);
|
||||
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
|
||||
});
|
||||
if (duplicate) throw new Error('That checklist step already exists.');
|
||||
parts[index] = task[1] + label;
|
||||
} else if (operation.action === 'remove') {
|
||||
if (index + 1 < parts.length) parts.splice(index, 2);
|
||||
else if (index > 0) parts.splice(index - 1, 2);
|
||||
} else if (operation.action === 'move-earlier' && index >= 2 && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index - 2])) {
|
||||
[parts[index - 2], parts[index]] = [parts[index], parts[index - 2]];
|
||||
} else if (operation.action === 'move-later' && index + 2 < parts.length && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index + 2])) {
|
||||
[parts[index], parts[index + 2]] = [parts[index + 2], parts[index]];
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function checklistIsComplete(raw) {
|
||||
const lines = String(raw || '').split(/(?:\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let found = false;
|
||||
for (const line of lines) {
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^\s*[-*+]\s+\[([ xX])\]\s+/);
|
||||
if (!task) continue;
|
||||
found = true;
|
||||
if (task[1].toLocaleLowerCase() !== 'x') return false;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function completeDelegatedParentTask(raw, childUrl) {
|
||||
const url = String(childUrl || '');
|
||||
if (!/^https:\/\/[^\s)]+$/.test(url)) throw new Error('The delegated issue link is unavailable.');
|
||||
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let taskIndex = 0;
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const line = parts[index];
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^(\s*[-*+]\s+\[)([ xX])(\]\s+.*)$/);
|
||||
if (!task) continue;
|
||||
const linkedTask = task[3].match(/^\]\s+\[[^\]]+\]\((https:\/\/[^\s)]+)\)\s*$/);
|
||||
if (linkedTask?.[1] === url) {
|
||||
const alreadyCompleted = task[2].toLocaleLowerCase() === 'x';
|
||||
if (!alreadyCompleted) parts[index] = task[1] + 'x' + task[3];
|
||||
const body = parts.join('');
|
||||
return { body, taskIndex, alreadyCompleted, checklistComplete:checklistIsComplete(body) };
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
throw new Error('The parent checklist no longer links to this delegated issue.');
|
||||
}
|
||||
|
||||
async function finishDelegatedParentReview({ parentItem, parentDetail, childUrl, updateContent, closeParent }) {
|
||||
const completion = completeDelegatedParentTask(parentDetail?.body, childUrl);
|
||||
if (!completion.alreadyCompleted) {
|
||||
await updateContent(parentItem, {
|
||||
title:parentDetail.title,
|
||||
body:completion.body,
|
||||
expectedUpdatedAt:parentDetail.updated_at,
|
||||
});
|
||||
}
|
||||
if (completion.checklistComplete) await closeParent(parentItem);
|
||||
return { updated:!completion.alreadyCompleted, closed:completion.checklistComplete };
|
||||
}
|
||||
|
||||
function delegatedParentReference(childDetail) {
|
||||
const body = String(childDetail?.body || '');
|
||||
const match = body.match(/(?:^|\n)Related to \[([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9]\d*)\]\((https:\/\/[^\s)]+)\)\.(?:\r?$|\s)/m);
|
||||
if (!match) return null;
|
||||
return { repository:match[1], number:Number(match[2]), url:match[3] };
|
||||
}
|
||||
|
||||
function resolveDelegatedParent(child, childDetail, parentDetail) {
|
||||
const parent = delegatedParentReference(childDetail);
|
||||
if (!parent) return null;
|
||||
try {
|
||||
const completion = completeDelegatedParentTask(parentDetail?.body, child?.url);
|
||||
return {
|
||||
parent,
|
||||
taskIndex:completion.taskIndex,
|
||||
alreadyCompleted:completion.alreadyCompleted,
|
||||
checklistComplete:completion.checklistComplete,
|
||||
};
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, manageTask: manageTaskTransform = manageChecklistTask, relatedTaskDraft: relatedTaskDraftTransform = (...args) => globalThis.createIssueCapture?.relatedChecklistDraft(...args), linkRelatedTask: linkRelatedTaskTransform = (...args) => globalThis.createIssueCapture?.linkChecklistTask(...args), enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
let handoffRequest = null;
|
||||
let reassignRequest = null;
|
||||
let labelRequest = null;
|
||||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
@@ -38,16 +173,25 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
delegatedParentReference,
|
||||
resolveDelegatedParent,
|
||||
completeDelegatedParentTask,
|
||||
readOnly(item) {
|
||||
return Boolean(item?.is_completed || (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' },
|
||||
});
|
||||
},
|
||||
conversation(item, initialPage) {
|
||||
const access = this.readOnly(item) ? '&access=filed' : '';
|
||||
const pager = createConversationPager({
|
||||
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
loadPage: page => fetchJson(
|
||||
issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20' + access,
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
),
|
||||
});
|
||||
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
|
||||
return pager;
|
||||
@@ -64,7 +208,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
});
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
return fetchJson(issuePath(item) + '/handoff-candidates', {
|
||||
const access = item?.is_filed ? '?access=filed' : '';
|
||||
return fetchJson(issuePath(item) + '/handoff-candidates' + access, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
@@ -111,7 +256,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
updateContent(item, draft) {
|
||||
if (editRequest) return editRequest;
|
||||
this.saveEditDraft(item, draft);
|
||||
editRequest = fetchJson(issuePath(item) + '/content', {
|
||||
const access = this.readOnly(item) ? '?access=filed' : '';
|
||||
editRequest = fetchJson(issuePath(item) + '/content' + access, {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -129,6 +275,218 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
}).finally(() => { editRequest = null; });
|
||||
return editRequest;
|
||||
},
|
||||
toggleTask(item, detail, taskIndex, checked) {
|
||||
if (typeof toggleTask !== 'function') return Promise.reject(new Error('Checklist updates are unavailable.'));
|
||||
return this.updateContent(item, {
|
||||
title: detail.title,
|
||||
body: toggleTask(detail.body, taskIndex, checked),
|
||||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
manageTask(item, detail, taskIndex, operation) {
|
||||
if (typeof manageTaskTransform !== 'function') return Promise.reject(new Error('Checklist management is unavailable.'));
|
||||
return this.updateContent(item, {
|
||||
title: detail.title,
|
||||
body: manageTaskTransform(detail.body, taskIndex, operation),
|
||||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
relatedTaskDraft(item, _detail, _taskIndex, label) {
|
||||
if (typeof relatedTaskDraftTransform !== 'function') throw new Error('Related issue capture is unavailable.');
|
||||
return relatedTaskDraftTransform(item, label);
|
||||
},
|
||||
linkRelatedTask(item, detail, taskIndex, child) {
|
||||
if (typeof linkRelatedTaskTransform !== 'function') return Promise.reject(new Error('Related issue linking is unavailable.'));
|
||||
return this.updateContent(item, {
|
||||
title:detail.title,
|
||||
body:linkRelatedTaskTransform(detail.body, taskIndex, child),
|
||||
expectedUpdatedAt:detail.updated_at,
|
||||
});
|
||||
},
|
||||
async addTask(item, detail, label) {
|
||||
const body = appendChecklistTask(detail.body, label);
|
||||
return this.updateContent(item, {
|
||||
title: detail.title,
|
||||
body,
|
||||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
async queueAddedTask(item, detail, label) {
|
||||
if (typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist updates are unavailable.');
|
||||
}
|
||||
const body = appendChecklistTask(detail.body, label);
|
||||
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
|
||||
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||
},
|
||||
async queueManagedTask(item, detail, taskIndex, operation) {
|
||||
if (typeof manageTaskTransform !== 'function' || typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist management is unavailable.');
|
||||
}
|
||||
const body = manageTaskTransform(detail.body, taskIndex, operation);
|
||||
const checklistOperation = {
|
||||
action:String(operation?.action || ''), index:Number(taskIndex),
|
||||
...(operation?.label ? { label:String(operation.label) } : {}),
|
||||
};
|
||||
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at, checklistOperation });
|
||||
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||
},
|
||||
async queueTask(item, detail, taskIndex, checked) {
|
||||
if (typeof toggleTask !== 'function' || typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist updates are unavailable.');
|
||||
}
|
||||
const body = toggleTask(detail.body, taskIndex, checked);
|
||||
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
|
||||
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||
},
|
||||
pendingTask(item, detail, items, ownerLogin) {
|
||||
const pending = [...(items || [])].reverse().find(candidate => candidate?.kind === 'issue-content' &&
|
||||
candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) &&
|
||||
candidate.ownerLogin === ownerLogin && ['queued', 'sending', 'attention'].includes(candidate.status));
|
||||
if (!pending) return { ...detail };
|
||||
return { ...detail, title:pending.title, body:pending.body, checklist_pending:true };
|
||||
},
|
||||
bindTaskToggles({ container, status, retry, current, confirmed, restore }) {
|
||||
container.addEventListener('change', async event => {
|
||||
const control = event.target.closest('input.task-list-toggle');
|
||||
const state = current();
|
||||
if (!control || !state?.item || !state?.detail?.updated_at) return;
|
||||
container.classList.add('checklist-pending');
|
||||
container.querySelectorAll('input.task-list-toggle').forEach(input => { input.disabled = true; });
|
||||
status.textContent = 'Updating checklist…';
|
||||
try {
|
||||
const result = await (state.offline ? this.queueTask : this.toggleTask).call(
|
||||
this, state.item, state.detail, Number(control.dataset.taskIndex), control.checked
|
||||
);
|
||||
const latest = current();
|
||||
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
|
||||
confirmed(state.item, state.detail, state.offline ? result.detail : result);
|
||||
if (state.offline) status.textContent = 'Checklist queued. Pending sync.';
|
||||
else status.textContent = 'Checklist updated.';
|
||||
}
|
||||
} catch (error) {
|
||||
const latest = current();
|
||||
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
|
||||
restore(state.detail);
|
||||
status.textContent = error.message + ' Checklist was not changed; reload latest or use Edit issue.';
|
||||
retry.hidden = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
bindTaskManagement({ container, editor, label, earlier, later, fileRelated, onFileRelated, remove, cancel, status, sheetStatus, retry, current, confirmed }) {
|
||||
let taskIndex = null;
|
||||
let trigger = null;
|
||||
const reset = (returnFocus = false) => {
|
||||
editor.hidden = true;
|
||||
status.textContent = '';
|
||||
if (returnFocus && trigger?.isConnected) trigger.focus();
|
||||
taskIndex = null;
|
||||
trigger = null;
|
||||
};
|
||||
container.addEventListener('click', event => {
|
||||
const control = event.target.closest('button.task-list-manage');
|
||||
if (!control || !current()?.detail?.updated_at) return;
|
||||
taskIndex = Number(control.dataset.taskIndex);
|
||||
trigger = control;
|
||||
label.value = control.dataset.taskLabel || '';
|
||||
earlier.disabled = control.dataset.taskFirst === 'true';
|
||||
later.disabled = control.dataset.taskLast === 'true';
|
||||
fileRelated.disabled = /^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(label.value);
|
||||
editor.hidden = false;
|
||||
status.textContent = fileRelated.disabled ? 'This step already links to related work.' :
|
||||
'Rename, reorder, remove, or file this step as related work.';
|
||||
label.focus();
|
||||
});
|
||||
const run = async operation => {
|
||||
const state = current();
|
||||
const selectedIndex = taskIndex;
|
||||
if (!state?.item || !state.detail?.updated_at || selectedIndex === null) return;
|
||||
if (operation.action === 'remove' && !globalThis.confirm('Remove this checklist step?')) return;
|
||||
const controls = editor.querySelectorAll('button,input');
|
||||
controls.forEach(control => { control.disabled = true; });
|
||||
status.textContent = state.offline ? 'Queueing checklist change…' : 'Updating checklist…';
|
||||
try {
|
||||
const result = await (state.offline ? this.queueManagedTask : this.manageTask).call(
|
||||
this, state.item, state.detail, selectedIndex, operation
|
||||
);
|
||||
if (current()?.item !== state.item) return;
|
||||
confirmed(state.item, state.detail, state.offline ? result.detail : result);
|
||||
reset();
|
||||
sheetStatus.textContent = state.offline ? 'Checklist change queued. Pending sync.' : 'Checklist step updated.';
|
||||
const focusIndex = operation.action === 'remove' ? Math.max(0, selectedIndex - 1) : selectedIndex;
|
||||
container.querySelector('button.task-list-manage[data-task-index="' + focusIndex + '"]')?.focus();
|
||||
} catch (error) {
|
||||
if (current()?.item === state.item) {
|
||||
status.textContent = error.message + ' Your change is still here; retry.';
|
||||
retry.hidden = false;
|
||||
label.focus();
|
||||
}
|
||||
} finally {
|
||||
if (!editor.hidden) controls.forEach(control => { control.disabled = false; });
|
||||
}
|
||||
};
|
||||
editor.addEventListener('submit', event => { event.preventDefault(); run({ action:'rename', label:label.value }); });
|
||||
earlier.addEventListener('click', () => run({ action:'move-earlier' }));
|
||||
later.addEventListener('click', () => run({ action:'move-later' }));
|
||||
fileRelated.addEventListener('click', async () => {
|
||||
const state = current();
|
||||
const selectedIndex = taskIndex;
|
||||
if (!state?.item || !state.detail?.updated_at || selectedIndex === null || fileRelated.disabled) return;
|
||||
try {
|
||||
await onFileRelated({ item:state.item, detail:state.detail, taskIndex:selectedIndex,
|
||||
label:label.value, trigger });
|
||||
reset();
|
||||
} catch (error) {
|
||||
status.textContent = error.message + ' The checklist step was not changed.';
|
||||
label.focus();
|
||||
}
|
||||
});
|
||||
remove.addEventListener('click', () => run({ action:'remove' }));
|
||||
cancel.addEventListener('click', () => reset(true));
|
||||
return { reset };
|
||||
},
|
||||
renderTasks(container, detail, interactive) {
|
||||
container.classList.toggle('checklist-pending', detail.checklist_pending === true);
|
||||
container.innerHTML = renderMarkdown(
|
||||
detail.body || 'No description provided.', {
|
||||
interactiveTasks: Boolean(interactive),
|
||||
manageTasks: Boolean(interactive),
|
||||
}
|
||||
);
|
||||
},
|
||||
checklistCompletion(container, { interactive, today, offline, dismissed = false }) {
|
||||
const tasks = Array.from(container.querySelectorAll('input.task-list-toggle'));
|
||||
const visible = Boolean(interactive && !dismissed && tasks.length && tasks.every(task => task.checked));
|
||||
return {
|
||||
visible,
|
||||
status: offline ? 'Checklist complete · pending sync' : 'Checklist complete',
|
||||
label: today ? (offline ? 'Queue close & next' : 'Close & next') :
|
||||
(offline ? 'Queue issue closure' : 'Close issue'),
|
||||
};
|
||||
},
|
||||
mergeContent(snapshot, item, detail, confirmed, replace) {
|
||||
return {
|
||||
snapshot: snapshot ? replace(snapshot, item.repository, item.number, confirmed) : snapshot,
|
||||
item: { ...item, ...confirmed, key:item.key },
|
||||
detail: { ...detail, ...confirmed },
|
||||
};
|
||||
},
|
||||
renderMilestoneEditor(item, confirmed, milestones, document = globalThis.document) {
|
||||
const select = document.querySelector('#issue-milestone');
|
||||
const status = document.querySelector('#issue-milestone-status');
|
||||
select.innerHTML = '<option value="">No milestone</option>' + milestones.map(milestone =>
|
||||
'<option value="' + Number(milestone.id) + '">' + escapeOptionHtml(milestone.title) + '</option>'
|
||||
).join('');
|
||||
select.value = String(this.loadMilestoneDraft(item) ?? confirmed?.id ?? '');
|
||||
select.disabled = false;
|
||||
document.querySelector('#save-issue-milestone').disabled = false;
|
||||
status.textContent = confirmed ? 'Planned for ' + confirmed.title + '.' : 'No milestone set.';
|
||||
},
|
||||
|
||||
loadDueDateDraft(item) {
|
||||
try {
|
||||
const raw = storage?.getItem(dueDateDraftKey(item));
|
||||
@@ -208,7 +566,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
},
|
||||
close(item) {
|
||||
if (closeRequest) return closeRequest;
|
||||
closeRequest = fetchJson(issuePath(item) + '/close', {
|
||||
const access = item?.is_filed && !item?.is_assigned ? '?access=filed' : '';
|
||||
closeRequest = fetchJson(issuePath(item) + '/close' + access, {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
@@ -254,6 +613,28 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
}).finally(() => { handoffRequest = null; });
|
||||
return handoffRequest;
|
||||
},
|
||||
reassign(item, recipient) {
|
||||
if (reassignRequest) return reassignRequest;
|
||||
const expectedAssignees = Array.isArray(item?.assignees) ? item.assignees.slice() : [];
|
||||
reassignRequest = fetchJson(issuePath(item) + '/reassign', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient, expected_assignees: expectedAssignees }),
|
||||
}).then(result => {
|
||||
if (
|
||||
result?.number !== item.number ||
|
||||
result?.recipient !== recipient ||
|
||||
!Array.isArray(result.assignees) ||
|
||||
result.assignees.length !== 1 ||
|
||||
result.assignees[0] !== recipient ||
|
||||
JSON.stringify(result.previous_assignees) !== JSON.stringify(expectedAssignees)
|
||||
) {
|
||||
throw new Error('Issue reassignment was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { reassignRequest = null; });
|
||||
return reassignRequest;
|
||||
},
|
||||
comment(item, body) {
|
||||
if (commentRequest) return commentRequest;
|
||||
this.saveDraft(item, body);
|
||||
@@ -262,7 +643,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(operationKey(item), operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
commentRequest = fetchJson(issuePath(item) + '/comments', {
|
||||
const access = this.readOnly(item) ? '?access=filed' : '';
|
||||
commentRequest = fetchJson(issuePath(item) + '/comments' + access, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
|
||||
body: JSON.stringify({ body }),
|
||||
@@ -280,4 +662,9 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
|
||||
createIssueSheet.createPlanningLoader = createPlanningLoader;
|
||||
|
||||
createIssueSheet.manageChecklistTask = manageChecklistTask;
|
||||
createIssueSheet.completeDelegatedParentTask = completeDelegatedParentTask;
|
||||
createIssueSheet.finishDelegatedParentReview = finishDelegatedParentReview;
|
||||
createIssueSheet.delegatedParentReference = delegatedParentReference;
|
||||
createIssueSheet.resolveDelegatedParent = resolveDelegatedParent;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.liveDataStatus = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const feeds = [
|
||||
['context', 'Work'],
|
||||
['notifications', 'Updates'],
|
||||
['events', 'Activity'],
|
||||
];
|
||||
|
||||
function boundedSeconds(value) {
|
||||
const seconds = Number(value);
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(Math.round(seconds), 86400) : null;
|
||||
}
|
||||
|
||||
function describe(freshness = {}) {
|
||||
const sections = freshness.sections || {};
|
||||
const hasSectionData = feeds.some(([key]) => Object.prototype.hasOwnProperty.call(sections, key));
|
||||
const described = feeds.map(([key, label]) => {
|
||||
const section = sections[key] || {};
|
||||
const state = section.revalidating ? 'refreshing' :
|
||||
(section.stale || section.degraded ? 'delayed' : 'live');
|
||||
return { key, label, state, ageSeconds: boundedSeconds(section.age_seconds) };
|
||||
});
|
||||
const delayed = described.filter(feed => feed.state === 'delayed');
|
||||
const refreshing = described.filter(feed => feed.state === 'refreshing');
|
||||
let summary = hasSectionData ? 'Live' : 'Live data unavailable';
|
||||
if (delayed.length === 1) summary = delayed[0].label + ' delayed';
|
||||
else if (delayed.length > 1) summary = delayed.length + ' data feeds delayed';
|
||||
else if (refreshing.length === 1) summary = refreshing[0].label + ' refreshing';
|
||||
else if (refreshing.length > 1) summary = 'Refreshing live data';
|
||||
const retryValues = described.map(feed => {
|
||||
const section = sections[feed.key] || {};
|
||||
return boundedSeconds(section.retry_in_seconds);
|
||||
}).filter(value => value !== null && value > 0);
|
||||
const aggregateRetry = boundedSeconds(freshness.retry_in_seconds);
|
||||
if (aggregateRetry !== null && aggregateRetry > 0) retryValues.push(aggregateRetry);
|
||||
return {
|
||||
summary,
|
||||
feeds: described,
|
||||
nextRetrySeconds: retryValues.length ? Math.min(...retryValues) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function createRefreshController({ button, output, refresh, onState = () => {} }) {
|
||||
let pending = null;
|
||||
function run() {
|
||||
if (pending) return pending;
|
||||
button.disabled = true;
|
||||
output.textContent = 'Refreshing live data…';
|
||||
onState('refreshing');
|
||||
let request;
|
||||
try {
|
||||
request = refresh();
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
pending = Promise.resolve(request).then(result => {
|
||||
if (!result) throw new Error('Live data remains unavailable.');
|
||||
output.textContent = 'Live data refreshed.';
|
||||
onState('success');
|
||||
return result;
|
||||
}).catch(error => {
|
||||
output.textContent = error && error.message ? error.message : 'Live data refresh failed.';
|
||||
onState('error');
|
||||
return null;
|
||||
}).finally(() => {
|
||||
button.disabled = false;
|
||||
pending = null;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
return { run };
|
||||
}
|
||||
|
||||
return { describe, createRefreshController };
|
||||
});
|
||||
+73
-10
@@ -4,7 +4,8 @@
|
||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||
function validShareContinuation(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
||||
const limits = { title: 200, text: 8000, url: 2048 };
|
||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200,
|
||||
preview: 200, search_kind: 5, search_state: 6, search_repository: 161 };
|
||||
const params = new URLSearchParams(value.slice(3));
|
||||
const entries = Array.from(params.entries());
|
||||
if (!entries.length) return './';
|
||||
@@ -14,6 +15,27 @@
|
||||
|| content.length > limits[name]
|
||||
));
|
||||
if (invalid) return './';
|
||||
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
|
||||
const search = params.get('search');
|
||||
const preview = params.get('preview');
|
||||
if (search !== null || preview !== null) {
|
||||
if (entries.some(([name]) => ![
|
||||
'search', 'preview', 'search_kind', 'search_state', 'search_repository'
|
||||
].includes(name))) return './';
|
||||
if (search === null || preview === null) return './';
|
||||
if (!/^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9]\d*$/.test(preview)) return './';
|
||||
const kind = params.get('search_kind');
|
||||
const state = params.get('search_state');
|
||||
const repository = params.get('search_repository');
|
||||
if (kind !== null && !['all', 'issue', 'pull'].includes(kind)) return './';
|
||||
if (state !== null && !['all', 'open', 'closed'].includes(state)) return './';
|
||||
if (repository !== null && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return './';
|
||||
return value;
|
||||
}
|
||||
const launch = params.get('launch');
|
||||
const shared = params.get('shared');
|
||||
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
|
||||
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -28,7 +50,31 @@
|
||||
const continuation = validShareContinuation(options.continuation);
|
||||
const setIntervalImpl = options.setIntervalImpl || setInterval;
|
||||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||||
const setTimeoutImpl = options.setTimeoutImpl || setTimeout;
|
||||
const clearTimeoutImpl = options.clearTimeoutImpl || clearTimeout;
|
||||
const requestTimeoutMs = options.requestTimeoutMs || 15000;
|
||||
let timer = null;
|
||||
let activeAttempt = false;
|
||||
|
||||
function setAttemptActive(active) {
|
||||
activeAttempt = active;
|
||||
button.disabled = active;
|
||||
if (passkeyButton) passkeyButton.disabled = active || !credentials?.get;
|
||||
}
|
||||
|
||||
async function requestWithDeadline(url, init) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeoutImpl(() => {
|
||||
const error = new Error('Sign-in request timed out');
|
||||
error.name = 'TimeoutError';
|
||||
controller.abort(error);
|
||||
}, requestTimeoutMs);
|
||||
try {
|
||||
return await fetchImpl(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeoutImpl(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
|
||||
@@ -58,7 +104,12 @@
|
||||
};
|
||||
}
|
||||
|
||||
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
|
||||
if (continuation !== './') {
|
||||
const continuationParams = new URLSearchParams(continuation.slice(3));
|
||||
status.textContent = continuationParams.has('preview')
|
||||
? 'Sign in to open the shared Search result.'
|
||||
: 'Sign in to continue your shared capture.';
|
||||
}
|
||||
|
||||
async function showReason(reason) {
|
||||
if (reason === 'session-expired') {
|
||||
@@ -100,18 +151,24 @@
|
||||
}
|
||||
|
||||
async function submit(accessToken, deviceLabel = 'This device') {
|
||||
if (activeAttempt) return false;
|
||||
setAttemptActive(true);
|
||||
status.textContent = 'Signing in…';
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl('api/v1/session', {
|
||||
response = await requestWithDeadline('api/v1/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }),
|
||||
});
|
||||
} catch (_error) {
|
||||
} catch (error) {
|
||||
form.reset();
|
||||
status.textContent = 'Sign-in failed. Check your connection and try again.';
|
||||
return;
|
||||
status.textContent = error?.name === 'TimeoutError'
|
||||
? 'Sign-in timed out. Check your connection and try again.'
|
||||
: 'Sign-in failed. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
setAttemptActive(false);
|
||||
}
|
||||
form.reset();
|
||||
if (response.ok) {
|
||||
@@ -130,9 +187,11 @@
|
||||
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
|
||||
return false;
|
||||
}
|
||||
if (activeAttempt) return false;
|
||||
setAttemptActive(true);
|
||||
status.textContent = 'Waiting for your passkey…';
|
||||
try {
|
||||
const optionsResponse = await fetchImpl('api/v1/passkeys/authentication/options', {
|
||||
const optionsResponse = await requestWithDeadline('api/v1/passkeys/authentication/options', {
|
||||
method: 'POST', headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
|
||||
@@ -143,7 +202,7 @@
|
||||
...item, id: decodeBase64Url(item.id),
|
||||
}));
|
||||
const credential = await credentials.get({ publicKey });
|
||||
const response = await fetchImpl('api/v1/passkeys/authentication/verify', {
|
||||
const response = await requestWithDeadline('api/v1/passkeys/authentication/verify', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -157,9 +216,13 @@
|
||||
if (!response.ok) throw new Error('Passkey verification failed');
|
||||
location.replace(continuation);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
status.textContent = 'Passkey sign-in was not completed. Try again or use the access token.';
|
||||
} catch (error) {
|
||||
status.textContent = error?.name === 'TimeoutError'
|
||||
? 'Passkey sign-in timed out. Check your connection and try again.'
|
||||
: 'Passkey sign-in was not completed. Try again or use the access token.';
|
||||
return false;
|
||||
} finally {
|
||||
setAttemptActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,38 @@
|
||||
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Continue work",
|
||||
"short_name": "Continue",
|
||||
"description": "Resume the highest-priority mobile work flow.",
|
||||
"url": "./?launch=continue",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
},
|
||||
{
|
||||
"name": "New issue",
|
||||
"short_name": "New",
|
||||
"description": "Capture work now and file it online or offline.",
|
||||
"url": "./?launch=new",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
},
|
||||
{
|
||||
"name": "Open Agenda",
|
||||
"short_name": "Agenda",
|
||||
"description": "Check every assigned deadline and continue the Agenda session.",
|
||||
"url": "./?launch=agenda",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "./",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {"title": "title", "text": "text", "url": "url"}
|
||||
"action": "./share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}]
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-6
@@ -32,23 +32,33 @@
|
||||
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]);
|
||||
}
|
||||
|
||||
function renderList(lines) {
|
||||
function renderList(lines, options, firstTaskIndex) {
|
||||
const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line));
|
||||
const items = lines.map(line => {
|
||||
const items = lines.map((line, offset) => {
|
||||
let body = line.replace(/^[-*+]\s+/, '');
|
||||
if (!taskList) return '<li>' + renderInline(body) + '</li>';
|
||||
const checked = /^\[[xX]\]\s+/.test(body);
|
||||
body = body.replace(/^\[[ xX]\]\s+/, '');
|
||||
return '<li class="task-list-item"><input type="checkbox" disabled' +
|
||||
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
|
||||
const control = options.interactiveTasks ?
|
||||
' class="task-list-toggle" data-task-index="' + (firstTaskIndex + offset) +
|
||||
'" aria-label="Mark ' + escapeHtml(body) + (checked ? ' incomplete"' : ' complete"') :
|
||||
' disabled';
|
||||
const manage = options.interactiveTasks && options.manageTasks ?
|
||||
'<button type="button" class="task-list-manage" data-task-index="' + (firstTaskIndex + offset) +
|
||||
'" data-task-label="' + escapeHtml(body) + '" aria-label="Manage step: ' + escapeHtml(body) + '"' +
|
||||
(offset === 0 ? ' data-task-first="true"' : '') +
|
||||
(offset === lines.length - 1 ? ' data-task-last="true"' : '') + '>Manage</button>' : '';
|
||||
return '<li class="task-list-item"><input type="checkbox"' + control +
|
||||
(checked ? ' checked' : '') + '> ' + renderInline(body) + manage + '</li>';
|
||||
}).join('');
|
||||
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
|
||||
}
|
||||
|
||||
return function renderMarkdown(raw) {
|
||||
function renderMarkdown(raw, options = {}) {
|
||||
const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n');
|
||||
const output = [];
|
||||
let index = 0;
|
||||
let taskIndex = 0;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
@@ -90,7 +100,10 @@
|
||||
list.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
output.push(renderList(list));
|
||||
output.push(renderList(list, options, taskIndex));
|
||||
if (list.every(candidate => /^[-*+]\s+\[[ xX]\]\s+/.test(candidate))) {
|
||||
taskIndex += list.length;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const paragraph = [];
|
||||
@@ -103,5 +116,29 @@
|
||||
output.push('<p>' + renderInline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
|
||||
}
|
||||
return output.join('');
|
||||
}
|
||||
|
||||
renderMarkdown.toggleTask = function toggleTask(raw, targetIndex, checked) {
|
||||
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let taskIndex = 0;
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const line = parts[index];
|
||||
if (/^\s*```/.test(line)) {
|
||||
fenced = !fenced;
|
||||
continue;
|
||||
}
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^([-*+]\s+\[)([ xX])(\])/);
|
||||
if (!task) continue;
|
||||
if (taskIndex === Number(targetIndex)) {
|
||||
parts[index] = task[1] + (checked ? 'x' : ' ') + task[3] + line.slice(task[0].length);
|
||||
return parts.join('');
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
return parts.join('');
|
||||
};
|
||||
|
||||
return renderMarkdown;
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.mobileAppShortcuts = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
const ACTIONS = new Set(['continue', 'new', 'agenda']);
|
||||
|
||||
function parse(search) {
|
||||
const action = new URLSearchParams(search || '').get('launch');
|
||||
return ACTIONS.has(action) ? action : null;
|
||||
}
|
||||
|
||||
function createController(options) {
|
||||
const launchAction = parse(options.search);
|
||||
let handled = false;
|
||||
|
||||
function action() { return launchAction; }
|
||||
|
||||
function run() {
|
||||
if (!launchAction || handled) return null;
|
||||
handled = true;
|
||||
if (launchAction === 'continue') return options.continueWork();
|
||||
if (launchAction === 'new') return options.newIssue();
|
||||
return options.agenda();
|
||||
}
|
||||
|
||||
return {action, run};
|
||||
}
|
||||
|
||||
return {parse, createController};
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
['install', options.installButton, options.installStatus, options.install],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
||||
];
|
||||
let trigger = options.launcher;
|
||||
|
||||
@@ -100,8 +101,10 @@ function mountMobileDeviceSetup(options) {
|
||||
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
|
||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
deadlineButton:qs('#device-setup-deadline'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), readyStatus:qs('#device-setup-ready-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
||||
readyStatus:qs('#device-setup-ready-status'),
|
||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
||||
promptStorage:options.promptStorage,
|
||||
@@ -118,9 +121,11 @@ function mountMobileDeviceSetup(options) {
|
||||
: qs('#push-updates').checked
|
||||
? {state:'complete', detail:'New update notifications are enabled.'}
|
||||
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
enablePush:options.enablePush,
|
||||
enableDeadline:options.enableDeadline,
|
||||
});
|
||||
}
|
||||
@@ -4,13 +4,14 @@
|
||||
else root.mobileLaunch = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const allowedFilters = new Set([
|
||||
'all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft',
|
||||
'all', 'today', 'attention', 'agenda', 'issue', 'pull', 'review', 'update', 'later', 'draft',
|
||||
]);
|
||||
|
||||
function chooseFilter({ saved, today = 0, attention = 0 } = {}) {
|
||||
function chooseFilter({ saved, today = 0, attention = 0, agenda = 0 } = {}) {
|
||||
if (allowedFilters.has(saved)) return saved;
|
||||
if (today > 0) return 'today';
|
||||
if (attention > 0) return 'attention';
|
||||
if (agenda > 0) return 'agenda';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
const continuation = [
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['update', 'Resume Updates'],
|
||||
['agenda', 'Open Agenda'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
];
|
||||
@@ -26,6 +28,9 @@
|
||||
|
||||
function open(name) {
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileStartDay = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileStartDay(options) {
|
||||
const checkpointKey = 'stackchain.mobile-start-day.v1';
|
||||
const storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null);
|
||||
const reviewOrder = [
|
||||
['agenda', 'Agenda'],
|
||||
['attention', 'Attention'],
|
||||
['update', 'Updates'],
|
||||
['filed', 'Filed'],
|
||||
];
|
||||
|
||||
function count(value) {
|
||||
return Math.max(0, Number(value) || 0);
|
||||
}
|
||||
|
||||
function localDay() {
|
||||
const now = new Date();
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
return now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate());
|
||||
}
|
||||
|
||||
function identity() {
|
||||
try {
|
||||
return {
|
||||
login: String(options.getLogin ? options.getLogin() : '').trim(),
|
||||
day: String(options.getDay ? options.getDay() : localDay()),
|
||||
};
|
||||
} catch (_) {
|
||||
return {login:'', day:''};
|
||||
}
|
||||
}
|
||||
|
||||
function checkpoint() {
|
||||
if (!storage) return null;
|
||||
const current = identity();
|
||||
if (!current.login || !current.day) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(checkpointKey) || 'null');
|
||||
return saved?.login === current.login && saved?.day === current.day ? saved : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveCheckpoint(phase = null) {
|
||||
if (!storage) return false;
|
||||
const current = identity();
|
||||
if (!current.login || !current.day) return false;
|
||||
try {
|
||||
storage.setItem(checkpointKey, JSON.stringify({...current, phase}));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCheckpoint() {
|
||||
if (!storage || !checkpoint()) return false;
|
||||
try {
|
||||
storage.removeItem(checkpointKey);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function briefing() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const phases = reviewOrder
|
||||
.map(([name, label]) => ({name, label, count: count(counts[name])}))
|
||||
.filter(phase => phase.count > 0);
|
||||
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
|
||||
const today = count(counts.today);
|
||||
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
|
||||
const nextLabel = phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work');
|
||||
return {
|
||||
total,
|
||||
next,
|
||||
label: nextLabel,
|
||||
summary: total ? total + ' items before Today · ' + today + ' planned' :
|
||||
(today ? 'Review clear · ' + today + ' planned' : 'Review clear · Today is empty'),
|
||||
phases,
|
||||
};
|
||||
}
|
||||
|
||||
function startNext() {
|
||||
const next = briefing().next;
|
||||
if (reviewOrder.some(([name]) => name === next)) saveCheckpoint(next);
|
||||
else clearCheckpoint();
|
||||
options.openQueue(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function state() {
|
||||
const current = briefing();
|
||||
return {active:Boolean(checkpoint()), next:current.next, label:current.label};
|
||||
}
|
||||
|
||||
function completePhase(phase) {
|
||||
const saved = checkpoint();
|
||||
if (!saved || (saved.phase && saved.phase !== phase)) return false;
|
||||
const current = render();
|
||||
saveCheckpoint();
|
||||
if (options.onHandoff) options.onHandoff(current);
|
||||
return true;
|
||||
}
|
||||
|
||||
function reconcile({authoritative = false} = {}) {
|
||||
const saved = checkpoint();
|
||||
if (!authoritative || !saved?.phase) return false;
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
if (count(counts[saved.phase]) > 0) return false;
|
||||
return completePhase(saved.phase);
|
||||
}
|
||||
|
||||
function finish() {
|
||||
const cleared = clearCheckpoint();
|
||||
render();
|
||||
return cleared;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const current = briefing();
|
||||
if (!options.elements) return current;
|
||||
options.elements.summary.textContent = current.summary;
|
||||
options.elements.phases.textContent = current.phases.length ?
|
||||
current.phases.map(phase => phase.label + ' ' + phase.count).join(' · ') :
|
||||
'All urgent queues reviewed';
|
||||
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
|
||||
if (options.elements.finish) options.elements.finish.hidden = !checkpoint();
|
||||
return current;
|
||||
}
|
||||
|
||||
function start() {
|
||||
render();
|
||||
if (options.elements) options.elements.action.addEventListener('click', startNext);
|
||||
}
|
||||
|
||||
return {briefing, completePhase, finish, reconcile, render, start, startNext, state};
|
||||
});
|
||||
@@ -64,18 +64,15 @@
|
||||
}
|
||||
|
||||
function updateWork(mode) {
|
||||
const labels = {
|
||||
continue: ['Continue', 'Continue Today'],
|
||||
resume: ['Resume', 'Resume Today'],
|
||||
start: ['Start', 'Start Today'],
|
||||
plan: ['Plan', 'Plan Today'],
|
||||
work: ['Work', 'Work'],
|
||||
};
|
||||
const workMode = labels[mode] ? mode : 'work';
|
||||
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
|
||||
if (buttons.work) {
|
||||
buttons.work.setAttribute('aria-label', labels[workMode][1]);
|
||||
}
|
||||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', queue
|
||||
? (mode === 'update' ? 'Resume ' : 'Open ') + text
|
||||
: mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today'));
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
@@ -94,9 +91,10 @@
|
||||
}
|
||||
|
||||
function updateQueues(counts) {
|
||||
const names = 'today 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 total = ['today', 'attention', 'later', 'draft'].reduce((sum, name) => sum + normalized[name], 0);
|
||||
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);
|
||||
});
|
||||
@@ -104,10 +102,28 @@
|
||||
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
|
||||
);
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = String(total);
|
||||
options.queueBadge.hidden = total === 0;
|
||||
options.queueBadge.textContent = active + ' active';
|
||||
options.queueBadge.hidden = active === 0;
|
||||
}
|
||||
buttons.queues?.setAttribute('aria-label', 'Queues, ' + total + ' items');
|
||||
if (options.deadlineBadge) {
|
||||
options.deadlineBadge.textContent = normalized.agenda + ' due';
|
||||
options.deadlineBadge.hidden = normalized.agenda === 0;
|
||||
}
|
||||
if (options.queueRows?.agenda) {
|
||||
if (normalized.agenda > 0) options.queueRows.agenda.setAttribute('data-deadlines', 'true');
|
||||
else options.queueRows.agenda.removeAttribute('data-deadlines');
|
||||
}
|
||||
const label = active === 0 && normalized.agenda === 0
|
||||
? 'Queues: no active queues; no upcoming deadlines'
|
||||
: 'Queues: Today ' + normalized.today
|
||||
+ ', 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');
|
||||
buttons.queues?.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
const recommended = options.queueLauncher && options.queueLauncher.recommend();
|
||||
if (recommended && recommended.name !== 'today') return recommended.name;
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
return 'work';
|
||||
return 'find';
|
||||
}
|
||||
|
||||
function open() {
|
||||
@@ -16,7 +18,8 @@
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else if (current === 'plan') options.planToday();
|
||||
else options.openFallback();
|
||||
else if (current === 'find') options.findWork();
|
||||
else options.queueLauncher.open(current);
|
||||
return current;
|
||||
}
|
||||
|
||||
|
||||
+285
-29
@@ -1,15 +1,26 @@
|
||||
function issueDueState(dueDate, now) {
|
||||
if (!dueDate) return null;
|
||||
const due = new Date(dueDate);
|
||||
if (Number.isNaN(due.getTime())) return null;
|
||||
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
function calendarDay(value) {
|
||||
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
|
||||
if (!match) return '';
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
return date.getFullYear() === Number(match[1]) && date.getMonth() === Number(match[2]) - 1 &&
|
||||
date.getDate() === Number(match[3]) ? match.slice(1, 4).join('-') : '';
|
||||
}
|
||||
|
||||
function localDay(value) {
|
||||
return value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(value.getDate()).padStart(2, '0');
|
||||
const dueDay = day(due);
|
||||
const today = day(now);
|
||||
}
|
||||
|
||||
function issueDueState(dueDate, now) {
|
||||
const dueDay = calendarDay(dueDate);
|
||||
if (!dueDay) return null;
|
||||
const today = localDay(now);
|
||||
if (dueDay < today) return { label: 'Overdue', priority: 2 };
|
||||
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
|
||||
return {
|
||||
label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
label: 'Due ' + new Date(
|
||||
Number(dueDay.slice(0, 4)), Number(dueDay.slice(5, 7)) - 1, Number(dueDay.slice(8, 10))
|
||||
).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
priority: 4,
|
||||
};
|
||||
}
|
||||
@@ -33,6 +44,19 @@ function needsAttention(item) {
|
||||
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
|
||||
}
|
||||
|
||||
function updateActionability(item) {
|
||||
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||
if ((item?.labels || []).some(label => priorityLabels.includes(String(label).toLowerCase()))) {
|
||||
return { priority: 0, reason: 'Critical' };
|
||||
}
|
||||
if (item?.is_review) return { priority: 1, reason: 'Review requested' };
|
||||
if (item?.is_assigned && ['Overdue', 'Due today'].includes(item?.due_label)) {
|
||||
return { priority: 2, reason: item.due_label };
|
||||
}
|
||||
if (item?.is_assigned) return { priority: 3, reason: 'Assigned to you' };
|
||||
return { priority: 4, reason: '' };
|
||||
}
|
||||
|
||||
function buildMyWork(data, now = new Date()) {
|
||||
const login = data.user?.login || '';
|
||||
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
||||
@@ -46,19 +70,24 @@ 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 isCompleted = isFiled && item.state === 'closed';
|
||||
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_completed: isCompleted,
|
||||
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' : (isCompleted ? 'Completed · review outcome' :
|
||||
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work'))))),
|
||||
_priority: priorityLabel ? 0 :
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))),
|
||||
};
|
||||
normalized.needs_attention = needsAttention(normalized);
|
||||
normalized.attention_reason = attentionReason(normalized);
|
||||
@@ -71,13 +100,15 @@ function buildMyWork(data, now = new Date()) {
|
||||
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
|
||||
const existing = byKey.get(subjectKind + ':' + key);
|
||||
if (existing) {
|
||||
const actionability = updateActionability(existing);
|
||||
existing.has_update = true;
|
||||
existing.needs_attention = true;
|
||||
existing.attention_reason = 'Unread update';
|
||||
existing.notification_id = update.id;
|
||||
existing.url = update.url || existing.url;
|
||||
existing.updated_at = update.updated_at || existing.updated_at;
|
||||
existing._priority = Math.min(existing._priority, 1);
|
||||
existing.update_reason = actionability.reason;
|
||||
existing._priority = actionability.priority;
|
||||
return;
|
||||
}
|
||||
if (!update.url) return;
|
||||
@@ -92,7 +123,8 @@ function buildMyWork(data, now = new Date()) {
|
||||
attention_reason: 'Unread update',
|
||||
notification_id: update.id,
|
||||
reason: 'Unread update',
|
||||
_priority: 1,
|
||||
update_reason: '',
|
||||
_priority: 4,
|
||||
};
|
||||
work.push(item);
|
||||
byKey.set(subjectKind + ':' + key, item);
|
||||
@@ -237,7 +269,8 @@ function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
return {
|
||||
let completing = null;
|
||||
const pager = {
|
||||
reset(next) {
|
||||
pagination = { ...pagination, ...(next || {}) };
|
||||
onPagination(pagination);
|
||||
@@ -271,18 +304,31 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
loadAll(getExisting) {
|
||||
if (completing) return completing;
|
||||
completing = (async () => {
|
||||
while (pagination.has_more) {
|
||||
const loaded = await pager.loadMore(getExisting());
|
||||
if (!loaded) return false;
|
||||
}
|
||||
return true;
|
||||
})().finally(() => { completing = null; });
|
||||
return completing;
|
||||
},
|
||||
};
|
||||
return pager;
|
||||
}
|
||||
|
||||
function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
let pagination = {};
|
||||
const pending = new Set();
|
||||
const completing = new Map();
|
||||
const labels = {
|
||||
issue: 'issues',
|
||||
pull: 'pull requests',
|
||||
review: 'review requests',
|
||||
};
|
||||
return {
|
||||
const pager = {
|
||||
reset(next) {
|
||||
Object.entries(next || {}).forEach(([stream, value]) => {
|
||||
const current = pagination[stream];
|
||||
@@ -331,7 +377,20 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
pending.delete(stream);
|
||||
}
|
||||
},
|
||||
loadAll(stream, getExisting) {
|
||||
if (completing.has(stream)) return completing.get(stream);
|
||||
const completion = (async () => {
|
||||
while (pagination[stream]?.has_more) {
|
||||
const loaded = await pager.loadMore(stream, getExisting());
|
||||
if (!loaded) return false;
|
||||
}
|
||||
return true;
|
||||
})().finally(() => completing.delete(stream));
|
||||
completing.set(stream, completion);
|
||||
return completion;
|
||||
},
|
||||
};
|
||||
return pager;
|
||||
}
|
||||
|
||||
function createNotificationReader({
|
||||
@@ -341,13 +400,52 @@ function createNotificationReader({
|
||||
loadSaved = () => null,
|
||||
loadConversation = null,
|
||||
onConversation = () => {},
|
||||
onConversationStatus = () => {},
|
||||
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
|
||||
getScope = () => '',
|
||||
}) {
|
||||
let selected = null;
|
||||
let loadVersion = 0;
|
||||
let marking = false;
|
||||
let conversationPager = null;
|
||||
let offlineHydrated = false;
|
||||
let prefetched = null;
|
||||
let prefetchKey = '';
|
||||
|
||||
async function hydrateConversation(item, version) {
|
||||
if (!loadConversation || !createPager || !item || offlineHydrated) return false;
|
||||
onConversationStatus('Loading conversation…');
|
||||
try {
|
||||
const initial = await loadConversation(item.notification_id, null);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
conversationPager = createPager({
|
||||
loadPage: page => loadConversation(item.notification_id, page),
|
||||
});
|
||||
const state = conversationPager.reset(initial);
|
||||
onConversation(state);
|
||||
onConversationStatus(state.comments.length + ' of ' +
|
||||
Math.max(state.total || 0, state.comments.length) + ' messages loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (selected === item && version === loadVersion) {
|
||||
conversationPager = null;
|
||||
onConversationStatus('Conversation temporarily unavailable. Retry.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function prefetch(item) {
|
||||
if (!item) { prefetched = null; prefetchKey = ''; return false; }
|
||||
const notificationId = item?.notification_id;
|
||||
if (!Number.isInteger(notificationId) || offlineHydrated) return false;
|
||||
const key = getScope() + notificationId;
|
||||
if (prefetchKey === key) return prefetched;
|
||||
if (prefetched) return false;
|
||||
prefetchKey = key;
|
||||
prefetched = load(notificationId);
|
||||
return prefetched;
|
||||
}
|
||||
|
||||
async function advanceAfterRead(items, current, queueing = false) {
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
@@ -369,10 +467,19 @@ function createNotificationReader({
|
||||
selected = item;
|
||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||
const version = ++loadVersion;
|
||||
const preload = !offlineHydrated && prefetchKey === getScope() + item.notification_id ? prefetched : null;
|
||||
prefetched = null;
|
||||
prefetchKey = '';
|
||||
onOpen(item);
|
||||
onStatus('Loading update…');
|
||||
if (!preload) onStatus('Loading update…');
|
||||
try {
|
||||
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
|
||||
let detail;
|
||||
if (offlineHydrated) detail = savedDetail;
|
||||
else if (preload) detail = await preload.catch(() => {
|
||||
onStatus('Loading update…');
|
||||
return load(item.notification_id);
|
||||
});
|
||||
else detail = await load(item.notification_id);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
onDetail(detail);
|
||||
if (createPager && loadConversation && detail.conversation) {
|
||||
@@ -384,6 +491,9 @@ function createNotificationReader({
|
||||
conversationPager = null;
|
||||
}
|
||||
onStatus('Update ready.');
|
||||
if (!offlineHydrated && detail.conversation_available) {
|
||||
void hydrateConversation(item, version);
|
||||
}
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (selected === item && version === loadVersion) {
|
||||
@@ -395,6 +505,11 @@ function createNotificationReader({
|
||||
|
||||
return {
|
||||
open,
|
||||
prefetch,
|
||||
retryConversation() {
|
||||
if (!selected || offlineHydrated) return Promise.resolve(false);
|
||||
return hydrateConversation(selected, loadVersion);
|
||||
},
|
||||
commentPager() {
|
||||
return conversationPager;
|
||||
},
|
||||
@@ -478,7 +593,7 @@ function createNotificationReplier({
|
||||
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
|
||||
storage.setItem(keyFor(item), body);
|
||||
}
|
||||
catch (_error) { /* Keep the editable textarea as the fallback. */ }
|
||||
catch (_error) {}
|
||||
},
|
||||
async submit(item, body, attachment = null) {
|
||||
if (pending) return false;
|
||||
@@ -504,15 +619,15 @@ function createNotificationReplier({
|
||||
return { queued:true };
|
||||
}
|
||||
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
|
||||
catch (_error) { /* Confirmed delivery is authoritative. */ }
|
||||
catch (_error) {}
|
||||
onStatus('Reply posted. You can mark this update read when ready.');
|
||||
return delivery.confirmed[0];
|
||||
}
|
||||
const result = await post(item.notification_id, body, operationId);
|
||||
try { storage.removeItem(keyFor(item)); }
|
||||
catch (_error) { /* The posted reply is still authoritative. */ }
|
||||
catch (_error) {}
|
||||
try { storage.removeItem(operationKeyFor(item)); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
catch (_error) {}
|
||||
onStatus('Reply posted. You can mark this update read when ready.');
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -541,6 +656,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);
|
||||
@@ -553,6 +669,134 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
);
|
||||
}
|
||||
|
||||
function createCompletedFiledReview({
|
||||
storage,
|
||||
getLogin,
|
||||
key = 'stackchain.completed-filed-review.v1',
|
||||
}) {
|
||||
const login = () => String(getLogin?.() || '').trim();
|
||||
const ownerKey = () => key + ':' + login();
|
||||
const identity = item => String(item?.repository || '') + '#' + String(item?.number || '');
|
||||
const read = () => {
|
||||
if (!login()) return { items:{}, pending:{} };
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(ownerKey()) || '{}');
|
||||
return value && value.version === 1 && value.items && typeof value.items === 'object' ? {
|
||||
items:value.items,
|
||||
pending:value.pending && typeof value.pending === 'object' ? value.pending : {},
|
||||
} : { items:{}, pending:{} };
|
||||
} catch (_error) { return { items:{}, pending:{} }; }
|
||||
};
|
||||
const receipt = (item, stamp = String(item?.updated_at || '')) => ({
|
||||
repository:String(item?.repository || ''), number:item?.number, updated_at:stamp,
|
||||
});
|
||||
const save = value => storage?.setItem(ownerKey(), JSON.stringify({ version:1, ...value }));
|
||||
const partition = items => {
|
||||
const acknowledged = read().items;
|
||||
const needsReview = [];
|
||||
const reviewed = [];
|
||||
(items || []).forEach(item => {
|
||||
const isReviewed = item?.is_completed &&
|
||||
acknowledged[identity(item)] === String(item.updated_at || '');
|
||||
(isReviewed ? reviewed : needsReview).push(item);
|
||||
});
|
||||
reviewed.sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || '')));
|
||||
return { needsReview, reviewed };
|
||||
};
|
||||
return {
|
||||
partition,
|
||||
visible(items) {
|
||||
return partition(items).needsReview;
|
||||
},
|
||||
pending() {
|
||||
const state = read();
|
||||
return Object.entries(state.pending).map(([itemIdentity, stamp]) => {
|
||||
const boundary = itemIdentity.lastIndexOf('#');
|
||||
return receipt({
|
||||
repository:itemIdentity.slice(0, boundary),
|
||||
number:Number(itemIdentity.slice(boundary + 1)),
|
||||
}, stamp);
|
||||
});
|
||||
},
|
||||
adopt(snapshot) {
|
||||
const state = read();
|
||||
let changed = false;
|
||||
(snapshot?.receipts || []).forEach(remote => {
|
||||
const itemIdentity = identity(remote);
|
||||
const stamp = String(remote?.updated_at || '');
|
||||
if (!remote?.repository || !Number.isInteger(remote?.number) || !stamp) return;
|
||||
if (!state.items[itemIdentity] || state.items[itemIdentity] < stamp) {
|
||||
state.items[itemIdentity] = stamp;
|
||||
changed = true;
|
||||
}
|
||||
if (state.pending[itemIdentity] && state.pending[itemIdentity] <= stamp) {
|
||||
delete state.pending[itemIdentity];
|
||||
}
|
||||
});
|
||||
try { save(state); } catch (_error) { return false; }
|
||||
return changed;
|
||||
},
|
||||
acknowledge(item) {
|
||||
const stamp = String(item?.updated_at || '');
|
||||
if (!login() || !item?.is_completed || !item?.repository || !Number.isInteger(item?.number) || !stamp) {
|
||||
return false;
|
||||
}
|
||||
const state = read();
|
||||
const entries = Object.entries({ ...state.items, [identity(item)]: stamp }).slice(-200);
|
||||
const pending = Object.entries({ ...state.pending, [identity(item)]: stamp }).slice(-200);
|
||||
try {
|
||||
save({ items:Object.fromEntries(entries), pending:Object.fromEntries(pending) });
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFiledHistoryTabs({ root, review, onSelect }) {
|
||||
const buttons = Array.from(root.querySelectorAll('[data-filed-view]'));
|
||||
let partition = { needsReview:[], reviewed:[] };
|
||||
buttons.forEach(button => button.addEventListener('click', () => onSelect(button.dataset.filedView)));
|
||||
return {
|
||||
prepare(items) {
|
||||
partition = review.partition(items);
|
||||
root.querySelector('#filed-needs-review-count').textContent =
|
||||
partition.needsReview.filter(item => item.is_filed).length;
|
||||
root.querySelector('#filed-reviewed-count').textContent = partition.reviewed.length;
|
||||
return partition.needsReview;
|
||||
},
|
||||
render(selected, visible) {
|
||||
root.hidden = !visible;
|
||||
buttons.forEach(button =>
|
||||
button.setAttribute('aria-pressed', String(button.dataset.filedView === selected))
|
||||
);
|
||||
return selected === 'reviewed' ? partition.reviewed : partition.needsReview;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function agendaMyWork(items, now = new Date()) {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const today = localDay(start);
|
||||
const tomorrowDate = new Date(start); tomorrowDate.setDate(tomorrowDate.getDate() + 1);
|
||||
const tomorrow = localDay(tomorrowDate);
|
||||
const afterTomorrowDate = new Date(start); afterTomorrowDate.setDate(afterTomorrowDate.getDate() + 2);
|
||||
const afterTomorrow = localDay(afterTomorrowDate);
|
||||
const horizonDate = new Date(start); horizonDate.setDate(horizonDate.getDate() + 7);
|
||||
const horizon = localDay(horizonDate);
|
||||
return (items || []).flatMap(item => {
|
||||
if (item?.kind !== 'issue' || !item.is_assigned || !item.due_date) return [];
|
||||
const due = calendarDay(item.due_date);
|
||||
if (!due || due >= horizon) return [];
|
||||
const group = due < today ? 'Overdue' : due < tomorrow ? 'Today' :
|
||||
due < afterTomorrow ? 'Tomorrow' : 'Next 7 days';
|
||||
return [{ ...item, agenda_group: group, _agenda_due: due }];
|
||||
}).sort((left, right) =>
|
||||
left._agenda_due.localeCompare(right._agenda_due) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
).map(({ _agenda_due, ...item }) => item);
|
||||
}
|
||||
|
||||
function milestoneLanes(items) {
|
||||
const lanes = new Map();
|
||||
(items || []).forEach(item => {
|
||||
@@ -627,13 +871,14 @@ function createWorkSessionCheckpoint({
|
||||
|
||||
function createWorkSession({
|
||||
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
|
||||
checkpointEnabled = () => true,
|
||||
checkpointEnabled = () => true, checkpointedEnabled = checkpointEnabled,
|
||||
onOpen, onProgress, onFinish,
|
||||
}) {
|
||||
let currentIdentity = '';
|
||||
let currentIndex = -1;
|
||||
let running = false;
|
||||
let durable = false;
|
||||
const activeCheckpoint = () => typeof checkpoint === 'function' ? checkpoint() : checkpoint;
|
||||
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||
const report = (items, index) => onProgress({
|
||||
@@ -648,7 +893,7 @@ function createWorkSession({
|
||||
durable = false;
|
||||
currentIdentity = '';
|
||||
currentIndex = -1;
|
||||
if (clearCheckpoint) checkpoint?.clear();
|
||||
if (clearCheckpoint) activeCheckpoint()?.clear();
|
||||
onFinish();
|
||||
return false;
|
||||
};
|
||||
@@ -656,7 +901,7 @@ function createWorkSession({
|
||||
if (!items.length || index < 0 || index >= items.length) return finish();
|
||||
currentIndex = index;
|
||||
currentIdentity = workIdentity(items[index]);
|
||||
if (durable) checkpoint?.save(currentIdentity, currentIndex);
|
||||
if (durable) activeCheckpoint()?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
@@ -664,9 +909,9 @@ function createWorkSession({
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
|
||||
checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity),
|
||||
end: () => finish(),
|
||||
resumable: () => Boolean(checkpoint?.read()),
|
||||
resumable: () => Boolean(activeCheckpoint()?.read()),
|
||||
reopen(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
@@ -675,13 +920,13 @@ function createWorkSession({
|
||||
if (index < 0) return false;
|
||||
currentIdentity = requestedIdentity;
|
||||
currentIndex = index;
|
||||
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
|
||||
if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
},
|
||||
resume(requested = null) {
|
||||
const saved = checkpoint?.read();
|
||||
const saved = activeCheckpoint()?.read();
|
||||
if (!saved) return false;
|
||||
durable = true;
|
||||
const items = queue();
|
||||
@@ -695,7 +940,7 @@ function createWorkSession({
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
durable = Boolean(checkpoint && checkpointEnabled());
|
||||
durable = Boolean(activeCheckpoint() && checkpointEnabled());
|
||||
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
|
||||
return openAt(items, requested >= 0 ? requested : 0);
|
||||
},
|
||||
@@ -743,7 +988,7 @@ function createWorkSession({
|
||||
if (!items.length) return null;
|
||||
if (action === 'start') return items[0];
|
||||
if (action === 'resume') {
|
||||
const saved = checkpoint?.read();
|
||||
const saved = activeCheckpoint()?.read();
|
||||
if (!saved) return null;
|
||||
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
|
||||
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
|
||||
@@ -824,10 +1069,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,
|
||||
@@ -837,6 +1089,9 @@ function countMyWork(items) {
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.createCompletedFiledReview = createCompletedFiledReview;
|
||||
buildMyWork.createFiledHistoryTabs = createFiledHistoryTabs;
|
||||
buildMyWork.agendaMyWork = agendaMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
|
||||
@@ -848,6 +1103,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;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
(function (root) {
|
||||
const databases = Object.freeze([
|
||||
'stackchain-background-outbox-v1',
|
||||
'stackchain-offline-work-v2',
|
||||
'stackchain-unfiled-captures-v1',
|
||||
'stackchain-voice-transcripts-v1',
|
||||
]);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
|
||||
else root.stackchainPrivateDatabases = databases;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
@@ -1,5 +1,10 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = options => factory({
|
||||
...options,
|
||||
privateDatabases: require('./private-data-registry.js'),
|
||||
});
|
||||
}
|
||||
else root.stackchainPrivateDeviceData = factory({
|
||||
localStorage: root.localStorage,
|
||||
sessionStorage: root.sessionStorage,
|
||||
@@ -7,9 +12,10 @@
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
MessageChannel: root.MessageChannel,
|
||||
privateDatabases: root.stackchainPrivateDatabases,
|
||||
});
|
||||
})(typeof window !== 'undefined' ? window : this, function createPrivateDeviceDataPurger({
|
||||
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel,
|
||||
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, privateDatabases,
|
||||
}) {
|
||||
function removeOwnedStorage(storage) {
|
||||
if (!storage) return;
|
||||
@@ -52,9 +58,7 @@
|
||||
removeOwnedStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
for (const name of privateDatabases) await deletePrivateDatabase(name);
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
function createProtectToday() {
|
||||
function propose({ agenda = [], today = [], identity, limit = 5 }) {
|
||||
const key = item => String(identity?.(item) || '');
|
||||
const urgent = [];
|
||||
const seen = new Set();
|
||||
for (const item of [...agenda, ...today]) {
|
||||
const id = key(item);
|
||||
const eligible = item?.kind === 'issue' && item.is_assigned === true && item.state !== 'closed' &&
|
||||
['Overdue', 'Today'].includes(item.agenda_group);
|
||||
if (!eligible || !id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
urgent.push(item);
|
||||
}
|
||||
urgent.sort((left, right) =>
|
||||
String(left.due_date || '').localeCompare(String(right.due_date || '')) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
);
|
||||
if (!urgent.length) return {
|
||||
selected:[], protected:[], displaced:[],
|
||||
summary:'No overdue or due-today work needs protection.',
|
||||
};
|
||||
const existing = [];
|
||||
for (const item of today) {
|
||||
const id = key(item);
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
existing.push(item);
|
||||
}
|
||||
const combined = [...urgent, ...existing];
|
||||
const selected = combined.slice(0, limit);
|
||||
const selectedIds = new Set(selected.map(key));
|
||||
const displaced = today.filter(item => !selectedIds.has(key(item)) && !urgent.some(candidate => key(candidate) === key(item)));
|
||||
return {
|
||||
selected,
|
||||
protected:urgent,
|
||||
displaced,
|
||||
summary:urgent.length + ' urgent ' + (urgent.length === 1 ? 'item' : 'items') + ' protected · ' +
|
||||
displaced.length + (displaced.length ? ' displaced for review' : ' displaced'),
|
||||
};
|
||||
}
|
||||
return { propose };
|
||||
}
|
||||
|
||||
const protectToday = createProtectToday();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = protectToday;
|
||||
@@ -2,10 +2,29 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createPushNotifications = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, notification, serviceWorker, fetchJson,
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
let configuration = null;
|
||||
|
||||
function formattedHour(value) {
|
||||
return `${String(Number(value)).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function enabledDeadlineText(hour, days) {
|
||||
const value = Number(days ?? 2);
|
||||
const horizon = value === 0 ? 'due today' : `next ${value} days`;
|
||||
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
|
||||
}
|
||||
|
||||
function deadlineReadiness() {
|
||||
if (!configuration?.available || deadlineControl?.disabled) {
|
||||
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
|
||||
}
|
||||
return configuration.deadline_enabled
|
||||
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
|
||||
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
|
||||
}
|
||||
|
||||
function applicationServerKey(value) {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
||||
if (typeof atob === 'function') {
|
||||
@@ -20,15 +39,17 @@
|
||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||
await subscription?.unsubscribe?.();
|
||||
control.checked = false;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
async function ensureSubscription() {
|
||||
const permission = await notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
control.checked = false;
|
||||
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
@@ -45,6 +66,12 @@
|
||||
});
|
||||
control.checked = true;
|
||||
status.textContent = 'New update notifications enabled for this device.';
|
||||
configuration.subscribed = true;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
await ensureSubscription();
|
||||
}
|
||||
|
||||
async function change() {
|
||||
@@ -60,20 +87,73 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function changeDeadline() {
|
||||
deadlineControl.disabled = true;
|
||||
if (deadlineHour) deadlineHour.disabled = true;
|
||||
if (deadlineDays) deadlineDays.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (deadlineControl.checked && !subscription) {
|
||||
deadlineControl.checked = false;
|
||||
deadlineStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9);
|
||||
const reminderDays = Number(deadlineDays?.value ?? configuration?.reminder_days ?? 2);
|
||||
await fetchJson('api/v1/push-subscription/deadlines', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour, reminder_days:reminderDays}),
|
||||
});
|
||||
configuration.deadline_enabled = deadlineControl.checked;
|
||||
configuration.reminder_hour = reminderHour;
|
||||
configuration.reminder_days = reminderDays;
|
||||
configuration.timezone = timezone;
|
||||
deadlineStatus.textContent = deadlineControl.checked
|
||||
? enabledDeadlineText(reminderHour, reminderDays)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
return true;
|
||||
} catch (error) {
|
||||
deadlineControl.checked = !deadlineControl.checked;
|
||||
deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
deadlineControl.disabled = false;
|
||||
if (deadlineHour) deadlineHour.disabled = false;
|
||||
if (deadlineDays) deadlineDays.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDeadline() {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!control || !notification || !serviceWorker) return;
|
||||
control.addEventListener('change', change);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
if (deadlineControl) deadlineControl.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
status.textContent = configuration.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
return {init, change};
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
function createReconnectOutboxes({
|
||||
refresh,
|
||||
restoreIdentity,
|
||||
flushIssue,
|
||||
flushAuthored,
|
||||
flushNotificationReads,
|
||||
}) {
|
||||
return async function reconnectOutboxes() {
|
||||
const snapshot = await refresh();
|
||||
const freshness = snapshot?.freshness?.sections?.context;
|
||||
const identityFresh = snapshot?.context && !snapshot.context.error &&
|
||||
!freshness?.stale && !freshness?.degraded && !freshness?.revalidating;
|
||||
const login = identityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (!login) return false;
|
||||
restoreIdentity(login);
|
||||
await Promise.all([
|
||||
flushIssue(),
|
||||
flushAuthored(),
|
||||
flushNotificationReads(),
|
||||
]);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createReconnectOutboxes;
|
||||
@@ -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;
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
(function (root, factory) {
|
||||
const createSearchBatchPlan = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchBatchPlan;
|
||||
if (root) {
|
||||
root.createSearchBatchPlan = createSearchBatchPlan;
|
||||
root.mountSearchBatchPlanning = createSearchBatchPlan.mount;
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function createSearchBatchPlan({
|
||||
limit = 50,
|
||||
resolve = item => Promise.resolve(item),
|
||||
claim = item => Promise.resolve(item),
|
||||
onChange = () => {},
|
||||
} = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
const selected = new Map();
|
||||
let active = false;
|
||||
|
||||
const identity = item => [item?.kind || '', item?.repository || '', item?.number || ''].join(':');
|
||||
const eligible = item => item?.kind === 'issue' && item?.state === 'open' &&
|
||||
Boolean(item.repository) && Number.isInteger(item.number);
|
||||
function snapshot() {
|
||||
return { active, count:selected.size, items:Array.from(selected.values()) };
|
||||
}
|
||||
function changed() {
|
||||
const state = snapshot();
|
||||
onChange(state);
|
||||
return state;
|
||||
}
|
||||
function start() {
|
||||
active = true;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
function cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
function toggle(item) {
|
||||
if (!active) return 'inactive';
|
||||
if (!eligible(item)) return 'ineligible';
|
||||
const key = identity(item);
|
||||
if (selected.has(key)) {
|
||||
selected.delete(key);
|
||||
changed();
|
||||
return 'removed';
|
||||
}
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.set(key, { ...item });
|
||||
changed();
|
||||
return 'selected';
|
||||
}
|
||||
async function prepare(item) {
|
||||
const detail = await resolve(item);
|
||||
if (detail?.assigned_to_me) return detail;
|
||||
if (detail?.claimable) return claim(detail);
|
||||
throw new Error('This issue is no longer available to assign.');
|
||||
}
|
||||
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,
|
||||
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(
|
||||
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(detail.number) + '/claim',
|
||||
{method:'PATCH', headers:{Accept:'application/json'}}
|
||||
),
|
||||
onChange:state => {
|
||||
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();
|
||||
},
|
||||
});
|
||||
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') {
|
||||
get('#search-selection-status').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
} 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', () => {
|
||||
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;
|
||||
if (index === undefined) return;
|
||||
const result = lookup(Number(index));
|
||||
if (result) plan.toggle(result);
|
||||
});
|
||||
function restore() {
|
||||
const pending = processor.pending() + laterProcessor.pending() + releaseProcessor.pending();
|
||||
get('#resume-search-batch').hidden = pending === 0;
|
||||
get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
|
||||
}
|
||||
function resultHtml(result, index) {
|
||||
const allowed = result.kind === 'issue' && result.state === 'open';
|
||||
const selected = plan.snapshot().items.some(item => plan.identity(item) === plan.identity(result));
|
||||
return '<label class="cmd-select-result"' + (allowed ? '' : ' aria-disabled="true"') + '>' +
|
||||
'<input type="checkbox" data-search-select="' + index + '" ' + (selected ? 'checked ' : '') +
|
||||
(allowed ? '' : 'disabled ') + 'aria-label="Select ' + escapeAttribute(result.title) + '" />' +
|
||||
'<span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' +
|
||||
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
|
||||
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
|
||||
}
|
||||
return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
|
||||
};
|
||||
|
||||
return createSearchBatchPlan;
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
function createSearchDefer({ claim, accept, defer, refresh, announce, formatTime }) {
|
||||
let request = null;
|
||||
|
||||
function run(detail, until) {
|
||||
if (request) return request;
|
||||
const wasClaimable = Boolean(detail?.claimable);
|
||||
request = Promise.resolve(wasClaimable ? claim(detail) : detail)
|
||||
.then(confirmed => {
|
||||
const item = accept(confirmed);
|
||||
const outcome = defer(item, until);
|
||||
refresh();
|
||||
if (outcome === 'deferred') {
|
||||
announce((wasClaimable ? 'Assigned and deferred until ' : 'Deferred until ') + formatTime(until) + '.');
|
||||
} else {
|
||||
announce(wasClaimable
|
||||
? 'Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry.'
|
||||
: 'Could not save Later on this device. Retry scheduling.');
|
||||
}
|
||||
return outcome;
|
||||
})
|
||||
.finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { run, pending:() => Boolean(request) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createSearchDefer;
|
||||
+304
-9
@@ -1,40 +1,225 @@
|
||||
(function (root, factory) {
|
||||
const createSearchPreview = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
||||
if (root) root.createSearchPreview = createSearchPreview;
|
||||
if (root) {
|
||||
root.createSearchPreview = createSearchPreview;
|
||||
root.safeSearchUrl = value => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||||
} catch (_) { return ''; }
|
||||
};
|
||||
root.searchPreviewUrl = (state, defaultScope, location) => {
|
||||
const { query, preview, scope = defaultScope } = state;
|
||||
const params = new URLSearchParams({
|
||||
search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number,
|
||||
search_kind:scope.kind, search_state:scope.state,
|
||||
});
|
||||
if (scope.repository) params.set('search_repository', scope.repository);
|
||||
return new URL('?' + params, location.origin + location.pathname).href;
|
||||
};
|
||||
root.searchPreviewPath = item => {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview?kind=' + encodeURIComponent(item.kind);
|
||||
};
|
||||
root.searchPreviewConversationPath = (item, page) => {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const query = new URLSearchParams({ kind:item.kind, limit:'20' });
|
||||
if (page) query.set('page', String(page));
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview/conversation?' + query.toString();
|
||||
};
|
||||
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||
'/comments?kind=' + encodeURIComponent(item.kind);
|
||||
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
|
||||
storage,
|
||||
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
postReply:(item,body,operationId) => fetchJson(root.searchPreviewReplyPath(item), {
|
||||
method:'POST', headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':operationId},
|
||||
body:JSON.stringify({body}),
|
||||
}),
|
||||
});
|
||||
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
|
||||
const comments = document.querySelector('#search-preview-comments');
|
||||
const status = document.querySelector('#search-preview-conversation-status');
|
||||
const retry = document.querySelector('#retry-search-preview-conversation');
|
||||
const older = document.querySelector('#load-older-search-preview-comments');
|
||||
if (!conversation) {
|
||||
comments.textContent = status.textContent = '';
|
||||
retry.hidden = older.hidden = true;
|
||||
return;
|
||||
}
|
||||
comments.innerHTML = (conversation.comments || []).map(comment =>
|
||||
'<article class="search-preview-comment" data-comment-id="' + Number(comment.id || 0) + '">' +
|
||||
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '</div>' +
|
||||
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
|
||||
).join('');
|
||||
retry.hidden = conversation.status !== 'error';
|
||||
older.hidden = conversation.status !== 'ready' || !conversation.olderPage;
|
||||
older.disabled = conversation.status === 'loading';
|
||||
if (conversation.status === 'loading') status.textContent = conversation.comments?.length
|
||||
? 'Loading older messages…' : 'Loading current conversation…';
|
||||
else if (conversation.status === 'error') status.textContent =
|
||||
'Conversation unavailable. Preview and planning actions still work.';
|
||||
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
|
||||
else status.textContent = conversation.comments.length +
|
||||
(conversation.comments.length === 1 ? ' message.' : ' messages.');
|
||||
};
|
||||
root.renderSearchPreviewReply = (state, detail, preview, document) => {
|
||||
const section = document.querySelector('.search-preview-reply');
|
||||
const input = document.querySelector('#search-preview-reply');
|
||||
const status = document.querySelector('#search-preview-reply-status');
|
||||
const buttons = ['#send-search-preview-reply', '#send-search-preview-reply-next']
|
||||
.map(selector => document.querySelector(selector));
|
||||
section.hidden = detail?.commentable !== true;
|
||||
status.textContent = '';
|
||||
if (section.hidden) {
|
||||
buttons.forEach(button => { button.disabled = true; });
|
||||
return;
|
||||
}
|
||||
input.value = preview.replyDraft();
|
||||
const replying = state.status === 'replying';
|
||||
buttons.forEach(button => { button.disabled = replying || !input.value.trim(); });
|
||||
if (replying) status.textContent = 'Sending reply…';
|
||||
else if (state.status === 'replied') status.textContent = 'Reply posted.';
|
||||
else if (state.status === 'reply-error') status.textContent =
|
||||
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
|
||||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, mutate, onState }) {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||
if (Array.isArray(session)) {
|
||||
getSession = session[0];
|
||||
loadMore = () => session[1].loadMore();
|
||||
onNavigate = session[2];
|
||||
navigationRoot = document;
|
||||
}
|
||||
let generation = 0;
|
||||
let current = null;
|
||||
let mutationRequest = null;
|
||||
let shareRequest = null;
|
||||
let moveRequest = null;
|
||||
let replyRequest = null;
|
||||
let conversation = null;
|
||||
|
||||
function sameItem(left, right) {
|
||||
return left && right && left.kind === right.kind && left.repository === right.repository &&
|
||||
Number(left.number) === Number(right.number);
|
||||
}
|
||||
|
||||
function replyKey(item, suffix) {
|
||||
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
|
||||
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
|
||||
}
|
||||
|
||||
function stored(key) {
|
||||
try { return storage?.getItem(key) || ''; }
|
||||
catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function save(key, value) {
|
||||
try {
|
||||
if (value) storage?.setItem(key, value);
|
||||
else storage?.removeItem(key);
|
||||
} catch (_) { /* Volatile drafts still remain in the textarea. */ }
|
||||
}
|
||||
|
||||
function navigation(item) {
|
||||
const session = typeof getSession === 'function' ? getSession() : null;
|
||||
const items = Array.isArray(session?.items) ? session.items : [];
|
||||
const index = items.findIndex(candidate => sameItem(candidate, item));
|
||||
if (index < 0) return null;
|
||||
return {
|
||||
position:index + 1,
|
||||
total:items.length,
|
||||
hasPrevious:index > 0,
|
||||
hasNext:index < items.length - 1 || session.more === true,
|
||||
};
|
||||
}
|
||||
|
||||
function publish(state) {
|
||||
const position = navigation(state.item || current);
|
||||
if (navigationRoot) {
|
||||
const bar = navigationRoot.querySelector('.search-preview-navigation');
|
||||
bar.hidden = !position;
|
||||
if (position) {
|
||||
navigationRoot.querySelector('#search-preview-position').textContent = position.position + ' of ' + position.total;
|
||||
navigationRoot.querySelector('#previous-search-result').disabled = !position.hasPrevious;
|
||||
navigationRoot.querySelector('#next-search-result').disabled = !position.hasNext;
|
||||
}
|
||||
}
|
||||
onState(position ? { ...state, navigation:position } : state);
|
||||
if (state.complete && navigationRoot) {
|
||||
navigationRoot.querySelector('#search-preview-status').textContent = 'Search pass complete.';
|
||||
}
|
||||
}
|
||||
|
||||
function run(action, pending, success, detail) {
|
||||
if (mutationRequest) return mutationRequest;
|
||||
onState({ status: pending, item: current, detail });
|
||||
publish({ status: pending, item: current, detail });
|
||||
mutationRequest = mutate(detail, action).then(result => {
|
||||
onState({ status: success, item: current, detail, result });
|
||||
publish({ status: success, item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
publish({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { mutationRequest = null; });
|
||||
return mutationRequest;
|
||||
}
|
||||
|
||||
return {
|
||||
function loadConversation(detail, requestGeneration, page) {
|
||||
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
|
||||
const previousComments = page && Array.isArray(conversation?.comments)
|
||||
? conversation.comments : [];
|
||||
conversation = { status:'loading', comments:previousComments, olderPage:page ?? null };
|
||||
publish({ status:'ready', item:current, detail, conversation });
|
||||
return fetchConversation(detail, page).then(result => {
|
||||
if (requestGeneration !== generation) return result;
|
||||
const incoming = Array.isArray(result?.comments) ? result.comments : [];
|
||||
const liveComments = Array.isArray(conversation?.comments) ? conversation.comments : previousComments;
|
||||
const comments = [...incoming, ...liveComments].filter((comment, index, all) =>
|
||||
all.findIndex(candidate => candidate?.id === comment?.id) === index);
|
||||
conversation = {
|
||||
status:'ready',
|
||||
comments,
|
||||
olderPage:result?.older_page ?? null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
conversation = {
|
||||
status:'error', comments:previousComments,
|
||||
olderPage:page ?? conversation?.olderPage ?? null, error,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
const api = {
|
||||
open(item) {
|
||||
generation += 1;
|
||||
const requestGeneration = generation;
|
||||
current = { ...item };
|
||||
onState({ status: 'loading', item: current });
|
||||
conversation = null;
|
||||
publish({ status: 'loading', item: current });
|
||||
return fetchJson(current).then(detail => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'ready', item: current, detail });
|
||||
current = { ...current, ...detail };
|
||||
if (typeof fetchConversation === 'function') {
|
||||
loadConversation(current, requestGeneration);
|
||||
} else {
|
||||
publish({ status: 'ready', item: current, detail });
|
||||
}
|
||||
}
|
||||
return detail;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'error', item: current, error });
|
||||
publish({ status: 'error', item: current, error });
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
@@ -44,6 +229,98 @@
|
||||
current = null;
|
||||
onState({ status: 'closed' });
|
||||
},
|
||||
previous() {
|
||||
const session = typeof getSession === 'function' ? getSession() : null;
|
||||
const items = Array.isArray(session?.items) ? session.items : [];
|
||||
const index = items.findIndex(item => sameItem(item, current));
|
||||
if (index <= 0) return Promise.resolve(current);
|
||||
const item = items[index - 1];
|
||||
if (typeof onNavigate === 'function') onNavigate(item);
|
||||
return api.open(item);
|
||||
},
|
||||
next() {
|
||||
if (moveRequest) return moveRequest;
|
||||
moveRequest = (async () => {
|
||||
let session = typeof getSession === 'function' ? getSession() : null;
|
||||
let items = Array.isArray(session?.items) ? session.items : [];
|
||||
let index = items.findIndex(item => sameItem(item, current));
|
||||
if (index < 0) return current;
|
||||
if (index === items.length - 1 && session?.more === true && typeof loadMore === 'function') {
|
||||
await loadMore();
|
||||
session = getSession();
|
||||
items = Array.isArray(session?.items) ? session.items : [];
|
||||
index = items.findIndex(item => sameItem(item, current));
|
||||
}
|
||||
if (index >= 0 && index < items.length - 1) {
|
||||
const item = items[index + 1];
|
||||
if (typeof onNavigate === 'function') onNavigate(item);
|
||||
return api.open(item);
|
||||
}
|
||||
publish({ status:'ready', item:current, detail:current, complete:true });
|
||||
return current;
|
||||
})().finally(() => { moveRequest = null; });
|
||||
return moveRequest;
|
||||
},
|
||||
retryConversation() {
|
||||
if (!current) return Promise.resolve(null);
|
||||
return loadConversation(current, generation);
|
||||
},
|
||||
loadOlderConversation() {
|
||||
if (!current || !conversation?.olderPage) return Promise.resolve(null);
|
||||
return loadConversation(current, generation, conversation.olderPage);
|
||||
},
|
||||
saveReplyDraft(body) {
|
||||
if (!current) return '';
|
||||
const key = replyKey(current, 'draft');
|
||||
const next = String(body || '');
|
||||
if (stored(key) !== next) save(replyKey(current, 'operation'), '');
|
||||
save(key, next);
|
||||
return next;
|
||||
},
|
||||
replyDraft() {
|
||||
return current ? stored(replyKey(current, 'draft')) : '';
|
||||
},
|
||||
reply({ advance = false } = {}) {
|
||||
if (replyRequest) return replyRequest;
|
||||
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
|
||||
const body = api.replyDraft().trim();
|
||||
if (!body) return Promise.reject(new Error('Write a reply first.'));
|
||||
const item = { ...current };
|
||||
const operationKey = replyKey(item, 'operation');
|
||||
let operationId = stored(operationKey);
|
||||
if (!operationId) {
|
||||
operationId = String(createOperationId?.() || Date.now()).slice(0, 128);
|
||||
save(operationKey, operationId);
|
||||
}
|
||||
publish({ status:'replying', item:current, detail:current, conversation });
|
||||
replyRequest = postReply(item, body, operationId).then(comment => {
|
||||
const comments = [...(conversation?.comments || [])];
|
||||
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
|
||||
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
|
||||
save(replyKey(item, 'draft'), '');
|
||||
save(operationKey, '');
|
||||
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
|
||||
return advance ? api.next().then(() => comment) : comment;
|
||||
}).catch(error => {
|
||||
publish({ status:'reply-error', item:current, detail:current, conversation, error });
|
||||
throw error;
|
||||
}).finally(() => { replyRequest = null; });
|
||||
return replyRequest;
|
||||
},
|
||||
share(url) {
|
||||
if (shareRequest) return shareRequest;
|
||||
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
|
||||
const detail = current;
|
||||
publish({ status:'sharing', item:current, detail });
|
||||
shareRequest = share(url).then(result => {
|
||||
publish({ status:result, item:current, detail });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
publish({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { shareRequest = null; });
|
||||
return shareRequest;
|
||||
},
|
||||
claim(detail) {
|
||||
return run('claim', 'claiming', 'claimed', detail);
|
||||
},
|
||||
@@ -51,5 +328,23 @@
|
||||
return run('reopen', 'reopening', 'reopened', detail);
|
||||
},
|
||||
};
|
||||
if (navigationRoot) {
|
||||
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
|
||||
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
|
||||
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
|
||||
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
|
||||
const reply = navigationRoot.querySelector('#search-preview-reply');
|
||||
reply?.addEventListener('input', event => {
|
||||
api.saveReplyDraft(event.target.value);
|
||||
const disabled = !event.target.value.trim();
|
||||
navigationRoot.querySelector('#send-search-preview-reply').disabled = disabled;
|
||||
navigationRoot.querySelector('#send-search-preview-reply-next').disabled = disabled;
|
||||
});
|
||||
reply?.addEventListener('focus', event => event.target.scrollIntoView({ block:'center', behavior:'smooth' }));
|
||||
navigationRoot.querySelector('#send-search-preview-reply')?.addEventListener('click', () => api.reply().catch(() => {}));
|
||||
navigationRoot.querySelector('#send-search-preview-reply-next')?.addEventListener('click', () =>
|
||||
api.reply({ advance:true }).catch(() => {}));
|
||||
}
|
||||
return api;
|
||||
};
|
||||
});
|
||||
@@ -131,6 +131,7 @@
|
||||
passkey_counter_anomaly: 'Passkey counter anomaly',
|
||||
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
comment_deleted: 'Comment deleted',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||
};
|
||||
|
||||
+159
-5
@@ -1,10 +1,12 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v98';
|
||||
const CACHE = 'stackchain-dashboard-shell-v108';
|
||||
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;
|
||||
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
|
||||
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
@@ -14,17 +16,23 @@ 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',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/reconnect-outboxes.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-issue-close.js',
|
||||
BASE + 'static/offline-issue-blocker.js',
|
||||
@@ -32,6 +40,8 @@ const SHELL = [
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/agenda-replan.js',
|
||||
BASE + 'static/protect-today.js',
|
||||
BASE + 'static/notification-undo.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/work-selection.js',
|
||||
@@ -56,34 +66,105 @@ const SHELL = [
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/batch-find-work.js',
|
||||
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/checklist-conflict.js',
|
||||
BASE + 'static/voice-transcript-store.js',
|
||||
BASE + 'static/voice-issue-capture.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
BASE + 'static/filed-claim.js',
|
||||
BASE + 'static/queue-today.js',
|
||||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/task-overlay-history.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/live-data-status.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-start-day.js',
|
||||
BASE + 'static/update-triage-session.js',
|
||||
BASE + 'static/update-review-handoff.js',
|
||||
BASE + 'static/update-read-position.js',
|
||||
BASE + 'static/work-detail-position.js',
|
||||
BASE + 'static/update-triage-launcher.js',
|
||||
BASE + 'static/update-triage-gesture.js',
|
||||
BASE + 'static/update-decision-transaction.js',
|
||||
BASE + 'static/agenda-session-launcher.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
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 = [
|
||||
];
|
||||
|
||||
const SHARED_IMAGE_ID = 'shared-image';
|
||||
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const SHARE_TARGET_PATH = BASE + 'share-target';
|
||||
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
|
||||
|
||||
function boundedShareField(form, name, limit) {
|
||||
const value = form.get(name);
|
||||
return typeof value === 'string' ? value.trim().slice(0, limit) : '';
|
||||
}
|
||||
|
||||
function sharedContentRedirect(marker) {
|
||||
const target = new URL(BASE, self.location.origin);
|
||||
target.searchParams.set('launch', 'new');
|
||||
if (marker) target.searchParams.set('shared', marker);
|
||||
return new Response(null, {status:303, headers:{Location:target.pathname + target.search}});
|
||||
}
|
||||
|
||||
async function acceptSharedContent(request) {
|
||||
if (request.headers.get('Sec-Fetch-Site') === 'cross-site') {
|
||||
return new Response('Cross-site share submissions are not accepted.', {
|
||||
status:403,
|
||||
headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'},
|
||||
});
|
||||
}
|
||||
const form = await request.formData();
|
||||
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
|
||||
const content = {
|
||||
title:boundedShareField(form, 'title', 255),
|
||||
text:boundedShareField(form, 'text', 10000),
|
||||
url:boundedShareField(form, 'url', 2048),
|
||||
};
|
||||
if (images.length > 5) return sharedContentRedirect('multiple');
|
||||
const supported = images.every(image =>
|
||||
SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES
|
||||
);
|
||||
const totalBytes = images.reduce((total, image) => total + image.size, 0);
|
||||
if (!supported || totalBytes > MAX_SHARED_IMAGE_BYTES) return sharedContentRedirect('unsupported');
|
||||
if (!images.length && !Object.values(content).some(Boolean)) return sharedContentRedirect('unsupported');
|
||||
const attachments = images.map(image => ({
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
}));
|
||||
try {
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {...content, attachments});
|
||||
} catch (_error) {
|
||||
return sharedContentRedirect('unavailable');
|
||||
}
|
||||
return sharedContentRedirect('bundle');
|
||||
}
|
||||
|
||||
async function fetchNavigation(request) {
|
||||
const controller = new AbortController();
|
||||
let timeout;
|
||||
@@ -139,9 +220,13 @@ async function deletePrivateDatabase(name) {
|
||||
});
|
||||
}
|
||||
|
||||
async function deletePrivateDatabases() {
|
||||
for (const name of PRIVATE_DATABASES) await deletePrivateDatabase(name);
|
||||
}
|
||||
|
||||
async function purgeRevokedSessionData() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
await deletePrivateDatabases();
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
@@ -178,7 +263,7 @@ async function offlineLeaseState(cache) {
|
||||
|
||||
async function expiredOfflineResponse() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
await deletePrivateDatabases();
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
@@ -262,7 +347,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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -299,7 +384,7 @@ self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
await deletePrivateDatabases();
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||
@@ -312,9 +397,33 @@ self.addEventListener('push', event => {
|
||||
try { payload = event.data?.json?.() || {}; }
|
||||
catch (_error) { return; }
|
||||
const route = String(payload.route || '');
|
||||
const protectRoute = String(payload.protect_route || '');
|
||||
const tag = String(payload.tag || '');
|
||||
const notificationId = Number(payload.notification_id);
|
||||
const updateCount = Number(payload.update_count);
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
if (
|
||||
route === '#/my-work/agenda'
|
||||
&& protectRoute === '#/my-work/agenda/protect-today'
|
||||
&& /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag)
|
||||
&& Number.isSafeInteger(deadlineCount)
|
||||
&& deadlineCount > 0
|
||||
&& deadlineCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(
|
||||
deadlineCount + ' deadline' + (deadlineCount === 1 ? '' : 's') + ' need' + (deadlineCount === 1 ? 's' : '') + ' attention',
|
||||
{
|
||||
body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'),
|
||||
tag,
|
||||
actions: [
|
||||
{ action: 'protect-today', title: 'Protect Today' },
|
||||
{ action: 'open-agenda', title: 'Open Agenda' },
|
||||
],
|
||||
data: {route, protectRoute},
|
||||
}
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/updates'
|
||||
&& tag === 'stackchain-update-digest'
|
||||
@@ -358,8 +467,45 @@ 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 (
|
||||
protectRoute !== '#/my-work/agenda/protect-today'
|
||||
|| !['#/my-work/agenda', protectRoute].includes(route)
|
||||
) return;
|
||||
event.notification.close();
|
||||
event.waitUntil(openWorkRoute(protectRoute));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'open-agenda') {
|
||||
if (route !== '#/my-work/agenda') return;
|
||||
event.notification.close();
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'tomorrow') {
|
||||
const notificationId = Number(event.notification.data?.notificationId);
|
||||
if (
|
||||
@@ -420,6 +566,14 @@ self.addEventListener('notificationclick', event => {
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request;
|
||||
const requestUrl = new URL(request.url);
|
||||
if (
|
||||
request.method === 'POST' && request.mode === 'navigate'
|
||||
&& requestUrl.origin === self.location.origin && requestUrl.pathname === SHARE_TARGET_PATH
|
||||
) {
|
||||
event.respondWith(acceptSharedContent(request));
|
||||
return;
|
||||
}
|
||||
if (request.method !== 'GET' || request.url.includes('/api/')) return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
||||
|
||||
+17
-5
@@ -1,5 +1,10 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = options => factory({
|
||||
...options,
|
||||
privateDatabases: require('./private-data-registry.js'),
|
||||
});
|
||||
}
|
||||
else {
|
||||
const base = new URL('./', root.location.href).pathname;
|
||||
const originalFetch = root.fetch.bind(root);
|
||||
@@ -15,6 +20,7 @@
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
credentials: root.navigator?.credentials,
|
||||
MessageChannel: root.MessageChannel,
|
||||
privateDatabases: root.stackchainPrivateDatabases,
|
||||
location: root.location,
|
||||
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
|
||||
confirmAction: message => root.confirm(message),
|
||||
@@ -30,6 +36,7 @@
|
||||
root.fetch = boundary.fetch;
|
||||
const attach = () => {
|
||||
boundary.startActivityHeartbeat();
|
||||
boundary.startReconnectResume();
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
@@ -82,7 +89,7 @@
|
||||
root.stackchainSession = boundary;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, location, confirmAction, addActivityListener,
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, privateDatabases, location, confirmAction, addActivityListener,
|
||||
promptAuthorization = () => null,
|
||||
onExpired = () => {},
|
||||
onClearError = () => {},
|
||||
@@ -381,6 +388,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
function startReconnectResume() {
|
||||
addActivityListener?.('online', async () => {
|
||||
if (await refreshOfflineLease()) await resumeQueuedWork();
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshOfflineLease() {
|
||||
try {
|
||||
const response = await sessionFetch(base + 'api/v1/session');
|
||||
@@ -454,9 +467,7 @@
|
||||
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
||||
try {
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
for (const name of privateDatabases) await deletePrivateDatabase(name);
|
||||
} catch (_error) {
|
||||
const error = new Error('Could not clear private queued work from this device.');
|
||||
onClearError(error);
|
||||
@@ -613,6 +624,7 @@
|
||||
handleServiceWorkerMessage,
|
||||
refreshOfflineLease,
|
||||
resumeQueuedWork,
|
||||
startReconnectResume,
|
||||
recordActivity,
|
||||
startActivityHeartbeat,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.sharedImageCapture = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const RECORD_ID = 'shared-image';
|
||||
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
async function consume({marker, store, restore, restoreContent = () => {}, status = () => {}}) {
|
||||
if (!marker) return false;
|
||||
const value = await store?.get(RECORD_ID);
|
||||
const attachments = Array.isArray(value?.attachments) ? value.attachments : null;
|
||||
if (marker === 'bundle') {
|
||||
const content = {
|
||||
title:typeof value?.title === 'string' ? value.title.slice(0, 255) : '',
|
||||
text:typeof value?.text === 'string' ? value.text.slice(0, 10000) : '',
|
||||
url:typeof value?.url === 'string' ? value.url.slice(0, 2048) : '',
|
||||
};
|
||||
const invalidAttachments = !attachments || attachments.length > 5 || attachments.some(attachment =>
|
||||
!attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')));
|
||||
if (invalidAttachments || (!attachments.length && !Object.values(content).some(Boolean))) {
|
||||
status('The shared content is unavailable. Share it again.');
|
||||
return false;
|
||||
}
|
||||
restoreContent(content);
|
||||
if (attachments.length) restore(attachments);
|
||||
await store.delete(RECORD_ID);
|
||||
const noun = attachments.length === 1 ? 'screenshot' : attachments.length > 1 ? 'screenshots' : 'content';
|
||||
status(attachments.length
|
||||
? 'Shared content and ' + (attachments.length > 1 ? attachments.length + ' ' : '') + noun + ' ready to file with this issue.'
|
||||
: 'Shared content ready to file with this issue.');
|
||||
return true;
|
||||
}
|
||||
if (!['image', 'images'].includes(marker)) {
|
||||
status('Share one PNG, JPEG, or WebP screenshot.');
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
restore(value);
|
||||
await store.delete(RECORD_ID);
|
||||
status('Shared screenshot ready to file with this issue.');
|
||||
return true;
|
||||
}
|
||||
|
||||
return {consume, RECORD_ID};
|
||||
});
|
||||
@@ -6,21 +6,132 @@
|
||||
'use strict';
|
||||
|
||||
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
|
||||
const searchKinds = new Set(['search', 'search-preview']);
|
||||
const maxQueryLength = 200;
|
||||
|
||||
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
||||
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
||||
function cleanQuery(value) {
|
||||
const query = typeof value === 'string' ? value.trim() : '';
|
||||
return query.length <= maxQueryLength ? query : '';
|
||||
}
|
||||
|
||||
function cleanScope(value) {
|
||||
const repository = typeof value?.repository === 'string' &&
|
||||
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
|
||||
? value.repository.trim() : '';
|
||||
return {
|
||||
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
|
||||
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
|
||||
repository,
|
||||
};
|
||||
}
|
||||
|
||||
function cleanPreview(value) {
|
||||
if (!value || !['issue', 'pull'].includes(value.kind)) return null;
|
||||
const repository = typeof value.repository === 'string' ? value.repository : '';
|
||||
const parts = repository.split('/');
|
||||
const validPart = part => /^[A-Za-z0-9_.-]+$/.test(part);
|
||||
const number = Number(value.number);
|
||||
if (parts.length !== 2 || !parts.every(validPart) || !Number.isInteger(number) || number < 1) return null;
|
||||
return { kind:value.kind, repository, number };
|
||||
}
|
||||
|
||||
function previewToken(preview) {
|
||||
return preview ? `${preview.kind}:${preview.repository}:${preview.number}` : '';
|
||||
}
|
||||
|
||||
function parsePreview(value) {
|
||||
const match = /^(issue|pull):([^/:]+\/[^/:]+):(\d+)$/.exec(value || '');
|
||||
return match ? cleanPreview({ kind:match[1], repository:match[2], number:Number(match[3]) }) : null;
|
||||
}
|
||||
|
||||
return function createTaskOverlayHistory({ history, location, eventTarget, onChange }) {
|
||||
let started = false;
|
||||
let restoredFromUrl = false;
|
||||
|
||||
function stateKind(state = history.state) {
|
||||
return allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||
function stateDetail(state = history.state) {
|
||||
const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||
if (!searchKinds.has(kind)) return { kind };
|
||||
const query = cleanQuery(state.searchQuery);
|
||||
const scope = state.searchScope === undefined ? null : cleanScope(state.searchScope);
|
||||
const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null;
|
||||
if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) {
|
||||
return { kind:'search', ...(query ? { query } : {}) };
|
||||
}
|
||||
return { kind, ...(query ? { query } : {}), ...(scope ? { scope } : {}), ...(preview ? { preview } : {}) };
|
||||
}
|
||||
|
||||
function urlDetail() {
|
||||
if (!location || typeof URLSearchParams === 'undefined') return { kind:null };
|
||||
const params = new URLSearchParams(location.search || '');
|
||||
const rawQuery = params.get('search');
|
||||
if (rawQuery === null) return { kind:null };
|
||||
const query = cleanQuery(rawQuery);
|
||||
if (rawQuery.trim() && !query) return { kind:null };
|
||||
const preview = parsePreview(params.get('preview'));
|
||||
const hasScope = params.has('search_kind') || params.has('search_state') || params.has('search_repository');
|
||||
const scope = hasScope ? cleanScope({
|
||||
kind:params.get('search_kind'), state:params.get('search_state'),
|
||||
repository:params.get('search_repository'),
|
||||
}) : null;
|
||||
return {
|
||||
kind:preview ? 'search-preview' : 'search',
|
||||
...(query ? { query } : {}),
|
||||
...(scope ? { scope } : {}),
|
||||
...(preview ? { preview } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toState(detail, base = history.state) {
|
||||
const state = { ...(base || {}), taskOverlay:detail.kind };
|
||||
delete state.searchQuery;
|
||||
delete state.searchPreview;
|
||||
delete state.searchScope;
|
||||
if (searchKinds.has(detail.kind)) {
|
||||
if (detail.query) state.searchQuery = detail.query;
|
||||
if (detail.scope) state.searchScope = cleanScope(detail.scope);
|
||||
if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function urlFor(detail) {
|
||||
if (!location || typeof URLSearchParams === 'undefined') return undefined;
|
||||
const params = new URLSearchParams(location.search || '');
|
||||
params.delete('search');
|
||||
params.delete('preview');
|
||||
params.delete('search_kind');
|
||||
params.delete('search_state');
|
||||
params.delete('search_repository');
|
||||
if (searchKinds.has(detail.kind)) {
|
||||
params.set('search', detail.query || '');
|
||||
if (detail.scope) {
|
||||
params.set('search_kind', detail.scope.kind);
|
||||
params.set('search_state', detail.scope.state);
|
||||
if (detail.scope.repository) params.set('search_repository', detail.scope.repository);
|
||||
}
|
||||
if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
|
||||
}
|
||||
const query = params.toString();
|
||||
return `${location.pathname || ''}${query ? `?${query}` : ''}${location.hash || ''}`;
|
||||
}
|
||||
|
||||
let initial = stateDetail();
|
||||
if (!initial.kind) {
|
||||
const fromUrl = urlDetail();
|
||||
if (fromUrl.kind) {
|
||||
initial = fromUrl;
|
||||
restoredFromUrl = true;
|
||||
history.replaceState(toState(fromUrl), '', urlFor(fromUrl));
|
||||
}
|
||||
}
|
||||
let active = initial;
|
||||
|
||||
function apply(state) {
|
||||
const next = stateKind(state);
|
||||
if (next === active) return;
|
||||
const previous = active;
|
||||
const next = stateDetail(state);
|
||||
if (JSON.stringify(next) === JSON.stringify(active)) return;
|
||||
const previous = active.kind;
|
||||
active = next;
|
||||
onChange(next, previous);
|
||||
onChange(next.kind, previous, next);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -28,33 +139,54 @@
|
||||
if (started) return;
|
||||
started = true;
|
||||
eventTarget.addEventListener('popstate', event => apply(event.state));
|
||||
if (restoredFromUrl) onChange(active.kind, null, active);
|
||||
},
|
||||
open(kind) {
|
||||
open(kind, detail = {}) {
|
||||
if (!allowed.has(kind)) return false;
|
||||
if (active === kind) return true;
|
||||
const previous = active;
|
||||
const state = { ...(history.state || {}), taskOverlay: kind };
|
||||
history.pushState(state, '');
|
||||
active = kind;
|
||||
onChange(kind, previous);
|
||||
const next = stateDetail(toState({
|
||||
kind, query:cleanQuery(detail.query),
|
||||
scope:detail.scope === undefined ? undefined : cleanScope(detail.scope),
|
||||
preview:cleanPreview(detail.preview),
|
||||
}));
|
||||
if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true;
|
||||
const previous = active.kind;
|
||||
history.pushState(toState(next), '', urlFor(next));
|
||||
active = next;
|
||||
onChange(next.kind, previous, next);
|
||||
return true;
|
||||
},
|
||||
update(detail = {}) {
|
||||
if (!searchKinds.has(active.kind)) return false;
|
||||
const next = stateDetail(toState({
|
||||
kind:active.kind,
|
||||
query:detail.query === undefined ? active.query : cleanQuery(detail.query),
|
||||
scope:detail.scope === undefined ? active.scope : cleanScope(detail.scope),
|
||||
preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview),
|
||||
}));
|
||||
history.replaceState(toState(next), '', urlFor(next));
|
||||
active = next;
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (!active) return false;
|
||||
if (!active.kind) return false;
|
||||
history.back();
|
||||
return true;
|
||||
},
|
||||
leave() {
|
||||
if (!active) return false;
|
||||
const previous = active;
|
||||
if (!active.kind) return false;
|
||||
const previous = active.kind;
|
||||
const state = { ...(history.state || {}) };
|
||||
delete state.taskOverlay;
|
||||
history.replaceState(state, '');
|
||||
active = null;
|
||||
onChange(null, previous);
|
||||
delete state.searchQuery;
|
||||
delete state.searchPreview;
|
||||
delete state.searchScope;
|
||||
active = { kind:null };
|
||||
history.replaceState(state, '', urlFor(active));
|
||||
onChange(null, previous, active);
|
||||
return true;
|
||||
},
|
||||
current() { return active; },
|
||||
current() { return active.kind; },
|
||||
currentState() { return { ...active, ...(active.preview ? { preview:{ ...active.preview } } : {}) }; },
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
(function(r){
|
||||
function transaction({controls=[],status}) {
|
||||
let busy=false, disabled=[];
|
||||
async function run(message, action, {origin,failure}={}) {
|
||||
if (busy) return false;
|
||||
busy=true;
|
||||
disabled=controls.map(control=>control.disabled);
|
||||
controls.forEach(control=>{ control.disabled=true; });
|
||||
status.textContent=message;
|
||||
let completed=false;
|
||||
try { completed=Boolean(await action()); }
|
||||
catch (error) { failure=failure || error.message || 'Could not complete decision. Retry.'; }
|
||||
if (!completed && failure) status.textContent=failure;
|
||||
busy=false;
|
||||
controls.forEach((control,index)=>{ control.disabled=disabled[index]; });
|
||||
if (!completed) origin?.focus?.();
|
||||
return completed;
|
||||
}
|
||||
return {run,busy:()=>busy};
|
||||
}
|
||||
function actions(o) {
|
||||
return {
|
||||
keepUnread:()=>o.transaction.run('Keeping unread…',()=>{
|
||||
if(o.triage.active()) o.triage.keepUnreadAndNext(); else o.close();
|
||||
return true;
|
||||
},{origin:o.keepControl,failure:'Could not keep this update unread. Retry.'}),
|
||||
markRead:()=>o.transaction.run('Marking read…',async()=>{
|
||||
const result=await o.reader.markReadAndNext(o.items());
|
||||
if(!result) return false;
|
||||
o.undo.offer(result.item,result.items);
|
||||
if(o.triage.active()) o.triage.acceptCompleted();
|
||||
return true;
|
||||
},{origin:o.readControl,failure:'Could not mark update read. Retry.'}),
|
||||
};
|
||||
}
|
||||
if(typeof module==='object'&&module.exports){module.exports=transaction;module.exports.createActions=actions;}
|
||||
else {r.createUpdateDecisionTransaction=transaction;r.createUpdateDecisionActions=actions;}
|
||||
})(typeof self!=='undefined'?self:this);
|
||||
@@ -0,0 +1,49 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateReadPosition = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateReadPosition(options) {
|
||||
const panel = options.panel;
|
||||
const jump = options.jump;
|
||||
let identity = '';
|
||||
let openingTop = 0;
|
||||
let movedByUser = false;
|
||||
let positioned = false;
|
||||
let target = null;
|
||||
|
||||
function moveToTarget() {
|
||||
if (!target) return false;
|
||||
target.classList?.add('update-new-activity');
|
||||
target.scrollIntoView({ block:'end' });
|
||||
positioned = true;
|
||||
jump.hidden = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
panel.addEventListener('scroll', () => {
|
||||
if (identity && !positioned && panel.scrollTop !== openingTop) movedByUser = true;
|
||||
}, { passive:true });
|
||||
|
||||
return {
|
||||
open(nextIdentity) {
|
||||
identity = String(nextIdentity || '');
|
||||
openingTop = panel.scrollTop;
|
||||
movedByUser = false;
|
||||
positioned = false;
|
||||
target = null;
|
||||
jump.hidden = true;
|
||||
},
|
||||
ready(readyIdentity, newest) {
|
||||
if (!identity || String(readyIdentity || '') !== identity || positioned || !newest) return false;
|
||||
target = newest;
|
||||
target.classList?.add('update-new-activity');
|
||||
if (movedByUser) {
|
||||
jump.hidden = false;
|
||||
return false;
|
||||
}
|
||||
return moveToTarget();
|
||||
},
|
||||
jump() {
|
||||
return moveToTarget();
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateReviewHandoff = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateReviewHandoff(options) {
|
||||
let source = null;
|
||||
let completing = null;
|
||||
|
||||
function eligible(detail, item = detail) {
|
||||
const merged = { ...(item || {}), ...(detail || {}) };
|
||||
return Boolean(
|
||||
merged.is_review && merged.update_reason === 'Review requested' &&
|
||||
merged.subject_type === 'Pull' && /^[^/]+\/[^/]+$/.test(String(merged.repository || '')) &&
|
||||
Number.isInteger(Number(merged.number)) && Number(merged.number) > 0 &&
|
||||
Number.isInteger(Number(merged.notification_id))
|
||||
);
|
||||
}
|
||||
|
||||
function reviewItem(item) {
|
||||
return {
|
||||
...item,
|
||||
kind: 'review',
|
||||
is_review: true,
|
||||
key: item.key || item.repository + '#' + item.number,
|
||||
};
|
||||
}
|
||||
|
||||
function begin(item) {
|
||||
if (!eligible(item) || source) return false;
|
||||
source = item;
|
||||
options.openReview(reviewItem(item));
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (!source || completing) return false;
|
||||
const item = source;
|
||||
source = null;
|
||||
options.restoreUpdate?.(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
function complete() {
|
||||
if (!source) return Promise.resolve(false);
|
||||
if (completing) return completing;
|
||||
const item = source;
|
||||
completing = Promise.resolve(options.admitRead(item.notification_id)).then(() => {
|
||||
options.advance(item);
|
||||
source = null;
|
||||
return true;
|
||||
}).finally(() => { completing = null; });
|
||||
return completing;
|
||||
}
|
||||
|
||||
return {
|
||||
eligible,
|
||||
begin,
|
||||
cancel,
|
||||
complete,
|
||||
active: () => Boolean(source),
|
||||
source: () => source,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageGesture = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageGesture(options) {
|
||||
const viewportWidth = options.viewportWidth || (() => root.innerWidth);
|
||||
const surface = options.surface;
|
||||
const threshold = options.threshold || 72;
|
||||
const edgeInset = options.edgeInset || 24;
|
||||
const interactive = 'a,button,input,textarea,select,label,summary,[contenteditable="true"],[role="button"]';
|
||||
let active = null;
|
||||
let busy = false;
|
||||
|
||||
function horizontalScroller(target) {
|
||||
for (let node = target; node && node !== surface; node = node.parentElement) {
|
||||
if (Number(node.scrollWidth) > Number(node.clientWidth) + 1) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function ignored(event) {
|
||||
return event.pointerType === 'mouse' || event.clientX <= edgeInset ||
|
||||
event.clientX >= viewportWidth() - edgeInset ||
|
||||
Boolean(event.target?.closest?.(interactive)) || horizontalScroller(event.target);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
active = null;
|
||||
delete surface.dataset.triageGesture;
|
||||
}
|
||||
|
||||
function start(event) {
|
||||
if (busy || !options.enabled() || viewportWidth() > 700 || ignored(event) || active) return;
|
||||
active = {id:event.pointerId, x:event.clientX, y:event.clientY, cancelled:false};
|
||||
surface.setPointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
function move(event) {
|
||||
if (!active || active.id !== event.pointerId) return;
|
||||
const dx = event.clientX - active.x;
|
||||
const dy = event.clientY - active.y;
|
||||
if (Math.abs(dy) > Math.abs(dx) || Math.abs(dy) > 36) {
|
||||
active.cancelled = true;
|
||||
delete surface.dataset.triageGesture;
|
||||
return;
|
||||
}
|
||||
if (Math.abs(dx) < 12) return;
|
||||
event.preventDefault?.();
|
||||
surface.dataset.triageGesture = dx > 0 ? 'keep' : 'read';
|
||||
}
|
||||
|
||||
function decide(callback) {
|
||||
busy = true;
|
||||
let outcome;
|
||||
try {
|
||||
outcome = callback();
|
||||
} catch (error) {
|
||||
busy = false;
|
||||
throw error;
|
||||
}
|
||||
if (outcome && typeof outcome.then === 'function') {
|
||||
Promise.resolve(outcome).finally(() => { busy = false; });
|
||||
} else {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function finish(event) {
|
||||
if (!active || active.id !== event.pointerId) return;
|
||||
const gesture = active;
|
||||
const dx = event.clientX - gesture.x;
|
||||
const dy = event.clientY - gesture.y;
|
||||
surface.releasePointerCapture?.(event.pointerId);
|
||||
reset();
|
||||
if (gesture.cancelled || Math.abs(dx) < threshold || Math.abs(dx) <= Math.abs(dy)) return;
|
||||
decide(dx > 0 ? options.keepUnread : options.markRead);
|
||||
}
|
||||
|
||||
surface.addEventListener('pointerdown', start);
|
||||
surface.addEventListener('pointermove', move);
|
||||
surface.addEventListener('pointerup', finish);
|
||||
surface.addEventListener('pointercancel', reset);
|
||||
|
||||
function destroy() {
|
||||
surface.removeEventListener('pointerdown', start);
|
||||
surface.removeEventListener('pointermove', move);
|
||||
surface.removeEventListener('pointerup', finish);
|
||||
surface.removeEventListener('pointercancel', reset);
|
||||
reset();
|
||||
}
|
||||
|
||||
return {destroy};
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageLauncher(options) {
|
||||
let pending = null;
|
||||
|
||||
function open() {
|
||||
if (pending) return pending;
|
||||
options.selectUpdates();
|
||||
options.announce('Checking all unread updates…');
|
||||
pending = Promise.resolve()
|
||||
.then(() => options.discover())
|
||||
.then(discovery => {
|
||||
if (!options.isUpdatesSelected()) return 'cancelled';
|
||||
if (discovery === false || discovery?.complete === false) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
return 'incomplete';
|
||||
}
|
||||
if (discovery?.complete) options.applySnapshot?.(discovery);
|
||||
if (options.hasMore()) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
return 'incomplete';
|
||||
}
|
||||
const opened = options.hasCheckpoint() ? options.resume() : options.start();
|
||||
if (!opened) options.announce('No unread updates are ready.');
|
||||
return opened ? 'opened' : 'empty';
|
||||
})
|
||||
.catch(() => {
|
||||
if (options.isUpdatesSelected()) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
}
|
||||
return 'incomplete';
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {open};
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageSession = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageSession(options) {
|
||||
const key = options.key || 'stackchain.update-triage.v1';
|
||||
const identity = item => Number.isInteger(item?.notification_id) ? String(item.notification_id) : '';
|
||||
const login = () => String(options.getLogin() || '').trim();
|
||||
let state = null;
|
||||
let running = false;
|
||||
let lastOutcome = null;
|
||||
|
||||
function read() {
|
||||
const owner = login();
|
||||
if (!owner) return null;
|
||||
try {
|
||||
const value = JSON.parse(options.storage.getItem(key) || 'null');
|
||||
if (![1, 2].includes(value?.version) || value.login !== owner || !Array.isArray(value.identities) ||
|
||||
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
|
||||
const identities = value.identities.filter(value => typeof value === 'string' && value);
|
||||
const completed = value.completed.filter(value => identities.includes(value));
|
||||
const kept = Array.isArray(value.kept) ? value.kept.filter(value => identities.includes(value)) : [];
|
||||
if (!identities.length || !identities.includes(value.current)) return null;
|
||||
return { version:2, login:owner, identities, current:value.current, completed, kept };
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
if (!state) return;
|
||||
options.storage.setItem(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function available() {
|
||||
const byIdentity = new Map((options.getItems() || []).map(item => [identity(item), item]));
|
||||
return state.identities.filter(id => !state.completed.includes(id) && byIdentity.has(id))
|
||||
.map(id => byIdentity.get(id));
|
||||
}
|
||||
|
||||
function nextAvailable() {
|
||||
if (!running || !state) return null;
|
||||
const candidates = available().filter(item => identity(item) !== state.current);
|
||||
return candidates.find(item => state.identities.indexOf(identity(item)) > state.identities.indexOf(state.current)) || candidates[0] || null;
|
||||
}
|
||||
|
||||
function finish() {
|
||||
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
|
||||
const keptIdentities = state ? state.kept.filter(id => unread.has(id)) : [];
|
||||
const reviewed = state ? state.completed.length : 0;
|
||||
const outcome = { reviewed, resolved:reviewed - keptIdentities.length, kept:keptIdentities.length, keptIdentities };
|
||||
lastOutcome = outcome;
|
||||
running = false;
|
||||
state = null;
|
||||
options.storage.removeItem(key);
|
||||
options.onFinish(outcome);
|
||||
return false;
|
||||
}
|
||||
|
||||
function openCurrent(preferredIndex = null) {
|
||||
const items = available();
|
||||
if (!items.length) return finish();
|
||||
let index = items.findIndex(item => identity(item) === state.current);
|
||||
if (index < 0) index = Math.min(preferredIndex ?? 0, items.length - 1);
|
||||
state.current = identity(items[index]);
|
||||
persist();
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
const progress = {
|
||||
index:originalIndex + 1,
|
||||
total:state.identities.length,
|
||||
};
|
||||
if (items[index]?.update_reason) progress.reason = String(items[index].update_reason);
|
||||
options.onProgress(progress);
|
||||
options.onOpen(items[index]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function advance(openNext = true) {
|
||||
if (!running || !state) return false;
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
if (!state.completed.includes(state.current)) state.completed.push(state.current);
|
||||
const candidates = available();
|
||||
const next = candidates.find(item => state.identities.indexOf(identity(item)) > originalIndex) || candidates[0];
|
||||
if (!next) return finish();
|
||||
state.current = identity(next);
|
||||
if (openNext) return openCurrent();
|
||||
persist();
|
||||
const progress = {
|
||||
index:state.identities.indexOf(state.current) + 1,
|
||||
total:state.identities.length,
|
||||
};
|
||||
if (next?.update_reason) progress.reason = String(next.update_reason);
|
||||
options.onProgress(progress);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
resumable: () => Boolean(read()),
|
||||
start() {
|
||||
const identities = (options.getItems() || []).map(identity).filter(Boolean);
|
||||
if (!identities.length) return finish();
|
||||
state = { version:2, login:login(), identities, current:identities[0], completed:[], kept:[] };
|
||||
if (!state.login) return false;
|
||||
running = true;
|
||||
return openCurrent();
|
||||
},
|
||||
resume() {
|
||||
state = read();
|
||||
if (!state) return false;
|
||||
running = true;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
reconcile() {
|
||||
if (!running || !state) return false;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
completeAndNext: advance,
|
||||
acceptCompleted: () => advance(false),
|
||||
keepUnreadAndNext() {
|
||||
if (!running || !state) return false;
|
||||
if (!state.kept.includes(state.current)) state.kept.push(state.current);
|
||||
return advance();
|
||||
},
|
||||
reviewKept(identities = lastOutcome?.keptIdentities || []) {
|
||||
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
|
||||
const kept = (identities || []).filter(id => unread.has(id));
|
||||
if (!kept.length) return false;
|
||||
state = { version:2, login:login(), identities:kept, current:kept[0], completed:[], kept:[] };
|
||||
if (!state.login) return false;
|
||||
running = true;
|
||||
return openCurrent();
|
||||
},
|
||||
next: nextAvailable,
|
||||
items: () => state ? available().slice() : [],
|
||||
end: finish,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
function mapVoiceTranscript(value) {
|
||||
const transcript = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!transcript) return {title:'', body:''};
|
||||
const sentence = transcript.match(/^(.+?[.!?])(?:\s+|$)(.*)$/);
|
||||
const title = (sentence ? sentence[1] : transcript).trim().slice(0, 255);
|
||||
const body = (sentence ? sentence[2] : '').trim().slice(0, 10000);
|
||||
return {title, body};
|
||||
}
|
||||
|
||||
function createVoiceIssueCapture({
|
||||
Recognition, elements, createEvent = name => new Event(name, {bubbles:true}),
|
||||
transcriptStore = null, getLogin = () => '',
|
||||
}) {
|
||||
const supported = typeof Recognition === 'function';
|
||||
const login = String(getLogin() || '').trim();
|
||||
const ready = supported && login && transcriptStore ? Promise.resolve(transcriptStore.load(login)).then(value => {
|
||||
const recovered = String(value || '').replace(/\s+/g, ' ').trim().slice(0, 10255);
|
||||
if (recovered) showReview(recovered, {persist:false, recovered:true});
|
||||
}) : Promise.resolve();
|
||||
elements.root.hidden = !supported;
|
||||
if (!supported) {
|
||||
elements.status.textContent = 'Voice capture is unavailable; type the title and note instead.';
|
||||
return {supported:false, ready, cancel() {}};
|
||||
}
|
||||
let recognition = null;
|
||||
|
||||
function showReview(value, {persist = true, recovered = false} = {}) {
|
||||
elements.transcript.value = value;
|
||||
const activeLogin = String(getLogin() || '').trim();
|
||||
if (persist && activeLogin && transcriptStore) void transcriptStore.save(activeLogin, value);
|
||||
const hasDraft = Boolean(elements.title.value.trim() || elements.body.value.trim());
|
||||
elements.append.textContent = hasDraft ? 'Append to draft' : 'Use transcript';
|
||||
elements.replace.hidden = !hasDraft;
|
||||
elements.review.hidden = false;
|
||||
elements.status.textContent = recovered ?
|
||||
'Recovered transcript. Review it before using or discarding it.' :
|
||||
'Transcript ready. Review it before using it.';
|
||||
}
|
||||
|
||||
elements.start.addEventListener('click', () => {
|
||||
recognition = new Recognition();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = false;
|
||||
recognition.onresult = event => {
|
||||
const finalText = Array.from(event.results || [])
|
||||
.filter(result => result.isFinal)
|
||||
.map(result => result[0]?.transcript || '').join(' ').replace(/\s+/g, ' ').trim();
|
||||
if (finalText) {
|
||||
const accumulated = [elements.transcript.value.trim(), finalText]
|
||||
.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 10255);
|
||||
showReview(accumulated);
|
||||
}
|
||||
};
|
||||
recognition.onerror = event => {
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
elements.status.textContent = event.error === 'not-allowed' || event.error === 'service-not-allowed' ?
|
||||
'Microphone permission was denied. Your draft and transcript are unchanged.' :
|
||||
'Voice capture stopped. Your draft and transcript are unchanged.';
|
||||
};
|
||||
recognition.onend = () => {
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
};
|
||||
try {
|
||||
recognition.start();
|
||||
} catch (_) {
|
||||
recognition = null;
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
elements.status.textContent = 'Voice capture could not start. Your draft and transcript are unchanged.';
|
||||
return;
|
||||
}
|
||||
elements.start.hidden = true;
|
||||
elements.stop.hidden = false;
|
||||
elements.status.textContent = 'Listening… Tap stop when you are finished.';
|
||||
});
|
||||
elements.stop.addEventListener('click', () => {
|
||||
recognition?.stop();
|
||||
elements.status.textContent = 'Finishing transcript…';
|
||||
});
|
||||
|
||||
function clearCheckpoint() {
|
||||
const activeLogin = String(getLogin() || '').trim();
|
||||
if (activeLogin && transcriptStore) void transcriptStore.clear(activeLogin);
|
||||
}
|
||||
|
||||
function commit(mode) {
|
||||
const mapped = mapVoiceTranscript(elements.transcript.value);
|
||||
if (mode === 'replace') {
|
||||
elements.title.value = mapped.title;
|
||||
elements.body.value = mapped.body;
|
||||
} else if (elements.title.value.trim()) {
|
||||
elements.body.value = [elements.body.value.trim(), mapped.title, mapped.body]
|
||||
.filter(Boolean).join('\n\n').slice(0, 10000);
|
||||
} else {
|
||||
elements.title.value = mapped.title;
|
||||
elements.body.value = [elements.body.value.trim(), mapped.body]
|
||||
.filter(Boolean).join('\n\n').slice(0, 10000);
|
||||
}
|
||||
elements.title.dispatchEvent(createEvent('input'));
|
||||
elements.body.dispatchEvent(createEvent('input'));
|
||||
clearCheckpoint();
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = 'Transcript added. Review the title and note before saving.';
|
||||
}
|
||||
elements.append.addEventListener('click', () => commit('append'));
|
||||
elements.replace.addEventListener('click', () => commit('replace'));
|
||||
elements.discard?.addEventListener('click', () => {
|
||||
clearCheckpoint();
|
||||
elements.transcript.value = '';
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = 'Recovered transcript discarded.';
|
||||
});
|
||||
|
||||
return {supported:true, ready, cancel() { recognition?.abort(); recognition = null; }};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = {mapVoiceTranscript, createVoiceIssueCapture};
|
||||
@@ -0,0 +1,87 @@
|
||||
(function (root, factory) {
|
||||
const createVoiceTranscriptStore = factory(root.indexedDB);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createVoiceTranscriptStore;
|
||||
else root.createVoiceTranscriptStore = createVoiceTranscriptStore;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function buildFactory(defaultIndexedDB) {
|
||||
const dbName = 'stackchain-voice-transcripts-v1';
|
||||
const STORE_NAME = 'checkpoints';
|
||||
const MAX_TRANSCRIPT_LENGTH = 10255;
|
||||
|
||||
function requestResult(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Private transcript storage failed.'));
|
||||
});
|
||||
}
|
||||
|
||||
function createIndexedDbTransaction(indexedDB) {
|
||||
if (!indexedDB) return null;
|
||||
let databasePromise = null;
|
||||
function openDatabase() {
|
||||
if (databasePromise) return databasePromise;
|
||||
databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
||||
request.result.createObjectStore(STORE_NAME, {keyPath:'login'});
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Private transcript storage is unavailable.'));
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
return async (work, mode = 'readonly') => {
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction(STORE_NAME, mode);
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const completed = new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Private transcript storage failed.'));
|
||||
transaction.onabort = () => reject(transaction.error || new Error('Private transcript storage was interrupted.'));
|
||||
});
|
||||
const result = await work({
|
||||
get:key => requestResult(store.get(key)),
|
||||
put:value => requestResult(store.put(value)),
|
||||
delete:key => requestResult(store.delete(key)),
|
||||
});
|
||||
await completed;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLogin(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeTranscript(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TRANSCRIPT_LENGTH);
|
||||
}
|
||||
|
||||
function createVoiceTranscriptStore({transaction = createIndexedDbTransaction(defaultIndexedDB)} = {}) {
|
||||
async function load(login) {
|
||||
const key = normalizeLogin(login);
|
||||
if (!key || !transaction) return '';
|
||||
const record = await transaction(store => store.get(key));
|
||||
return normalizeTranscript(record?.transcript);
|
||||
}
|
||||
|
||||
async function save(login, transcript) {
|
||||
const key = normalizeLogin(login);
|
||||
const value = normalizeTranscript(transcript);
|
||||
if (!key || !value || !transaction) return;
|
||||
await transaction(store => store.put({login:key, transcript:value}), 'readwrite');
|
||||
}
|
||||
|
||||
async function clear(login) {
|
||||
const key = normalizeLogin(login);
|
||||
if (!key || !transaction) return;
|
||||
await transaction(store => store.delete(key), 'readwrite');
|
||||
}
|
||||
|
||||
return {load, save, clear};
|
||||
}
|
||||
|
||||
createVoiceTranscriptStore.databaseName = dbName;
|
||||
return createVoiceTranscriptStore;
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
(function(r,f){if(typeof module==='object'&&module.exports)module.exports=f;else r.createWorkDetailPosition=f})(typeof self!=='undefined'?self:this,function(o){let i='';return{open(n){n=String(n||'');if(n&&n===i)return false;i=n;o.panel.scrollTop=0;return true}}});
|
||||
+11
-6
@@ -6,7 +6,7 @@
|
||||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'attention', 'update', 'later', 'draft'];
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
@@ -20,11 +20,14 @@
|
||||
if (queueFilters.includes(queue) && parts.length === 3) {
|
||||
return { kind: 'queue', filter: queue };
|
||||
}
|
||||
if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) {
|
||||
return { kind: 'queue', filter: 'agenda', action: 'protect-today' };
|
||||
}
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
}
|
||||
if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
|
||||
if (!['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;
|
||||
@@ -35,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) {
|
||||
@@ -47,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;
|
||||
}
|
||||
@@ -114,7 +119,7 @@
|
||||
if (active && active !== fragment) onClose();
|
||||
if (active === fragment) return;
|
||||
active = fragment;
|
||||
onQueue(route.filter);
|
||||
onQueue(route.filter, route.action || null);
|
||||
return;
|
||||
}
|
||||
const item = items.find(candidate => sameRoute(candidate, route));
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
playwright==1.55.0
|
||||
@@ -3,14 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
class RefreshLeaseLost(RuntimeError):
|
||||
pass
|
||||
@@ -29,12 +29,8 @@ class AvailableIssueSnapshotStore:
|
||||
def __init__(self, path, *, clock=None):
|
||||
self.path = Path(path)
|
||||
self.clock = clock or time.time
|
||||
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(self.path.parent, stat.S_IRWXU)
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS available_issue_snapshot (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
@@ -54,15 +50,12 @@ class AvailableIssueSnapshotStore:
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO available_issue_snapshot VALUES (1, NULL, NULL, NULL)"
|
||||
)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO available_issue_snapshot VALUES (1, NULL, NULL, NULL)"
|
||||
)
|
||||
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
|
||||
connection = connect_private_sqlite(self.path, timeout=1.0, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA busy_timeout = 1000")
|
||||
return connection
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Durable, account-scoped completed Filed review receipts."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
|
||||
|
||||
class CompletedFiledReviewStore:
|
||||
def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS completed_filed_reviews (
|
||||
login TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
touched_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (login, repository, issue_number)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(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 _receipt(raw: dict) -> tuple[str, int, str]:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("receipt must be an object")
|
||||
repository = raw.get("repository")
|
||||
number = raw.get("number")
|
||||
updated_at = raw.get("updated_at")
|
||||
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
||||
raise ValueError("repository is invalid")
|
||||
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||
raise ValueError("number is invalid")
|
||||
if not isinstance(updated_at, str):
|
||||
raise ValueError("updated_at is invalid")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
|
||||
except ValueError as error:
|
||||
raise ValueError("updated_at is invalid") from error
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("updated_at is invalid")
|
||||
return repository, number, updated_at
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(rows) -> dict:
|
||||
return {"receipts": [
|
||||
{"repository": row[0], "number": int(row[1]), "updated_at": row[2]}
|
||||
for row in rows
|
||||
]}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? ORDER BY touched_at",
|
||||
(self._login(login),),
|
||||
).fetchall()
|
||||
return self._snapshot(rows)
|
||||
|
||||
def merge(self, login: str, receipts: list[dict]) -> dict:
|
||||
login = self._login(login)
|
||||
if not isinstance(receipts, list) or len(receipts) > self.limit:
|
||||
raise ValueError(f"receipts are limited to {self.limit}")
|
||||
normalized = [self._receipt(receipt) for receipt in receipts]
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
touched = int(connection.execute(
|
||||
"SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?",
|
||||
(login,),
|
||||
).fetchone()[0])
|
||||
for repository, number, updated_at in normalized:
|
||||
current = connection.execute(
|
||||
"SELECT updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? AND repository = ? AND issue_number = ?",
|
||||
(login, repository, number),
|
||||
).fetchone()
|
||||
if current is not None and current[0] >= updated_at:
|
||||
continue
|
||||
touched += 1
|
||||
connection.execute(
|
||||
"INSERT INTO completed_filed_reviews "
|
||||
"(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(login, repository, issue_number) DO UPDATE SET "
|
||||
"updated_at=excluded.updated_at, touched_at=excluded.touched_at",
|
||||
(login, repository, number, updated_at, touched),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN ("
|
||||
"SELECT rowid FROM completed_filed_reviews WHERE login = ? "
|
||||
"ORDER BY touched_at DESC LIMIT ?)",
|
||||
(login, login, self.limit),
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? ORDER BY touched_at",
|
||||
(login,),
|
||||
).fetchall()
|
||||
return self._snapshot(rows)
|
||||
@@ -244,6 +244,14 @@ async def managed_session_active(management_id: str) -> bool:
|
||||
return status == "active"
|
||||
|
||||
|
||||
async def managed_session_statuses(management_ids) -> dict[str, str]:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().managed_statuses,
|
||||
management_ids,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
|
||||
|
||||
async def touch_session(session: Session) -> bool:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().touch,
|
||||
|
||||
@@ -22,16 +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/voice-transcript-store.js", "static/voice-issue-capture.js", "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/mobile-task-dock.js", "static/mobile-queue-launcher.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/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/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-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/filed-claim.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/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
|
||||
+423
-34
@@ -4,7 +4,7 @@ import re
|
||||
import shlex
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
from urllib.parse import quote, urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -379,7 +380,8 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||
"/api/v1/repos/issues/search",
|
||||
headers=_auth(),
|
||||
params={
|
||||
"state": "open", selector.split("=", 1)[0]: "true",
|
||||
"state": "all" if stream == "filed" else "open",
|
||||
selector.split("=", 1)[0]: "true",
|
||||
"type": item_type, "limit": limit, "page": page,
|
||||
},
|
||||
)
|
||||
@@ -403,6 +405,41 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def assigned_issue_snapshot(
|
||||
*,
|
||||
limit: int = 50,
|
||||
max_pages: int = 20,
|
||||
max_concurrency: int = 4,
|
||||
deadline_seconds: float = 5.0,
|
||||
) -> dict:
|
||||
"""Load a complete, bounded assigned-issue snapshot for deadline dispatch."""
|
||||
async with asyncio.timeout(deadline_seconds):
|
||||
first = await work_page("issue", 1, limit)
|
||||
page_count = max(1, (first["total"] + limit - 1) // limit)
|
||||
if page_count > max_pages:
|
||||
raise ValueError("Assigned issue snapshot exceeds the scan limit")
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def load(page: int) -> dict:
|
||||
async with semaphore:
|
||||
return await work_page("issue", page, limit)
|
||||
|
||||
remaining = await asyncio.gather(
|
||||
*(load(page) for page in range(2, page_count + 1))
|
||||
)
|
||||
pages = [first, *remaining]
|
||||
if any(page.get("total") != first["total"] for page in pages[1:]):
|
||||
raise ValueError("Assigned issue pagination changed during the scan")
|
||||
items = [item for page in pages for item in page["items"]]
|
||||
issue_ids = [
|
||||
item.get("id") for item in items
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), int) and item["id"] > 0
|
||||
]
|
||||
if len(items) != first["total"] or len(set(issue_ids)) != first["total"]:
|
||||
raise ValueError("Assigned issue snapshot has an incomplete issue set")
|
||||
return {"items": items, "complete": True}
|
||||
|
||||
|
||||
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
@@ -428,13 +465,46 @@ def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
async def global_search(query: str, limit: int = 10) -> dict:
|
||||
"""Search accessible issues and pulls concurrently with a bounded result set."""
|
||||
async def global_search(
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
page: int = 1,
|
||||
kind: str = "all",
|
||||
state: str = "all",
|
||||
repository: str | None = None,
|
||||
continuation: dict[str, int | None] | None = None,
|
||||
) -> dict:
|
||||
"""Search accessible work with lossless, independently retryable streams."""
|
||||
item_types = ("issues", "pulls") if kind == "all" else (
|
||||
"issues" if kind == "issue" else "pulls",
|
||||
)
|
||||
stream_limits = {
|
||||
item_type: (
|
||||
(limit + 1) // 2 if item_type == "issues" else limit // 2
|
||||
) if kind == "all" else limit
|
||||
for item_type in item_types
|
||||
}
|
||||
stream_pages = {
|
||||
item_type: continuation.get(item_type) if continuation is not None else page
|
||||
for item_type in item_types
|
||||
}
|
||||
active_types = tuple(
|
||||
item_type for item_type in item_types
|
||||
if stream_pages[item_type] is not None and stream_limits[item_type] > 0
|
||||
)
|
||||
|
||||
async def load(item_type: str) -> Any:
|
||||
params = {
|
||||
"q": query, "type": item_type, "state": state,
|
||||
"limit": stream_limits[item_type], "page": stream_pages[item_type],
|
||||
}
|
||||
if repository:
|
||||
owner, repo = repository.split("/", 1)
|
||||
params.update({"owner": owner, "repo": repo})
|
||||
response = await _get_client().get(
|
||||
"/api/v1/repos/issues/search",
|
||||
headers=_auth(),
|
||||
params={"q": query, "type": item_type, "limit": limit, "page": 1},
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
@@ -443,30 +513,58 @@ async def global_search(query: str, limit: int = 10) -> dict:
|
||||
return payload
|
||||
|
||||
outcomes = await asyncio.gather(
|
||||
load("issues"), load("pulls"), return_exceptions=True
|
||||
*(load(item_type) for item_type in active_types), return_exceptions=True
|
||||
)
|
||||
for outcome in outcomes:
|
||||
if isinstance(outcome, asyncio.CancelledError):
|
||||
raise outcome
|
||||
if all(isinstance(outcome, BaseException) for outcome in outcomes):
|
||||
if outcomes and all(isinstance(outcome, BaseException) for outcome in outcomes):
|
||||
raise outcomes[0]
|
||||
|
||||
results = []
|
||||
streams: list[list[dict]] = []
|
||||
seen: set[tuple[str, str, int]] = set()
|
||||
for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True):
|
||||
result_kinds = tuple(
|
||||
"issue" if item_type == "issues" else "pull" for item_type in active_types
|
||||
)
|
||||
for outcome, result_kind in zip(outcomes, result_kinds, strict=True):
|
||||
normalized_stream = []
|
||||
if isinstance(outcome, BaseException):
|
||||
streams.append(normalized_stream)
|
||||
continue
|
||||
for item in outcome:
|
||||
normalized = _normalize_global_search_item(item, kind)
|
||||
normalized = _normalize_global_search_item(item, result_kind)
|
||||
if normalized is not None:
|
||||
identity = (kind, normalized["repository"], normalized["number"])
|
||||
identity = (result_kind, normalized["repository"], normalized["number"])
|
||||
if identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
results.append(normalized)
|
||||
normalized_stream.append(normalized)
|
||||
streams.append(normalized_stream)
|
||||
results = []
|
||||
for index in range(max((len(stream) for stream in streams), default=0)):
|
||||
for stream in streams:
|
||||
if index < len(stream):
|
||||
results.append(stream[index])
|
||||
|
||||
failed_streams = [
|
||||
"issue" if item_type == "issues" else "pull"
|
||||
for item_type, outcome in zip(active_types, outcomes, strict=True)
|
||||
if isinstance(outcome, BaseException)
|
||||
]
|
||||
next_continuation = {item_type: None for item_type in item_types}
|
||||
for item_type, outcome in zip(active_types, outcomes, strict=True):
|
||||
if isinstance(outcome, BaseException):
|
||||
next_continuation[item_type] = stream_pages[item_type]
|
||||
elif len(outcome) >= stream_limits[item_type]:
|
||||
next_continuation[item_type] = int(stream_pages[item_type]) + 1
|
||||
has_more = any(value is not None for value in next_continuation.values())
|
||||
return {
|
||||
"items": results[:limit],
|
||||
"partial": any(isinstance(outcome, BaseException) for outcome in outcomes),
|
||||
"partial": bool(failed_streams),
|
||||
"has_more": has_more,
|
||||
"next_page": page + 1,
|
||||
"continuation": next_continuation,
|
||||
"failed_streams": failed_streams,
|
||||
}
|
||||
|
||||
|
||||
@@ -490,6 +588,19 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
login = user.get("login") if isinstance(user, dict) else ""
|
||||
state = issue.get("state") if isinstance(issue.get("state"), str) else ""
|
||||
actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue"
|
||||
reviewable = False
|
||||
if actual_kind == "pull" and state == "open" and login:
|
||||
try:
|
||||
pull = await fetch(f"repos/{repository}/pulls/{number}")
|
||||
except Exception:
|
||||
pull = None
|
||||
requested_reviewers = pull.get("requested_reviewers") if isinstance(pull, dict) else []
|
||||
if not isinstance(requested_reviewers, list):
|
||||
requested_reviewers = []
|
||||
reviewable = any(
|
||||
isinstance(reviewer, dict) and reviewer.get("login") == login
|
||||
for reviewer in requested_reviewers
|
||||
)
|
||||
return {
|
||||
"kind": actual_kind,
|
||||
"repository": repository,
|
||||
@@ -507,6 +618,8 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
"claimable": actual_kind == "issue" and state == "open" and not assignee_names,
|
||||
"reopenable": actual_kind == "issue" and state == "closed",
|
||||
"assigned_to_me": bool(login and login in assignee_names),
|
||||
"reviewable": reviewable,
|
||||
"commentable": bool(login),
|
||||
}
|
||||
|
||||
|
||||
@@ -627,8 +740,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:
|
||||
@@ -881,6 +1006,14 @@ def _gitea_api_path(value: Any) -> str:
|
||||
return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "")
|
||||
|
||||
|
||||
async def _notification_subscription(repository: str, number: str) -> dict:
|
||||
try:
|
||||
value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
|
||||
except Exception:
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
async def notification_detail(thread_id: int) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -905,18 +1038,16 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
)
|
||||
if supported_conversation:
|
||||
assert conversation_match is not None
|
||||
subject_detail, comment, conversation = await asyncio.gather(
|
||||
subject_detail, comment, subscription = await asyncio.gather(
|
||||
fetch(subject_path),
|
||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||
issue_conversation_page(
|
||||
conversation_match.group(1), int(conversation_match.group(3))
|
||||
),
|
||||
_notification_subscription(conversation_match.group(1), conversation_match.group(3)),
|
||||
)
|
||||
else:
|
||||
subject_detail, comment, conversation = await asyncio.gather(
|
||||
subject_detail, comment, subscription = await asyncio.gather(
|
||||
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
|
||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
|
||||
asyncio.sleep(0, result={}),
|
||||
)
|
||||
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
|
||||
comment = comment if isinstance(comment, dict) else {}
|
||||
@@ -977,12 +1108,18 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
and comment_match
|
||||
and comment_match.group(1) == repository_name
|
||||
),
|
||||
"conversation": conversation,
|
||||
"mute_supported": bool(
|
||||
supported_conversation
|
||||
and isinstance(subscription, dict)
|
||||
and subscription.get("subscribed") is True
|
||||
and subscription.get("ignored") is not True
|
||||
),
|
||||
"conversation_available": bool(supported_conversation),
|
||||
}
|
||||
|
||||
|
||||
async def notification_conversation_page(
|
||||
thread_id: int, page: int, limit: int = 20
|
||||
thread_id: int, page: int | None, limit: int = 20
|
||||
) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -1023,6 +1160,21 @@ async def notification_conversation_target(thread_id: int) -> tuple[str, int]:
|
||||
return match.group(1), int(match.group(3))
|
||||
|
||||
|
||||
async def mute_notification(thread_id: int) -> dict:
|
||||
"""Unsubscribe the current operator from the trusted notification conversation."""
|
||||
repository, number = await notification_conversation_target(thread_id)
|
||||
user = await fetch("user")
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("Gitea did not identify the current operator")
|
||||
response = await _get_client().delete(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/subscriptions/{quote(login, safe='')}",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {"id": thread_id, "repository": repository, "number": number, "muted": True}
|
||||
|
||||
|
||||
async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -1101,13 +1253,16 @@ async def close_issue(repository: str, number: int) -> dict:
|
||||
issue = response.json()
|
||||
if not isinstance(issue, dict) or issue.get("state") != "closed":
|
||||
raise ValueError("Gitea did not confirm issue closure")
|
||||
return {
|
||||
result = {
|
||||
"number": issue.get("number"),
|
||||
"state": "closed",
|
||||
"closed_at": issue.get("closed_at", "")
|
||||
if isinstance(issue.get("closed_at"), str)
|
||||
else "",
|
||||
}
|
||||
if isinstance(issue.get("updated_at"), str):
|
||||
result["updated_at"] = issue["updated_at"]
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_issue_comment(comment: dict) -> dict:
|
||||
@@ -1262,6 +1417,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",
|
||||
@@ -1286,12 +1486,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:
|
||||
@@ -1314,8 +1516,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")
|
||||
@@ -1638,6 +1843,63 @@ async def handoff_assigned_issue(
|
||||
}
|
||||
|
||||
|
||||
async def reassign_authored_issue(
|
||||
repository: str, number: int, recipient: str, expected_assignees: list[str]
|
||||
) -> dict:
|
||||
login, issue = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
author = issue.get("user")
|
||||
author_login = author.get("login") if isinstance(author, dict) else None
|
||||
assignees_value = issue.get("assignees")
|
||||
current_assignees = [
|
||||
item["login"] for item in assignees_value
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
] if isinstance(assignees_value, list) else []
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or issue.get("pull_request") is not None
|
||||
or not isinstance(author_login, str)
|
||||
or author_login.casefold() != login.casefold()
|
||||
or not current_assignees
|
||||
or current_assignees != expected_assignees
|
||||
):
|
||||
raise IssueNotAvailableError("Authored issue delegate changed")
|
||||
|
||||
eligible = {
|
||||
item["login"] for item in await issue_handoff_candidates(repository)
|
||||
}
|
||||
if recipient not in eligible or recipient in current_assignees:
|
||||
raise IssueNotAvailableError("Reassignment recipient is not eligible")
|
||||
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
headers=_auth(),
|
||||
json={"assignees": [recipient]},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
|
||||
confirmed_assignees = [
|
||||
item["login"] for item in confirmed_value
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
] if isinstance(confirmed_value, list) else []
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or confirmed_assignees != [recipient]
|
||||
):
|
||||
raise ValueError("Gitea did not confirm issue reassignment")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"assignees": confirmed_assignees,
|
||||
"recipient": recipient,
|
||||
"previous_assignees": expected_assignees,
|
||||
}
|
||||
|
||||
|
||||
async def pull_handoff_candidates(repository: str) -> list[dict]:
|
||||
candidates = await issue_handoff_candidates(repository)
|
||||
return [
|
||||
@@ -1831,6 +2093,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,
|
||||
@@ -2035,16 +2349,58 @@ async def update_assigned_issue(
|
||||
title: str,
|
||||
body: str,
|
||||
expected_updated_at: str,
|
||||
) -> dict:
|
||||
return await _update_issue_content(
|
||||
repository, number, title, body, expected_updated_at, require_author=False
|
||||
)
|
||||
|
||||
|
||||
async def update_authored_issue(
|
||||
repository: str,
|
||||
number: int,
|
||||
title: str,
|
||||
body: str,
|
||||
expected_updated_at: str,
|
||||
) -> dict:
|
||||
return await _update_issue_content(
|
||||
repository, number, title, body, expected_updated_at, require_author=True
|
||||
)
|
||||
|
||||
|
||||
async def _update_issue_content(
|
||||
repository: str,
|
||||
number: int,
|
||||
title: str,
|
||||
body: str,
|
||||
expected_updated_at: str,
|
||||
*,
|
||||
require_author: bool,
|
||||
) -> dict:
|
||||
path = f"repos/{repository}/issues/{number}"
|
||||
login, issue = await _current_login_and_target(path)
|
||||
author = issue.get("user") if isinstance(issue.get("user"), dict) else {}
|
||||
authorized = (
|
||||
author.get("login") == login
|
||||
if require_author
|
||||
else _login_in_users(login, issue.get("assignees"))
|
||||
)
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or isinstance(issue.get("pull_request"), dict)
|
||||
or not _login_in_users(login, issue.get("assignees"))
|
||||
or not authorized
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
raise IssueNotAvailableError("issue not found")
|
||||
if issue.get("updated_at") != expected_updated_at:
|
||||
if issue.get("title") == title and issue.get("body", "") == body:
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"state": issue.get("state", "open"),
|
||||
"updated_at": issue.get("updated_at", ""),
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
}
|
||||
raise IssueEditConflictError("issue changed upstream")
|
||||
|
||||
response = await _get_client().patch(
|
||||
@@ -2107,19 +2463,23 @@ 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
|
||||
state = target.get("state")
|
||||
eligible = (
|
||||
target.get("state") == "open"
|
||||
(state == "open" or (kind == "filed" and state == "closed"))
|
||||
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)
|
||||
)
|
||||
@@ -2131,9 +2491,14 @@ async def resolve_work_route(
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": target.get("title", "") if isinstance(target.get("title"), str) else "",
|
||||
"state": "open",
|
||||
"state": state,
|
||||
"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 {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -2148,6 +2513,30 @@ 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") in {"open", "closed"}
|
||||
and not isinstance(issue.get("pull_request"), dict)
|
||||
and author.get("login") == login
|
||||
)
|
||||
|
||||
|
||||
async def is_open_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"),
|
||||
|
||||
+3
-2
@@ -5,6 +5,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reservation:
|
||||
@@ -33,11 +35,10 @@ class IdempotencyLedger:
|
||||
self.max_entries = max_entries
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
self.clock = clock
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection = connect_private_sqlite(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection.execute(
|
||||
f"PRAGMA busy_timeout = {max(1, int(self.lock_timeout_seconds * 1000))}"
|
||||
)
|
||||
|
||||
+13
-4
@@ -6,6 +6,8 @@ import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
class LaterStore:
|
||||
def __init__(
|
||||
@@ -22,10 +24,13 @@ class LaterStore:
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
def _initialize(self) -> None:
|
||||
connection = connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
|
||||
connection.close()
|
||||
return
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
@@ -63,8 +68,12 @@ class LaterStore:
|
||||
"UPDATE later_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(self.clock(),),
|
||||
)
|
||||
connection.execute("PRAGMA user_version = 1")
|
||||
connection.commit()
|
||||
return connection
|
||||
connection.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
|
||||
+37
-46
@@ -6,12 +6,13 @@ import json
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import stat
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
SECTIONS = ("context", "events", "notifications")
|
||||
|
||||
|
||||
@@ -58,56 +59,46 @@ class LiveSnapshotStore:
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
|
||||
connection = connect_private_sqlite(self.path, timeout=1.0, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA busy_timeout = 1000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(self.path.parent, stat.S_IRWXU)
|
||||
except OSError:
|
||||
pass
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS live_snapshot (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
value_json TEXT,
|
||||
created_at_json TEXT NOT NULL,
|
||||
failure_count_json TEXT NOT NULL,
|
||||
retry_at_json TEXT NOT NULL,
|
||||
revisions_json TEXT NOT NULL,
|
||||
generation TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS live_refresh_lease (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
owner TEXT NOT NULL,
|
||||
sections_json TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS live_read_notification (
|
||||
notification_id INTEGER PRIMARY KEY
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""INSERT OR IGNORE INTO live_snapshot VALUES
|
||||
(1, NULL, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
json.dumps({section: None for section in SECTIONS}),
|
||||
json.dumps({section: 0 for section in SECTIONS}),
|
||||
json.dumps({section: None for section in SECTIONS}),
|
||||
json.dumps({section: 0 for section in SECTIONS}),
|
||||
secrets.token_hex(8),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS live_snapshot (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
value_json TEXT,
|
||||
created_at_json TEXT NOT NULL,
|
||||
failure_count_json TEXT NOT NULL,
|
||||
retry_at_json TEXT NOT NULL,
|
||||
revisions_json TEXT NOT NULL,
|
||||
generation TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS live_refresh_lease (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
owner TEXT NOT NULL,
|
||||
sections_json TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS live_read_notification (
|
||||
notification_id INTEGER PRIMARY KEY
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""INSERT OR IGNORE INTO live_snapshot VALUES
|
||||
(1, NULL, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
json.dumps({section: None for section in SECTIONS}),
|
||||
json.dumps({section: 0 for section in SECTIONS}),
|
||||
json.dumps({section: None for section in SECTIONS}),
|
||||
json.dumps({section: 0 for section in SECTIONS}),
|
||||
secrets.token_hex(8),
|
||||
),
|
||||
)
|
||||
|
||||
def try_acquire_refresh(
|
||||
self, sections: Iterable[str], *, lease_seconds: float
|
||||
|
||||
@@ -7,6 +7,8 @@ import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
class LoginAttemptStoreError(RuntimeError):
|
||||
"""Raised when sign-in throttling state cannot be accessed safely."""
|
||||
@@ -69,8 +71,7 @@ class LoginAttemptStore:
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection = connect_private_sqlite(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
|
||||
+970
-65
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.session_store import SessionStoreError
|
||||
|
||||
|
||||
@@ -40,8 +41,7 @@ class PasskeyStore:
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=0.1)
|
||||
connection = connect_private_sqlite(self.path, timeout=0.1)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS passkey_credentials (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Filesystem boundary for SQLite state containing private operator data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PRIVATE_DIRECTORY_MODE = stat.S_IRWXU
|
||||
PRIVATE_FILE_MODE = stat.S_IRUSR | stat.S_IWUSR
|
||||
|
||||
|
||||
def _reject_symlink(path: Path, *, label: str) -> None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if stat.S_ISLNK(metadata.st_mode):
|
||||
raise ValueError(f"private SQLite {label} must not be a symlink: {path}")
|
||||
|
||||
|
||||
def prepare_private_sqlite_path(path: str | Path, *, create: bool = True) -> Path:
|
||||
"""Create or repair a private SQLite path without following a database symlink."""
|
||||
database = Path(path)
|
||||
parent = database.parent
|
||||
_reject_symlink(parent, label="directory")
|
||||
if create:
|
||||
parent.mkdir(parents=True, mode=PRIVATE_DIRECTORY_MODE, exist_ok=True)
|
||||
elif not parent.exists():
|
||||
raise FileNotFoundError(database)
|
||||
_reject_symlink(parent, label="directory")
|
||||
if not parent.is_dir():
|
||||
raise ValueError(f"private SQLite parent is not a directory: {parent}")
|
||||
os.chmod(parent, PRIVATE_DIRECTORY_MODE, follow_symlinks=False)
|
||||
|
||||
_reject_symlink(database, label="database")
|
||||
flags = os.O_RDWR | (os.O_CREAT if create else 0)
|
||||
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(database, flags, PRIVATE_FILE_MODE)
|
||||
try:
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise ValueError(f"private SQLite database is not a regular file: {database}")
|
||||
os.fchmod(descriptor, PRIVATE_FILE_MODE)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
for suffix in ("-wal", "-shm", "-journal"):
|
||||
sidecar = Path(str(database) + suffix)
|
||||
_reject_symlink(sidecar, label="sidecar")
|
||||
try:
|
||||
os.chmod(sidecar, PRIVATE_FILE_MODE, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return database
|
||||
|
||||
|
||||
def connect_private_sqlite(
|
||||
path: str | Path, *, existing_only: bool = False, **kwargs: Any
|
||||
) -> sqlite3.Connection:
|
||||
"""Open SQLite only after enforcing its private directory and file modes."""
|
||||
database = prepare_private_sqlite_path(path, create=not existing_only)
|
||||
target: str | Path = database
|
||||
if existing_only:
|
||||
target = f"{database.resolve().as_uri()}?mode=rw"
|
||||
kwargs["uri"] = True
|
||||
connection = sqlite3.connect(target, **kwargs)
|
||||
# SQLite derives new journal/WAL/SHM permissions from the database. Repair
|
||||
# sidecars left by older releases as soon as a connection has opened them.
|
||||
prepare_private_sqlite_path(database, create=not existing_only)
|
||||
return connection
|
||||
+173
-8
@@ -3,7 +3,9 @@ import json
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Awaitable, Callable
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
@@ -47,7 +49,7 @@ async def dispatch_unread_updates(
|
||||
unread: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
session_active: Callable[[str], Awaitable[bool]] | None = None,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
lease_seconds: float = 60.0,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
@@ -60,6 +62,7 @@ async def dispatch_unread_updates(
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
@@ -76,20 +79,22 @@ async def dispatch_unread_updates(
|
||||
}
|
||||
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
|
||||
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
|
||||
if session_active is not None:
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
authorized = [await session_active(item.session_id) for item in deliveries]
|
||||
statuses = await session_statuses(
|
||||
[item.session_id for item in deliveries]
|
||||
)
|
||||
except Exception:
|
||||
# Authorization state is mandatory for delivery. Preserve subscriptions
|
||||
# so a temporary registry failure can be retried safely.
|
||||
return 0
|
||||
for delivery, active in zip(deliveries, authorized):
|
||||
if not active:
|
||||
for delivery in deliveries:
|
||||
if statuses.get(delivery.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, delivery.session_id)
|
||||
deliveries = [
|
||||
delivery
|
||||
for delivery, active in zip(deliveries, authorized)
|
||||
if active
|
||||
for delivery in deliveries
|
||||
if statuses.get(delivery.session_id) == "active"
|
||||
]
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
ownership_lost = asyncio.Event()
|
||||
@@ -126,6 +131,7 @@ async def dispatch_unread_updates(
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
@@ -172,6 +178,7 @@ async def dispatch_unread_updates(
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
@@ -233,4 +240,162 @@ async def dispatch_unread_updates(
|
||||
)
|
||||
return sum(counts)
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner)
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="unread")
|
||||
|
||||
|
||||
async def dispatch_deadline_reminders(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
assigned: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
**kwargs,
|
||||
) -> int:
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="deadline",
|
||||
now=time.time(),
|
||||
lease_seconds=max(
|
||||
15.0,
|
||||
float(kwargs.get("lease_seconds", 60.0)),
|
||||
float(kwargs.get("send_timeout_seconds", 10.0)) + 5.0,
|
||||
),
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
return await _dispatch_deadline_reminders_unlocked(
|
||||
store, configuration, assigned, send, owner=owner, **kwargs
|
||||
)
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="deadline")
|
||||
|
||||
|
||||
async def _dispatch_deadline_reminders_unlocked(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
assigned: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
lease_seconds: float = 60.0,
|
||||
max_concurrency: int = 8,
|
||||
owner: str,
|
||||
) -> int:
|
||||
"""Send one privacy-safe Agenda digest per eligible device and local day."""
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
devices = await asyncio.to_thread(store.deadline_reminder_devices)
|
||||
if not devices:
|
||||
return 0
|
||||
current = now or datetime.now(timezone.utc)
|
||||
eligible_devices = []
|
||||
for device in devices:
|
||||
try:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
except ZoneInfoNotFoundError:
|
||||
continue
|
||||
if (
|
||||
local_now.hour >= device.reminder_hour
|
||||
and device.delivered_local_day != local_now.date().isoformat()
|
||||
):
|
||||
eligible_devices.append(device)
|
||||
if not eligible_devices:
|
||||
return 0
|
||||
snapshot = await assigned()
|
||||
if snapshot.get("complete") is False:
|
||||
return 0
|
||||
due_days = []
|
||||
for item in snapshot.get("items", []):
|
||||
if not isinstance(item, dict) or not item.get("due_date"):
|
||||
continue
|
||||
raw_due = str(item["due_date"])
|
||||
try:
|
||||
due_day = datetime.strptime(raw_due[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
continue
|
||||
due_days.append(due_day)
|
||||
if not due_days:
|
||||
return 0
|
||||
due_counts = {}
|
||||
for device in eligible_devices:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
local_cutoff = local_now.date() + timedelta(days=device.reminder_days)
|
||||
due_count = sum(due_day <= local_cutoff for due_day in due_days)
|
||||
if due_count:
|
||||
due_counts[device.session_id] = due_count
|
||||
eligible_devices = [
|
||||
device for device in eligible_devices if device.session_id in due_counts
|
||||
]
|
||||
if not eligible_devices:
|
||||
return 0
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses(
|
||||
[device.session_id for device in eligible_devices]
|
||||
)
|
||||
except Exception:
|
||||
return 0
|
||||
for device in eligible_devices:
|
||||
if statuses.get(device.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
eligible_devices = [
|
||||
device
|
||||
for device in eligible_devices
|
||||
if statuses.get(device.session_id) == "active"
|
||||
]
|
||||
if not eligible_devices:
|
||||
return 0
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def dispatch_device(device) -> int:
|
||||
async with semaphore:
|
||||
try:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
except ZoneInfoNotFoundError:
|
||||
return 0
|
||||
local_day = local_now.date().isoformat()
|
||||
if (
|
||||
local_now.hour < device.reminder_hour
|
||||
or device.delivered_local_day == local_day
|
||||
):
|
||||
return 0
|
||||
due_count = due_counts[device.session_id]
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="deadline",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not still_owner:
|
||||
return 0
|
||||
payload = json.dumps({
|
||||
"title": f"{due_count} deadline{'s' if due_count != 1 else ''} need{'s' if due_count == 1 else ''} attention",
|
||||
"body": f"Open Agenda to review or replan {'it' if due_count == 1 else 'them'}.",
|
||||
"route": "#/my-work/agenda",
|
||||
"protect_route": "#/my-work/agenda/protect-today",
|
||||
"tag": f"stackchain-deadline-digest-{local_day}",
|
||||
"deadline_count": due_count,
|
||||
}, separators=(",", ":"))
|
||||
try:
|
||||
operation = (
|
||||
send(device.subscription, payload)
|
||||
if send is not None
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception:
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_deadline_reminder_delivered, device.session_id, local_day
|
||||
)
|
||||
return 1
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(dispatch_device(device) for device in eligible_devices), return_exceptions=True
|
||||
)
|
||||
return sum(result for result in results if isinstance(result, int))
|
||||
+113
-16
@@ -1,10 +1,11 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushDelivery:
|
||||
@@ -22,6 +23,16 @@ class PushDelivery:
|
||||
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeadlineReminderDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
timezone: str
|
||||
reminder_hour: int
|
||||
reminder_days: int
|
||||
delivered_local_day: str | None
|
||||
|
||||
|
||||
def _revisions(
|
||||
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
) -> tuple[tuple[int, str], ...]:
|
||||
@@ -47,8 +58,6 @@ class PushSubscriptionStore:
|
||||
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.path.parent, 0o700)
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
@@ -66,7 +75,7 @@ class PushSubscriptionStore:
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_dispatch_lease (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
channel TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
@@ -78,6 +87,16 @@ class PushSubscriptionStore:
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_deadline_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
reminder_days INTEGER NOT NULL DEFAULT 2,
|
||||
delivered_local_day TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
delivery_columns = {
|
||||
@@ -95,37 +114,62 @@ class PushSubscriptionStore:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_digest_pending ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
|
||||
)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
lease_columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(push_dispatch_lease)")
|
||||
}
|
||||
if "singleton" in lease_columns:
|
||||
connection.executescript(
|
||||
"""
|
||||
ALTER TABLE push_dispatch_lease RENAME TO push_dispatch_lease_legacy;
|
||||
CREATE TABLE push_dispatch_lease (
|
||||
channel TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
INSERT INTO push_dispatch_lease(channel, owner, expires_at)
|
||||
SELECT 'unread', owner, expires_at
|
||||
FROM push_dispatch_lease_legacy WHERE singleton = 1;
|
||||
DROP TABLE push_dispatch_lease_legacy;
|
||||
"""
|
||||
)
|
||||
preference_columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(push_deadline_preferences)")
|
||||
}
|
||||
if "reminder_days" not in preference_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
|
||||
)
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.path, timeout=2)
|
||||
connection = connect_private_sqlite(self.path, timeout=2)
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
return connection
|
||||
|
||||
def acquire_dispatch_lease(
|
||||
self, owner: str, *, now: float, lease_seconds: float
|
||||
self, owner: str, *, channel: str = "unread", now: float, lease_seconds: float
|
||||
) -> bool:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current = connection.execute(
|
||||
"SELECT owner, expires_at FROM push_dispatch_lease WHERE singleton = 1"
|
||||
"SELECT owner, expires_at FROM push_dispatch_lease WHERE channel = ?",
|
||||
(channel,),
|
||||
).fetchone()
|
||||
if current is not None and current[0] != owner and current[1] > now:
|
||||
return False
|
||||
connection.execute(
|
||||
"""INSERT INTO push_dispatch_lease(singleton, owner, expires_at)
|
||||
VALUES (1, ?, ?)
|
||||
ON CONFLICT(singleton) DO UPDATE SET
|
||||
"""INSERT INTO push_dispatch_lease(channel, owner, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(channel) DO UPDATE SET
|
||||
owner = excluded.owner, expires_at = excluded.expires_at""",
|
||||
(owner, now + lease_seconds),
|
||||
(channel, owner, now + lease_seconds),
|
||||
)
|
||||
return True
|
||||
|
||||
def release_dispatch_lease(self, owner: str) -> bool:
|
||||
def release_dispatch_lease(self, owner: str, *, channel: str = "unread") -> bool:
|
||||
with self._connect() as connection:
|
||||
result = connection.execute(
|
||||
"DELETE FROM push_dispatch_lease WHERE singleton = 1 AND owner = ?",
|
||||
(owner,),
|
||||
"DELETE FROM push_dispatch_lease WHERE channel = ? AND owner = ?",
|
||||
(channel, owner),
|
||||
)
|
||||
return result.rowcount == 1
|
||||
|
||||
@@ -154,6 +198,59 @@ class PushSubscriptionStore:
|
||||
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
||||
).fetchone() is not None
|
||||
|
||||
def set_deadline_preferences(
|
||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int,
|
||||
reminder_days: int = 2,
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_deadline_preferences(
|
||||
session_id, enabled, timezone, reminder_hour, reminder_days
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
timezone = excluded.timezone,
|
||||
reminder_hour = excluded.reminder_hour,
|
||||
reminder_days = excluded.reminder_days""",
|
||||
(session_id, int(enabled), timezone, reminder_hour, reminder_days),
|
||||
)
|
||||
|
||||
def deadline_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT enabled, timezone, reminder_hour, reminder_days
|
||||
FROM push_deadline_preferences WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"enabled": bool(row[0]) if row else False,
|
||||
"timezone": row[1] if row else "UTC",
|
||||
"reminder_hour": row[2] if row else 9,
|
||||
"reminder_days": row[3] if row else 2,
|
||||
}
|
||||
|
||||
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
||||
p.reminder_hour, p.reminder_days, p.delivered_local_day
|
||||
FROM push_subscriptions s
|
||||
JOIN push_deadline_preferences p ON p.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
return [
|
||||
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4], row[5])
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def mark_deadline_reminder_delivered(self, session_id: str, local_day: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_deadline_preferences SET delivered_local_day = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(local_day, session_id),
|
||||
)
|
||||
|
||||
def claim_unseen(
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||
) -> list[PushDelivery]:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Durable, account-scoped saved Search views."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
_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:
|
||||
with self._connect() 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 connect_private_sqlite(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}
|
||||
@@ -6,6 +6,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
class SecurityEventStoreError(RuntimeError):
|
||||
"""Raised when security activity cannot be persisted or read safely."""
|
||||
@@ -53,8 +55,7 @@ class SecurityEventStore:
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection = connect_private_sqlite(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS security_events (
|
||||
|
||||
+35
-15
@@ -7,6 +7,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
class SessionStoreError(RuntimeError):
|
||||
"""Raised when session state cannot be read or changed safely."""
|
||||
@@ -51,15 +53,14 @@ class SessionStore:
|
||||
def _connect(self, *, initialize: bool = False) -> sqlite3.Connection:
|
||||
try:
|
||||
if initialize:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(
|
||||
connection = connect_private_sqlite(
|
||||
self.path, timeout=self.lock_timeout_seconds
|
||||
)
|
||||
else:
|
||||
connection = sqlite3.connect(
|
||||
f"{self.path.resolve().as_uri()}?mode=rw",
|
||||
connection = connect_private_sqlite(
|
||||
self.path,
|
||||
timeout=self.lock_timeout_seconds,
|
||||
uri=True,
|
||||
existing_only=True,
|
||||
)
|
||||
if initialize:
|
||||
connection.execute(
|
||||
@@ -273,21 +274,40 @@ class SessionStore:
|
||||
|
||||
def managed_status(self, management_id: str, *, idle_timeout_seconds: int) -> str:
|
||||
"""Return the authorization state for a durable device identifier."""
|
||||
return self.managed_statuses(
|
||||
[management_id], idle_timeout_seconds=idle_timeout_seconds
|
||||
)[management_id]
|
||||
|
||||
def managed_statuses(
|
||||
self, management_ids, *, idle_timeout_seconds: int
|
||||
) -> dict[str, str]:
|
||||
"""Return authorization states for durable device identifiers in one read."""
|
||||
requested = set(management_ids)
|
||||
if not requested:
|
||||
return {}
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT expires_at, last_active_at FROM active_sessions "
|
||||
"WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
placeholders = ",".join("?" for _ in requested)
|
||||
rows = connection.execute(
|
||||
"SELECT management_id, expires_at, last_active_at "
|
||||
f"FROM active_sessions WHERE management_id IN ({placeholders})",
|
||||
tuple(requested),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
if row is None or row[0] <= now:
|
||||
return "revoked"
|
||||
if row[1] + max(1, idle_timeout_seconds) <= now:
|
||||
return "idle"
|
||||
return "active"
|
||||
known = {row[0]: row[1:] for row in rows if row[0] in requested}
|
||||
idle_timeout = max(1, idle_timeout_seconds)
|
||||
statuses = {}
|
||||
for management_id in requested:
|
||||
row = known.get(management_id)
|
||||
if row is None or row[0] <= now:
|
||||
statuses[management_id] = "revoked"
|
||||
elif row[1] + idle_timeout <= now:
|
||||
statuses[management_id] = "idle"
|
||||
else:
|
||||
statuses[management_id] = "active"
|
||||
return statuses
|
||||
|
||||
def revoke_managed(self, management_id: str) -> bool:
|
||||
try:
|
||||
|
||||
+13
-4
@@ -6,6 +6,8 @@ import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
class TodayPlanFull(ValueError):
|
||||
"""Raised when an add would exceed the bounded Today plan."""
|
||||
@@ -30,10 +32,13 @@ class TodayStore:
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.recap_limit = recap_limit
|
||||
self.clock = clock
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
def _initialize(self) -> None:
|
||||
connection = connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
|
||||
connection.close()
|
||||
return
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
@@ -101,8 +106,12 @@ class TodayStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute("PRAGMA user_version = 1")
|
||||
connection.commit()
|
||||
return connection
|
||||
connection.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""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
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
_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:
|
||||
with self._connect() 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 connect_private_sqlite(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}
|
||||
+1
-1
@@ -17,7 +17,7 @@ LOGIN_HTML = """<!doctype html>
|
||||
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
|
||||
<body><main><h1>Operator sign in</h1><p>Use an enrolled passkey, or enter the dashboard access token for bootstrap and recovery. The token is exchanged for a private, short-lived session and is never stored on this device.</p>
|
||||
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><button id="passkey-sign-in" type="button">Sign in with a passkey</button><p>Recovery</p><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in with access token</button><p id="status" role="status" aria-live="polite"></p></form></main>
|
||||
<script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""
|
||||
<script src="static/private-data-registry.js"></script><script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""
|
||||
|
||||
|
||||
class RevalidatingHTMLResponse(HTMLResponse):
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""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] = []
|
||||
self.issue_creation_enabled = False
|
||||
|
||||
|
||||
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
|
||||
if not self.server.issue_creation_enabled:
|
||||
self._json(503, {"message": "release journey is still offline"})
|
||||
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,272 @@
|
||||
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,
|
||||
)
|
||||
context.add_init_script("""
|
||||
class DeterministicSpeechRecognition {
|
||||
start() { window.__voiceRecognition = this; }
|
||||
stop() { if (this.onend) this.onend(); }
|
||||
abort() { if (this.onend) this.onend(); }
|
||||
}
|
||||
window.SpeechRecognition = DeterministicSpeechRecognition;
|
||||
""")
|
||||
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()
|
||||
expect(page.locator("#voice-issue-capture")).to_be_visible()
|
||||
page.locator("#start-voice-issue-capture").click()
|
||||
page.evaluate("""([title, body]) => {
|
||||
const result = [{ transcript: title + ' ' + body }];
|
||||
result.isFinal = true;
|
||||
window.__voiceRecognition.onresult({ results: [result] });
|
||||
}""", [TITLE, BODY])
|
||||
expect(page.locator("#voice-issue-review")).to_be_visible()
|
||||
expect(page.locator("#append-voice-issue-transcript")).to_have_text("Use transcript")
|
||||
page.locator("#append-voice-issue-transcript").click()
|
||||
expect(page.locator("#create-issue-title")).to_have_value(TITLE)
|
||||
expect(page.locator("#create-issue-body")).to_have_value(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()
|
||||
fake.issue_creation_enabled = True
|
||||
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)
|
||||
@@ -8,6 +8,8 @@ from tests.dashboard_bundle import dashboard_bundle_text
|
||||
def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
html = dashboard_bundle_text()
|
||||
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
|
||||
commands = (Path(__file__).parents[1] / "frontend" / "commands.js").read_text()
|
||||
api_paths += re.findall(r"return ['\"]([^'\"]*api/v1/[^'\"]*)['\"]", commands)
|
||||
|
||||
assert api_paths
|
||||
assert {
|
||||
@@ -18,6 +20,7 @@ def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/repositories/search?q=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
|
||||
}
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
|
||||
ASSIGN_AND_START = Path(__file__).parents[1] / "frontend" / "assign-and-start.js"
|
||||
FILED_CLAIM = Path(__file__).parents[1] / "frontend" / "filed-claim.js"
|
||||
QUEUE_TODAY = Path(__file__).parents[1] / "frontend" / "queue-today.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
@@ -17,6 +18,47 @@ def run_node(script):
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_filed_claim_actions_require_open_authored_unassigned_canonical_issue():
|
||||
script = f"""
|
||||
const eligible=require({json.dumps(str(FILED_CLAIM))});
|
||||
const item={{is_filed:true,is_assigned:false,is_completed:false,state:'open'}};
|
||||
process.stdout.write(JSON.stringify({{
|
||||
eligible:eligible(item, {{state:'open',assignees:[]}}),
|
||||
assigned:eligible(item, {{state:'open',assignees:['alex']}}),
|
||||
closed:eligible(item, {{state:'closed',assignees:[]}}),
|
||||
ordinary:eligible({{...item,is_filed:false}}, {{state:'open',assignees:[]}}),
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"eligible": True,
|
||||
"assigned": False,
|
||||
"closed": False,
|
||||
"ordinary": False,
|
||||
}
|
||||
|
||||
|
||||
def test_filed_sheet_exposes_phone_safe_claim_actions_after_canonical_detail():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert 'id="filed-claim-actions"' in html
|
||||
assert 'id="queue-filed-issue"' in html
|
||||
assert 'id="start-filed-issue"' in html
|
||||
assert "filedClaimEligible(item, detail)" in dashboard
|
||||
assert "filedAssignAndStart.run(item, { destination:'queue' })" in dashboard
|
||||
assert "filedAssignAndStart.run(item)" in dashboard
|
||||
assert "Claim failed; this issue is still in Filed." in dashboard
|
||||
assert ".filed-claim-actions" in css
|
||||
action_rule = css.split(".filed-claim-actions", 1)[1].split("}", 1)[0]
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in action_rule
|
||||
assert "width:min(560px,100%)" in action_rule
|
||||
assert "BASE + 'static/filed-claim.js'" in worker
|
||||
assert '<script src="static/filed-claim.js"></script>' in html
|
||||
|
||||
|
||||
def test_repeated_assign_and_start_taps_claim_once_then_start_confirmed_issue():
|
||||
script = f"""
|
||||
const fs=require('fs');
|
||||
|
||||
@@ -77,6 +77,171 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_revision_checked_issue_content():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = 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 = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
fetchJson: async (url, options) => {{
|
||||
calls.push({{url,method:options.method,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
return {{repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',updated_at:'2026-08-15T11:00:00Z'}};
|
||||
}},
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
kind:'issue-content',repository:'stackchain/dashboard',number:17,operationId:'check-op',
|
||||
title:'Ship',body:'- [x] Test',expectedUpdatedAt:'2026-08-15T10:00:00Z',
|
||||
}});
|
||||
const restored = createAuthoredOutbox({{storage}}).list()[0];
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{queued,restored,calls,result,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["expectedUpdatedAt"] == "2026-08-15T10:00:00Z"
|
||||
assert output["restored"]["title"] == "Ship"
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/content",
|
||||
"method": "PATCH",
|
||||
"key": "check-op",
|
||||
"body": {
|
||||
"title": "Ship", "body": "- [x] Test",
|
||||
"expected_updated_at": "2026-08-15T10:00:00Z",
|
||||
},
|
||||
}]
|
||||
assert output["result"]["confirmed"][0]["body"] == "- [x] Test"
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_durable_issue_content_admission_coalesces_latest_body_on_original_revision():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const mirrors = []; const ids = ['check-1', 'check-2'];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=>ids.shift(),
|
||||
backgroundSync:{{
|
||||
reconcile:async items=>mirrors.push(items.map(item=>({{...item}}))),
|
||||
requestSync:async()=>{{}},
|
||||
}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await outbox.enqueueDurably({{kind:'issue-content',repository:'stackchain/dashboard',number:17,
|
||||
title:'Ship',body:'- [x] Build\\n- [ ] Test',expectedUpdatedAt:'server-revision'}});
|
||||
const second = await outbox.enqueueDurably({{kind:'issue-content',repository:'stackchain/dashboard',number:17,
|
||||
title:'Ship',body:'- [x] Build\\n- [x] Test',expectedUpdatedAt:'pending-local-revision'}});
|
||||
process.stdout.write(JSON.stringify({{items:outbox.list(),second,mirrors}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert len(output["items"]) == 1
|
||||
assert output["items"][0]["id"] == "check-1"
|
||||
assert output["items"][0]["operationId"] == "check-2"
|
||||
assert output["items"][0]["body"] == "- [x] Build\n- [x] Test"
|
||||
assert output["items"][0]["expectedUpdatedAt"] == "server-revision"
|
||||
assert output["second"]["item"] == output["items"][0]
|
||||
assert len(output["mirrors"][1]) == 1
|
||||
assert output["mirrors"][1][0]["operationId"] == "check-2"
|
||||
|
||||
|
||||
def test_durable_issue_content_coalesces_ordered_checklist_management_intent():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const ids = ['manage-1','manage-2'];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage,getOwnerLogin:()=> 'timmy',createOperationId:()=>ids.shift(),
|
||||
backgroundSync:{{reconcile:async()=>{{}},requestSync:async()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await outbox.enqueueDurably({{kind:'issue-content',repository:'o/r',number:9,title:'Ship',
|
||||
baseBody:'- [ ] Build\\n- [ ] Test',body:'- [ ] Compile\\n- [ ] Test',expectedUpdatedAt:'old',
|
||||
checklistOperation:{{action:'rename',index:0,label:'Compile'}}}});
|
||||
await outbox.enqueueDurably({{kind:'issue-content',repository:'o/r',number:9,title:'Ship',
|
||||
baseBody:'- [ ] Compile\\n- [ ] Test',body:'- [ ] Test\\n- [ ] Compile',expectedUpdatedAt:'old',
|
||||
checklistOperation:{{action:'move-later',index:0}}}});
|
||||
process.stdout.write(JSON.stringify(outbox.list()[0]));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["baseBody"] == "- [ ] Build\n- [ ] Test"
|
||||
assert output["body"] == "- [ ] Test\n- [ ] Compile"
|
||||
assert output["checklistOperations"] == [
|
||||
{"action": "rename", "index": 0, "label": "Compile"},
|
||||
{"action": "move-later", "index": 0},
|
||||
]
|
||||
|
||||
|
||||
def test_failed_durable_issue_content_replacement_restores_previous_intent():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const ids = ['check-1', 'check-2']; let admissions = 0;
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=>ids.shift(),
|
||||
backgroundSync:{{
|
||||
reconcile:async()=>{{if (++admissions === 2) throw new Error('IndexedDB unavailable');}},
|
||||
requestSync:async()=>{{}},
|
||||
}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await outbox.enqueueDurably({{kind:'issue-content',repository:'o/r',number:9,
|
||||
title:'Ship',body:'- [x] Build\\n- [ ] Test',expectedUpdatedAt:'server-revision'}});
|
||||
let error = '';
|
||||
try {{
|
||||
await outbox.enqueueDurably({{kind:'issue-content',repository:'o/r',number:9,
|
||||
title:'Ship',body:'- [x] Build\\n- [x] Test',expectedUpdatedAt:'server-revision'}});
|
||||
}} catch (caught) {{ error = caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["error"] == "IndexedDB unavailable"
|
||||
assert len(output["items"]) == 1
|
||||
assert output["items"][0]["operationId"] == "check-1"
|
||||
assert output["items"][0]["body"] == "- [x] Build\n- [ ] Test"
|
||||
|
||||
|
||||
def test_issue_content_conflict_resolution_rebases_task_delta_and_mirrors_before_retry():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const mergeChecklistConflict = require({json.dumps(str(Path(__file__).parents[1] / 'frontend' / 'checklist-conflict.js'))});
|
||||
const initial = {{version:2,items:[{{
|
||||
id:'check-1',operationId:'old-op',kind:'issue-content',ownerLogin:'timmy',status:'attention',
|
||||
repository:'o/r',number:9,title:'Ship',baseBody:'Intro\\n- [ ] Build\\n- [ ] Test',
|
||||
body:'Intro\\n- [x] Build\\n- [ ] Test',expectedUpdatedAt:'old-revision',error:'Issue changed',
|
||||
}}]}};
|
||||
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify(initial)]]);
|
||||
const mirrors = []; let syncs = 0;
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'rebased-op', mergeChecklistConflict,
|
||||
backgroundSync:{{reconcile:async items=>mirrors.push(items.map(item=>({{...item}}))),requestSync:async()=>{{syncs += 1;}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const result = await outbox.resolveIssueContentConflict('check-1', {{
|
||||
title:'Ship safely', body:'Remote intro\\n- [ ] Test\\n- [ ] Build', updated_at:'latest-revision',
|
||||
}}, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{result,item:outbox.list()[0],mirrors,syncs}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["result"]["changes"] == [{"label": "Build", "checked": True}]
|
||||
assert output["item"]["status"] == "queued"
|
||||
assert output["item"]["operationId"] == "rebased-op"
|
||||
assert output["item"]["title"] == "Ship safely"
|
||||
assert output["item"]["body"] == "Remote intro\n- [ ] Test\n- [x] Build"
|
||||
assert output["item"]["baseBody"] == "Remote intro\n- [ ] Test\n- [ ] Build"
|
||||
assert output["item"]["expectedUpdatedAt"] == "latest-revision"
|
||||
assert "error" not in output["item"]
|
||||
assert output["mirrors"][-1][0] == output["item"]
|
||||
assert output["syncs"] == 1
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -64,6 +64,101 @@ 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_revision_checked_issue_content():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item = {{
|
||||
id:'check-1',operationId:'check-1',kind:'issue-content',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',expectedUpdatedAt:'2026-08-15T10:00:00Z',
|
||||
}};
|
||||
const state={{calls:[],completed:[]}};
|
||||
const store={{
|
||||
claimNext:async()=>item?{{...item}}:null,
|
||||
complete:async id=>{{state.completed.push(id);item=null;}},
|
||||
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
state.calls.push({{url,method:options.method,key:options.headers?.['Idempotency-Key']||'',body:options.body?JSON.parse(options.body):null}});
|
||||
if(url==='api/v1/background-identity') return {{login:'timmy'}};
|
||||
return {{repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',updated_at:'2026-08-15T11:00:00Z'}};
|
||||
}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["calls"][1] == {
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/content",
|
||||
"method": "PATCH",
|
||||
"key": "check-1",
|
||||
"body": {
|
||||
"title": "Ship", "body": "- [x] Test",
|
||||
"expected_updated_at": "2026-08-15T10:00:00Z",
|
||||
},
|
||||
}
|
||||
assert output["state"]["completed"] == ["check-1"]
|
||||
assert output["result"]["confirmed"][0]["body"] == "- [x] Test"
|
||||
|
||||
|
||||
def test_active_issue_content_delivery_rejects_a_false_durable_replacement():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records = new Map([['check-1', {{
|
||||
id:'check-1',operationId:'old-op',kind:'issue-content',outboxLane:'authored',
|
||||
ownerLogin:'timmy',status:'sending',claimUntil:200,body:'- [x] Build\\n- [ ] Test'
|
||||
}}]]); let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}});
|
||||
let error='';
|
||||
try {{ await store.reconcile([{{
|
||||
id:'check-1',operationId:'new-op',kind:'issue-content',ownerLogin:'timmy',status:'queued',
|
||||
body:'- [x] Build\\n- [x] Test'
|
||||
}}], 'authored'); }} catch (caught) {{ error=caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{error,snapshot:await store.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["error"] == "Checklist sync is already delivering. Retry this change."
|
||||
assert output["snapshot"][0]["operationId"] == "old-op"
|
||||
assert output["snapshot"][0]["body"] == "- [x] Build\n- [ ] Test"
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -164,6 +259,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:''}};}}
|
||||
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"] == [""]
|
||||
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**", "",
|
||||
"**Screenshot 2 — Tap \\[Submit\\]\\(unsafe\\)**", "",
|
||||
""
|
||||
]
|
||||
|
||||
|
||||
|
||||
def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
@@ -633,22 +793,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():
|
||||
|
||||
@@ -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))});
|
||||
@@ -185,6 +234,41 @@ flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})
|
||||
}
|
||||
|
||||
|
||||
def test_search_batch_uses_an_isolated_owner_journal_and_can_resume_without_dom_mounting():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const values={{}};
|
||||
const calls=[];
|
||||
const storage={{
|
||||
getItem:key=>values[key] || null,
|
||||
setItem:(key,value)=>{{values[key]=value;}},
|
||||
removeItem:key=>{{delete values[key];}},
|
||||
}};
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1, owner:()=> 'timmy', storage,
|
||||
journalName:'search-today-batch', autoMount:false,
|
||||
claim:item=>Promise.resolve({{...item,assigned_to_me:true}}),
|
||||
queue:()=>{{calls.push('queue');return 'sync-unavailable';}},
|
||||
}});
|
||||
flow.run([{{repository:'stackchain/dashboard',number:809}}]).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,keys:Object.keys(values),calls,pending:flow.pending()}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 1, "available": 1, "queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#809",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"keys": ["stackchain.search-today-batch.v1.timmy"],
|
||||
"calls": ["queue"],
|
||||
"pending": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_time_budget_blocks_claims_until_every_estimate_fits():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MERGER = Path(__file__).parents[1] / "frontend" / "checklist-conflict.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_merge_applies_local_task_delta_to_latest_body_without_reverting_remote_edits():
|
||||
script = f"""
|
||||
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||
const result = mergeChecklistConflict({{
|
||||
baseBody:'Intro\\n- [ ] Build\\n- [ ] Test',
|
||||
localBody:'Intro\\n- [x] Build\\n- [ ] Test',
|
||||
remoteBody:'Updated intro\\n- [ ] Test\\n- [ ] Build\\n- [x] Document',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"body": "Updated intro\n- [ ] Test\n- [x] Build\n- [x] Document",
|
||||
"changes": [{"label": "Build", "checked": True}],
|
||||
"conflicts": [],
|
||||
}
|
||||
|
||||
|
||||
def test_merge_reports_missing_or_ambiguous_changed_tasks_without_writing_a_body():
|
||||
script = f"""
|
||||
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||
const missing = mergeChecklistConflict({{
|
||||
baseBody:'- [ ] Ship', localBody:'- [x] Ship', remoteBody:'- [ ] Release',
|
||||
}});
|
||||
const duplicate = mergeChecklistConflict({{
|
||||
baseBody:'- [ ] Ship', localBody:'- [x] Ship', remoteBody:'- [ ] Ship\\n- [ ] Ship',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{missing,duplicate}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["missing"]["body"] is None
|
||||
assert output["missing"]["conflicts"] == [{"label": "Ship", "reason": "missing"}]
|
||||
assert output["duplicate"]["body"] is None
|
||||
assert output["duplicate"]["conflicts"] == [{"label": "Ship", "reason": "ambiguous"}]
|
||||
|
||||
|
||||
def test_merge_appends_a_locally_added_step_to_remote_edits_without_reverting_them():
|
||||
script = f"""
|
||||
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||
const result = mergeChecklistConflict({{
|
||||
baseBody:'Intro\\n- [ ] Build',
|
||||
localBody:'Intro\\n- [ ] Build\\n- [ ] Verify rollback',
|
||||
remoteBody:'Updated intro\\n- [x] Build\\n- [ ] Notify support',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"body": "Updated intro\n- [x] Build\n- [ ] Notify support\n- [ ] Verify rollback",
|
||||
"changes": [{"label": "Verify rollback", "checked": False, "added": True}],
|
||||
"conflicts": [],
|
||||
}
|
||||
|
||||
|
||||
def test_merge_rejects_an_ambiguous_locally_added_duplicate():
|
||||
script = f"""
|
||||
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||
const result = mergeChecklistConflict({{
|
||||
baseBody:'- [ ] Build',
|
||||
localBody:'- [ ] Build\\n- [ ] Verify rollback\\n- [ ] verify rollback ',
|
||||
remoteBody:'- [x] Build',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["body"] is None
|
||||
assert output["conflicts"] == [{"label": "Verify rollback", "reason": "ambiguous"}]
|
||||
|
||||
|
||||
def test_merge_replays_rename_and_reorder_intent_onto_unrelated_remote_edits():
|
||||
script = f"""
|
||||
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||
const result = mergeChecklistConflict({{
|
||||
baseBody:'Intro\\n- [ ] Build\\n- [ ] Test',
|
||||
localBody:'Intro\\n- [ ] Test\\n- [ ] Compile',
|
||||
remoteBody:'Updated intro\\n- [x] Build\\n- [ ] Test\\nRemote note',
|
||||
operations:[
|
||||
{{action:'rename',index:0,label:'Compile'}},
|
||||
{{action:'move-later',index:0}},
|
||||
],
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"body": "Updated intro\n- [ ] Test\n- [x] Compile\nRemote note",
|
||||
"changes": [
|
||||
{"label": "Build", "renamed": "Compile"},
|
||||
{"label": "Compile", "moved": "later"},
|
||||
],
|
||||
"conflicts": [],
|
||||
}
|
||||
@@ -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
|
||||
@@ -11,6 +11,7 @@ FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
COMMANDS = FRONTEND / "commands.js"
|
||||
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
||||
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
|
||||
SEARCH_DEFER = FRONTEND / "search-defer.js"
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
@@ -52,6 +53,80 @@ def test_command_script_resolves_inside_dashboard_subpath():
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
|
||||
|
||||
|
||||
def test_mobile_search_renders_touch_sized_type_and_status_scope_controls():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
commands = COMMANDS.read_text()
|
||||
|
||||
assert '<fieldset class="cmd-search-scope"' in html
|
||||
assert '<legend>Filter search results</legend>' in html
|
||||
assert '<label for="cmd-search-kind">Type</label>' in html
|
||||
assert '<select id="cmd-search-kind"' in html
|
||||
assert '<option value="pull">Pull requests</option>' in html
|
||||
assert '<label for="cmd-search-state">Status</label>' in html
|
||||
assert '<select id="cmd-search-state"' in html
|
||||
assert '.cmd-search-scope' in css
|
||||
assert '#cmd-search-kind, #cmd-search-state { min-height:44px;' in css
|
||||
assert "commandSearch.setScope(scope)" in html
|
||||
assert "taskOverlayHistory.update({ scope })" in html
|
||||
assert "'&kind=' + encodeURIComponent(scope.kind)" in commands
|
||||
assert "'&state=' + encodeURIComponent(scope.state)" in commands
|
||||
|
||||
|
||||
def test_mobile_search_exposes_accessible_repository_scope_and_forwards_it():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
commands = COMMANDS.read_text()
|
||||
|
||||
assert '<label for="cmd-search-repository">Repository</label>' in html
|
||||
assert '<input id="cmd-search-repository"' in html
|
||||
assert 'list="cmd-search-repositories"' in html
|
||||
assert '<datalist id="cmd-search-repositories">' in html
|
||||
assert "api/v1/repositories/search?q=" in html
|
||||
assert "repository:qs('#cmd-search-repository').value" in html
|
||||
assert "'&repository=' + encodeURIComponent(scope.repository)" in commands
|
||||
assert '#cmd-search-repository' in css
|
||||
|
||||
|
||||
def test_mobile_search_preview_exposes_a_keyboard_safe_reply_and_continue_composer():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
preview = SEARCH_PREVIEW.read_text()
|
||||
|
||||
assert '<section class="search-preview-reply"' in html
|
||||
assert '<textarea id="search-preview-reply"' in html
|
||||
assert '<button id="send-search-preview-reply"' in html
|
||||
assert '<button id="send-search-preview-reply-next"' in html
|
||||
assert "searchPreviewReplyOptions(fetchReviewJson" in html
|
||||
assert "root.searchPreviewReplyPath(item)" in preview
|
||||
assert "api.saveReplyDraft" in preview
|
||||
assert "api.reply({ advance:true })" in preview
|
||||
assert ".search-preview-reply textarea" in css
|
||||
assert "scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom))" in css
|
||||
|
||||
|
||||
def test_remote_command_search_repository_change_aborts_and_restarts_scoped_search():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const pending = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{signal, scope, resolve}})),
|
||||
onState() {{}},
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setScope({{kind:'all', state:'all', repository:'stackchain/api'}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (!pending[0].signal.aborted) throw new Error('repository change did not abort old request');
|
||||
if (pending[1].scope.repository !== 'stackchain/api') throw new Error('repository was not forwarded');
|
||||
pending[1].resolve({{items:[],has_more:false,next_page:2}});
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_ignores_stale_responses():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
@@ -85,6 +160,37 @@ if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result'
|
||||
)
|
||||
|
||||
|
||||
def test_remote_command_search_scope_change_aborts_and_restarts_without_stale_results():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const pending = [];
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{query, signal, page, scope, resolve}})),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setScope({{kind:'pull', state:'open'}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (!pending[0].signal.aborted) throw new Error('scope change did not abort old request');
|
||||
if (JSON.stringify(pending[1].scope) !== JSON.stringify({{kind:'pull', state:'open'}})) {{
|
||||
throw new Error('new request did not receive scope');
|
||||
}}
|
||||
pending[1].resolve({{items:[{{kind:'pull',repository:'a/b',number:2}}],has_more:false,next_page:2}});
|
||||
pending[0].resolve({{items:[{{kind:'issue',repository:'a/b',number:1}}],has_more:false,next_page:2}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.filter(state => state.status === 'ready').at(-1);
|
||||
if (ready.items.length !== 1 || ready.items[0].kind !== 'pull') throw new Error('stale scope result entered list');
|
||||
if (ready.scope.kind !== 'pull' || ready.scope.state !== 'open') throw new Error('scope missing from state');
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_aborts_superseded_and_cleared_queries():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
@@ -142,9 +248,83 @@ if (!ready || ready.items.length !== 1 || ready.partial !== true) {{
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_retries_partial_continuation_without_losing_results():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const requests = [];
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal, page, scope, continuation) => {{
|
||||
requests.push({{page, continuation}});
|
||||
if (requests.length === 1) return Promise.resolve({{
|
||||
items:[{{kind:'pull',repository:'a/b',number:2}}], partial:true,
|
||||
has_more:true, next_page:2, continuation:{{issues:1,pulls:null}}, failed_streams:['issue']
|
||||
}});
|
||||
return Promise.resolve({{
|
||||
items:[{{kind:'issue',repository:'a/b',number:1}}], partial:false,
|
||||
has_more:false, next_page:2, continuation:{{issues:null,pulls:null}}, failed_streams:[]
|
||||
}});
|
||||
}},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await controller.loadMore();
|
||||
const ready = states.filter(state => state.status === 'ready').at(-1);
|
||||
if (JSON.stringify(requests[1].continuation) !== JSON.stringify({{issues:1,pulls:null}})) {{
|
||||
throw new Error('retry did not preserve failed stream page: ' + JSON.stringify(requests));
|
||||
}}
|
||||
if (ready.partial || ready.items.length !== 2 || ready.items[0].kind !== 'pull' || ready.items[1].kind !== 'issue') {{
|
||||
throw new Error('recovered results were not appended: ' + JSON.stringify(ready));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_appends_deduplicated_pages_and_ignores_stale_load_more():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const pending = new Map();
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal, page) => new Promise(resolve => pending.set(query + ':' + page, resolve)),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get('mobile:1')({{ items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2 }});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.loadMore();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setQuery('release');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get('mobile:2')({{ items:[{{kind:'issue',repository:'a/b',number:1}},{{kind:'pull',repository:'a/b',number:2}}], has_more:false, next_page:3 }});
|
||||
pending.get('release:1')({{ items:[{{kind:'issue',repository:'a/b',number:3}}], has_more:false, next_page:2 }});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.filter(state => state.status === 'ready').at(-1);
|
||||
if (ready.query !== 'release' || ready.items.length !== 1 || ready.items[0].number !== 3) {{
|
||||
throw new Error('stale page entered current query: ' + JSON.stringify(states));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_command_selection_wraps_for_arrow_keys():
|
||||
script = f"""
|
||||
const commands = require({json.dumps(str(COMMANDS))});
|
||||
if (commands.searchUrl('mobile', 2, {{kind:'all',state:'open'}}, {{issues:1,pulls:3}}) !==
|
||||
'api/v1/search?q=mobile&limit=10&page=2&kind=all&state=open&issues_page=1&pulls_page=3') {{
|
||||
throw new Error('independent continuation query was not encoded');
|
||||
}}
|
||||
if (commands.nextSelection(-1, 'ArrowDown', 3) !== 0) throw new Error('should select first');
|
||||
if (commands.nextSelection(2, 'ArrowDown', 3) !== 0) throw new Error('should wrap down');
|
||||
if (commands.nextSelection(0, 'ArrowUp', 3) !== 2) throw new Error('should wrap up');
|
||||
@@ -160,16 +340,287 @@ def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath()
|
||||
assert 'role="combobox"' in html
|
||||
assert 'aria-controls="cmd-results"' in html
|
||||
assert 'role="listbox"' in html
|
||||
assert "fetch('api/v1/search?q='" in html
|
||||
assert "async function searchGlobalWork(query, signal)" in html
|
||||
assert "fetch(filterCommands.searchUrl(" in html
|
||||
assert "async function searchGlobalWork(query, signal, page = 1, scope" in html
|
||||
assert "signal," in html
|
||||
assert "Some results are temporarily unavailable." in html
|
||||
assert "Retry missing results" in html
|
||||
assert 'id="cmd-load-more"' in html
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/",
|
||||
"api/v1/search?q=mobile",
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile"
|
||||
|
||||
|
||||
def test_search_preview_navigates_a_loaded_result_session_and_reports_position():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const items = [1, 2, 3].map(number => ({{repository:'stackchain/api', number, kind:'issue'}}));
|
||||
const states = [];
|
||||
const navigated = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
|
||||
mutate: () => Promise.resolve(),
|
||||
getSession: () => ({{items, more:false}}),
|
||||
loadMore: () => Promise.resolve(),
|
||||
onNavigate: item => navigated.push(item.number),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open(items[1]);
|
||||
await preview.previous();
|
||||
await preview.next();
|
||||
const ready = states.filter(state => state.status === 'ready');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
navigated,
|
||||
positions:ready.map(state => state.navigation && [state.navigation.position, state.navigation.total,
|
||||
state.navigation.hasPrevious, state.navigation.hasNext]),
|
||||
current:ready.at(-1).detail.number,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"navigated": [1, 2],
|
||||
"positions": [[2, 3, True, True], [1, 3, False, True], [2, 3, True, True]],
|
||||
"current": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const item = {{repository:'stackchain/api', number:42, kind:'pull'}};
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const calls = [];
|
||||
const states = [];
|
||||
let attempt = 0;
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: candidate => Promise.resolve({{...candidate, title:'Review reply'}}),
|
||||
mutate: () => Promise.resolve(),
|
||||
postReply: (candidate, body, operationId) => {{
|
||||
calls.push([candidate.number, body, operationId]);
|
||||
attempt += 1;
|
||||
return attempt === 1 ? Promise.reject(new Error('offline')) : Promise.resolve({{
|
||||
id:91, author:'timmy', body, created_at:'2026-08-14T12:30:00Z'
|
||||
}});
|
||||
}},
|
||||
storage,
|
||||
createOperationId: () => 'reply-operation-42',
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open(item);
|
||||
preview.saveReplyDraft('Ready to merge.');
|
||||
try {{ await preview.reply(); }} catch (_) {{}}
|
||||
const draftAfterFailure = preview.replyDraft();
|
||||
await preview.reply();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
draftAfterFailure,
|
||||
draftAfterSuccess:preview.replyDraft(),
|
||||
comments:states.filter(state => state.conversation).at(-1).conversation.comments,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": [
|
||||
[42, "Ready to merge.", "reply-operation-42"],
|
||||
[42, "Ready to merge.", "reply-operation-42"],
|
||||
],
|
||||
"draftAfterFailure": "Ready to merge.",
|
||||
"draftAfterSuccess": "",
|
||||
"comments": [{
|
||||
"id": 91,
|
||||
"author": "timmy",
|
||||
"body": "Ready to merge.",
|
||||
"created_at": "2026-08-14T12:30:00Z",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_reply_and_next_advances_only_after_server_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const items = [1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}}));
|
||||
const values = new Map();
|
||||
let confirm;
|
||||
const navigated = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson:item => Promise.resolve(item), mutate:() => Promise.resolve(),
|
||||
postReply:() => new Promise(resolve => {{ confirm = resolve; }}),
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
createOperationId:() => 'reply-next-1',
|
||||
getSession:() => ({{items,more:false}}), loadMore:() => Promise.resolve(),
|
||||
onNavigate:item => navigated.push(item.number), onState:() => {{}},
|
||||
}});
|
||||
await preview.open(items[0]);
|
||||
preview.saveReplyDraft('Done.');
|
||||
const pending = preview.reply({{advance:true}});
|
||||
await Promise.resolve();
|
||||
const before = [...navigated];
|
||||
confirm({{id:7,author:'timmy',body:'Done.'}});
|
||||
await pending;
|
||||
process.stdout.write(JSON.stringify({{before,after:navigated,draft:preview.replyDraft()}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": [],
|
||||
"after": [2],
|
||||
"draft": "",
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_keeps_confirmed_reply_when_earlier_conversation_load_finishes_late():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const values = new Map();
|
||||
let finishConversation;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson:item => Promise.resolve({{...item,title:'Race'}}),
|
||||
fetchConversation:() => new Promise(resolve => {{ finishConversation = resolve; }}),
|
||||
mutate:() => Promise.resolve(),
|
||||
postReply:(_item,body) => Promise.resolve({{id:9,author:'timmy',body}}),
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
createOperationId:() => 'reply-race-9', onState:state => states.push(state),
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api',number:9,kind:'issue'}});
|
||||
preview.saveReplyDraft('Confirmed reply');
|
||||
await preview.reply();
|
||||
finishConversation({{comments:[{{id:3,author:'alex',body:'Earlier'}}],older_page:null}});
|
||||
await Promise.resolve();
|
||||
const comments = states.filter(state => state.conversation?.status === 'ready').at(-1).conversation.comments;
|
||||
process.stdout.write(JSON.stringify(comments.map(comment => comment.id)));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [3, 9]
|
||||
|
||||
|
||||
def test_search_preview_next_loads_one_page_at_boundary_then_announces_completion():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let session = {{items:[1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}})), more:true}};
|
||||
let loads = 0;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(),
|
||||
getSession: () => session,
|
||||
loadMore: async () => {{
|
||||
loads += 1;
|
||||
session = {{items:[...session.items, {{repository:'stackchain/api', number:3, kind:'issue'}}], more:false}};
|
||||
}},
|
||||
onNavigate() {{}}, onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open(session.items[1]);
|
||||
await preview.next();
|
||||
await preview.next();
|
||||
const final = states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{loads, current:final.detail.number, complete:final.complete,
|
||||
navigation:final.navigation}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"loads": 1,
|
||||
"current": 3,
|
||||
"complete": True,
|
||||
"navigation": {"position": 3, "total": 3, "hasPrevious": True, "hasNext": False},
|
||||
}
|
||||
|
||||
|
||||
def test_remote_search_load_more_is_awaitable_for_preview_boundary_navigation():
|
||||
script = f"""
|
||||
const commands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const states = [];
|
||||
const controller = commands.createGlobalSearchController({{
|
||||
delay:0,
|
||||
search: async (_query, _signal, page) => page === 1
|
||||
? {{items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2}}
|
||||
: {{items:[{{kind:'issue',repository:'a/b',number:2}}], has_more:false, next_page:3}},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
const loaded = await controller.loadMore();
|
||||
process.stdout.write(JSON.stringify({{numbers:loaded.items.map(item => item.number), more:loaded.more}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"numbers": [1, 2], "more": False}
|
||||
|
||||
|
||||
def test_mobile_search_preview_progressively_renders_a_retryable_paged_conversation():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
preview_source = SEARCH_PREVIEW.read_text()
|
||||
|
||||
assert 'id="search-preview-conversation"' in html
|
||||
assert 'id="search-preview-comments"' in html
|
||||
assert 'id="search-preview-conversation-status"' in html
|
||||
assert 'id="retry-search-preview-conversation"' in html
|
||||
assert 'id="load-older-search-preview-comments"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "fetchConversation:" in html
|
||||
assert "api.retryConversation()" in preview_source
|
||||
assert "api.loadOlderConversation()" in preview_source
|
||||
assert "renderMarkdown(comment.body || '')" in preview_source
|
||||
assert ".search-preview-conversation" in css
|
||||
assert ".search-preview-comment" in css
|
||||
assert "min-height:44px" in css
|
||||
|
||||
|
||||
def test_mobile_search_preview_renders_session_navigation_and_advances_after_planning():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="previous-search-result"' in html
|
||||
assert 'id="search-preview-position"' in html
|
||||
assert 'id="next-search-result"' in html
|
||||
assert 'aria-label="Search result navigation"' in html
|
||||
assert ".search-preview-navigation" in css
|
||||
assert ".search-preview-header button, .search-preview-actions button" in css
|
||||
assert "session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})]" in html
|
||||
preview_source = SEARCH_PREVIEW.read_text()
|
||||
assert "api.previous()" in preview_source
|
||||
assert "api.next()" in preview_source
|
||||
|
||||
defer_flow = html.split("if (context === 'search')", 1)[1].split(
|
||||
"if (context === 'detail')", 1
|
||||
)[0]
|
||||
queue_flow = html.split(
|
||||
"qs('#queue-search-result').addEventListener('click'", 1
|
||||
)[1].split("qs('#start-search-result')", 1)[0]
|
||||
assert "next()" in defer_flow
|
||||
assert "next()" in queue_flow
|
||||
assert "closeSearchPreview()" not in defer_flow
|
||||
assert "if (outcome === 'queued') await next()" in queue_flow
|
||||
|
||||
|
||||
def test_search_preview_ignores_stale_result_details():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
@@ -197,6 +648,85 @@ if (ready.length !== 1 || ready[0].detail.number !== 2) {{
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_publishes_detail_before_progressive_conversation_and_ignores_stale_comments():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const pending = new Map();
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
|
||||
fetchConversation: item => new Promise(resolve => pending.set(item.number, resolve)),
|
||||
mutate: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api', number:1, kind:'issue'}});
|
||||
const firstReady = states.find(state => state.status === 'ready' && state.detail?.number === 1);
|
||||
if (!firstReady || firstReady.conversation?.status !== 'loading') throw new Error('detail waited for conversation');
|
||||
await preview.open({{repository:'stackchain/api', number:2, kind:'issue'}});
|
||||
pending.get(1)({{comments:[{{id:1,body:'stale'}}], page:1, older_page:null}});
|
||||
pending.get(2)({{comments:[{{id:2,body:'current'}}], page:1, older_page:null}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const final = states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
number:final.detail.number,
|
||||
status:final.conversation.status,
|
||||
comments:final.conversation.comments.map(comment => comment.body),
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"number": 2,
|
||||
"status": "ready",
|
||||
"comments": ["current"],
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_retries_failed_conversation_and_prepends_older_messages_without_duplicates():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let calls = 0;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve({{...item, title:'Issue'}}),
|
||||
fetchConversation: (_item, page) => {{
|
||||
calls += 1;
|
||||
if (calls === 1) return Promise.reject(new Error('offline'));
|
||||
if (page === 1) return Promise.resolve({{comments:[{{id:1,body:'old'}},{{id:3,body:'duplicate'}}], older_page:null}});
|
||||
return Promise.resolve({{comments:[{{id:3,body:'newer'}},{{id:4,body:'newest'}}], older_page:1}});
|
||||
}},
|
||||
mutate: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api', number:42, kind:'issue'}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (states.at(-1).conversation.status !== 'error') throw new Error('failure unavailable');
|
||||
await preview.retryConversation();
|
||||
await preview.loadOlderConversation();
|
||||
const final = states.at(-1).conversation;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
status:final.status,
|
||||
ids:final.comments.map(comment => comment.id),
|
||||
olderPage:final.olderPage,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": 3,
|
||||
"status": "ready",
|
||||
"ids": [1, 3, 4],
|
||||
"olderPage": None,
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_claim_is_single_flight_and_reports_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
@@ -249,6 +779,71 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_shares_canonical_url_without_closing_the_preview():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const calls = [];
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item),
|
||||
mutate: () => Promise.resolve(),
|
||||
share: async url => {{ calls.push(url); return 'shared'; }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
const detail = {{ repository:'stackchain/api', number:42, kind:'issue' }};
|
||||
await preview.open(detail);
|
||||
const result = await preview.share('https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42');
|
||||
process.stdout.write(JSON.stringify({{ result, calls, states }}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["result"] == "shared"
|
||||
assert payload["calls"] == [
|
||||
"https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42"
|
||||
]
|
||||
assert payload["states"][-1]["status"] == "shared"
|
||||
assert payload["states"][-1]["detail"]["number"] == 42
|
||||
|
||||
|
||||
def test_search_preview_share_is_single_flight_and_cancellation_restores_ready_context():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let calls = 0;
|
||||
let rejectShare;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(),
|
||||
share: () => {{ calls += 1; return new Promise((_resolve, reject) => rejectShare = reject); }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
|
||||
const first = preview.share('https://forge.example/dashboard/?search=x');
|
||||
const second = preview.share('https://forge.example/dashboard/?search=x');
|
||||
const canceled = new Error('canceled'); canceled.name = 'AbortError'; rejectShare(canceled);
|
||||
await Promise.allSettled([first, second]);
|
||||
const state = states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{ calls, same:first === second, status:state.status,
|
||||
item:state.item, detail:state.detail, errorName:state.error?.name }}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": 1,
|
||||
"same": True,
|
||||
"status": "share-canceled",
|
||||
"item": {"repository": "stackchain/api", "number": 42, "kind": "issue"},
|
||||
"detail": {"repository": "stackchain/api", "number": 42, "kind": "issue"},
|
||||
"errorName": "AbortError",
|
||||
}
|
||||
|
||||
|
||||
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
@@ -261,12 +856,60 @@ def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
assert "qs('#cmd-input').value = ''" not in remote_branch
|
||||
|
||||
|
||||
def test_search_preview_share_action_uses_canonical_stackchain_url_and_keeps_context():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "share: url => createWorkRoute.share(url, navigator, navigator.clipboard)" in html
|
||||
handler = html.split("qs('#share-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#claim-search-result')", 1
|
||||
)[0]
|
||||
assert "canonicalSearchPreviewUrl()" in handler
|
||||
assert "searchPreview.share" in handler
|
||||
assert "closeSearchPreview" not in handler
|
||||
assert "taskOverlayHistory.close" not in handler
|
||||
|
||||
|
||||
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
|
||||
assert "claimButton.hidden = !(detail.claimable || detail.assigned_to_me)" in html
|
||||
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
|
||||
assert "openPreviewIssueInMyWork" in html
|
||||
assert "openPreviewWorkInMyWork" in html
|
||||
|
||||
|
||||
def test_assigned_pull_preview_hands_off_to_existing_my_work_sheet_without_claiming():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "claimButton.hidden = !(detail.claimable || detail.assigned_to_me)" in html
|
||||
assert "async function openPreviewWorkInMyWork(detail)" in html
|
||||
handoff = html.split("async function openPreviewWorkInMyWork(detail)", 1)[1].split(
|
||||
"function runCommandItem", 1
|
||||
)[0]
|
||||
assert "candidate.kind === detail.kind" in handoff
|
||||
assert "candidate.key === detail.repository + '#' + detail.number" in handoff
|
||||
|
||||
click_handler = html.split(
|
||||
"qs('#claim-search-result').addEventListener('click'", 1
|
||||
)[1].split("qs('#start-search-result')", 1)[0]
|
||||
assert "if (claimed.claimable) await searchPreview.claim(claimed)" in click_handler
|
||||
assert "await openPreviewWorkInMyWork(claimed)" in click_handler
|
||||
|
||||
|
||||
def test_requested_pull_preview_opens_native_review_without_assignment_mutation():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert "startButton.hidden = !(detail.reviewable" in html
|
||||
assert "detail.reviewable ? 'Review now'" in html
|
||||
handler = html.split(
|
||||
"qs('#start-search-result').addEventListener('click'", 1
|
||||
)[1].split("qs('#close-whiteboard')", 1)[0]
|
||||
assert "detail.kind = 'review'" in handler
|
||||
assert "openRoutedWork" in handler
|
||||
assert "{ replace:true }" in handler
|
||||
assert "searchPreview.claim" not in handler
|
||||
assert "fetchReviewJson" not in handler
|
||||
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
|
||||
|
||||
|
||||
def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
@@ -281,10 +924,144 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
|
||||
|
||||
def test_search_preview_queues_eligible_issue_and_continues_preserved_search():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="queue-search-result"' in html
|
||||
assert "queueButton.hidden = !(detail.kind === 'issue'" in html
|
||||
assert "detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today'" in html
|
||||
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
|
||||
"const searchAssignAndStart", 1
|
||||
)[0]
|
||||
assert "queue: confirmed => queueToday(acceptClaimedIssue(confirmed))" in orchestrator
|
||||
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#start-search-result')", 1
|
||||
)[0]
|
||||
assert "destination: 'queue'" in handler
|
||||
assert "alreadyOwned: detail.assigned_to_me" in handler
|
||||
assert "next()" in handler
|
||||
assert "closeSearchPreview()" not in handler
|
||||
assert "mobileSearchViewport.restoreScroll()" in html
|
||||
assert "@media (max-width:420px)" in css
|
||||
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
|
||||
|
||||
|
||||
def test_search_queue_confirmation_is_accessible_and_existing_today_item_is_not_readded():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'id="cmd-search-action-status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
|
||||
"const searchAssignAndStart", 1
|
||||
)[0]
|
||||
assert "qs('#cmd-search-action-status').textContent = message" in orchestrator
|
||||
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#start-search-result')", 1
|
||||
)[0]
|
||||
assert "todayWork.contains(detail)" in handler
|
||||
assert "Already in Today." in handler
|
||||
assert handler.index("todayWork.contains(detail)") < handler.index("searchAssignAndStart.run")
|
||||
|
||||
|
||||
def test_search_defer_claims_once_then_schedules_the_canonical_issue_without_touching_today():
|
||||
script = f"""
|
||||
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
|
||||
(async () => {{
|
||||
const calls = [];
|
||||
let releaseClaim;
|
||||
const flow = createSearchDefer({{
|
||||
claim: detail => {{
|
||||
calls.push(['claim', detail.number]);
|
||||
return new Promise(resolve => releaseClaim = resolve);
|
||||
}},
|
||||
accept: confirmed => {{
|
||||
calls.push(['accept', confirmed.key]);
|
||||
return {{kind:'issue', repository:confirmed.repository, number:confirmed.number, key:confirmed.key}};
|
||||
}},
|
||||
defer: (item, until) => {{ calls.push(['defer', item.key, until.toISOString()]); return 'deferred'; }},
|
||||
refresh: () => calls.push(['refresh']),
|
||||
announce: message => calls.push(['announce', message]),
|
||||
formatTime: value => value.toISOString(),
|
||||
}});
|
||||
const detail = {{kind:'issue', repository:'stackchain/api', number:42, claimable:true}};
|
||||
const until = new Date('2026-08-15T09:00:00.000Z');
|
||||
const first = flow.run(detail, until);
|
||||
const second = flow.run(detail, until);
|
||||
if (first !== second) throw new Error('confirmation was not single-flight');
|
||||
releaseClaim({{kind:'issue', repository:'stackchain/api', number:42, key:'stackchain/api#42'}});
|
||||
const outcome = await first;
|
||||
process.stdout.write(JSON.stringify({{outcome, calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"outcome": "deferred",
|
||||
"calls": [
|
||||
["claim", 42],
|
||||
["accept", "stackchain/api#42"],
|
||||
["defer", "stackchain/api#42", "2026-08-15T09:00:00.000Z"],
|
||||
["refresh"],
|
||||
["announce", "Assigned and deferred until 2026-08-15T09:00:00.000Z."],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_opens_later_picker_before_claiming_an_eligible_issue():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="defer-search-result"' in html
|
||||
assert "const deferButton = qs('#defer-search-result')" in html
|
||||
assert "deferButton.hidden = !(detail.kind === 'issue'" in html
|
||||
assert "detail.assigned_to_me ? 'Defer' : 'Assign & defer'" in html
|
||||
handler = html.split("qs('#defer-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#queue-search-result')", 1
|
||||
)[0]
|
||||
assert "laterPicker.open(detail, event.currentTarget, 'search')" in handler
|
||||
assert "searchPreview.claim" not in handler
|
||||
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
|
||||
assert "@media (max-width:420px)" in css
|
||||
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
|
||||
|
||||
|
||||
def test_search_defer_reports_recoverable_partial_outcome_when_later_storage_fails_after_claim():
|
||||
script = f"""
|
||||
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
|
||||
(async () => {{
|
||||
const messages = [];
|
||||
let refreshes = 0;
|
||||
const flow = createSearchDefer({{
|
||||
claim: () => Promise.resolve({{kind:'issue', repository:'stackchain/api', number:42}}),
|
||||
accept: issue => issue,
|
||||
defer: () => 'unavailable',
|
||||
refresh: () => refreshes += 1,
|
||||
announce: message => messages.push(message),
|
||||
formatTime: value => value.toISOString(),
|
||||
}});
|
||||
const outcome = await flow.run({{kind:'issue', repository:'stackchain/api', number:42, claimable:true}},
|
||||
new Date('2026-08-15T09:00:00.000Z'));
|
||||
process.stdout.write(JSON.stringify({{outcome, refreshes, messages}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"outcome": "unavailable",
|
||||
"refreshes": 1,
|
||||
"messages": [
|
||||
"Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry."
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "startButton.textContent = detail.reopenable ? 'Reopen & resume'" in html
|
||||
assert "detail.reopenable ? 'Reopen & resume'" in html
|
||||
assert "mutate: (detail, action) => fetchReviewJson(" in html
|
||||
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-whiteboard')", 1
|
||||
|
||||
@@ -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-v98" in worker
|
||||
assert "stackchain-dashboard-shell-v108" in worker
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user