Compare commits
257
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5c5c8ec31 | ||
|
|
87e0d74414 | ||
|
|
f031a37e8d | ||
|
|
057bec7dc2 | ||
|
|
654ae8714e | ||
|
|
a0dd044995 | ||
|
|
c93a682dfb | ||
|
|
5675ca5d41 | ||
|
|
0767ccaa9d | ||
|
|
c6eabb5fc7 | ||
|
|
77fab85a66 | ||
|
|
dcf26c896c | ||
|
|
fba1f4238d | ||
|
|
9e301924b4 | ||
|
|
8edd542379 | ||
|
|
0fbe047e03 | ||
|
|
995d8f8527 | ||
|
|
fabd6123f8 | ||
|
|
2553e7121b | ||
|
|
681e10c7db | ||
|
|
d96a77c5d5 | ||
|
|
97eb928187 | ||
|
|
80a4096118 | ||
|
|
f04a547e19 | ||
|
|
ea3712ca3e | ||
|
|
7421a8f62d | ||
|
|
982c2a1fb7 | ||
|
|
3e7d6f7111 | ||
|
|
32394fcd8f | ||
|
|
ea3194cdca | ||
|
|
85043b4af6 | ||
|
|
dc202bde11 | ||
|
|
c4ba78c8cb | ||
|
|
e35d7dea35 | ||
|
|
b81d695585 | ||
|
|
75da76c6f5 | ||
|
|
f78d69fba5 | ||
|
|
8ce64d2aa7 | ||
|
|
48f75cff05 | ||
|
|
58260d8a26 | ||
|
|
54ff44462e | ||
|
|
a87a5a4a90 | ||
|
|
c91194c283 | ||
|
|
5806d47b61 | ||
|
|
95f24a4e90 | ||
|
|
c016261b1a | ||
|
|
bbc3e10525 | ||
|
|
9737300d55 | ||
|
|
a08a51af02 | ||
|
|
528d721cd3 | ||
|
|
a91788276c | ||
|
|
28a2c9faf1 | ||
|
|
b8befc2041 | ||
|
|
d23c0c894b | ||
|
|
7948d2e353 | ||
|
|
1079ec6561 | ||
|
|
8d142c808b | ||
|
|
776ae70019 | ||
|
|
285fdff869 | ||
|
|
89d153d6a5 | ||
|
|
fcb0575e38 | ||
|
|
14ba1bcf14 | ||
|
|
a68639668c | ||
|
|
1f0e9fb59d | ||
|
|
065275b4ef | ||
|
|
90f1c48565 | ||
|
|
ae07dd185c | ||
|
|
f43f776479 | ||
|
|
48e4344f28 | ||
|
|
269916486c | ||
|
|
07235d3870 | ||
|
|
8c32a9dd81 | ||
|
|
9edd3f6441 | ||
|
|
4faf0447be | ||
|
|
8ea46974ff | ||
|
|
075a277265 | ||
|
|
a7ec4437d9 | ||
|
|
bf2cf37151 | ||
|
|
fb58ba30b2 | ||
|
|
17823352f2 | ||
|
|
14a3e1ee8d | ||
|
|
8dc155e49a | ||
|
|
7aa25f1d8c | ||
|
|
f023414929 | ||
|
|
96e7b35419 | ||
|
|
4c13e13275 | ||
|
|
ebe19f2281 | ||
|
|
798790ce1a | ||
|
|
9629643a63 | ||
|
|
9f3855d779 | ||
|
|
c99dd3e3be | ||
|
|
7e173640ed | ||
|
|
87f63370f6 | ||
|
|
614ffae188 | ||
|
|
aaa9aef78b | ||
|
|
10a8b2738b | ||
|
|
379f2b8203 | ||
|
|
53bf89bd47 | ||
|
|
697f72da09 | ||
|
|
c170240057 | ||
|
|
3043ccc846 | ||
|
|
c5ddb1275b | ||
|
|
1fb26e0a86 | ||
|
|
be7bfcaa5a | ||
|
|
6a3c6c7a9e | ||
|
|
cab230e3a2 | ||
|
|
80ea81ad73 | ||
|
|
8cd2459468 | ||
|
|
7ee10619a4 | ||
|
|
a8da5ce949 | ||
|
|
8e357a097b | ||
|
|
43f5833f7f | ||
|
|
27fff15da4 | ||
|
|
a2b78710f2 | ||
|
|
e9271f5aab | ||
|
|
257bc0110e | ||
|
|
5e0683cd4a | ||
|
|
c94fc5308f | ||
|
|
0304f5ab2c | ||
|
|
50d51ed159 | ||
|
|
8e74f022eb | ||
|
|
92b0fd56ca | ||
|
|
7ce352553d | ||
|
|
f4a4ed84c3 | ||
|
|
b21888fd7a | ||
|
|
320d6348ca | ||
|
|
7f8ee771dc | ||
|
|
f446e10670 | ||
|
|
79309196b8 | ||
|
|
cdf6ecdcb8 | ||
|
|
797fd0d544 | ||
|
|
a13e25f6c9 | ||
|
|
91e5b1e0c9 | ||
|
|
1f05521094 | ||
|
|
59c263fc59 | ||
|
|
4c64cc4c3e | ||
|
|
6e90e3fa9e | ||
|
|
83702e75b0 | ||
|
|
446281674b | ||
|
|
9c5b100d6a | ||
|
|
163bfad30a | ||
|
|
23c8e57a8e | ||
|
|
350bfc067d | ||
|
|
98aecd8255 | ||
|
|
7d4ff1f095 | ||
|
|
3605c42651 | ||
|
|
d7e2813700 | ||
|
|
fb34b5ffda | ||
|
|
820da7130d | ||
|
|
3bad8a0198 | ||
|
|
8bc5f623ac | ||
|
|
175817e6d8 | ||
|
|
fff7bccf08 | ||
|
|
ee36b8824a | ||
|
|
2c8a1225e0 | ||
|
|
29ceedfc01 | ||
|
|
11a56b21a1 | ||
|
|
3e16dd00d1 | ||
|
|
31f6097afc | ||
|
|
beaf5e0587 | ||
|
|
0a96dfb173 | ||
|
|
7cadb36b41 | ||
|
|
b1890a29ff | ||
|
|
100d38cc5c | ||
|
|
e5a352b418 | ||
|
|
8907263760 | ||
|
|
fdcd0d31ab | ||
|
|
5bef6291e8 | ||
|
|
9724358c89 | ||
|
|
7555cefccb | ||
|
|
04621223fd | ||
|
|
e5016d8b86 | ||
|
|
b455dc6286 | ||
|
|
338b6ebbd0 | ||
|
|
f18c59c7b3 | ||
|
|
e9cab92aff | ||
|
|
877dc475dd | ||
|
|
28e70f6415 | ||
|
|
2a6f2ab12d | ||
|
|
c8809a0728 | ||
|
|
2717f41957 | ||
|
|
b26b57d616 | ||
|
|
1052ce2c84 | ||
|
|
4f0e6544b3 | ||
|
|
91c59a2953 | ||
|
|
6d1fa3c068 | ||
|
|
21a386b94f | ||
|
|
7e5b966f77 | ||
|
|
78d044f1af | ||
|
|
814c5726ec | ||
|
|
95a14c6de5 | ||
|
|
37aa2dd090 | ||
|
|
6565165295 | ||
|
|
4749d15d85 | ||
|
|
16604d697b | ||
|
|
59b4d5e6bf | ||
|
|
83af187c73 | ||
|
|
d4896e10be | ||
|
|
81e1af7f16 | ||
|
|
b1b3f758e4 | ||
|
|
ced644b275 | ||
|
|
131848cc43 | ||
|
|
62a0841eb5 | ||
|
|
cc63fc6499 | ||
|
|
9dede4bf2a | ||
|
|
b667963968 | ||
|
|
31b0e79aa1 | ||
|
|
8206856d94 | ||
|
|
7ec3330175 | ||
|
|
ec339d242d | ||
|
|
d161cf41c0 | ||
|
|
535658185a | ||
|
|
8b7c56161c | ||
|
|
2b2e2cceb4 | ||
|
|
de596f397f | ||
|
|
af86fc1e98 | ||
|
|
5bf491c725 | ||
|
|
11341ec5d9 | ||
|
|
eefb760169 | ||
|
|
3f9dd49766 | ||
|
|
72453b76db | ||
|
|
7639a42d7b | ||
|
|
0b992543f1 | ||
|
|
9904f17e3d | ||
|
|
0dd3932d14 | ||
|
|
8e6ee96ede | ||
|
|
161977cb67 | ||
|
|
e3c07c2dfb | ||
|
|
a610708e36 | ||
|
|
f8c5c2b058 | ||
|
|
72c470b652 | ||
|
|
b214a49686 | ||
|
|
a9508984e2 | ||
|
|
20d5176713 | ||
|
|
c96ed51b0d | ||
|
|
cf2e49300a | ||
|
|
5e49f27142 | ||
|
|
0280077e45 | ||
|
|
f781410e80 | ||
|
|
0311f85d96 | ||
|
|
5a6ef62c4c | ||
|
|
461c796227 | ||
|
|
6309ed43cd | ||
|
|
a0aa3c0a22 | ||
|
|
60bc7cd686 | ||
|
|
79c8f1626b | ||
|
|
cd9f40a1c4 | ||
|
|
0a3fb180ed | ||
|
|
74d1435ca7 | ||
|
|
8989e93c2c | ||
|
|
7b3b7dadb9 | ||
|
|
05f9e06a10 | ||
|
|
e0de45ea90 | ||
|
|
3224389441 | ||
|
|
63723e7fa3 | ||
|
|
740e91a615 | ||
|
|
0363f23cef |
No files matched your search
@@ -19,9 +19,35 @@ threads, create and self-assign issues, discover, claim, and release issue assig
|
||||
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
||||
inspect/comment on assigned pull
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
|
||||
For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload
|
||||
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
|
||||
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.
|
||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||
issue-comment API; mobile issue capture requires issue
|
||||
creation and assignment permission. Once a repository and meaningful title are selected,
|
||||
issue-comment API. In issue, pull-request, and unread-update conversations, typing
|
||||
at least two characters after `@` offers repository-scoped teammate suggestions;
|
||||
touch or keyboard selection inserts the login without leaving the draft. Mention
|
||||
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
||||
creation and assignment permission. A fresh capture never silently targets the first repository:
|
||||
the operator must explicitly choose one, either from the paginated browser or through the bounded
|
||||
authenticated repository search. Search results include only visible repository identities, stale
|
||||
responses cannot replace a newer query, and search failure leaves the draft and browse fallback
|
||||
intact. Saved drafts restore their exact repository even when it is outside the first page. Once a
|
||||
repository and meaningful title are selected,
|
||||
the New issue sheet checks for similar open issues in that repository. Candidate links keep
|
||||
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
|
||||
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
|
||||
@@ -37,13 +63,26 @@ 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,
|
||||
five-item Today plan syncs across the operator's devices. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device. After a reload or installed-app
|
||||
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,
|
||||
and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through **Plan Today**
|
||||
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
|
||||
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
|
||||
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);
|
||||
**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. Delivery or local-admission failure preserves both the draft
|
||||
and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
||||
one without closing or merging it. **Reply & next** provides the same one-action continuation
|
||||
for the current unread-update conversation. It deliberately leaves the notification unread;
|
||||
an open, unassigned issue update also offers **Take ownership & start**, which checks Today capacity
|
||||
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
|
||||
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,
|
||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
||||
@@ -73,16 +112,30 @@ not changed by this session boundary. Installed-app
|
||||
navigations are also deadline-bounded: after four seconds without a network response,
|
||||
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
|
||||
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
|
||||
Live dashboard refreshes use the server's per-section freshness windows: healthy sections
|
||||
refresh at their earliest deadline while a fully degraded snapshot waits for its reported
|
||||
cooldown. HTTP `Retry-After` delays are honored up to five minutes, and transport failures
|
||||
back off from eight seconds to a one-minute cap. Reconnect, foreground return, and explicit
|
||||
refresh still run immediately; a successful response resets transport backoff.
|
||||
Closed-app
|
||||
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
|
||||
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
|
||||
with the unchanged idempotency key. Device purge cancels an active drain before closing
|
||||
with the unchanged idempotency key. Each finite, lane-fair drain admits at most 70 records
|
||||
and runs up to three deliveries concurrently, but claims a record only when a delivery slot
|
||||
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
|
||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
||||
writes run outside the request event loop, and lock admission is bounded to 100 ms by
|
||||
default. Tune it with `STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS`; keep the value below
|
||||
route deadlines. Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
|
||||
route deadlines. During a controlled shutdown, the server gives in-flight authored mutations
|
||||
five seconds to finish and persist their ledger result before it closes the Gitea transport.
|
||||
Set `STACKCHAIN_AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS` to match the service manager's
|
||||
shutdown budget; when that deadline expires, remaining operations are cancelled and their
|
||||
pending reservations continue to fail closed rather than being retried automatically.
|
||||
Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
|
||||
If contention occurs after the upstream mutation, the dashboard fails closed with
|
||||
`Retry-After: 5` and asks the caller to verify the result before retrying. Direct API callers
|
||||
should preserve the `Idempotency-Key` header with the unchanged route and payload until a
|
||||
@@ -91,7 +144,12 @@ Approve, and Request changes reviews, and assigned-PR merge require repository
|
||||
write permission. Native Comment, Approve, and Request changes reviews support
|
||||
head-scoped draft comments anchored to changed lines; the dashboard validates each
|
||||
comment path and submits the summary, decision, and inline comments in one review
|
||||
request. The dashboard rechecks the current pull-request head, CI success, draft
|
||||
request. On phone-width review sheets, changed lines wrap inside a stable old/new
|
||||
line-number gutter by default so long source lines remain readable and commentable
|
||||
without horizontal panning. **Lines wrapped** toggles back to whitespace-preserving
|
||||
horizontal inspection, and an explicit choice persists on the device across files,
|
||||
review-sheet reopen, and reload. Desktop review diffs remain horizontally scrollable
|
||||
until wrapping is explicitly enabled. The dashboard rechecks the current pull-request head, CI success, draft
|
||||
state, and mergeability immediately before every merge.
|
||||
Serve the dashboard only to trusted users on its own origin; cross-origin API
|
||||
access is intentionally disabled. Authentication defaults to fail-closed
|
||||
@@ -108,28 +166,69 @@ export GITEA_TOKEN='<read-notification-and-issue-write-token>'
|
||||
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
|
||||
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
||||
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||
export STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN='https://forge.example.com'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
|
||||
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/security-events.sqlite3.
|
||||
export STACKCHAIN_SECURITY_EVENT_DB='/var/lib/stackchain-dashboard/security-events.sqlite3'
|
||||
# Optional compatibility overrides; defaults derive from the required public origin.
|
||||
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
|
||||
export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com'
|
||||
# Optional; defaults to eight hours.
|
||||
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
|
||||
# Optional; explicit pointer, keyboard, or touch activity renews this idle window.
|
||||
# Background polling and queued delivery do not. Defaults to 15 minutes.
|
||||
export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
|
||||
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
|
||||
export STACKCHAIN_LOGIN_MAX_FAILURES=5
|
||||
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
|
||||
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
|
||||
# Public passkey ceremonies share the same window and durable source ledger.
|
||||
export STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS=10
|
||||
# Bound live one-time challenges even if anonymous clients rotate addresses.
|
||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
|
||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
||||
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
|
||||
# The feature stays disabled unless all three values are present. Privacy-safe update
|
||||
# alerts offer Mark read and Tomorrow; Tomorrow syncs the unread item to Later at
|
||||
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
|
||||
# three individual alerts followed by one private digest that opens Updates. A later
|
||||
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent.
|
||||
# Browser push services must resolve exclusively to public IP addresses. Stackchain
|
||||
# validates endpoints at enrollment and again before delivery, rejects redirects,
|
||||
# and removes legacy subscriptions that resolve to private or reserved networks.
|
||||
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
|
||||
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
|
||||
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
||||
# Optional; defaults to a 30-second poll, 10-second endpoint deadline,
|
||||
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
|
||||
# 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
|
||||
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.
|
||||
export STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS=3
|
||||
export STACKCHAIN_PUSH_LEASE_SECONDS=60
|
||||
export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqlite3'
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Sign-in failures are scoped to a hashed canonical client address and persisted across
|
||||
workers and restarts. Once the budget is exhausted, the server returns `429` with
|
||||
`Retry-After`; the mobile login form disables retries for that interval. Expired
|
||||
source records are pruned and the ledger is size-bounded. Keep its SQLite file on
|
||||
shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is
|
||||
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
|
||||
that setting, a reverse proxy is safely treated as one shared source.
|
||||
Token and passkey sign-in failures are scoped to a hashed canonical client address and
|
||||
persisted across workers and restarts. Public passkey option issuance has a separate
|
||||
fixed-window admission budget in the same ledger, and live challenges are bounded per
|
||||
source and globally in the session registry. Once either sign-in budget is exhausted,
|
||||
the server returns `429` with `Retry-After`; a successful sign-in clears that source's
|
||||
failure state. Expired source records and challenges are pruned, and both ledgers are
|
||||
size-bounded. Keep their SQLite files on shared writable storage. `X-Forwarded-For` is
|
||||
ignored unless the immediate peer is inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only
|
||||
networks you operate. Without that setting, a reverse proxy is safely treated as one
|
||||
shared source.
|
||||
|
||||
Inbound API mutations are admitted through a body-size boundary before FastAPI
|
||||
parses JSON: sign-in is capped at 16 KiB and other `POST`, `PUT`, and `PATCH`
|
||||
@@ -145,18 +244,58 @@ storage shared by all dashboard workers. Operators name a device at sign-in and
|
||||
open **Active devices** to review creation/expiry times, identify the current device,
|
||||
and revoke one remote session without interrupting other trusted devices. The API
|
||||
exposes only independent management IDs and bounded labels—never cookie values,
|
||||
session hashes, CSRF proofs, or source addresses. Existing two-column registries are
|
||||
migrated in place and their live sessions remain valid.
|
||||
session hashes, CSRF proofs, or source addresses. The registry also stores each
|
||||
session's last explicit activity. Existing two-column registries are migrated in
|
||||
place, their live sessions remain valid, and their idle clock starts at migration.
|
||||
|
||||
The same sheet includes **Security activity**, a reverse-chronological journal of
|
||||
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
|
||||
revocations, issue closures, and pull-request merges. The separate SQLite journal
|
||||
retains at most 10,000 events for 90 days and stores only bounded device labels and
|
||||
action targets. It never stores access tokens, cookies, session/CSRF values,
|
||||
credential IDs, public keys, challenges, attestation data, raw network addresses,
|
||||
request bodies, or comment content. Keep its database on the same class of persistent,
|
||||
writable storage as the session registry. Before passkey credential creation, session
|
||||
revocation, or issue closure, the journal durably reserves a pending event; if that
|
||||
reservation fails, the consequential action does not begin. A successful action
|
||||
remains truthfully reported even if its event cannot immediately be finalized, and
|
||||
the activity sheet marks that durable record as **Outcome confirmation pending**.
|
||||
|
||||
After token bootstrap, **Active devices → Add a passkey for this device** enrolls a
|
||||
WebAuthn credential with required user verification. That device can then sign in
|
||||
and authorize high-impact actions with its biometric/PIN gesture. The access token
|
||||
remains the recovery fallback for browsers without WebAuthn or devices without an
|
||||
enrolled credential. Registration and authentication challenges are exact-purpose,
|
||||
single-use, and short-lived. **Enrolled passkeys** lists every durable credential,
|
||||
including those whose original session has expired or signed out, using only its
|
||||
bounded label, enrollment time, and active/current status. Removing one requires a
|
||||
single-use fresh authorization bound to that exact management ID. A remote active
|
||||
session linked to the credential is revoked atomically; unrelated credentials and
|
||||
sessions remain valid. Removing the current device's passkey keeps its current
|
||||
session active, so the sheet warns that the recovery token will be required after
|
||||
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
|
||||
normally keeps the passkey available for the next sign-in. Passkey assertion counters
|
||||
advance with an atomic compare-and-swap across workers: stale or equal nonzero counters
|
||||
fail before a session or authorization grant is issued, while counterless authenticators
|
||||
remain compatible. The bounded **Passkey counter anomaly** Security activity entry names
|
||||
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 the operator access token again.
|
||||
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
|
||||
first use. Expired, replayed, cross-session, and target-substituted grants fail before
|
||||
Gitea or session state is changed. The browser preserves the pending request and
|
||||
retries it once after the built-in mobile/keyboard-accessible authorization prompt.
|
||||
|
||||
If an active session expires, the first authenticated API rejection replaces the
|
||||
After 15 minutes without pointer, keyboard, or touch activity (configurable through
|
||||
`STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS`), the server rejects the session even if
|
||||
polling or background delivery continued. The dashboard locks and background outbox
|
||||
delivery pauses, but drafts, Today/Later state, queued mutations, and caches remain on
|
||||
the device; signing in resumes the existing account-bound work. If an active session
|
||||
expires, the first authenticated API rejection replaces the
|
||||
dashboard with sign-in and explains that private drafts remain on the device; signing
|
||||
in again resumes account-bound queued delivery. Expiry recovery does not clear offline
|
||||
state. A selectively or globally revoked device instead receives a bounded revocation
|
||||
@@ -197,12 +336,14 @@ successful response is JSON containing
|
||||
to search commands plus issues and pull requests across every repository visible to
|
||||
the configured Gitea token. Remote search starts after two characters, is debounced,
|
||||
and keeps local commands usable if Gitea search is unavailable. Selecting a remote
|
||||
result opens a mobile-safe, read-only preview without discarding the search query;
|
||||
open unassigned issues can be claimed in place and handed into My Work after Gitea
|
||||
confirms the assignment. Closed work and pull requests remain read-only with a safe
|
||||
canonical Gitea link. The bounded APIs are available at
|
||||
`GET /api/v1/search?q=<query>&limit=<1-25>` and
|
||||
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`. Never commit the token
|
||||
result opens a mobile-safe preview without discarding the search query. Open
|
||||
unassigned issues can be claimed in place and handed into My Work after Gitea
|
||||
confirms the assignment. A closed issue can be reopened, self-assigned, added to
|
||||
Today, and resumed through the same capacity-guarded flow; pull requests remain
|
||||
read-only with a safe canonical Gitea link. The bounded APIs are available at
|
||||
`GET /api/v1/search?q=<query>&limit=<1-25>`,
|
||||
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`, and
|
||||
`PATCH /api/v1/repos/<owner>/<repo>/issues/<number>/reopen`. Never commit the token
|
||||
or place it in a tracked configuration file.
|
||||
|
||||
For service monitoring, GET `/healthz` is a liveness check that confirms the
|
||||
@@ -221,12 +362,31 @@ below the route deadlines. Streaming diff reads share the same read capacity,
|
||||
while POST, PATCH, PUT, and DELETE requests are never coalesced.
|
||||
|
||||
Live snapshots expose independent `context`, `events`, and `notifications`
|
||||
revision tokens. Each bounded opaque token includes a per-process generation, so a token
|
||||
from another worker or from before a restart cannot suppress different content. The browser
|
||||
sends its known tokens on later polls, so `/api/v1/live` can omit unchanged section bodies
|
||||
while still returning current freshness and retry metadata. The client retains omitted data
|
||||
and only rebuilds or persists the sections that changed. Malformed or oversized tokens are
|
||||
rejected before any upstream work.
|
||||
revision tokens. Snapshot content, freshness/backoff metadata, and revisions are published
|
||||
atomically through a private SQLite store shared by all application workers. An expiring
|
||||
refresh lease ensures only one worker loads currently due sections; other workers serve the
|
||||
same stale snapshot while that refresh runs, and can recover an abandoned lease after expiry.
|
||||
Set `STACKCHAIN_LIVE_SNAPSHOT_DB` to override the default
|
||||
`STACKCHAIN_STATE_DIR/live-snapshot.sqlite3`; keep the containing directory on private,
|
||||
worker-shared writable storage. The database and directory are restricted to the service
|
||||
account and never contain the Gitea token. Find Work uses the same worker-shared pattern:
|
||||
`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
|
||||
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
|
||||
cannot suppress different content. The browser sends its known tokens on later polls, so
|
||||
`/api/v1/live` can omit unchanged section bodies while still returning current freshness and
|
||||
retry metadata. The client retains omitted data and only rebuilds or persists the sections
|
||||
that changed. Malformed or oversized tokens are rejected before any upstream work.
|
||||
Confirmed notification acknowledgements update this worker's live view immediately, then
|
||||
maintain the shared snapshot outside the async request loop. Bulk acknowledgements remove
|
||||
all successful notification IDs in one SQLite transaction and advance the notification
|
||||
revision once. If shared-store lock admission fails after Gitea confirms the mutation, the
|
||||
API keeps the confirmed result and process-local filtering rather than falsely reporting the
|
||||
upstream write as failed; a later shared refresh reconciles the cache.
|
||||
|
||||
## Offline mobile shell
|
||||
|
||||
@@ -242,8 +402,11 @@ accepts a valid future local date and time and returns the item at that exact in
|
||||
shows the device timezone and rejects empty, normalized, invalid, or past values before saving.
|
||||
Deferred items leave normal and Attention queues without marking notifications read
|
||||
or changing any Gitea issue or pull request. They automatically return to their
|
||||
existing priority position at the wake time, and **Bring back now** restores them
|
||||
early. Later wake times are scoped to the confirmed Gitea login and synchronize
|
||||
existing priority position at the wake time. **Start now** atomically admits a deferred
|
||||
item to Today, removes its Later record, and opens that exact item in a resumable Today
|
||||
session; full or unavailable Today storage leaves the deferral intact for retry. Items
|
||||
already in Today are opened without duplication. **Bring back now** still restores an
|
||||
item early without starting it. Later wake times are scoped to the confirmed Gitea login and synchronize
|
||||
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
|
||||
and replay after reconnect. Each queued edit carries the account revision it was based
|
||||
on; if another device has since changed the same item, Stackchain keeps the newer
|
||||
@@ -267,8 +430,12 @@ newest 20 comments. Requested-review records also retain the head SHA, CI state,
|
||||
newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines
|
||||
per file. A previously opened unread update is retained by notification
|
||||
identity with allowlisted subject context and its newest 20 conversation messages.
|
||||
This account-bound cache is limited to ten records across all detail kinds; credentials,
|
||||
repository catalogs, events, raw patches, and complete API responses are excluded.
|
||||
The snapshot and each account-bound detail are committed as separate IndexedDB records,
|
||||
so large review previews do not consume the small synchronous localStorage quota or require
|
||||
rewriting the whole cache. Only the opt-in preference remains in localStorage. Existing v1
|
||||
localStorage payloads migrate after a successful IndexedDB commit. The detail cache remains
|
||||
limited to ten records across all detail kinds; credentials, repository catalogs, events, raw
|
||||
patches, and complete API responses are excluded.
|
||||
A cold offline launch labels the saved time. Cached Today details open in the existing
|
||||
phone sheet, where comments can enter the account-bound durable outbox. For issue and
|
||||
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
|
||||
@@ -282,8 +449,8 @@ queued operation and its matching SHA-scoped draft and progress.
|
||||
Cached unread updates use the same phone conversation sheet and replies enter the
|
||||
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
|
||||
Cards without a saved detail explain that reconnection is required. **Clear offline
|
||||
work data** deletes both stores, and opting out or seven-day expiry deletes them
|
||||
automatically.
|
||||
work data**, opt-out, sign-out, remote revocation, and absolute session expiry delete the
|
||||
IndexedDB records; seven-day expiry removes stale records automatically.
|
||||
|
||||
API responses and mutations are never cached by the service worker. New issue captures,
|
||||
issue comments, pull-request comments, and unread-update replies use bounded local
|
||||
|
||||
+2
-1
@@ -6,4 +6,5 @@ def explicit_local_dashboard_auth_mode(monkeypatch):
|
||||
"""Keep tests intentional now that deployed authentication fails closed."""
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")
|
||||
@@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
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', 'pull-review', 'issue-close']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
@@ -74,6 +74,14 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
|
||||
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? {
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
stored: true,
|
||||
},
|
||||
} : {}),
|
||||
...(message.kind === 'pull-review' ? {
|
||||
decision: String(message.decision || 'comment'),
|
||||
expectedHeadSha: String(message.expectedHeadSha || ''),
|
||||
@@ -83,6 +91,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
draftFingerprint: String(message.draftFingerprint || ''),
|
||||
progressFingerprint: String(message.progressFingerprint || ''),
|
||||
} : {}),
|
||||
...(message.kind === 'issue-blocker' ? {
|
||||
blockerRepository: String(message.blockerRepository || ''),
|
||||
blockerNumber: Number(message.blockerNumber || 0),
|
||||
present: message.present === true,
|
||||
} : {}),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
@@ -91,11 +104,30 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
|
||||
async function enqueueDurably(message) {
|
||||
const item = enqueue(message, false);
|
||||
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
|
||||
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
|
||||
write(read().filter(candidate => candidate.id !== item.id), false);
|
||||
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
|
||||
}
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? {
|
||||
...candidate,
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
...(message.attachment.blob ? { blob: message.attachment.blob } :
|
||||
{ data: String(message.attachment.data || '') }),
|
||||
},
|
||||
} : candidate);
|
||||
try {
|
||||
await backgroundSync.reconcile(durableItems, 'authored');
|
||||
} catch (error) {
|
||||
write(read().filter(candidate => candidate.id !== item.id), false);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await backgroundSync.reconcile(read(), 'authored');
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
@@ -136,6 +168,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
if (item.kind === 'issue-close') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
@@ -146,6 +181,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const attemptAt = Number(now());
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'sending', lastAttemptAt:attemptAt,
|
||||
} : candidate), false);
|
||||
const request = (async () => {
|
||||
try {
|
||||
let result;
|
||||
@@ -158,12 +197,49 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
}
|
||||
result = delivery.message;
|
||||
} else {
|
||||
if (item.kind === 'issue-close') {
|
||||
if (item.kind === 'update-reply-read') {
|
||||
if (!item.replyConfirmed) {
|
||||
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
});
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, replyConfirmed: true, status: 'sending', lastAttemptAt: attemptAt,
|
||||
} : candidate), false);
|
||||
}
|
||||
result = await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
} else if (item.kind === 'issue-close') {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
});
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
} else if (item.kind === 'issue-blocker') {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: item.blockerRepository,
|
||||
number: item.blockerNumber,
|
||||
present: item.present,
|
||||
}),
|
||||
});
|
||||
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
|
||||
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
|
||||
Number(candidate?.number) === item.blockerNumber);
|
||||
if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) {
|
||||
throw new Error('Blocker change was not confirmed.');
|
||||
}
|
||||
} else {
|
||||
const body = item.kind === 'pull-review' ? {
|
||||
body: item.body,
|
||||
@@ -189,13 +265,20 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
const permanent = status >= 400 && status < 500;
|
||||
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
|
||||
if (permanent) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate,
|
||||
status: 'attention',
|
||||
error: String(error.message || 'Message needs attention').slice(0, 240),
|
||||
lastAttemptAt: attemptAt,
|
||||
lastAttemptError: attemptError,
|
||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||
} : candidate));
|
||||
} else {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !permanent };
|
||||
}
|
||||
@@ -210,7 +293,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
if (item.status === 'attention' || item.kind === 'issue-close') continue;
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const outcome = await sendItem(item, currentLogin);
|
||||
if (outcome.result) confirmed.push(outcome.result);
|
||||
@@ -265,6 +348,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
error: String(background.error || 'Message needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
if (background?.status === 'authorization') return [{
|
||||
...item,
|
||||
status: 'authorization',
|
||||
error: String(background.error || 'Fresh authorization required').slice(0, 240),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
write(items);
|
||||
|
||||
@@ -35,6 +35,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted'));
|
||||
Promise.resolve(work({
|
||||
get: id => requested(objectStore.get(id)),
|
||||
getAll: () => requested(objectStore.getAll()),
|
||||
put: value => requested(objectStore.put(value)),
|
||||
delete: id => requested(objectStore.delete(id)),
|
||||
@@ -54,7 +55,25 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
return transact;
|
||||
}
|
||||
|
||||
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
|
||||
function createUnfiledAttachmentStore(indexedDB = globalThis.indexedDB) {
|
||||
const transact = createIndexedDbTransaction(indexedDB, 'stackchain-unfiled-captures-v1');
|
||||
return {
|
||||
put: (id, value) => transact(records => records.put({id, ...value})),
|
||||
get: id => transact(async records => {
|
||||
const value = await records.get(id);
|
||||
if (!value) return null;
|
||||
const {id: _id, ...attachment} = value;
|
||||
return attachment;
|
||||
}),
|
||||
delete: id => transact(records => records.delete(id)),
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueSyncStore({
|
||||
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
|
||||
createToken = () => globalThis.crypto?.randomUUID?.() ||
|
||||
(Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)),
|
||||
} = {}) {
|
||||
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
||||
|
||||
async function reconcile(items, outboxLane = 'issue') {
|
||||
@@ -65,15 +84,23 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
if (current.recordType === 'receipt-preference') continue;
|
||||
const currentLane = current.outboxLane || 'issue';
|
||||
if (currentLane !== outboxLane) continue;
|
||||
const replacement = incoming.get(current.id);
|
||||
let replacement = incoming.get(current.id);
|
||||
if (!replacement) {
|
||||
if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) {
|
||||
await records.delete(current.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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 };
|
||||
incoming.set(current.id, replacement);
|
||||
}
|
||||
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && replacement.status === 'attention') ||
|
||||
(['attention', 'authorization'].includes(current.status) &&
|
||||
replacement.status === current.status) ||
|
||||
current.status === 'sent') {
|
||||
incoming.set(current.id, current);
|
||||
}
|
||||
@@ -87,21 +114,25 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
const timestamp = Number(now());
|
||||
const items = await records.getAll();
|
||||
const item = items.find(candidate => candidate.ownerLogin === ownerLogin &&
|
||||
candidate.kind !== 'issue-close' &&
|
||||
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
||||
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
||||
if (!item) return null;
|
||||
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
|
||||
const claimed = {
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
|
||||
};
|
||||
await records.put(claimed);
|
||||
return claimed;
|
||||
});
|
||||
}
|
||||
|
||||
async function claimBatch(ownerLogin, limit = 70) {
|
||||
async function planBatch(ownerLogin, limit = 70) {
|
||||
return transact(async records => {
|
||||
const timestamp = Number(now());
|
||||
const eligible = (await records.getAll()).filter(candidate =>
|
||||
candidate.recordType !== 'receipt-preference' &&
|
||||
candidate.ownerLogin === ownerLogin &&
|
||||
candidate.kind !== 'issue-close' &&
|
||||
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
||||
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
||||
const lanes = {
|
||||
@@ -111,14 +142,10 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
const selected = [];
|
||||
const maximum = Math.max(0, Number(limit) || 0);
|
||||
while (selected.length < maximum && (lanes.issue.length || lanes.authored.length)) {
|
||||
if (lanes.issue.length) selected.push(lanes.issue.shift());
|
||||
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift());
|
||||
if (lanes.issue.length) selected.push(lanes.issue.shift().id);
|
||||
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift().id);
|
||||
}
|
||||
const claimed = selected.map(item => ({
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs,
|
||||
}));
|
||||
for (const item of claimed) await records.put(item);
|
||||
return claimed;
|
||||
return selected;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,7 +156,9 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
if (!item || item.ownerLogin !== ownerLogin ||
|
||||
!['queued', 'sending'].includes(item.status) ||
|
||||
(item.status === 'sending' && Number(item.claimUntil) > timestamp)) return null;
|
||||
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
|
||||
const claimed = {
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
|
||||
};
|
||||
await records.put(claimed);
|
||||
return claimed;
|
||||
});
|
||||
@@ -139,10 +168,19 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
return transact(async records => {
|
||||
const current = (await records.getAll()).find(candidate => candidate.id === item.id);
|
||||
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && item.status === 'attention') ||
|
||||
(['attention', 'authorization'].includes(current.status) &&
|
||||
item.status === current.status) ||
|
||||
current.status === 'sent')) return current;
|
||||
await records.put({ ...item });
|
||||
return item;
|
||||
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
|
||||
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
|
||||
? { attachment: current.attachment } : {};
|
||||
const next = current && current.operationId === item.operationId ? {
|
||||
...item, ...preservedAttachment,
|
||||
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
|
||||
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
|
||||
} : { ...item };
|
||||
await records.put(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,6 +191,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
});
|
||||
}
|
||||
|
||||
async function updateClaim(id, claimToken, transform) {
|
||||
return transact(async records => {
|
||||
const item = records.get ? await records.get(id) :
|
||||
(await records.getAll()).find(candidate => candidate.id === id);
|
||||
if (!item || item.status !== 'sending' || !claimToken || item.claimToken !== claimToken) {
|
||||
return false;
|
||||
}
|
||||
await records.put(transform(item));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function clearClaim(item, changes) {
|
||||
const { claimToken: _claimToken, ...unclaimed } = item;
|
||||
return { ...unclaimed, ...changes, claimUntil: 0 };
|
||||
}
|
||||
|
||||
async function renew(id, claimToken) {
|
||||
let renewed = null;
|
||||
await updateClaim(id, claimToken, item => {
|
||||
renewed = { ...item, claimUntil: Number(now()) + claimMs };
|
||||
return renewed;
|
||||
});
|
||||
return renewed;
|
||||
}
|
||||
|
||||
function preferenceId(ownerLogin) {
|
||||
return 'receipt-preference:' + String(ownerLogin || '').trim();
|
||||
}
|
||||
@@ -175,20 +239,29 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
}
|
||||
|
||||
return {
|
||||
get: id => transact(records => records.get(id)),
|
||||
reconcile,
|
||||
upsert,
|
||||
update,
|
||||
claim,
|
||||
claimNext,
|
||||
claimBatch,
|
||||
complete: (id, deliveredIssue) => update(id, item => ({
|
||||
...item, status: 'sent', claimUntil: 0,
|
||||
planBatch,
|
||||
supportsClaimTokens: true,
|
||||
renew,
|
||||
checkpoint: updateClaim,
|
||||
complete: (id, claimToken, deliveredIssue) => updateClaim(id, claimToken, item => clearClaim(item, {
|
||||
status: 'sent',
|
||||
...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}),
|
||||
})),
|
||||
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
||||
fail: (id, error, deliveryState) => update(id, item => ({
|
||||
...item, status: 'attention', claimUntil: 0, error,
|
||||
release: (id, claimToken) => updateClaim(id, claimToken,
|
||||
item => clearClaim(item, { status: 'queued' })),
|
||||
fail: (id, claimToken, error, deliveryState) => updateClaim(id, claimToken, item => clearClaim(item, {
|
||||
status: 'attention', error,
|
||||
...(deliveryState ? { deliveryState } : {}),
|
||||
})),
|
||||
authorization: (id, claimToken, error) => updateClaim(
|
||||
id, claimToken, item => clearClaim(item, { status: 'authorization', error })
|
||||
),
|
||||
snapshot: () => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
|
||||
countBlocked: ownerLogin => transact(async records =>
|
||||
@@ -210,6 +283,27 @@ function createBackgroundIssueSync({
|
||||
const activeRequests = new Set();
|
||||
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
|
||||
|
||||
const completeClaim = (item, delivered) => store.supportsClaimTokens
|
||||
? store.complete(item.id, item.claimToken, delivered) : store.complete(item.id, delivered);
|
||||
const releaseClaim = item => store.supportsClaimTokens
|
||||
? store.release(item.id, item.claimToken) : store.release(item.id);
|
||||
const failClaim = (item, error, deliveryState) => store.supportsClaimTokens
|
||||
? store.fail(item.id, item.claimToken, error, deliveryState)
|
||||
: store.fail(item.id, error, deliveryState);
|
||||
const requireAuthorization = (item, error) => store.supportsClaimTokens
|
||||
? store.authorization(item.id, item.claimToken, error)
|
||||
: store.authorization(item.id, error);
|
||||
const checkpointClaim = (item, transform) => store.supportsClaimTokens
|
||||
? store.checkpoint(item.id, item.claimToken, transform) : store.update?.(item.id, transform);
|
||||
|
||||
async function requestStage(item, url, options) {
|
||||
if (store.renew) {
|
||||
const renewed = await store.renew(item.id, item.claimToken);
|
||||
if (!renewed) throw new Error('Background delivery claim was lost.');
|
||||
}
|
||||
return requestJson(url, options);
|
||||
}
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
activeRequests.add(controller);
|
||||
@@ -239,7 +333,7 @@ function createBackgroundIssueSync({
|
||||
if (item.kind === 'notification-read') {
|
||||
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
|
||||
}
|
||||
if (item.kind === 'update-reply') {
|
||||
if (item.kind === 'update-reply' || item.kind === 'update-reply-read') {
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
@@ -272,6 +366,12 @@ function createBackgroundIssueSync({
|
||||
if (item.kind === 'update-reply') {
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
}
|
||||
if (item.kind === 'update-reply-read') {
|
||||
return {
|
||||
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
|
||||
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
|
||||
};
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-close') {
|
||||
return {
|
||||
@@ -282,6 +382,24 @@ function createBackgroundIssueSync({
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers',
|
||||
options: {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: item.blockerRepository,
|
||||
number: item.blockerNumber,
|
||||
present: item.present === true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||
@@ -343,33 +461,226 @@ function createBackgroundIssueSync({
|
||||
};
|
||||
}
|
||||
|
||||
function stageOperationId(operationId, stage) {
|
||||
const suffix = ':' + stage;
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
async function deliverReplyRead(item) {
|
||||
let current = item;
|
||||
if (!current.replyConfirmed) {
|
||||
let attachmentMarkdown = current.attachmentMarkdown;
|
||||
if (current.attachment && !attachmentMarkdown) {
|
||||
const uploaded = await requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
||||
body:attachmentMultipart(current.attachment),
|
||||
});
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
||||
current = { ...current, attachmentMarkdown };
|
||||
}
|
||||
const text = String(current.body || '').trim();
|
||||
const replyBody = attachmentMarkdown ?
|
||||
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
|
||||
const options = authoredRequest('', { ...current, body:replyBody }).options;
|
||||
if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
|
||||
await requestStage(
|
||||
current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
||||
options,
|
||||
);
|
||||
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
|
||||
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
|
||||
current = { ...current, replyConfirmed: true };
|
||||
}
|
||||
const request = deliveryRequest(current);
|
||||
return requestStage(current, request.url, request.options);
|
||||
}
|
||||
|
||||
function attachmentMultipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
const binary = atob(String(attachment.data));
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
||||
}
|
||||
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
||||
const form = new FormData();
|
||||
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
||||
return form;
|
||||
}
|
||||
|
||||
async function deliverIssueCapture(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
let deliveredIssue = item.deliveredIssue;
|
||||
if (!deliveredIssue) {
|
||||
const request = deliveryRequest(item);
|
||||
deliveredIssue = await requestStage(item, request.url, request.options);
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
|
||||
}
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
|
||||
}
|
||||
await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
|
||||
},
|
||||
body: JSON.stringify({ body: attachmentMarkdown }),
|
||||
},
|
||||
);
|
||||
return deliveredIssue;
|
||||
}
|
||||
|
||||
async function deliverScreenshotComment(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(item, current => ({ ...current, attachmentMarkdown }));
|
||||
}
|
||||
const text = String(item.body || '').trim();
|
||||
return requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'comment'),
|
||||
},
|
||||
body: JSON.stringify({ body: text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function deliverUpdateScreenshotReply(item) {
|
||||
let current = item;
|
||||
let attachmentMarkdown = current.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
||||
body:attachmentMultipart(current.attachment),
|
||||
});
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
||||
current = { ...current, attachmentMarkdown };
|
||||
}
|
||||
const text = String(current.body || '').trim();
|
||||
return requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Content-Type':'application/json',
|
||||
'Idempotency-Key':stageOperationId(current.operationId, 'reply') },
|
||||
body:JSON.stringify({ body:text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
|
||||
});
|
||||
}
|
||||
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = await requestJson(request.url, request.options);
|
||||
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
|
||||
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);
|
||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||
const error = new Error('Issue closure was not confirmed.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await store.complete(item.id, delivered);
|
||||
if (item.kind === 'issue-blocker') {
|
||||
const dependencies = Array.isArray(delivered?.dependencies) ? delivered.dependencies : [];
|
||||
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
|
||||
Number(candidate?.number) === item.blockerNumber);
|
||||
if (delivered?.number !== item.number || delivered?.dependencies_available !== true || present !== item.present) {
|
||||
const error = new Error('Blocker change 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 };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status === 401) {
|
||||
await store.release(item.id);
|
||||
await releaseClaim(item);
|
||||
throw error;
|
||||
}
|
||||
if (status === 428 && item.kind === 'pull-review') {
|
||||
const message = String(error?.message || 'Fresh authorization required').slice(0, 240);
|
||||
await requireAuthorization(item, message);
|
||||
return {
|
||||
authorization: true,
|
||||
error,
|
||||
receipt: receiptFor(item, 'authorization'),
|
||||
};
|
||||
}
|
||||
if (status >= 400 && status < 500) {
|
||||
await store.fail(
|
||||
item.id,
|
||||
await failClaim(
|
||||
item,
|
||||
String(error?.message || 'Issue needs attention').slice(0, 240),
|
||||
error?.code === 'delivery_uncertain' ? 'uncertain' : undefined,
|
||||
);
|
||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
||||
}
|
||||
await store.release(item.id);
|
||||
await releaseClaim(item);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -391,14 +702,39 @@ function createBackgroundIssueSync({
|
||||
const confirmed = [];
|
||||
const receipts = [];
|
||||
let attention = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
|
||||
let authorization = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention, authorization, login, receipts };
|
||||
const collect = result => {
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.message) confirmed.push(result.message);
|
||||
if (result.attention) attention += 1;
|
||||
if (result.authorization) authorization += 1;
|
||||
if (result.receipt) receipts.push(result.receipt);
|
||||
};
|
||||
if (store.claimBatch) {
|
||||
if (store.planBatch) {
|
||||
const planned = await store.planBatch(login, batchSize);
|
||||
let next = 0;
|
||||
let authenticationError = null;
|
||||
let transientError = null;
|
||||
const worker = async () => {
|
||||
while (!purgeRequested && !authenticationError && next < planned.length) {
|
||||
const id = planned[next++];
|
||||
const item = await store.claim(id, login);
|
||||
if (!item) continue;
|
||||
try {
|
||||
collect(await deliver(item));
|
||||
} catch (error) {
|
||||
if (Number(error?.status || 0) === 401) authenticationError = error;
|
||||
else if (!transientError) transientError = error;
|
||||
}
|
||||
}
|
||||
};
|
||||
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, planned.length));
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
if (purgeRequested) throw new Error('Background delivery canceled.');
|
||||
if (authenticationError) throw authenticationError;
|
||||
if (transientError) throw transientError;
|
||||
} else if (store.claimBatch) {
|
||||
const claimed = await store.claimBatch(login, batchSize);
|
||||
let next = 0;
|
||||
let authenticationError = null;
|
||||
@@ -433,7 +769,7 @@ function createBackgroundIssueSync({
|
||||
}
|
||||
}
|
||||
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
||||
return { confirmed, blocked, attention, login, receipts };
|
||||
return { confirmed, blocked, attention, authorization, login, receipts };
|
||||
}
|
||||
|
||||
function flush() {
|
||||
@@ -463,6 +799,7 @@ function createBackgroundIssueSync({
|
||||
|
||||
return {
|
||||
flush, send, purge, resume,
|
||||
get: id => store.get(id),
|
||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||
snapshot: () => store.snapshot(),
|
||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
||||
@@ -475,4 +812,5 @@ if (typeof module !== 'undefined' && module.exports) module.exports = createBack
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
|
||||
globalThis.createIssueSyncStore = createIssueSyncStore;
|
||||
globalThis.createUnfiledAttachmentStore = createUnfiledAttachmentStore;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
function createBatchFindWork({
|
||||
capacity,
|
||||
claim,
|
||||
queue,
|
||||
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
|
||||
persistEstimate = () => {},
|
||||
onProgress = () => {},
|
||||
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
||||
owner = () => '',
|
||||
}) {
|
||||
let request = null;
|
||||
const journalKey = () => 'stackchain.find-work-batch.v1.' + encodeURIComponent(String(owner() || ''));
|
||||
|
||||
function readJournal() {
|
||||
if (!storage || !String(owner() || '')) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(journalKey()) || 'null');
|
||||
return value?.owner === String(owner()) && Array.isArray(value.items) ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function writeJournal(value) {
|
||||
if (!storage || !String(owner() || '')) return;
|
||||
storage.setItem(journalKey(), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clearJournal() {
|
||||
if (storage && String(owner() || '')) storage.removeItem(journalKey());
|
||||
}
|
||||
|
||||
function result(status, selected, available, queued = [], failed = []) {
|
||||
return { status, selected, available, queued, failed };
|
||||
}
|
||||
|
||||
function key(item) {
|
||||
return String(item.repository || '') + '#' + String(item.number || '');
|
||||
}
|
||||
|
||||
async function processJournal(journal) {
|
||||
const selected = journal.items;
|
||||
const queued = [];
|
||||
const failed = [];
|
||||
for (let index = 0; index < selected.length; index += 1) {
|
||||
const entry = selected[index];
|
||||
const item = entry.item;
|
||||
if (entry.state === 'queued') {
|
||||
queued.push(key(item));
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const confirmed = entry.confirmed || await claim(item);
|
||||
if (!entry.confirmed) {
|
||||
entry.confirmed = confirmed;
|
||||
entry.state = 'assigned';
|
||||
writeJournal(journal);
|
||||
}
|
||||
const queueResult = await queue(confirmed);
|
||||
if (queueResult === 'queued' || queueResult === 'exists') {
|
||||
queued.push(key(item));
|
||||
entry.state = 'queued';
|
||||
if (Number.isInteger(entry.estimate) && entry.estimate > 0) {
|
||||
persistEstimate(confirmed, entry.estimate);
|
||||
}
|
||||
writeJournal(journal);
|
||||
} else {
|
||||
failed.push({ key: key(item), reason: 'assigned but Today sync is unavailable', assigned: true });
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
||||
}
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
}
|
||||
const outcome = result('complete', selected.length, journal.available, queued, failed);
|
||||
if (!failed.length) clearJournal();
|
||||
else writeJournal(journal);
|
||||
onProgress({ ...outcome, processed: selected.length });
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function run(items, estimates = {}) {
|
||||
if (request) return request;
|
||||
const selected = Array.isArray(items) ? items.slice() : [];
|
||||
const available = Math.max(0, Number(capacity()) || 0);
|
||||
if (selected.length > available) {
|
||||
const outcome = result('full', selected.length, available);
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
const budget = timeBudget() || {};
|
||||
if (Number.isInteger(budget.capacity_minutes) && budget.capacity_minutes > 0) {
|
||||
const remaining = Math.max(0, budget.capacity_minutes - (Number(budget.planned_minutes) || 0));
|
||||
const invalid = selected.map(key).filter(id =>
|
||||
!Number.isInteger(estimates[id]) || estimates[id] <= 0
|
||||
);
|
||||
const requested = selected.reduce((sum, item) => sum +
|
||||
(Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0 ? estimates[key(item)] : 0), 0);
|
||||
if (invalid.length) {
|
||||
const outcome = { ...result('estimates-required', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, invalid };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
if (requested > remaining) {
|
||||
const outcome = { ...result('over-budget', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, over_minutes: requested - remaining };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
}
|
||||
const journal = {
|
||||
owner: String(owner() || ''), available,
|
||||
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
|
||||
};
|
||||
writeJournal(journal);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (request) return request;
|
||||
const journal = readJournal();
|
||||
if (!journal) return Promise.resolve(null);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function mountRecovery(button, opener) {
|
||||
const show = () => {
|
||||
const journal = readJournal();
|
||||
button.hidden = !journal;
|
||||
if (journal) button.textContent = 'Resume ' + journal.items.filter(item => item.state !== 'queued').length + ' interrupted';
|
||||
};
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
const outcome = await resume();
|
||||
button.disabled = false;
|
||||
show();
|
||||
if (outcome) {
|
||||
button.previousElementSibling.textContent = outcome.failed.length ?
|
||||
outcome.failed.length + ' still need recovery.' : outcome.queued.length + ' queued · batch recovered.';
|
||||
}
|
||||
});
|
||||
opener.addEventListener('click', show);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') mountRecovery(
|
||||
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
|
||||
);
|
||||
return { run, resume, mountRecovery };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|
||||
@@ -0,0 +1,103 @@
|
||||
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
|
||||
const encodedRepository = repository => String(repository || '').split('/')
|
||||
.map(encodeURIComponent).join('/');
|
||||
|
||||
function pathFor(context, commentId) {
|
||||
const item = context?.item || {};
|
||||
if (context?.kind === 'update') {
|
||||
return 'api/v1/notifications/' + encodeURIComponent(item.notification_id) +
|
||||
'/comments/' + encodeURIComponent(commentId);
|
||||
}
|
||||
if (!['issue', 'pull'].includes(context?.kind)) throw new Error('Comment conversation is unavailable.');
|
||||
return 'api/v1/repos/' + encodedRepository(item.repository) + '/' +
|
||||
(context.kind === 'pull' ? 'pulls/' : 'issues/') + encodeURIComponent(item.number) +
|
||||
'/comments/' + encodeURIComponent(commentId);
|
||||
}
|
||||
|
||||
const controller = {
|
||||
isOwned(comment) {
|
||||
const login = String(getLogin() || '').trim();
|
||||
return Boolean(login && comment && comment.author === login);
|
||||
},
|
||||
async edit(context, pager, commentId, body) {
|
||||
const draft = String(body || '').trim();
|
||||
if (!draft) throw new Error('Comment must not be blank.');
|
||||
const comment = await fetchJson(pathFor(context, commentId), {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: draft }),
|
||||
});
|
||||
pager.replace(comment);
|
||||
return pager.snapshot();
|
||||
},
|
||||
async remove(context, pager, commentId) {
|
||||
if (!confirmDelete('Delete this comment permanently?')) return null;
|
||||
const result = await fetchJson(pathFor(context, commentId), {
|
||||
method: 'DELETE', headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!result?.deleted || Number(result.id) !== Number(commentId)) {
|
||||
throw new Error('Comment deletion was not confirmed.');
|
||||
}
|
||||
pager.remove(commentId);
|
||||
return pager.snapshot();
|
||||
},
|
||||
actionHtml(comment) {
|
||||
return controller.isOwned(comment) ?
|
||||
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
|
||||
'<button type="button" data-comment-action="edit">Edit</button>' +
|
||||
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
|
||||
},
|
||||
wire({ root, getSurface, isOffline, escapeHtml }) {
|
||||
root.addEventListener('click', async event => {
|
||||
const button = event.target.closest('[data-comment-action]');
|
||||
if (!button) return;
|
||||
const card = button.closest('.issue-comment');
|
||||
const commentId = Number(card?.dataset.commentId);
|
||||
const surface = getSurface();
|
||||
const comment = surface.pager?.snapshot().comments.find(item => item.id === commentId);
|
||||
if (!comment || !controller.isOwned(comment)) return;
|
||||
if (isOffline()) {
|
||||
surface.status.textContent = 'Reconnect to edit or delete this comment.';
|
||||
return;
|
||||
}
|
||||
if (button.dataset.commentAction === 'delete') {
|
||||
try {
|
||||
const state = await controller.remove(surface.context, surface.pager, commentId);
|
||||
if (state) {
|
||||
surface.render(state);
|
||||
surface.status.textContent = 'Comment deleted.';
|
||||
}
|
||||
} catch (error) {
|
||||
surface.status.textContent = error.message || 'Comment deletion failed. Retry or open it in Gitea.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
card.innerHTML = '<div class="small">Editing your comment</div>' +
|
||||
'<textarea class="comment-edit-textarea" maxlength="10000" aria-label="Edit comment">' +
|
||||
escapeHtml(comment.body || '') + '</textarea>' +
|
||||
'<div class="comment-owned-actions"><button type="button" data-comment-edit-save>Save</button>' +
|
||||
'<button type="button" data-comment-edit-cancel>Cancel</button></div>';
|
||||
const textarea = card.querySelector('.comment-edit-textarea');
|
||||
textarea.focus();
|
||||
card.querySelector('[data-comment-edit-cancel]').addEventListener('click', () => surface.render(surface.pager.snapshot()));
|
||||
card.querySelector('[data-comment-edit-save]').addEventListener('click', async saveEvent => {
|
||||
const save = saveEvent.currentTarget;
|
||||
save.disabled = true;
|
||||
surface.status.textContent = 'Saving comment…';
|
||||
try {
|
||||
const state = await controller.edit(surface.context, surface.pager, commentId, textarea.value);
|
||||
surface.render(state);
|
||||
surface.status.textContent = 'Comment updated.';
|
||||
} catch (error) {
|
||||
save.disabled = false;
|
||||
surface.status.textContent = error.message || 'Comment update failed. Your edit is safe; retry or open it in Gitea.';
|
||||
textarea.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentActions;
|
||||
+30
-17
@@ -1,32 +1,39 @@
|
||||
function createCommentNext({ post, queue, canQueue, accept = () => undefined, complete }) {
|
||||
function createCommentNext({ post, queue, queueKind = '', canQueue, accept = () => undefined, complete }) {
|
||||
let inFlight = null;
|
||||
|
||||
async function admitQueued(item, message) {
|
||||
const admission = await queue(message);
|
||||
if (!admission || (!admission.item && admission.durable !== true)) {
|
||||
throw new Error('Comment was not saved for delivery.');
|
||||
}
|
||||
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: admission.background ? 'queued' : 'saved',
|
||||
background: Boolean(admission.background),
|
||||
completed: Boolean(complete(item)),
|
||||
};
|
||||
}
|
||||
|
||||
function submit(item, body, operationId = '') {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
let comment;
|
||||
try {
|
||||
comment = await post(item, body);
|
||||
comment = await post(item, body, typeof operationId === 'function' ? operationId() : operationId);
|
||||
} catch (error) {
|
||||
if (!canQueue(error)) throw error;
|
||||
const admission = await queue({
|
||||
const identity = queueKind === 'update-reply' ? {
|
||||
kind: 'update-reply', notificationId: item.notification_id,
|
||||
} : {
|
||||
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
|
||||
repository: item.repository,
|
||||
number: item.number,
|
||||
body,
|
||||
repository: item.repository, number: item.number,
|
||||
};
|
||||
return admitQueued(item, {
|
||||
...identity, body,
|
||||
operationId: typeof operationId === 'function' ? operationId() : operationId,
|
||||
});
|
||||
if (!admission || (!admission.item && admission.durable !== true)) {
|
||||
throw new Error('Comment was not saved for delivery.');
|
||||
}
|
||||
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: admission.background ? 'queued' : 'saved',
|
||||
background: Boolean(admission.background),
|
||||
completed: Boolean(complete(item)),
|
||||
};
|
||||
}
|
||||
accept(item, { delivery: 'posted', comment });
|
||||
return {
|
||||
@@ -42,7 +49,13 @@ function createCommentNext({ post, queue, canQueue, accept = () => undefined, co
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
return { submit, busy: () => Boolean(inFlight) };
|
||||
function admit(item, message) {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = admitQueued(item, message).finally(() => { inFlight = null; });
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
return { submit, admit, busy: () => Boolean(inFlight) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;
|
||||
@@ -10,6 +10,13 @@ function buildLiveRevisionQuery(revisions = {}) {
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function retryAfterMs(value) {
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||
return Math.min(seconds * 1000, 300000);
|
||||
}
|
||||
|
||||
function createContextPoller({
|
||||
fetchContext,
|
||||
onSnapshot,
|
||||
@@ -21,25 +28,51 @@ function createContextPoller({
|
||||
clearDeadlineTimer = clearTimeout,
|
||||
intervalMs = 8000,
|
||||
timeoutMs = 12000,
|
||||
maxBackoffMs = 60000,
|
||||
}) {
|
||||
let activeRequest = null;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
let revisions = {};
|
||||
let retainedSnapshot = null;
|
||||
let nextDelayMs = intervalMs;
|
||||
let failureStreak = 0;
|
||||
|
||||
function snapshotDelay(snapshot) {
|
||||
const freshness = snapshot && snapshot.freshness;
|
||||
const sections = freshness && freshness.sections;
|
||||
const sectionValues = sections && Object.values(sections);
|
||||
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
|
||||
const retrySeconds = Number(freshness.retry_in_seconds);
|
||||
if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000;
|
||||
}
|
||||
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
|
||||
const healthyDeadlines = (sectionValues || [])
|
||||
.filter(section => !section.degraded)
|
||||
.map(section => freshForSeconds - Number(section.age_seconds))
|
||||
.filter(seconds => Number.isFinite(seconds));
|
||||
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
|
||||
const earliestDeadline = Math.min(...healthyDeadlines);
|
||||
if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) {
|
||||
return intervalMs;
|
||||
}
|
||||
return Math.max(0, earliestDeadline) * 1000;
|
||||
}
|
||||
return intervalMs;
|
||||
}
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
function schedule(delayMs = intervalMs) {
|
||||
cancelTimer();
|
||||
if (stopped || isHidden()) return;
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
refresh();
|
||||
}, intervalMs);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function abortError() {
|
||||
@@ -91,6 +124,8 @@ function createContextPoller({
|
||||
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
|
||||
.then((snapshot) => {
|
||||
if (activeRequest !== requestState) return retainedSnapshot;
|
||||
failureStreak = 0;
|
||||
nextDelayMs = snapshotDelay(snapshot);
|
||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||
);
|
||||
@@ -100,7 +135,14 @@ function createContextPoller({
|
||||
return retainedSnapshot;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (activeRequest === requestState && !stopped) onError(error);
|
||||
if (activeRequest === requestState && !stopped) {
|
||||
const retryAfterMs = Number(error && error.retryAfterMs);
|
||||
failureStreak += 1;
|
||||
nextDelayMs = Number.isFinite(retryAfterMs) && retryAfterMs > 0
|
||||
? retryAfterMs
|
||||
: Math.min(intervalMs * (2 ** (failureStreak - 1)), maxBackoffMs);
|
||||
onError(error);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -108,7 +150,7 @@ function createContextPoller({
|
||||
clearDeadlineTimer(requestState.deadline);
|
||||
if (activeRequest !== requestState) return;
|
||||
activeRequest = null;
|
||||
schedule();
|
||||
schedule(nextDelayMs);
|
||||
});
|
||||
return requestState.promise;
|
||||
}
|
||||
@@ -136,6 +178,7 @@ function createContextPoller({
|
||||
}
|
||||
|
||||
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
|
||||
createContextPoller.retryAfterMs = retryAfterMs;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createContextPoller;
|
||||
|
||||
@@ -63,6 +63,21 @@ function createConversationPager({ loadPage }) {
|
||||
}
|
||||
return snapshot();
|
||||
},
|
||||
replace(comment) {
|
||||
if (!comment || !Number.isInteger(comment.id)) return snapshot();
|
||||
state = {
|
||||
...state,
|
||||
comments: state.comments.map(existing => existing.id === comment.id ? { ...comment } : existing),
|
||||
};
|
||||
return snapshot();
|
||||
},
|
||||
remove(commentId) {
|
||||
const comments = state.comments.filter(comment => comment.id !== commentId);
|
||||
if (comments.length !== state.comments.length) {
|
||||
state = { ...state, comments, total: Math.max(0, state.total - 1) };
|
||||
}
|
||||
return snapshot();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,10 @@ function normalizeSharedContent(value = {}) {
|
||||
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 duplicateRequest = 0;
|
||||
let repositorySearchRequest = 0;
|
||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||
let acknowledgedDuplicateKey = '';
|
||||
const repositoryPageRequests = new Map();
|
||||
@@ -125,6 +127,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function pendingFollowUp() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(followUpStorageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(parsed.repository || '')) ? String(parsed.repository) : '';
|
||||
const title = String(parsed.title || '').trim().slice(0, 240);
|
||||
const body = String(parsed.body || '').trim().slice(0, 9500);
|
||||
return title || body ? {repository, title, body, labelIds: []} : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function acceptFollowUp() {
|
||||
const followUp = pendingFollowUp();
|
||||
if (!followUp) return loadDraft();
|
||||
const accepted = saveDraft(followUp);
|
||||
try { storage.removeItem(followUpStorageKey); }
|
||||
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function discardFollowUp() {
|
||||
try { storage.removeItem(followUpStorageKey); }
|
||||
catch (_error) { /* The existing capture remains authoritative. */ }
|
||||
}
|
||||
|
||||
function stageFollowUp(value) {
|
||||
const followUp = {repository: String(value?.repository || ''), title: String(value?.title || ''), body: String(value?.body || '')};
|
||||
if (!followUp.title && !followUp.body) return {status: 'empty'};
|
||||
try { storage.setItem(followUpStorageKey, JSON.stringify(followUp)); }
|
||||
catch (_error) { /* The in-page flow can still continue. */ }
|
||||
const existing = loadDraft();
|
||||
if (existing.title || existing.body) return {status: 'conflict'};
|
||||
acceptFollowUp();
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function loadLabels(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||
@@ -159,6 +197,25 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
return request;
|
||||
}
|
||||
|
||||
async function searchRepositories(value) {
|
||||
const query = String(value || '').trim().slice(0, 80);
|
||||
const request = ++repositorySearchRequest;
|
||||
if (query.length < 2) return {status: 'idle', items: []};
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
'api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20'
|
||||
);
|
||||
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
|
||||
return {
|
||||
status: 'ready',
|
||||
items: Array.isArray(payload?.items) ? payload.items : [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (request !== repositorySearchRequest) 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();
|
||||
@@ -230,9 +287,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
|
||||
searchRepositories, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+285
-14
@@ -10,18 +10,61 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||
.notification-undo[hidden] { display:none; }
|
||||
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
|
||||
.draft-capacity-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); }
|
||||
.draft-capacity-sheet[hidden] { display:none; }
|
||||
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.draft-capacity-panel h3 { margin:.25rem 0; overflow-wrap:anywhere; }
|
||||
.draft-capacity-oldest { margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; overflow-wrap:anywhere; }
|
||||
.draft-capacity-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.draft-capacity-actions button { min-height:44px; width:100%; }
|
||||
#replace-oldest-draft { grid-column:1 / -1; border-color:#b45309; }
|
||||
#sign-out-all { min-height:44px; }
|
||||
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.active-devices-sheet[hidden] { display:none; }
|
||||
.active-devices-panel { box-sizing:border-box; width:min(560px,100%); height:100%; overflow:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.active-devices-header h2, .active-devices-header p { margin-top:0; }
|
||||
.active-devices-header button, .active-device button { min-height:44px; }
|
||||
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
|
||||
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
|
||||
.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.device-setup-sheet[hidden] { display:none; }
|
||||
.device-setup-panel { box-sizing:border-box; width:min(620px,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; }
|
||||
.device-setup-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.device-setup-header h2, .device-setup-header p { margin-top:0; }
|
||||
.device-setup-header button, .device-setup-action { min-height:44px; }
|
||||
.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-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; }
|
||||
.device-readiness-summary { color:#bfdbfe; font-weight:700; }
|
||||
.device-readiness-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.device-readiness-actions button { min-height:44px; min-width:0; }
|
||||
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
|
||||
.active-device-current { color:#55d6be; font-weight:700; }
|
||||
.enrolled-passkeys { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
|
||||
.enrolled-passkeys-header h3, .enrolled-passkeys-header p { margin:0 0 6px; }
|
||||
.enrolled-passkeys-list { display:grid; gap:10px; margin-top:12px; }
|
||||
.enrolled-passkey { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.enrolled-passkey strong, .enrolled-passkey span { display:block; overflow-wrap:anywhere; }
|
||||
.security-activity { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
|
||||
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
|
||||
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
|
||||
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
|
||||
.authentication-alert { border-color:#d69e2e; background:#241b09; }
|
||||
.authentication-alert strong { color:#f6c453; }
|
||||
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
||||
#load-more-security-activity { width:100%; min-height:44px; }
|
||||
button:hover { filter: brightness(1.15); }
|
||||
.issue-comment { min-width:0; overflow-wrap:anywhere; }
|
||||
.comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; }
|
||||
.comment-owned-actions button { min-height:44px; min-width:72px; }
|
||||
.comment-edit-textarea { box-sizing:border-box; display:block; width:100%; max-width:100%; min-height:132px; resize:vertical; overflow-wrap:anywhere; }
|
||||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||
.panel > summary h2 { display: inline-block; margin-left: 4px; }
|
||||
@@ -68,6 +111,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.offline-status[hidden] { display:none; }
|
||||
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||
.push-update-control { min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
@@ -87,9 +131,24 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
||||
.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; }
|
||||
.plan-today-header button { min-width:44px; min-height:44px; }
|
||||
.plan-today-list button, .plan-today-candidates button { min-height:44px; }
|
||||
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
|
||||
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
|
||||
.plan-today-available span { display:flex; align-items:center; gap:6px; }
|
||||
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
|
||||
.plan-today-build { min-height:44px; width:100%; font-weight:700; }
|
||||
.plan-today-build-status { min-height:1.4em; margin-top:6px; color:#bfdbfe; }
|
||||
.plan-today-estimates { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.plan-today-estimates h3, .plan-today-estimates p { margin-top:0; }
|
||||
.plan-today-estimates input { min-height:44px; width:108px; box-sizing:border-box; }
|
||||
.plan-today-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:8px 0; border-top:1px solid #29496d; }
|
||||
.plan-today-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.plan-today-skipped { display:grid; gap:6px; margin:8px 0; }
|
||||
.plan-today-skipped-item { padding:8px 10px; border-left:3px solid #b45309; background:#261a13; overflow-wrap:anywhere; }
|
||||
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
|
||||
.plan-today-error { min-height:1.4em; color:#fca5a5; }
|
||||
.discard-recap-replan { min-height:44px; margin-bottom:8px; }
|
||||
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
|
||||
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
|
||||
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
|
||||
@@ -100,6 +159,46 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-preview-actions button { min-height:44px; }
|
||||
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.plan-today-actions button { min-height:44px; width:100%; }
|
||||
.today-interruption-sheet { position:fixed; inset:0; z-index:92; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-interruption-sheet[hidden] { display:none; }
|
||||
.today-interruption-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-interruption-panel h2 { margin:.25rem 0; overflow-wrap:anywhere; }
|
||||
.today-interruption-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
.today-interruption-actions button { min-height:44px; width:100%; }
|
||||
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-recap-sheet[hidden] { display:none; }
|
||||
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-recap-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-recap-header h2 { margin:.2rem 0; }
|
||||
.today-recap-header button { min-height:44px; min-width:44px; }
|
||||
.today-recap-items, .today-recap-history { display:grid; gap:8px; }
|
||||
.today-recap-row, .today-recap-history-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:10px; }
|
||||
.today-recap-row strong { overflow-wrap:anywhere; }
|
||||
.today-recap-row input { width:6rem; min-height:44px; }
|
||||
.today-recap-context { display:block; overflow-wrap:anywhere; }
|
||||
.today-recap-variance { grid-column:1 / -1; color:#bfdbfe; font-weight:700; }
|
||||
.today-recap-log { grid-column:1 / -1; display:flex; align-items:center; gap:8px; min-height:44px; }
|
||||
.today-recap-log input { width:24px; min-height:24px; }
|
||||
.today-recap-log-success { color:#86efac; font-weight:700; }
|
||||
.today-recap-log-verify { color:#fde68a; font-weight:700; }
|
||||
.today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; }
|
||||
.today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-recap-actions button { min-height:44px; width:100%; }
|
||||
@media (max-width:420px) {
|
||||
.today-interruption-actions { grid-template-columns:1fr; }
|
||||
.today-recap-actions { grid-template-columns:1fr; }
|
||||
.today-recap-row { grid-template-columns:1fr; }
|
||||
.today-recap-row input { width:min(100%,8rem); }
|
||||
.today-recap-variance { grid-column:1; }
|
||||
}
|
||||
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-readiness-sheet[hidden] { display:none; }
|
||||
.today-readiness-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-readiness-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-readiness-header h2 { margin:.2rem 0; }
|
||||
.today-readiness-header button, .today-readiness-actions button { min-height:44px; }
|
||||
.today-readiness-item { min-width:0; overflow-wrap:anywhere; margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.today-readiness-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.work-settings { width:100%; }
|
||||
.work-settings > summary { display:none; }
|
||||
@@ -109,19 +208,47 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
|
||||
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.queue-finder { display:grid; gap:6px; margin:10px 0; }
|
||||
.queue-finder > label { font-weight:700; }
|
||||
.queue-finder-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.queue-finder input, .queue-finder button { min-height:44px; box-sizing:border-box; }
|
||||
.queue-finder input { min-width:0; width:100%; padding:8px 10px; border:1px solid #31577f; border-radius:10px; background:#08111f; color:var(--text); font:inherit; }
|
||||
.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; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
|
||||
.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%; }
|
||||
.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; }
|
||||
.delivery-attempt { color:#fbbf24; overflow-wrap:anywhere; }
|
||||
.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 #create-issue-status { grid-column:1/-1; }
|
||||
.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); }
|
||||
.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); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.update-selection-controls button { min-height:44px; }
|
||||
.selection-scope-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; max-width:100%; }
|
||||
.selection-scope-actions button { min-height:44px; max-width:100%; }
|
||||
.update-selector { min-height:44px; }
|
||||
.work-selector { min-height:44px; }
|
||||
.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
.update-selector input, .work-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.my-work-card.selection-active { border-color:#31577f; }
|
||||
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.later-actions button { min-height:44px; width:100%; }
|
||||
.today-actions button { min-height:44px; width:100%; }
|
||||
@@ -152,6 +279,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.retry-work-route[hidden] { display:none; }
|
||||
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-bulk button { min-height:44px; width:100%; }
|
||||
.my-work-bulk-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.my-work-bulk-actions button { min-height:44px; width:100%; }
|
||||
.my-work-bulk-actions #bulk-mark-read { grid-column:1 / -1; }
|
||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.review-sheet.open { display:flex; }
|
||||
@@ -179,9 +309,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.review-copy-fallback { min-height:140px; }
|
||||
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
|
||||
.review-progress-actions button { min-height:44px; max-width:100%; }
|
||||
.review-display-tools { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
|
||||
.review-display-tools button { min-height:44px; flex:0 0 auto; }
|
||||
.review-display-tools button[aria-pressed="true"] { color:#bfdbfe; background:#17365a; outline:1px solid #60a5fa; }
|
||||
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
|
||||
.review-diff-line { display:block; min-width:max-content; }
|
||||
.review-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; }
|
||||
.review-diff-line { display:grid; grid-template-columns:max-content minmax(max-content,1fr); align-items:stretch; min-width:max-content; }
|
||||
.review-line-numbers { position:sticky; left:0; z-index:1; display:grid; grid-template-columns:4ch 4ch; align-self:stretch; background:#0b1526; color:#7694b6; user-select:none; }
|
||||
.review-line-number { padding:8px 4px; text-align:right; border-right:1px solid #244363; }
|
||||
.review-line-code { min-width:0; padding:8px; white-space:pre; overflow-wrap:anywhere; }
|
||||
.review-inline-target { min-height:44px; width:100%; padding:0; border:0; border-radius:0; text-align:left; font:inherit; white-space:normal; }
|
||||
.wrap-lines .review-diff { overflow-x:hidden; white-space:normal; }
|
||||
.wrap-lines .review-diff-line { grid-template-columns:max-content minmax(0,1fr); min-width:0; }
|
||||
.wrap-lines .review-line-code { white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
|
||||
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
|
||||
.review-inline-composer[hidden] { display:none; }
|
||||
@@ -194,6 +333,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
|
||||
.review-action { min-height:44px; }
|
||||
.review-retry { min-height:44px; margin-top:10px; }
|
||||
.ci-checks { max-width:100%; margin:10px 0; border:1px solid #2a496e; border-radius:10px; overflow:hidden; }
|
||||
.ci-checks > summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0 10px; cursor:pointer; }
|
||||
.ci-checks > summary .small { min-width:0; overflow-wrap:anywhere; text-align:right; }
|
||||
.ci-check-refresh { width:100%; min-height:44px; margin-bottom:6px; }
|
||||
.ci-check-list { display:grid; gap:6px; min-width:0; padding:0 8px 8px; }
|
||||
.ci-check { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:8px; border:1px solid #203a5c; border-radius:8px; }
|
||||
.ci-check-failure, .ci-check-error { border-color:#dc2626; background:rgba(127,29,29,.22); }
|
||||
.ci-check-pending { border-color:#d97706; background:rgba(120,53,15,.2); }
|
||||
.ci-check-success { border-color:#15803d; }
|
||||
.ci-check-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
|
||||
.ci-check-copy strong, .ci-check-copy span { min-width:0; overflow-wrap:anywhere; }
|
||||
.ci-check-link { min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid #60a5fa; border-radius:8px; white-space:nowrap; }
|
||||
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.update-sheet.open { display:flex; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
@@ -203,8 +354,19 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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%; }
|
||||
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.update-reply-actions button { min-height:44px; width:100%; }
|
||||
.update-reply .issue-attachment-preview { width:100%; min-width:0; }
|
||||
@media (max-width:390px) {
|
||||
.update-reply-actions { grid-template-columns:1fr; }
|
||||
.update-reply .issue-attachment-preview { grid-template-columns:56px minmax(0,1fr); padding:8px; }
|
||||
.update-reply .issue-attachment-preview img { width:56px; height:56px; }
|
||||
}
|
||||
.update-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; }
|
||||
.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.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 a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.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); }
|
||||
@@ -213,6 +375,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.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; }
|
||||
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
|
||||
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
|
||||
.issue-blocker-row { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:start; }
|
||||
.issue-blocker-row .issue-blocker { min-width:0; }
|
||||
.issue-blocker-remove { min-height:44px; white-space:normal; }
|
||||
.issue-blocker-manager { display:grid; gap:8px; margin-top:10px; min-width:0; }
|
||||
.issue-blocker-manager :is(input,button) { min-height:44px; width:100%; max-width:100%; box-sizing:border-box; }
|
||||
.issue-blocker-manager [role="listbox"] { display:grid; gap:6px; max-height:240px; overflow:auto; }
|
||||
.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-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%; }
|
||||
@@ -226,6 +402,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.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%; }
|
||||
.issue-attachment-trigger { min-height:44px; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; border:1px solid #60a5fa; border-radius:10px; color:#dbeafe; font-weight:700; cursor:pointer; }
|
||||
.issue-attachment-preview { display:grid; grid-template-columns:64px minmax(0,1fr); gap:8px 12px; align-items:center; max-width:100%; overflow:hidden; padding:10px; border:1px solid #315781; border-radius:10px; background:#101f34; }
|
||||
.issue-attachment-preview[hidden] { display:none; }
|
||||
.issue-attachment-preview img { grid-row:span 2; width:64px; height:64px; object-fit:cover; border-radius:8px; }
|
||||
.issue-attachment-preview .small { min-width:0; overflow-wrap:anywhere; }
|
||||
.issue-attachment-preview button { min-height:44px; width:auto; justify-self:start; }
|
||||
.mention-options { display:grid; max-width:100%; max-height:220px; overflow:auto; border:1px solid #315781; border-radius:10px; background:#101f34; box-shadow:0 10px 28px rgba(0,0,0,.35); }
|
||||
.mention-options[hidden] { display:none; }
|
||||
.mention-option { min-height:44px; max-width:100%; overflow:hidden; padding:9px 12px; border:0; border-bottom:1px solid #203a5c; border-radius:0; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:#101f34; color:#dbeafe; }
|
||||
.mention-option:last-child { border-bottom:0; }
|
||||
.mention-option[aria-selected="true"], .mention-option:hover { background:#17365a; color:#fff; }
|
||||
.mention-status:empty { display:none; }
|
||||
.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.comment-actions button { min-height:44px; width:100%; }
|
||||
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
@@ -243,27 +433,58 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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; }
|
||||
.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff, .pull-ownership { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select, .pull-ownership select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff select, .issue-handoff button { min-height:44px; }
|
||||
.pull-ownership select, .pull-ownership button { min-height:44px; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.resume-today-session, .end-today-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav[hidden] { display:none; }
|
||||
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
||||
.work-session-nav [data-work-session-timer-toggle] { min-height:44px; }
|
||||
.work-session-nav button { min-height:44px; width:100%; }
|
||||
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.find-work-header-actions { display:flex; gap:8px; }
|
||||
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
|
||||
.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
|
||||
.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.find-work-search input, .find-work-search button { min-height:44px; }
|
||||
.find-work-filters { border:1px solid #2a496e; border-radius:10px; padding:10px; }
|
||||
.find-work-filters summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
|
||||
.find-work-filter-panel, .find-work-filter-options { display:grid; gap:8px; }
|
||||
.find-work-filter-panel fieldset { min-width:0; border:0; padding:6px 0; margin:0; }
|
||||
.find-work-filter-options { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
|
||||
.find-work-filter-option { min-height:44px; display:flex; align-items:center; gap:8px; padding:6px; overflow-wrap:anywhere; }
|
||||
.find-work-filter-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||
#clear-find-work-filters { min-height:44px; width:100%; }
|
||||
.find-work-list { display:grid; gap:10px; }
|
||||
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||
.find-work-select { display:flex; align-items:center; gap:10px; min-height:44px; }
|
||||
.find-work-select input { width:22px; height:22px; }
|
||||
.fill-find-work-today { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-batch-actions { position:sticky; bottom:0; z-index:2; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:12px 0 calc(12px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||
.find-work-batch-actions[hidden] { display:none; }
|
||||
.find-work-batch-actions span { grid-column:1 / -1; }
|
||||
.find-work-batch-actions button { min-height:44px; }
|
||||
.find-work-batch-recovery { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.find-work-estimate-review[hidden] { display:none; }
|
||||
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
|
||||
.find-work-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
|
||||
.find-work-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.find-work-estimate-review input { min-height:44px; width:76px; }
|
||||
.find-work-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.find-work-estimate-actions button { min-height:44px; }
|
||||
.find-work-card button { width:100%; font-weight:700; }
|
||||
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
|
||||
@@ -294,11 +515,22 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
@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); }
|
||||
.create-issue-sheet.open { display:flex; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||||
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.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-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; }
|
||||
#create-issue-repository-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; }
|
||||
#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-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-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||
@@ -344,11 +576,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.mobile-task-dock { display:none; }
|
||||
.mobile-task-dock[hidden] { display:none; }
|
||||
|
||||
.mobile-today-hud { display:none; }
|
||||
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
|
||||
.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; }
|
||||
.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) {
|
||||
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
||||
body.mobile-today-active { padding-bottom:calc(166px + env(safe-area-inset-bottom)); }
|
||||
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
|
||||
.app-brand .muted, #clock { display:none; }
|
||||
.app-live-status { margin-left:auto; }
|
||||
@@ -358,14 +597,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.app-menu:not([open]) > .app-menu-panel { display:none; }
|
||||
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
|
||||
.app-menu-panel button { min-height:44px; width:100%; }
|
||||
.device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.device-setup-step { grid-template-columns:1fr; }
|
||||
.device-setup-action { width:100%; }
|
||||
.device-readiness-card:not([hidden]) { display:grid; gap:12px; margin-left:max(0px,env(safe-area-inset-left)); margin-right:max(0px,env(safe-area-inset-right)); }
|
||||
.my-work { margin:0; }
|
||||
.attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); }
|
||||
.my-work-header { align-items:flex-start; }
|
||||
.my-work-actions { width:100%; flex-wrap:nowrap; }
|
||||
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
|
||||
.my-work-actions { display:none; }
|
||||
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.queue-finder { position:sticky; top:64px; z-index:5; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); padding:8px; background:rgba(11,21,38,.98); border:1px solid #2a496e; border-radius:10px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
|
||||
.my-work-card { min-width:0; overflow-x:hidden; }
|
||||
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
|
||||
.card-planning > summary::-webkit-details-marker { display:none; }
|
||||
@@ -379,8 +624,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filters { width:100%; }
|
||||
.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; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-blocker-row { grid-template-columns:1fr; }
|
||||
.issue-blocker-remove { width:100%; }
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||
#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; }
|
||||
@@ -390,13 +637,37 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
#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; }
|
||||
.create-issue-panel.composer-keyboard-active,
|
||||
.issue-sheet-panel.composer-keyboard-active,
|
||||
.pull-sheet-panel.composer-keyboard-active,
|
||||
.review-sheet-panel.composer-keyboard-active,
|
||||
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px; }
|
||||
.composer-keyboard-active .create-issue-form,
|
||||
.composer-keyboard-active .issue-comment-composer,
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .review-inline-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
|
||||
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
|
||||
.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-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-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
||||
.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; }
|
||||
.mobile-today-hud [data-mobile-today-complete] { grid-area:complete; }
|
||||
.mobile-today-hud [data-mobile-today-toggle] { grid-area:toggle; }
|
||||
.mobile-today-hud button { min-height:44px; max-width:100%; }
|
||||
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
|
||||
}
|
||||
.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; }
|
||||
|
||||
+2147
-311
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
function createDetailDefer({
|
||||
laterWork,
|
||||
session,
|
||||
continueSession = null,
|
||||
close,
|
||||
refresh,
|
||||
focus,
|
||||
@@ -16,6 +17,13 @@ function createDetailDefer({
|
||||
return false;
|
||||
}
|
||||
const inSession = session.active();
|
||||
if (continueSession && session.checkpointed?.(item)) {
|
||||
if (continueSession(item) === true) return true;
|
||||
laterWork.restore(item);
|
||||
refresh();
|
||||
announce('Could not remove this item from Today, so it was restored from Later.');
|
||||
return false;
|
||||
}
|
||||
if (!inSession && closeSheet) close();
|
||||
refresh();
|
||||
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
function showDraftCapacityDialog(captures) {
|
||||
const state = captures.capacity();
|
||||
if (!state.full) return false;
|
||||
const oldest = state.oldest;
|
||||
qs('#draft-capacity-oldest').innerHTML = '<strong>' + escapeHtml(oldest.title) + '</strong><div class="small">' +
|
||||
escapeHtml(oldest.body || 'No note') + (oldest.hasAttachment ? ' · Screenshot attached' : '') + '</div>';
|
||||
qs('#draft-capacity-sheet').dataset.oldestId = oldest.id;
|
||||
qs('#draft-capacity-status').textContent = '';
|
||||
qs('#draft-capacity-sheet').hidden = false;
|
||||
qs('#keep-editing-draft').focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindDraftCapacityDialog() {
|
||||
qs('#keep-editing-draft').addEventListener('click', () => {
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
qs('#save-unfiled-issue').focus();
|
||||
});
|
||||
qs('#review-full-drafts').addEventListener('click', () => {
|
||||
const oldestId = qs('#draft-capacity-sheet').dataset.oldestId;
|
||||
saveIssueCaptureDraft();
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('queues');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => {
|
||||
const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]');
|
||||
card?.scrollIntoView({block:'nearest'});
|
||||
card?.focus({preventScroll:true});
|
||||
});
|
||||
});
|
||||
qs('#replace-oldest-draft').addEventListener('click', async () => {
|
||||
const oldest = unfiledCaptures.capacity().oldest;
|
||||
if (!oldest || oldest.id !== qs('#draft-capacity-sheet').dataset.oldestId) {
|
||||
qs('#draft-capacity-status').textContent = 'Drafts changed. Review them before replacing anything.';
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('Replace “' + oldest.title + '” and permanently delete its saved contents?')) return;
|
||||
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 saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
issueCapture.clearDraft();
|
||||
qs('#create-issue-title').value = '';
|
||||
qs('#create-issue-body').value = '';
|
||||
createIssueAttachmentController.clear();
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('queues');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => qs('[data-capture-id="' + CSS.escape(saved.id) + '"]')?.focus());
|
||||
qs('#my-work-action-status').textContent = 'Oldest Draft replaced. New work saved to Drafts.';
|
||||
} catch (error) {
|
||||
qs('#draft-capacity-status').textContent = error.message + ' Nothing was replaced.';
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = {showDraftCapacityDialog, bindDraftCapacityDialog};
|
||||
@@ -0,0 +1,92 @@
|
||||
function createDraftFilingSession({list}) {
|
||||
let orderedIds = [];
|
||||
let currentId = '';
|
||||
|
||||
function visibleIds() {
|
||||
const available = new Set(list().map(item => item.id));
|
||||
orderedIds = orderedIds.filter(id => available.has(id));
|
||||
for (const item of list()) {
|
||||
if (!orderedIds.includes(item.id)) orderedIds.push(item.id);
|
||||
}
|
||||
return orderedIds;
|
||||
}
|
||||
|
||||
function state() {
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) {
|
||||
currentId = '';
|
||||
return null;
|
||||
}
|
||||
if (!ids.includes(currentId)) currentId = ids[0];
|
||||
const index = ids.indexOf(currentId);
|
||||
return {id:currentId, position:index + 1, total:ids.length, remaining:ids.length - index - 1};
|
||||
}
|
||||
|
||||
function start(id) {
|
||||
orderedIds = list().map(item => item.id);
|
||||
if (!orderedIds.includes(id)) throw new Error('This capture is no longer available.');
|
||||
currentId = id;
|
||||
return state();
|
||||
}
|
||||
|
||||
function move(offset) {
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) return state();
|
||||
const index = Math.max(0, ids.indexOf(currentId));
|
||||
currentId = ids[(index + offset + ids.length) % ids.length];
|
||||
return state();
|
||||
}
|
||||
|
||||
function removeCurrentAndNext(removedId) {
|
||||
const previousIds = orderedIds.slice();
|
||||
const removedIndex = Math.max(0, previousIds.indexOf(removedId));
|
||||
orderedIds = previousIds.filter(id => id !== removedId);
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) return state();
|
||||
currentId = ids[Math.min(removedIndex, ids.length - 1)];
|
||||
return state();
|
||||
}
|
||||
|
||||
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
|
||||
session.attach = (qs, dependencies) => {
|
||||
const openCapture = async captureId => {
|
||||
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
|
||||
dependencies.issueCapture.saveDraft(resumed);
|
||||
dependencies.setResumedId(captureId);
|
||||
await dependencies.openSheet();
|
||||
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
|
||||
dependencies.setFilingMode(true);
|
||||
session.render();
|
||||
};
|
||||
return Object.assign(session, {
|
||||
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
|
||||
render() {
|
||||
const state = session.current();
|
||||
qs('#draft-filing-session').hidden = !state;
|
||||
if (!state) return;
|
||||
qs('#draft-filing-progress').textContent = `Draft ${state.position} of ${state.total}`;
|
||||
qs('#draft-filing-remaining').textContent = state.remaining ? `${state.remaining} remaining` : 'Last Draft';
|
||||
qs('#previous-draft').disabled = qs('#skip-draft').disabled = state.total < 2;
|
||||
qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft';
|
||||
},
|
||||
bind() {
|
||||
[['#previous-draft','previous'],['#skip-draft','next']].forEach(([selector, method]) =>
|
||||
qs(selector).addEventListener('click', async () => {
|
||||
const state = session[method]();
|
||||
if (state) await openCapture(state.id);
|
||||
})
|
||||
);
|
||||
},
|
||||
async advance(completedId) {
|
||||
const state = session.removeCurrentAndNext(completedId);
|
||||
if (state) { await openCapture(state.id); return true; }
|
||||
qs('#draft-filing-session').hidden = true;
|
||||
qs('#submit-new-issue').textContent = 'Create & assign to me';
|
||||
return false;
|
||||
},
|
||||
});
|
||||
};
|
||||
return session;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftFilingSession;
|
||||
+28
-5
@@ -112,7 +112,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
id: 'stackchain.issue-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
kind: 'issue-outbox',
|
||||
status: item.status === 'completion' ? 'completion' : (item.status === 'attention' ? 'attention' : 'queued'),
|
||||
status: item.status === 'completion' ? 'completion' :
|
||||
(item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued')),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'completion' ? 'Created · ready to start' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
|
||||
@@ -125,6 +126,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
last_attempt_at: Number(item.lastAttemptAt || 0),
|
||||
last_attempt_error: textPreview(item.lastAttemptError),
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
};
|
||||
});
|
||||
@@ -138,7 +141,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
|
||||
.map(item => {
|
||||
const isUpdate = item.kind === 'update-reply';
|
||||
const isUpdate = item.kind === 'update-reply' || item.kind === 'update-reply-read';
|
||||
const isReview = item.kind === 'pull-review';
|
||||
const isClosure = item.kind === 'issue-close';
|
||||
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
|
||||
@@ -157,10 +160,13 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
outbox_id: item.id,
|
||||
outbox_kind: item.kind,
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
|
||||
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||
(isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
|
||||
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
|
||||
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
||||
authorization_required: isClosure || (isReview && item.status === 'authorization'),
|
||||
delivery_state: item.deliveryState,
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
@@ -170,6 +176,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
last_attempt_at: Number(item.lastAttemptAt || 0),
|
||||
last_attempt_error: textPreview(item.lastAttemptError),
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
|
||||
{ kind:routeKind, repository:item.repository, number:item.number },
|
||||
@@ -228,7 +236,22 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
return { list, discard };
|
||||
function partition(items = list()) {
|
||||
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
|
||||
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
|
||||
const counts = deliveries.reduce((summary, item) => {
|
||||
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
|
||||
(item.status === 'authorization' ? 'authorization' : 'waiting'));
|
||||
summary[state] += 1;
|
||||
return summary;
|
||||
}, { waiting:0, sending:0, attention:0, authorization:0 });
|
||||
const retryable = deliveries.filter(item =>
|
||||
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
|
||||
);
|
||||
return { drafts, deliveries, counts, retryable };
|
||||
}
|
||||
|
||||
return { list, discard, partition };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
|
||||
@@ -0,0 +1,63 @@
|
||||
function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
|
||||
const pending = new Map();
|
||||
const loaded = new Set();
|
||||
|
||||
function load(name) {
|
||||
if (loaded.has(name)) return Promise.resolve();
|
||||
if (pending.has(name)) return pending.get(name);
|
||||
const url = urls[name];
|
||||
if (!url) return Promise.reject(new Error('Unknown feature: ' + name));
|
||||
|
||||
const request = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
let timeout;
|
||||
const fail = () => {
|
||||
clearTimeout(timeout);
|
||||
script.remove();
|
||||
pending.delete(name);
|
||||
reject(new Error('Could not load ' + name.replace(/-/g, ' ') + '.'));
|
||||
};
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
clearTimeout(timeout);
|
||||
pending.delete(name);
|
||||
loaded.add(name);
|
||||
resolve();
|
||||
};
|
||||
script.onerror = fail;
|
||||
timeout = setTimeout(fail, timeoutMs);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
pending.set(name, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function run(name, elements, callback) {
|
||||
const trigger = elements?.trigger;
|
||||
const status = elements?.status;
|
||||
const retryLabel = elements?.retryLabel || 'Tap New issue to retry.';
|
||||
if (trigger) trigger.disabled = true;
|
||||
if (status) status.textContent = 'Loading ' + name.replace(/-/g, ' ') + '…';
|
||||
try {
|
||||
await load(name);
|
||||
callback();
|
||||
if (status) status.textContent = '';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) {
|
||||
const label = name === 'issue-capture' ? 'Issue capture' :
|
||||
name === 'pull-workflow' ? 'Pull workspace' :
|
||||
name === 'security-center' ? 'Security center' : name.replace(/-/g, ' ');
|
||||
status.textContent = label + ' could not load. ' + retryLabel;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (trigger) trigger.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { load, run, ready: name => loaded.has(name) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createFeatureLoader;
|
||||
+389
-15
@@ -18,6 +18,7 @@
|
||||
<div class="app-menu-panel">
|
||||
<button id="refresh">Refresh</button>
|
||||
<button id="open-palette">Command</button>
|
||||
<button id="open-device-setup" type="button">Device setup</button>
|
||||
<button id="active-devices" type="button">Active devices</button>
|
||||
<button id="sign-out" type="button">Sign out & clear this device</button>
|
||||
<button id="sign-out-all" type="button">Sign out all devices</button>
|
||||
@@ -30,13 +31,59 @@
|
||||
</div>
|
||||
|
||||
<div id="active-devices-sheet" class="active-devices-sheet" hidden>
|
||||
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices">
|
||||
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices and passkeys">
|
||||
<div class="active-devices-header">
|
||||
<div><h2>Active devices</h2><p class="small muted">Review signed-in devices and remove access you no longer trust.</p></div>
|
||||
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
||||
</div>
|
||||
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
|
||||
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
|
||||
<div id="active-devices-list" class="active-devices-list"></div>
|
||||
<section class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
|
||||
<div class="enrolled-passkeys-header">
|
||||
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
|
||||
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
|
||||
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
|
||||
</div>
|
||||
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
|
||||
</section>
|
||||
<section class="security-activity" aria-labelledby="security-activity-title">
|
||||
<div class="security-activity-header">
|
||||
<h3 id="security-activity-title">Security activity</h3>
|
||||
<p class="small muted">Recent sign-ins, device changes, and protected actions.</p>
|
||||
</div>
|
||||
<div id="security-activity-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="security-activity-list" class="security-activity-list"></div>
|
||||
<button id="load-more-security-activity" type="button" hidden>Load older activity</button>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="device-setup-sheet" class="device-setup-sheet" hidden>
|
||||
<section class="device-setup-panel" role="dialog" aria-modal="true" aria-labelledby="device-setup-heading">
|
||||
<div class="device-setup-header">
|
||||
<div>
|
||||
<h2 id="device-setup-heading">Make this device work-ready</h2>
|
||||
<p class="small muted">Prepare this phone for fast launch, outages, and new update alerts. Nothing asks for permission until you choose it.</p>
|
||||
</div>
|
||||
<button id="close-device-setup" type="button" aria-label="Close device setup">Close</button>
|
||||
</div>
|
||||
<ol class="device-setup-list">
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Install Stackchain</strong><p class="small" id="device-setup-install-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-install" type="button">Install</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<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>
|
||||
</ol>
|
||||
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +96,7 @@
|
||||
</div>
|
||||
<div class="my-work-actions">
|
||||
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
|
||||
<button class="today-recap-history-action" id="open-today-recaps" type="button">Recaps</button>
|
||||
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
|
||||
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
|
||||
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
|
||||
@@ -78,7 +126,10 @@
|
||||
<div class="offline-work-controls">
|
||||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<button 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>
|
||||
<span class="offline-today-readiness" id="offline-today-readiness" hidden>
|
||||
<span class="small" id="offline-today-status" role="status" aria-live="polite"></span>
|
||||
@@ -96,6 +147,41 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<aside class="attention-interruption" id="attention-interruption" role="status" aria-live="polite" hidden>
|
||||
<span><strong>Today paused</strong> while handling Attention</span>
|
||||
<button id="return-to-today" type="button">Return to Today</button>
|
||||
</aside>
|
||||
<section class="device-readiness-card" id="device-readiness-card" aria-labelledby="device-readiness-heading" hidden>
|
||||
<div>
|
||||
<strong id="device-readiness-heading">Make this phone work-ready</strong>
|
||||
<p class="small muted">Install, save work offline, and enable update alerts.</p>
|
||||
<p class="small device-readiness-summary" id="device-readiness-summary" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div class="device-readiness-actions">
|
||||
<button id="finish-device-setup" type="button">Finish setup</button>
|
||||
<button id="dismiss-device-readiness" type="button">Not now</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="update-selection-controls" id="update-selection-controls" hidden>
|
||||
<button id="select-work" type="button">Select work</button>
|
||||
<button id="cancel-work-selection" type="button" hidden>Cancel selection</button>
|
||||
<button id="select-updates" type="button">Select updates</button>
|
||||
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="selection-scope-actions" id="selection-scope-actions" hidden>
|
||||
<button id="select-matching-work" type="button">Select all matches</button>
|
||||
<button id="clear-work-selection" type="button">Clear selection</button>
|
||||
</div>
|
||||
<form class="queue-finder" id="queue-finder" role="search">
|
||||
<label for="queue-find-input">Find in <span id="queue-find-label">All</span></label>
|
||||
<div class="queue-finder-row">
|
||||
<input id="queue-find-input" type="search" autocomplete="off" aria-label="Find in active work queue" placeholder="Repository, #number, or title" />
|
||||
<button id="clear-queue-find" type="button" hidden>Clear</button>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
@@ -107,7 +193,14 @@
|
||||
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
|
||||
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
||||
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
||||
<button id="bulk-mark-read" type="button"></button>
|
||||
<div class="my-work-bulk-actions">
|
||||
<button id="batch-add-today" type="button" hidden>Add to Today</button>
|
||||
<button data-batch-defer="today" type="button" hidden>Later today</button>
|
||||
<button data-batch-defer="tomorrow" type="button" hidden>Tomorrow</button>
|
||||
<button id="defer-selected-today" data-defer-selected="today" type="button">Later today</button>
|
||||
<button id="defer-selected-tomorrow" data-defer-selected="tomorrow" type="button">Tomorrow</button>
|
||||
<button id="bulk-mark-read" type="button"></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="sidebar">
|
||||
@@ -180,7 +273,19 @@
|
||||
<button id="cancel-plan-today" type="button">Cancel</button>
|
||||
</div>
|
||||
<div class="plan-today-capacity" id="plan-today-capacity" role="status" aria-live="polite">0 of 5 selected</div>
|
||||
<label class="plan-today-available" for="plan-today-available">Available today
|
||||
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
|
||||
</label>
|
||||
<button class="plan-today-build" id="build-today-plan" type="button">Build my Today</button>
|
||||
<div class="small plan-today-build-status" id="plan-today-build-status" role="status" aria-live="polite"></div>
|
||||
<section class="plan-today-estimates" id="plan-today-estimates" aria-labelledby="plan-today-estimates-heading" hidden>
|
||||
<h3 id="plan-today-estimates-heading">Estimate to finish your plan</h3>
|
||||
<p class="small muted">Add minutes for ready work and your plan will re-fit immediately.</p>
|
||||
<div id="plan-today-estimate-list"></div>
|
||||
</section>
|
||||
<div class="plan-today-skipped" id="plan-today-skipped"></div>
|
||||
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
|
||||
<button class="discard-recap-replan" id="discard-recap-replan" type="button" hidden>Discard recap feedback</button>
|
||||
<section aria-labelledby="today-plan-heading">
|
||||
<h3 id="today-plan-heading">Today, in order</h3>
|
||||
<div class="plan-today-list" id="plan-today-list"></div>
|
||||
@@ -196,6 +301,59 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-interruption-sheet" id="today-interruption-sheet" role="dialog" aria-modal="true" aria-labelledby="today-interruption-title" hidden>
|
||||
<section class="today-interruption-panel">
|
||||
<div class="small">Today timer</div>
|
||||
<h2 id="today-interruption-title">Were you working while away?</h2>
|
||||
<p id="today-interruption-description" class="small"></p>
|
||||
<p class="small muted">Choose once before the timer continues. You can include the full interval or remove it from your recap.</p>
|
||||
<div class="today-interruption-actions">
|
||||
<button type="button" data-today-interruption="count">Count this time</button>
|
||||
<button type="button" data-today-interruption="exclude">Exclude this time</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-recap-sheet" id="today-recap-sheet" role="dialog" aria-modal="true" aria-labelledby="today-recap-title" hidden>
|
||||
<section class="today-recap-panel">
|
||||
<div class="today-recap-header">
|
||||
<div><div class="small">Estimate feedback</div><h2 id="today-recap-title">Today recap</h2></div>
|
||||
<button id="close-today-recap" type="button">Close</button>
|
||||
</div>
|
||||
<p class="small muted">Review actual time before saving. Gitea time logging is explicit and unchecked by default.</p>
|
||||
<div id="today-recap-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="today-recap-items" class="today-recap-items"></div>
|
||||
<div id="today-recap-totals" class="today-recap-totals"></div>
|
||||
<section aria-labelledby="today-recap-history-title">
|
||||
<h3 id="today-recap-history-title">Recent recaps</h3>
|
||||
<div id="today-recap-history" class="today-recap-history"></div>
|
||||
</section>
|
||||
<div class="today-recap-actions">
|
||||
<button id="save-today-recap" type="button">Save recap & adjust plan</button>
|
||||
<button id="save-log-today-recap" type="button">Log selected time to Gitea</button>
|
||||
<button id="discard-today-recap" type="button">Keep timer & close</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
|
||||
<section class="today-readiness-panel">
|
||||
<div class="today-readiness-header">
|
||||
<div><div class="small">Today readiness</div><h2 id="today-readiness-title">This item is blocked</h2></div>
|
||||
<button id="today-readiness-close" type="button">Close</button>
|
||||
</div>
|
||||
<p id="today-readiness-summary" aria-live="polite"></p>
|
||||
<div class="today-readiness-item" id="today-readiness-item"></div>
|
||||
<div class="issue-blocker-list" id="today-readiness-blockers"></div>
|
||||
<div class="today-readiness-actions">
|
||||
<button id="today-readiness-next" type="button">Start next ready</button>
|
||||
<button id="today-readiness-anyway" type="button">Work this item anyway</button>
|
||||
<button id="today-readiness-retry" type="button">Retry status</button>
|
||||
<button id="today-readiness-plan" type="button">Review Today plan</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="plan-preview-actions" id="plan-preview-actions" hidden>
|
||||
<button id="back-to-plan" type="button">Back to plan</button>
|
||||
<button id="add-plan-preview" type="button">Add to Today & back</button>
|
||||
@@ -226,6 +384,7 @@
|
||||
<div class="search-preview-primary-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
|
||||
</div>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
@@ -262,6 +421,21 @@
|
||||
<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>
|
||||
<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>
|
||||
<div id="issue-blocker-status" class="small" aria-live="polite"></div>
|
||||
<button id="manage-issue-blockers" type="button" hidden>Add blocker</button>
|
||||
<button id="start-unblocked-issue" type="button" hidden>Start now</button>
|
||||
<div class="issue-blocker-manager" id="issue-blocker-manager" hidden>
|
||||
<label for="issue-blocker-search">Search open issues</label>
|
||||
<input id="issue-blocker-search" type="search" maxlength="100" autocomplete="off"
|
||||
role="combobox" aria-controls="issue-blocker-results" aria-expanded="false" />
|
||||
<div id="issue-blocker-results" role="listbox" aria-label="Open issue results"></div>
|
||||
<div id="issue-blocker-search-status" class="small" aria-live="polite"></div>
|
||||
<button id="cancel-issue-blocker" type="button">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
<h2>Full conversation</h2>
|
||||
<div id="issue-comments"></div>
|
||||
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
|
||||
@@ -269,6 +443,17 @@
|
||||
<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>
|
||||
<div class="mention-options" id="issue-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="issue-comment-mention-status" aria-live="polite"></div>
|
||||
<div class="issue-attachment-controls">
|
||||
<input class="visually-hidden" id="issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<label class="issue-attachment-trigger" for="issue-attachment">Attach screenshot</label>
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="issue-attachment-preview" hidden>
|
||||
<img id="issue-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="issue-attachment-meta"></span>
|
||||
<button id="remove-issue-attachment" type="button">Remove screenshot</button>
|
||||
</div>
|
||||
<div class="comment-actions">
|
||||
<button id="send-issue-comment" type="button">Post comment</button>
|
||||
<button id="send-issue-comment-next" type="button" hidden>Comment & next</button>
|
||||
@@ -336,6 +521,7 @@
|
||||
<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>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="issue" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
@@ -346,19 +532,51 @@
|
||||
<section class="find-work-panel">
|
||||
<div class="find-work-header">
|
||||
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
|
||||
<button id="close-find-work" type="button">Close</button>
|
||||
<div class="find-work-header-actions"><button id="select-find-work" type="button">Select work</button><button id="close-find-work" type="button">Close</button></div>
|
||||
</div>
|
||||
<p class="small">Claim an available issue and continue it in My Work.</p>
|
||||
<form class="find-work-search" id="find-work-search-form" role="search">
|
||||
<label for="find-work-search">Search repository, #number, or title</label>
|
||||
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
|
||||
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
|
||||
<span id="find-work-match-status" class="small" aria-live="polite"></span>
|
||||
</form>
|
||||
<details class="find-work-filters" id="find-work-filters">
|
||||
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
|
||||
<div class="find-work-filter-panel" aria-label="Filter available work">
|
||||
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<button id="clear-find-work-filters" type="button">Clear filters</button>
|
||||
</div>
|
||||
</details>
|
||||
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
||||
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
||||
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
|
||||
aria-describedby="find-work-status" hidden>Resume assigning & queueing</button>
|
||||
<div class="find-work-list" id="find-work-list"></div>
|
||||
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
||||
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
|
||||
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
|
||||
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
|
||||
<div id="find-work-estimate-list"></div>
|
||||
<div class="find-work-estimate-actions">
|
||||
<button id="cancel-find-work-estimates" type="button">Back</button>
|
||||
<button id="confirm-find-work-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
|
||||
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
|
||||
<button id="cancel-find-work-selection" type="button">Cancel</button>
|
||||
<button id="claim-selected-work" type="button" disabled>Assign & queue Today</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
|
||||
<section class="create-issue-panel">
|
||||
<div class="create-issue-header">
|
||||
<h3 id="create-issue-heading">New issue</h3>
|
||||
<h3 id="create-issue-heading">Capture work</h3>
|
||||
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||
</div>
|
||||
<aside class="shared-content-conflict" id="shared-content-conflict" aria-live="assertive" hidden>
|
||||
@@ -370,17 +588,49 @@
|
||||
</div>
|
||||
</aside>
|
||||
<form class="create-issue-form" id="create-issue-form">
|
||||
<label for="create-issue-repository">Repository
|
||||
<select id="create-issue-repository" required></select>
|
||||
</label>
|
||||
<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 for="create-issue-title">Title
|
||||
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
|
||||
</label>
|
||||
<label for="create-issue-body">Description <span class="small">Optional</span>
|
||||
<label for="create-issue-body">Note <span class="small">Optional</span>
|
||||
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<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>
|
||||
<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" />
|
||||
</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>
|
||||
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||
</section>
|
||||
<section class="create-issue-filing" id="create-issue-filing" hidden>
|
||||
<aside class="draft-filing-session" id="draft-filing-session" aria-live="polite" hidden>
|
||||
<strong id="draft-filing-progress"></strong>
|
||||
<span class="small" id="draft-filing-remaining"></span>
|
||||
<div class="draft-filing-session-actions">
|
||||
<button id="previous-draft" type="button">Previous</button>
|
||||
<button id="skip-draft" type="button">Skip</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="create-issue-repository-picker">
|
||||
<label for="create-issue-repository-search">Repository search
|
||||
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
||||
</label>
|
||||
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
|
||||
<label for="create-issue-repository">Selected repository
|
||||
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
|
||||
</label>
|
||||
</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 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>
|
||||
@@ -403,15 +653,31 @@
|
||||
</section>
|
||||
<div class="small">The issue will be assigned to you.</div>
|
||||
<div class="create-issue-actions">
|
||||
<button id="save-unfiled-issue" type="button">Save for filing</button>
|
||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||
<button id="create-follow-up-next" type="submit" hidden>Create follow-up & next</button>
|
||||
<button id="create-and-start-issue" type="submit">Create & start</button>
|
||||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="create-issue-capture-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p class="small">Review saved work, keep editing, or explicitly replace the oldest Draft.</p>
|
||||
<div class="draft-capacity-oldest" id="draft-capacity-oldest"></div>
|
||||
<div class="draft-capacity-actions">
|
||||
<button id="review-full-drafts" type="button">Review Drafts</button>
|
||||
<button id="keep-editing-draft" type="button">Keep editing</button>
|
||||
<button id="replace-oldest-draft" type="button">Replace oldest & save</button>
|
||||
</div>
|
||||
<div id="draft-capacity-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||||
<section class="update-sheet-panel">
|
||||
<div class="update-sheet-header">
|
||||
@@ -439,12 +705,31 @@
|
||||
<h2 id="update-reply-title">Reply</h2>
|
||||
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
|
||||
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
<button id="send-update-reply" type="button">Send reply</button>
|
||||
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
|
||||
<div class="issue-attachment-controls">
|
||||
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<label class="issue-attachment-trigger" for="update-reply-attachment">Attach screenshot</label>
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="update-reply-attachment-preview" hidden>
|
||||
<img id="update-reply-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="update-reply-attachment-meta"></span>
|
||||
<button id="remove-update-reply-attachment" type="button">Remove screenshot</button>
|
||||
</div>
|
||||
<div class="update-reply-actions">
|
||||
<button id="send-update-reply" type="button">Send reply</button>
|
||||
<button id="send-update-reply-read-next" type="button">Reply, mark read & next</button>
|
||||
</div>
|
||||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="update-sheet-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<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="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>
|
||||
@@ -452,12 +737,18 @@
|
||||
<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>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="notification-undo" id="notification-undo" role="status" aria-live="assertive" hidden>
|
||||
<span id="notification-undo-status"></span>
|
||||
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
|
||||
</div>
|
||||
|
||||
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
|
||||
<section class="pull-sheet-panel">
|
||||
<div class="pull-sheet-header">
|
||||
@@ -473,6 +764,17 @@
|
||||
<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>
|
||||
<div class="mention-options" id="pull-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="pull-comment-mention-status" aria-live="polite"></div>
|
||||
<div class="issue-attachment-controls">
|
||||
<input class="visually-hidden" id="pull-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<label class="issue-attachment-trigger" for="pull-attachment">Attach screenshot</label>
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="pull-attachment-preview" hidden>
|
||||
<img id="pull-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="pull-attachment-meta"></span>
|
||||
<button id="remove-pull-attachment" type="button">Remove screenshot</button>
|
||||
</div>
|
||||
<div class="comment-actions">
|
||||
<button id="send-pull-comment" type="button">Post comment</button>
|
||||
<button id="send-pull-comment-next" type="button" hidden>Comment & next</button>
|
||||
@@ -484,18 +786,35 @@
|
||||
<div id="pull-review-status" class="small" aria-live="polite">Expand to load changed files and merge readiness.</div>
|
||||
<button class="pull-retry" id="pull-review-retry" type="button" hidden>Retry review data</button>
|
||||
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Review data not loaded</span></div>
|
||||
<details class="ci-checks" id="pull-checks">
|
||||
<summary><span>Checks</span><span class="small" id="pull-checks-summary">Not loaded</span></summary>
|
||||
<button class="ci-check-refresh" id="refresh-pull-checks" type="button">Refresh checks</button>
|
||||
<div class="ci-check-list" id="pull-check-list"></div>
|
||||
</details>
|
||||
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
|
||||
<div id="pull-files"></div>
|
||||
<button id="merge-pull" type="button" disabled>Merge</button>
|
||||
</details>
|
||||
<details class="pull-ownership" id="pull-ownership">
|
||||
<summary>Ownership</summary>
|
||||
<div>
|
||||
<button id="load-pull-handoff" type="button">Choose teammate</button>
|
||||
<label for="pull-handoff-recipient" class="small">Eligible repository assignee</label>
|
||||
<select id="pull-handoff-recipient" disabled><option value="">Select a teammate</option></select>
|
||||
<button id="confirm-pull-handoff" type="button" disabled>Confirm handoff</button>
|
||||
<div id="pull-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="pull-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="release-pull" type="button">Release assignment</button>
|
||||
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="pull" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
@@ -515,7 +834,16 @@
|
||||
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
|
||||
<div class="review-sheet-body markdown-content" id="review-sheet-body"></div>
|
||||
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><button class="share-work-route" type="button">Share</button><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
|
||||
<details class="ci-checks" id="review-checks">
|
||||
<summary><span>Checks</span><span class="small" id="review-checks-summary">Not loaded</span></summary>
|
||||
<button class="ci-check-refresh" id="refresh-review-checks" type="button">Refresh checks</button>
|
||||
<div class="ci-check-list" id="review-check-list"></div>
|
||||
</details>
|
||||
<h2>Changed files</h2>
|
||||
<div class="review-display-tools">
|
||||
<button id="review-wrap-lines" type="button" aria-pressed="false" aria-controls="review-files">Wrap lines</button>
|
||||
<span class="small">Keep long diffs inside the review sheet.</span>
|
||||
</div>
|
||||
<div id="review-files" class="muted"></div>
|
||||
<section class="review-inline-composer" id="review-inline-composer" aria-labelledby="review-inline-title" hidden>
|
||||
<strong id="review-inline-title">Comment on changed line</strong>
|
||||
@@ -561,6 +889,7 @@
|
||||
<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>
|
||||
<button type="button" data-work-session-timer-toggle>Pause timer</button>
|
||||
<button type="button" data-work-session-complete="review" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
@@ -579,46 +908,87 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
|
||||
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
|
||||
<button data-mobile-today-complete type="button" hidden>Done & next</button>
|
||||
<div class="small" data-work-session-progress aria-live="polite"></div>
|
||||
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
|
||||
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
|
||||
</section>
|
||||
<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>
|
||||
<p class="small muted">Choose what to work through next.</p>
|
||||
<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="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="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>
|
||||
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span> <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
|
||||
<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="drafts" type="button">Drafts <span class="mobile-task-count" id="mobile-draft-count" aria-label="0 drafts">0</span></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>
|
||||
</nav>
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/feature-loader.js"></script>
|
||||
<script src="static/security-center.js"></script>
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/unfiled-captures.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/authored-outbox.js"></script>
|
||||
<script src="static/offline-issue-close.js"></script>
|
||||
<script src="static/offline-issue-blocker.js"></script>
|
||||
<script src="static/notification-read-outbox.js"></script>
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/notification-undo.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/work-selection.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-timer.js"></script>
|
||||
<script src="static/today-recap.js"></script>
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
<script src="static/comment-next.js"></script>
|
||||
<script src="static/update-reply-read-next.js"></script>
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-readiness.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/today-rollover.js"></script>
|
||||
<script src="static/update-ownership.js"></script>
|
||||
<script src="static/update-follow-up.js"></script>
|
||||
<script src="static/later-work.js"></script>
|
||||
<script src="static/later-sync.js"></script>
|
||||
<script src="static/later-and-start.js"></script>
|
||||
<script src="static/detail-defer.js"></script>
|
||||
<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/conversation.js"></script>
|
||||
<script src="static/comment-actions.js"></script>
|
||||
<script src="static/issue-attachment.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
@@ -631,10 +1001,14 @@
|
||||
<script src="static/context-poller.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-launch.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/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+19
-2
@@ -3,7 +3,8 @@
|
||||
else root.createInstallApp = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
|
||||
const dismissedKey = 'stackchain.install.dismissed.v1';
|
||||
let deferredPrompt = null;
|
||||
let deferredPrompt = options.initialPrompt || null;
|
||||
let installed = false;
|
||||
|
||||
function hide() {
|
||||
options.card.hidden = true;
|
||||
@@ -25,6 +26,7 @@
|
||||
await prompt.prompt();
|
||||
const choice = await prompt.userChoice;
|
||||
if (choice.outcome === 'accepted') {
|
||||
installed = true;
|
||||
hide();
|
||||
options.status.textContent = 'Stackchain was added to your device.';
|
||||
} else {
|
||||
@@ -44,6 +46,7 @@
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
if (deferredPrompt) showNativePrompt(deferredPrompt);
|
||||
if (options.isIosSafari()) {
|
||||
options.installButton.hidden = true;
|
||||
options.guidance.hidden = false;
|
||||
@@ -51,6 +54,7 @@
|
||||
}
|
||||
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
|
||||
options.window.addEventListener('appinstalled', () => {
|
||||
installed = true;
|
||||
deferredPrompt = null;
|
||||
hide();
|
||||
});
|
||||
@@ -58,5 +62,18 @@
|
||||
options.dismissButton.addEventListener('click', dismiss);
|
||||
}
|
||||
|
||||
return {start};
|
||||
function state() {
|
||||
if (installed || options.isStandalone()) {
|
||||
return {state:'complete', detail:'Stackchain is installed on this device.'};
|
||||
}
|
||||
if (options.isIosSafari()) {
|
||||
return {state:'incomplete', detail:'On Safari, tap Share, then Add to Home Screen.'};
|
||||
}
|
||||
if (deferredPrompt) {
|
||||
return {state:'incomplete', detail:'Stackchain is ready to install.'};
|
||||
}
|
||||
return {state:'unavailable', detail:'Installation is available from a supported browser.'};
|
||||
}
|
||||
|
||||
return {start, install, state};
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.issueAttachment = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
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;
|
||||
}
|
||||
|
||||
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 multipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
const binary = atob(String(attachment.data));
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
||||
}
|
||||
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
||||
const form = new FormData();
|
||||
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
||||
return form;
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const upload = options.upload;
|
||||
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
|
||||
const createOperationId = options.createOperationId || (() => {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
});
|
||||
let selected = null;
|
||||
let confirmed = null;
|
||||
let serialized = null;
|
||||
let operationId = null;
|
||||
let selectionGeneration = 0;
|
||||
|
||||
function commitSelection(file) {
|
||||
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
|
||||
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();
|
||||
return state();
|
||||
}
|
||||
|
||||
function select(file) {
|
||||
const generation = ++selectionGeneration;
|
||||
if (!file || !IMAGE_TYPES.has(file.type)) {
|
||||
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
|
||||
}
|
||||
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) {
|
||||
return Promise.resolve(optimizeSelectedImage(file)).then(optimized => {
|
||||
if (generation !== selectionGeneration) return state();
|
||||
return commitSelection(optimized);
|
||||
});
|
||||
}
|
||||
return commitSelection(file);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selectionGeneration += 1;
|
||||
selected = null;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
operationId = null;
|
||||
}
|
||||
|
||||
function restore(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();
|
||||
}
|
||||
|
||||
function state() {
|
||||
return selected ? {
|
||||
name: selected.name,
|
||||
size: selected.size,
|
||||
uploaded: Boolean(confirmed),
|
||||
} : null;
|
||||
}
|
||||
|
||||
async function serialize() {
|
||||
if (!selected) return null;
|
||||
if (!serialized) {
|
||||
serialized = {
|
||||
filename: selected.name,
|
||||
contentType: selected.type,
|
||||
blob: selected.blob || selected,
|
||||
};
|
||||
}
|
||||
return { ...serialized };
|
||||
}
|
||||
|
||||
async function prepareComment(item, 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.');
|
||||
}
|
||||
}
|
||||
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
|
||||
}
|
||||
|
||||
return { select, restore, clear, state, serialize, prepareComment };
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
const controller = create(options);
|
||||
const clearSelection = controller.clear;
|
||||
let previewUrl = '';
|
||||
let selectionSequence = 0;
|
||||
|
||||
function setBusy(busy) {
|
||||
options.input.disabled = Boolean(busy);
|
||||
options.remove.disabled = Boolean(busy);
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
selectionSequence += 1;
|
||||
if (previewUrl) options.revokeObjectURL(previewUrl);
|
||||
previewUrl = '';
|
||||
options.image.src = '';
|
||||
options.preview.hidden = true;
|
||||
options.input.value = '';
|
||||
options.input.disabled = false;
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function showPreview(file, optimized) {
|
||||
if (previewUrl) options.revokeObjectURL(previewUrl);
|
||||
previewUrl = options.createObjectURL(file);
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
|
||||
options.preview.hidden = 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 sequence = ++selectionSequence;
|
||||
let result;
|
||||
try {
|
||||
result = controller.select(file);
|
||||
} catch (error) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
return;
|
||||
}
|
||||
if (!result || typeof result.then !== 'function') {
|
||||
showPreview(file, 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);
|
||||
}).catch(error => {
|
||||
if (sequence === selectionSequence) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
}
|
||||
}).finally(() => {
|
||||
if (sequence === selectionSequence) options.input.disabled = false;
|
||||
});
|
||||
});
|
||||
options.remove.addEventListener('click', () => {
|
||||
clearPreview();
|
||||
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
|
||||
});
|
||||
|
||||
function restorePreview(value) {
|
||||
clearPreview();
|
||||
const restored = controller.restore(value);
|
||||
previewUrl = value.blob ? options.createObjectURL(value.blob) :
|
||||
'data:' + value.contentType + ';base64,' + value.data;
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
||||
return restored;
|
||||
}
|
||||
|
||||
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
|
||||
}
|
||||
|
||||
return { create, mount, multipart, optimizeImage, MAX_BYTES };
|
||||
});
|
||||
+196
-33
@@ -5,6 +5,18 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
);
|
||||
const pending = new Map();
|
||||
|
||||
function captureAttachment(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '').slice(0, 255);
|
||||
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 };
|
||||
if (!data) return undefined;
|
||||
return { filename, contentType, data };
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
@@ -23,7 +35,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(draft, mirror = true) {
|
||||
function prepareItem(draft) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
|
||||
const items = read();
|
||||
@@ -39,34 +51,90 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
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';
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
if (attachment) item.attachment = attachment;
|
||||
item.operationId = item.id;
|
||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||
item.milestoneId = Number(draft.milestoneId);
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
|
||||
return item;
|
||||
}
|
||||
|
||||
function localIndexItem(item) {
|
||||
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
|
||||
return {
|
||||
...item,
|
||||
attachment: {
|
||||
filename: item.attachment.filename,
|
||||
contentType: item.attachment.contentType,
|
||||
stored: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function enqueue(draft, mirror = true) {
|
||||
const items = read();
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
const existing = sourceCaptureId && items.find(item => item.sourceCaptureId === sourceCaptureId);
|
||||
if (existing) return { ...existing };
|
||||
const item = prepareItem(draft);
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function enqueueDurably(draft) {
|
||||
const item = enqueue(draft, false);
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
try {
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
const existing = sourceCaptureId && read().find(item => item.sourceCaptureId === sourceCaptureId);
|
||||
if (existing) {
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item: { ...existing }, background: false, durability: 'foreground-only', reused: true };
|
||||
}
|
||||
await backgroundSync.reconcile(read());
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
return { item: { ...existing }, background: true, durability: 'background', reused: true };
|
||||
}
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
const item = enqueue(draft, false);
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
const item = prepareItem(draft);
|
||||
const current = read();
|
||||
try {
|
||||
await backgroundSync.reconcile([...current, item]);
|
||||
const localItem = localIndexItem(item);
|
||||
write([...current, item].map(localIndexItem), false);
|
||||
await backgroundSync.requestSync();
|
||||
return { item: localItem, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item, background: false, durability: 'foreground-only', error };
|
||||
const persisted = read().find(candidate => candidate.id === item.id);
|
||||
if (!persisted) throw error;
|
||||
return { item: persisted, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function update(id, draft, mirror = true) {
|
||||
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 };
|
||||
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) {
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
return { ...item, attachment };
|
||||
}
|
||||
|
||||
function prepareUpdate(id, draft) {
|
||||
let updated = null;
|
||||
write(read().map(item => {
|
||||
const items = read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const nextRepository = String(draft?.repository || '');
|
||||
const nextTitle = String(draft?.title || '');
|
||||
@@ -76,14 +144,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
? Number(draft.milestoneId) : undefined;
|
||||
const nextDueDate = /^\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;
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
||||
|| attachmentChanged;
|
||||
updated = {
|
||||
...item,
|
||||
repository: nextRepository, title: nextTitle,
|
||||
body: nextBody, labelIds: nextLabelIds,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
@@ -91,24 +162,38 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
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;
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
}), mirror);
|
||||
});
|
||||
return { items, updated };
|
||||
}
|
||||
|
||||
function update(id, draft, mirror = true) {
|
||||
const { items, updated } = prepareUpdate(id, draft);
|
||||
write(items, mirror);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function updateDurably(id, draft) {
|
||||
const item = update(id, draft, false);
|
||||
if (!item || !backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
const item = update(id, draft, false);
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
const { items, updated: item } = prepareUpdate(id, draft);
|
||||
if (!item) return { item, background: false, durability: 'foreground-only' };
|
||||
try {
|
||||
await backgroundSync.reconcile(read());
|
||||
await backgroundSync.reconcile(items);
|
||||
const localItems = items.map(localIndexItem);
|
||||
write(localItems, false);
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
return { item: localIndexItem(item), background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item, background: false, durability: 'foreground-only', error };
|
||||
const persisted = read().find(candidate => candidate.id === id);
|
||||
if (!persisted || persisted.operationId !== item.operationId) throw error;
|
||||
return { item: persisted, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,9 +204,88 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return true;
|
||||
}
|
||||
|
||||
function persistDeliveryStage(id, stage) {
|
||||
write(read().map(item => item.id === id ? { ...item, ...stage } : item));
|
||||
}
|
||||
|
||||
function stageOperationId(operationId, stage) {
|
||||
const suffix = ':' + stage;
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
function attachmentMultipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
const binary = atob(String(attachment.data));
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
||||
}
|
||||
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
||||
const form = new FormData();
|
||||
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
||||
return form;
|
||||
}
|
||||
|
||||
async function sendDirect(item, repository) {
|
||||
let issue = item.deliveredIssue;
|
||||
if (!issue) {
|
||||
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
});
|
||||
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
|
||||
}
|
||||
if (!item.attachment) return issue;
|
||||
let markdown = item.attachmentMarkdown;
|
||||
if (!markdown) {
|
||||
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'),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
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 });
|
||||
}
|
||||
await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
|
||||
},
|
||||
body: JSON.stringify({ body: markdown }),
|
||||
},
|
||||
);
|
||||
return issue;
|
||||
}
|
||||
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const attemptAt = Number(now());
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'sending', lastAttemptAt:attemptAt,
|
||||
} : candidate), false);
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
const request = (async () => {
|
||||
try {
|
||||
@@ -135,29 +299,24 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
issue = delivery.issue;
|
||||
} else {
|
||||
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
});
|
||||
issue = await sendDirect(item, repository);
|
||||
}
|
||||
if (!issue) return { blocked: true };
|
||||
discard(item.id);
|
||||
return { issue, item };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
|
||||
if (status >= 400 && status < 500) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
|
||||
lastAttemptAt: attemptAt, lastAttemptError: attemptError,
|
||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||
} : candidate));
|
||||
} else {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !(status >= 400 && status < 500) };
|
||||
}
|
||||
@@ -237,6 +396,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
const background = statuses.get(item.id);
|
||||
const deliveryStage = {
|
||||
...(background?.deliveredIssue ? { deliveredIssue: background.deliveredIssue } : {}),
|
||||
...(background?.attachmentMarkdown ? { attachmentMarkdown: background.attachmentMarkdown } : {}),
|
||||
};
|
||||
if (background?.status === 'sent') {
|
||||
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
|
||||
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
|
||||
@@ -244,10 +407,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return [];
|
||||
}
|
||||
if (background?.status === 'attention') return [{
|
||||
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...localIndexItem(item), ...deliveryStage, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
return [item];
|
||||
return [{ ...localIndexItem(item), ...deliveryStage }];
|
||||
});
|
||||
write(items);
|
||||
return items;
|
||||
@@ -276,7 +439,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
|
||||
return {
|
||||
enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground,
|
||||
enqueue, enqueueDurably, hydrateForEdit, update, updateDurably, discard, flush, retry, reconcileBackground,
|
||||
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
let milestoneRequest = null;
|
||||
let blockerRequest = null;
|
||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
||||
@@ -67,6 +68,24 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
updateBlocker(item, blocker, remove = false) {
|
||||
if (blockerRequest) return blockerRequest;
|
||||
blockerRequest = fetchJson(issuePath(item) + '/blockers', {
|
||||
method: remove ? 'DELETE' : 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ repository: blocker.repository, number: blocker.number }),
|
||||
}).then(result => {
|
||||
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
|
||||
const present = dependencies.some(candidate =>
|
||||
candidate?.repository === blocker.repository && Number(candidate?.number) === Number(blocker.number)
|
||||
);
|
||||
if (result?.number !== item.number || result?.dependencies_available !== true || present === remove) {
|
||||
throw new Error('Blocker change was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { blockerRequest = null; });
|
||||
return blockerRequest;
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
function createLaterAndStart({ todayWork, todaySync, laterWork, refresh, warm, start, announce }) {
|
||||
const pending = new Map();
|
||||
|
||||
async function run(item, identity) {
|
||||
const added = todayWork.add(item);
|
||||
if (added === 'full') {
|
||||
announce('Today is limited to 5 items. Remove one, then try Start now again.');
|
||||
return 'full';
|
||||
}
|
||||
if (added !== 'added' && added !== 'exists') {
|
||||
announce('Could not save Today on this device. The item remains in Later; try again.');
|
||||
return added;
|
||||
}
|
||||
if (added === 'added' && !todaySync.enqueue('add', identity)) {
|
||||
todayWork.remove(item);
|
||||
announce('Today sync is unavailable. The item remains in Later; try again.');
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
if (!laterWork.restore(item)) {
|
||||
if (added === 'added') {
|
||||
todayWork.remove(item);
|
||||
todaySync.enqueue('remove', identity);
|
||||
todaySync.flush();
|
||||
}
|
||||
announce('Could not remove this item from Later. Nothing was started; try again.');
|
||||
return 'later-unavailable';
|
||||
}
|
||||
refresh();
|
||||
if (added === 'added') todaySync.flush();
|
||||
warm();
|
||||
const outcome = await start(item);
|
||||
if (outcome === 'gated') {
|
||||
announce('Moved to Today. Choose how to handle its blocker before starting.');
|
||||
return 'gated';
|
||||
}
|
||||
announce(added === 'exists' ? 'Opened the existing Today item.' : 'Moved to Today and opened.');
|
||||
return 'started';
|
||||
}
|
||||
|
||||
function startDeferred(item) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (pending.has(identity)) return pending.get(identity);
|
||||
const operation = run(item, identity).finally(() => pending.delete(identity));
|
||||
pending.set(identity, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
return { start: startDeferred };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterAndStart;
|
||||
+17
-1
@@ -52,6 +52,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function deferMany(items, until) {
|
||||
const key = storageKey();
|
||||
const wake = new Date(until);
|
||||
const entries = (items || []).map(item => [identity(item), item]);
|
||||
if (!key) return 'unavailable';
|
||||
if (!entries.length || entries.some(([id]) => !id) || Number.isNaN(wake.getTime()) || wake <= now()) {
|
||||
return 'invalid';
|
||||
}
|
||||
const records = read();
|
||||
const wakeAt = wake.toISOString();
|
||||
entries.forEach(([id]) => { records[id] = wakeAt; });
|
||||
if (!write(records)) return 'unavailable';
|
||||
entries.forEach(([id]) => onChange('defer', id, wakeAt));
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function presetUntil(preset) {
|
||||
const current = new Date(now());
|
||||
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
|
||||
@@ -127,7 +143,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
return { active, later };
|
||||
}
|
||||
|
||||
return { identity, read, adopt, defer, restore, presetUntil, partition };
|
||||
return { identity, read, adopt, defer, deferMany, restore, presetUntil, partition };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
|
||||
+81
-1
@@ -20,6 +20,8 @@
|
||||
const form = options.form;
|
||||
const status = options.status;
|
||||
const button = options.button;
|
||||
const passkeyButton = options.passkeyButton;
|
||||
const credentials = options.credentials;
|
||||
const fetchImpl = options.fetchImpl;
|
||||
const location = options.location;
|
||||
const clearPrivateDeviceData = options.clearPrivateDeviceData;
|
||||
@@ -28,6 +30,34 @@
|
||||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||||
let timer = null;
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
|
||||
+ '='.repeat((4 - String(value).length % 4) % 4);
|
||||
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function encodeBase64Url(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const bytes = new Uint8Array(value);
|
||||
let binary = '';
|
||||
bytes.forEach(byte => { binary += String.fromCharCode(byte); });
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
||||
}
|
||||
|
||||
function credentialJSON(credential) {
|
||||
return {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeBase64Url(credential.rawId),
|
||||
response: {
|
||||
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
|
||||
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
|
||||
signature: encodeBase64Url(credential.response.signature),
|
||||
userHandle: encodeBase64Url(credential.response.userHandle),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
|
||||
|
||||
async function showReason(reason) {
|
||||
@@ -35,6 +65,10 @@
|
||||
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
|
||||
return;
|
||||
}
|
||||
if (reason === 'session-idle') {
|
||||
status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.';
|
||||
return;
|
||||
}
|
||||
if (reason !== 'session-revoked') return;
|
||||
button.disabled = true;
|
||||
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
|
||||
@@ -91,18 +125,60 @@
|
||||
status.textContent = 'Sign-in failed. Check the token and try again.';
|
||||
}
|
||||
|
||||
return { submit, showReason };
|
||||
async function signInWithPasskey(deviceLabel = 'This device') {
|
||||
if (!credentials?.get) {
|
||||
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
|
||||
return false;
|
||||
}
|
||||
status.textContent = 'Waiting for your passkey…';
|
||||
try {
|
||||
const optionsResponse = await fetchImpl('api/v1/passkeys/authentication/options', {
|
||||
method: 'POST', headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
|
||||
const publicKey = await optionsResponse.json();
|
||||
const challenge = publicKey.challenge;
|
||||
publicKey.challenge = decodeBase64Url(publicKey.challenge);
|
||||
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
|
||||
...item, id: decodeBase64Url(item.id),
|
||||
}));
|
||||
const credential = await credentials.get({ publicKey });
|
||||
const response = await fetchImpl('api/v1/passkeys/authentication/verify', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challenge,
|
||||
credential: credentialJSON(credential),
|
||||
device_label: deviceLabel,
|
||||
action: 'sign_in',
|
||||
target: 'dashboard',
|
||||
}),
|
||||
});
|
||||
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.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (passkeyButton) passkeyButton.disabled = !credentials?.get;
|
||||
return { submit, signInWithPasskey, showReason };
|
||||
}));
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const form = document.getElementById('sign-in');
|
||||
const status = document.getElementById('status');
|
||||
const button = document.getElementById('submit-sign-in');
|
||||
const passkeyButton = document.getElementById('passkey-sign-in');
|
||||
const loginParams = new URLSearchParams(window.location.search);
|
||||
const controller = createLoginController({
|
||||
form,
|
||||
status,
|
||||
button,
|
||||
passkeyButton,
|
||||
credentials: window.navigator?.credentials,
|
||||
fetchImpl: fetch.bind(window),
|
||||
location: window.location,
|
||||
continuation: loginParams.get('continue'),
|
||||
@@ -116,4 +192,8 @@ if (typeof document !== 'undefined') {
|
||||
const deviceLabel = data.get('device_label');
|
||||
controller.submit(accessToken, deviceLabel);
|
||||
});
|
||||
passkeyButton?.addEventListener('click', () => {
|
||||
const data = new FormData(form);
|
||||
controller.signInWithPasskey(data.get('device_label'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createMentionComposer = api.create;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function() {
|
||||
'use strict';
|
||||
|
||||
function activeMention(value, caret) {
|
||||
const before = String(value || '').slice(0, Math.max(0, caret));
|
||||
const match = before.match(/(^|\s)@([A-Za-z0-9_.-]{2,39})$/);
|
||||
if (!match) return null;
|
||||
const start = before.length - match[2].length - 1;
|
||||
return { start, end:before.length, query:match[2] };
|
||||
}
|
||||
|
||||
function insertMention(value, mention, login) {
|
||||
const text = String(value || '');
|
||||
const suffixStart = mention.end < text.length && /\s/.test(text[mention.end]) ? mention.end + 1 : mention.end;
|
||||
const inserted = '@' + login + ' ';
|
||||
return {
|
||||
value:text.slice(0, mention.start) + inserted + text.slice(suffixStart),
|
||||
caret:mention.start + inserted.length,
|
||||
};
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const textarea = options.textarea;
|
||||
const listbox = options.listbox;
|
||||
const status = options.status;
|
||||
const setTimer = options.setTimer || setTimeout;
|
||||
const clearTimer = options.clearTimer || clearTimeout;
|
||||
const createOption = options.createOption || (() => document.createElement('button'));
|
||||
let timer = null;
|
||||
let requestId = 0;
|
||||
let mention = null;
|
||||
let candidates = [];
|
||||
let activeIndex = -1;
|
||||
|
||||
function dismiss() {
|
||||
requestId += 1;
|
||||
candidates = [];
|
||||
activeIndex = -1;
|
||||
listbox.replaceChildren();
|
||||
listbox.hidden = true;
|
||||
textarea.setAttribute('aria-expanded', 'false');
|
||||
textarea.removeAttribute('aria-activedescendant');
|
||||
status.textContent = '';
|
||||
}
|
||||
|
||||
function activate(index) {
|
||||
if (!candidates.length) return;
|
||||
activeIndex = (index + candidates.length) % candidates.length;
|
||||
listbox.children.forEach((option, optionIndex) => {
|
||||
option.setAttribute('aria-selected', optionIndex === activeIndex ? 'true' : 'false');
|
||||
});
|
||||
textarea.setAttribute('aria-activedescendant', listbox.children[activeIndex].id);
|
||||
}
|
||||
|
||||
function select(index) {
|
||||
if (!mention || !candidates[index]) return;
|
||||
const result = insertMention(textarea.value, mention, candidates[index].login);
|
||||
textarea.value = result.value;
|
||||
textarea.setSelectionRange(result.caret, result.caret);
|
||||
dismiss();
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent?.(new Event('input', { bubbles:true }));
|
||||
}
|
||||
|
||||
function render(items) {
|
||||
candidates = Array.isArray(items) ? items : [];
|
||||
activeIndex = -1;
|
||||
listbox.replaceChildren();
|
||||
candidates.forEach((candidate, index) => {
|
||||
const option = createOption();
|
||||
option.type = 'button';
|
||||
option.id = listbox.id + '-option-' + index;
|
||||
option.className = 'mention-option';
|
||||
option.dataset.login = candidate.login;
|
||||
option.setAttribute('role', 'option');
|
||||
option.setAttribute('aria-selected', 'false');
|
||||
option.textContent = '@' + candidate.login + (candidate.name !== candidate.login ? ' · ' + candidate.name : '');
|
||||
option.addEventListener('pointerdown', event => {
|
||||
event.preventDefault();
|
||||
select(index);
|
||||
});
|
||||
listbox.appendChild(option);
|
||||
});
|
||||
listbox.hidden = !candidates.length;
|
||||
textarea.setAttribute('aria-expanded', candidates.length ? 'true' : 'false');
|
||||
status.textContent = candidates.length ? candidates.length + ' teammates found.' : 'No matching teammates.';
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
mention = activeMention(textarea.value, textarea.selectionStart);
|
||||
const repository = options.getRepository();
|
||||
if (!mention || !repository) {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
const currentRequest = ++requestId;
|
||||
timer = setTimer(async () => {
|
||||
try {
|
||||
const items = await options.loadCandidates(repository, mention.query);
|
||||
if (currentRequest === requestId && repository === options.getRepository()) render(items);
|
||||
} catch (_error) {
|
||||
if (currentRequest === requestId) {
|
||||
listbox.hidden = true;
|
||||
textarea.setAttribute('aria-expanded', 'false');
|
||||
status.textContent = 'Teammate suggestions unavailable; you can keep typing.';
|
||||
}
|
||||
}
|
||||
}, options.delayMs ?? 180);
|
||||
}
|
||||
|
||||
function keydown(event) {
|
||||
if (event.key === 'Escape' && !listbox.hidden) {
|
||||
event.preventDefault();
|
||||
dismiss();
|
||||
} else if (event.key === 'ArrowDown' && candidates.length) {
|
||||
event.preventDefault();
|
||||
activate(activeIndex + 1);
|
||||
} else if (event.key === 'ArrowUp' && candidates.length) {
|
||||
event.preventDefault();
|
||||
activate(activeIndex < 0 ? candidates.length - 1 : activeIndex - 1);
|
||||
} else if (event.key === 'Enter' && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
select(activeIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
textarea.setAttribute('autocomplete', 'off');
|
||||
textarea.setAttribute('aria-autocomplete', 'list');
|
||||
textarea.setAttribute('aria-controls', listbox.id);
|
||||
textarea.setAttribute('aria-expanded', 'false');
|
||||
textarea.addEventListener('input', schedule);
|
||||
textarea.addEventListener('click', schedule);
|
||||
textarea.addEventListener('keydown', keydown);
|
||||
},
|
||||
dismiss,
|
||||
};
|
||||
}
|
||||
|
||||
return { activeMention, insertMention, create };
|
||||
});
|
||||
@@ -14,7 +14,9 @@
|
||||
|
||||
function reveal() {
|
||||
if (!active) return;
|
||||
active.workspace.scrollIntoView({ block:'end', inline:'nearest' });
|
||||
const focused = documentRef.activeElement;
|
||||
const target = active.focusWithin && active.workspace.contains(focused) ? focused : active.workspace;
|
||||
target.scrollIntoView({ block:active.focusWithin ? 'nearest' : 'end', inline:'nearest' });
|
||||
}
|
||||
|
||||
function applyGeometry() {
|
||||
@@ -59,12 +61,14 @@
|
||||
entry.onFocusOut = () => schedule(() => {
|
||||
if (!entry.workspace.contains(documentRef.activeElement)) deactivate(entry);
|
||||
});
|
||||
entry.composer.addEventListener('focus', entry.onFocus);
|
||||
entry.focusTarget = entry.focusWithin ? entry.workspace : entry.composer;
|
||||
entry.focusEvent = entry.focusWithin ? 'focusin' : 'focus';
|
||||
entry.focusTarget.addEventListener(entry.focusEvent, entry.onFocus);
|
||||
entry.workspace.addEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
function unbind(entry) {
|
||||
entry.composer.removeEventListener('focus', entry.onFocus);
|
||||
entry.focusTarget.removeEventListener(entry.focusEvent, entry.onFocus);
|
||||
entry.workspace.removeEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileDeviceSetup = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileDeviceSetup(options) {
|
||||
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
|
||||
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const steps = [
|
||||
['install', options.installButton, options.installStatus, options.install],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush],
|
||||
];
|
||||
let trigger = options.launcher;
|
||||
|
||||
function readinessCounts(readiness) {
|
||||
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
|
||||
const complete = steps.filter(([name]) => readiness[name].state === 'complete').length;
|
||||
return {available, complete};
|
||||
}
|
||||
|
||||
function renderPrompt(readiness) {
|
||||
if (!options.promptCard) return;
|
||||
const {available, complete} = readinessCounts(readiness);
|
||||
let dismissed = false;
|
||||
try {
|
||||
dismissed = Number(options.promptStorage?.getItem(promptDismissKey) || 0) > (options.now?.() ?? Date.now());
|
||||
} catch (_error) {}
|
||||
options.promptSummary.textContent = `${complete} of ${available} steps complete`;
|
||||
options.promptCard.hidden = dismissed || available === 0 || complete === available;
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const readiness = await options.getReadiness();
|
||||
let available = 0;
|
||||
let complete = 0;
|
||||
steps.forEach(([name, button, status]) => {
|
||||
const step = readiness[name];
|
||||
status.textContent = step.detail;
|
||||
button.hidden = step.state === 'complete' || step.state === 'unavailable';
|
||||
button.disabled = step.state === 'pending';
|
||||
if (step.state !== 'unavailable') available += 1;
|
||||
if (step.state === 'complete') complete += 1;
|
||||
});
|
||||
options.readyStatus.textContent = complete === available
|
||||
? 'This device is ready.'
|
||||
: `${complete} of ${available} available steps ready.`;
|
||||
renderPrompt(readiness);
|
||||
return readiness;
|
||||
}
|
||||
|
||||
async function open(event) {
|
||||
trigger = event?.currentTarget || options.launcher;
|
||||
await render();
|
||||
options.sheet.hidden = false;
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
options.sheet.hidden = true;
|
||||
trigger?.focus?.();
|
||||
}
|
||||
|
||||
async function start() {
|
||||
options.launcher.addEventListener('click', open);
|
||||
options.promptLauncher?.addEventListener('click', open);
|
||||
options.promptDismiss?.addEventListener('click', () => {
|
||||
try {
|
||||
options.promptStorage?.setItem(promptDismissKey, String((options.now?.() ?? Date.now()) + promptDismissMs));
|
||||
} catch (_error) {}
|
||||
options.promptCard.hidden = true;
|
||||
});
|
||||
options.closeButton.addEventListener('click', close);
|
||||
options.sheet.addEventListener('click', event => {
|
||||
if (event.target === options.sheet) close();
|
||||
});
|
||||
options.escapeTarget.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !options.sheet.hidden) close();
|
||||
});
|
||||
steps.forEach(([_name, button, _status, action]) => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
try { await action(); }
|
||||
finally { await render(); }
|
||||
});
|
||||
});
|
||||
renderPrompt(await options.getReadiness());
|
||||
}
|
||||
|
||||
return {start, open, close, render};
|
||||
});
|
||||
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports.mount = mountMobileDeviceSetup;
|
||||
} else {
|
||||
self.createMobileDeviceSetup.mount = mountMobileDeviceSetup;
|
||||
}
|
||||
|
||||
function mountMobileDeviceSetup(options) {
|
||||
const qs = selector => options.document.querySelector(selector);
|
||||
return createMobileDeviceSetup({
|
||||
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'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
pushStatus:qs('#device-setup-push-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,
|
||||
escapeTarget:options.document,
|
||||
getReadiness:() => ({
|
||||
install:options.installApp.state(),
|
||||
offline:!options.offlineAvailable()
|
||||
? {state:'unavailable', detail:'Offline saving is unavailable in this browser.'}
|
||||
: options.offlineEnabled()
|
||||
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
|
||||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||
push:qs('#push-updates').disabled
|
||||
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
|
||||
: 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.'},
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
enablePush:options.enablePush,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileQueueLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileQueueLauncher(options) {
|
||||
const emptyMessages = {
|
||||
attention: 'No items need attention.',
|
||||
update: 'No unread updates are ready to open.',
|
||||
later: 'No deferred work is ready to open.',
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const continuation = [
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
];
|
||||
|
||||
function recommend() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const match = continuation.find(([name]) => Number(counts[name]) > 0);
|
||||
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
|
||||
const [name, label] = match;
|
||||
const count = Math.max(0, Number(counts[name]) || 0);
|
||||
return {name, count, label: label + ' (' + count + ')'};
|
||||
}
|
||||
|
||||
function open(name) {
|
||||
if (name === 'today') return options.openToday();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
if (!action) {
|
||||
options.announce(emptyMessages[name] || 'No work is ready to open.');
|
||||
return 'empty';
|
||||
}
|
||||
action.click();
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
function continueWork() {
|
||||
const next = recommend();
|
||||
if (next.name === 'find') {
|
||||
options.openFindWork();
|
||||
return 'find';
|
||||
}
|
||||
return open(next.name);
|
||||
}
|
||||
|
||||
return { open, recommend, continueWork };
|
||||
});
|
||||
@@ -7,60 +7,108 @@
|
||||
const overlays = options.overlays || [];
|
||||
let launcher = null;
|
||||
let wasHidden = false;
|
||||
let workMode = 'work';
|
||||
|
||||
function select(name) {
|
||||
if (!buttons[name]) return false;
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (key === name) button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function refreshVisibility() {
|
||||
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
|
||||
const hidden = overlays.some(overlay => overlay.open || overlay.classList.contains('open'));
|
||||
nav.hidden = hidden;
|
||||
if (options.sessionHud) {
|
||||
if (hidden) options.sessionHud.setAttribute('data-overlay-hidden', 'true');
|
||||
else options.sessionHud.removeAttribute('data-overlay-hidden');
|
||||
}
|
||||
if (wasHidden && !hidden && launcher) launcher.focus();
|
||||
wasHidden = hidden;
|
||||
}
|
||||
|
||||
function openQueues() {
|
||||
if (!options.queueSheet || options.queueSheet.open) return;
|
||||
options.queueSheet.showModal();
|
||||
}
|
||||
|
||||
function closeQueues() {
|
||||
options.queueSheet?.open && options.queueSheet.close();
|
||||
}
|
||||
|
||||
function start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
button.addEventListener('click', event => {
|
||||
launcher = event.currentTarget;
|
||||
select(name);
|
||||
options.actions[name]();
|
||||
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
|
||||
});
|
||||
});
|
||||
if (options.queueSheet) {
|
||||
options.queueClose?.addEventListener('click', closeQueues);
|
||||
options.queueSheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
closeQueues();
|
||||
});
|
||||
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
|
||||
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
|
||||
row.addEventListener('click', () => {
|
||||
closeQueues();
|
||||
options.onSelectQueue?.(name, row);
|
||||
});
|
||||
});
|
||||
}
|
||||
options.observe(refreshVisibility, overlays);
|
||||
refreshVisibility();
|
||||
}
|
||||
|
||||
function updateWork(mode, count) {
|
||||
function updateWork(mode) {
|
||||
const labels = {
|
||||
continue: ['Continue', 'Continue Today'],
|
||||
resume: ['Resume', 'Resume Today'],
|
||||
start: ['Start', 'Start Today'],
|
||||
plan: ['Plan', 'Plan Today'],
|
||||
work: ['Work', 'Work'],
|
||||
};
|
||||
workMode = labels[mode] ? mode : 'work';
|
||||
const total = Math.max(0, Number(count) || 0);
|
||||
const badge = options.attentionBadge;
|
||||
const workMode = labels[mode] ? mode : 'work';
|
||||
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
|
||||
if (badge) {
|
||||
badge.textContent = String(total);
|
||||
badge.hidden = total === 0;
|
||||
}
|
||||
if (buttons.work) {
|
||||
buttons.work.setAttribute(
|
||||
'aria-label',
|
||||
labels[workMode][1] + (total ? ', ' + total + ' items need attention' : '')
|
||||
);
|
||||
buttons.work.setAttribute('aria-label', labels[workMode][1]);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
updateWork(workMode, count);
|
||||
const total = Math.max(0, Number(count) || 0);
|
||||
const visible = total > 0;
|
||||
if (options.attentionBadge) {
|
||||
options.attentionBadge.textContent = String(total);
|
||||
options.attentionBadge.hidden = !visible;
|
||||
}
|
||||
if (buttons.attention) {
|
||||
buttons.attention.hidden = !visible;
|
||||
buttons.attention.setAttribute('aria-label', 'Attention, ' + total + ' items');
|
||||
}
|
||||
if (visible) nav.setAttribute('data-attention', 'true');
|
||||
else nav.removeAttribute('data-attention');
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention, updateWork};
|
||||
function updateQueues(counts) {
|
||||
const names = 'today attention update 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);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
});
|
||||
options.queueRows?.update?.setAttribute(
|
||||
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
|
||||
);
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = String(total);
|
||||
options.queueBadge.hidden = total === 0;
|
||||
}
|
||||
buttons.queues?.setAttribute('aria-label', 'Queues, ' + total + ' items');
|
||||
}
|
||||
|
||||
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
return 'work';
|
||||
}
|
||||
|
||||
@@ -14,6 +15,7 @@
|
||||
if (current === 'continue') options.continueToday();
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else if (current === 'plan') options.planToday();
|
||||
else options.openFallback();
|
||||
return current;
|
||||
}
|
||||
|
||||
+164
-20
@@ -178,6 +178,62 @@ function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
let active = false;
|
||||
const selected = new Set();
|
||||
const snapshot = () => ({
|
||||
active,
|
||||
ids: Array.from(selected),
|
||||
count: selected.size,
|
||||
limit: maximum,
|
||||
at_limit: selected.size >= maximum,
|
||||
});
|
||||
const publish = () => onChange(snapshot());
|
||||
const select = notificationId => {
|
||||
if (!active || !Number.isInteger(notificationId)) return 'inactive';
|
||||
if (selected.has(notificationId)) return 'already-selected';
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.add(notificationId);
|
||||
publish();
|
||||
return 'selected';
|
||||
};
|
||||
return {
|
||||
start() {
|
||||
if (active) return snapshot();
|
||||
active = true;
|
||||
publish();
|
||||
return snapshot();
|
||||
},
|
||||
select,
|
||||
toggle(notificationId) {
|
||||
if (!selected.has(notificationId)) return select(notificationId);
|
||||
selected.delete(notificationId);
|
||||
publish();
|
||||
return 'deselected';
|
||||
},
|
||||
retain(notificationIds) {
|
||||
const allowed = new Set((notificationIds || []).filter(Number.isInteger));
|
||||
let changed = false;
|
||||
selected.forEach(id => {
|
||||
if (!allowed.has(id)) {
|
||||
selected.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) publish();
|
||||
return snapshot();
|
||||
},
|
||||
cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
publish();
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
@@ -280,6 +336,7 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
|
||||
function createNotificationReader({
|
||||
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
|
||||
acknowledge = null,
|
||||
queueRead = null,
|
||||
loadSaved = () => null,
|
||||
loadConversation = null,
|
||||
@@ -292,6 +349,22 @@ function createNotificationReader({
|
||||
let conversationPager = null;
|
||||
let offlineHydrated = false;
|
||||
|
||||
async function advanceAfterRead(items, current, queueing = false) {
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item => item && item.has_update &&
|
||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, item: current, next: next || null };
|
||||
}
|
||||
|
||||
async function open(item, savedDetail = null) {
|
||||
selected = item;
|
||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||
@@ -322,11 +395,19 @@ function createNotificationReader({
|
||||
|
||||
return {
|
||||
open,
|
||||
commentPager() {
|
||||
return conversationPager;
|
||||
},
|
||||
appendReply(comment) {
|
||||
if (!conversationPager) return false;
|
||||
onConversation(conversationPager.append(comment));
|
||||
return true;
|
||||
},
|
||||
acceptReadAndNext(items, item = selected) {
|
||||
if (!item || (selected && selected !== item)) return false;
|
||||
selected = item;
|
||||
return advanceAfterRead(items, item);
|
||||
},
|
||||
async loadOlder() {
|
||||
if (!conversationPager || !selected || offlineHydrated) return false;
|
||||
const pager = conversationPager;
|
||||
@@ -354,19 +435,7 @@ function createNotificationReader({
|
||||
try {
|
||||
if (queueing) await queueRead(current.notification_id);
|
||||
else await markRead(current.notification_id);
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item => item && item.has_update &&
|
||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
return await advanceAfterRead(items, current, queueing);
|
||||
} catch (_error) {
|
||||
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
|
||||
return false;
|
||||
@@ -374,6 +443,21 @@ function createNotificationReader({
|
||||
marking = false;
|
||||
}
|
||||
},
|
||||
async acknowledgeAndNext(items) {
|
||||
if (!selected || marking || offlineHydrated || !acknowledge) return false;
|
||||
const current = selected;
|
||||
marking = true;
|
||||
onStatus('Adding reaction and marking read…');
|
||||
try {
|
||||
await acknowledge(current.notification_id);
|
||||
return await advanceAfterRead(items, current);
|
||||
} catch (_error) {
|
||||
onStatus('Could not acknowledge update. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
marking = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -396,7 +480,7 @@ function createNotificationReplier({
|
||||
}
|
||||
catch (_error) { /* Keep the editable textarea as the fallback. */ }
|
||||
},
|
||||
async submit(item, body) {
|
||||
async submit(item, body, attachment = null) {
|
||||
if (pending) return false;
|
||||
pending = true;
|
||||
this.saveDraft(item, body);
|
||||
@@ -407,6 +491,23 @@ function createNotificationReplier({
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
onStatus('Sending reply…');
|
||||
try {
|
||||
if (attachment) {
|
||||
onStatus('Saving screenshot for durable delivery…');
|
||||
const admission = await authoredOutbox.enqueueDurably({
|
||||
kind:'update-reply', notificationId:item.notification_id, body, operationId, attachment,
|
||||
});
|
||||
const delivery = await authoredOutbox.retry(admission.item.id, admission.item.ownerLogin);
|
||||
if (!delivery.confirmed?.length) {
|
||||
const remaining = delivery.remaining?.find(candidate => candidate.id === admission.item.id);
|
||||
if (remaining?.status === 'attention') return false;
|
||||
onStatus('Queued for sync when the connection returns.');
|
||||
return { queued:true };
|
||||
}
|
||||
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
|
||||
catch (_error) { /* Confirmed delivery is authoritative. */ }
|
||||
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. */ }
|
||||
@@ -566,24 +667,28 @@ function createWorkSession({
|
||||
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
|
||||
end: () => finish(),
|
||||
resumable: () => Boolean(checkpoint?.read()),
|
||||
reopen() {
|
||||
reopen(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
const requestedIdentity = requested ? workIdentity(requested) : currentIdentity;
|
||||
const index = items.findIndex(item => workIdentity(item) === requestedIdentity);
|
||||
if (index < 0) return false;
|
||||
currentIdentity = requestedIdentity;
|
||||
currentIndex = index;
|
||||
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
},
|
||||
resume() {
|
||||
resume(requested = null) {
|
||||
const saved = checkpoint?.read();
|
||||
if (!saved) return false;
|
||||
durable = true;
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
|
||||
const exact = items.findIndex(item => workIdentity(item) ===
|
||||
(requested ? workIdentity(requested) : saved.identity));
|
||||
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
|
||||
},
|
||||
start(item = null) {
|
||||
@@ -609,21 +714,48 @@ function createWorkSession({
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
return index > 0 ? openAt(items, index - 1) : false;
|
||||
},
|
||||
next() {
|
||||
next(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (requested) {
|
||||
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
|
||||
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
|
||||
}
|
||||
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
|
||||
},
|
||||
complete() {
|
||||
complete(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
if (requested) {
|
||||
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
|
||||
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
|
||||
}
|
||||
const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (stillPresent >= 0) {
|
||||
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
|
||||
}
|
||||
return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
|
||||
},
|
||||
items: () => queue().slice(),
|
||||
target(action) {
|
||||
const items = queue();
|
||||
if (!items.length) return null;
|
||||
if (action === 'start') return items[0];
|
||||
if (action === 'resume') {
|
||||
const saved = checkpoint?.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)];
|
||||
}
|
||||
const current = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (action === 'continue') return current >= 0 ? items[current] : null;
|
||||
if (action === 'next') return current >= 0 && current < items.length - 1 ? items[current + 1] : null;
|
||||
if (action === 'complete') {
|
||||
return items[current >= 0 ? Math.min(current + 1, items.length - 1) : Math.min(currentIndex, items.length - 1)];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -682,6 +814,16 @@ function summarizeMyWork(items) {
|
||||
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
|
||||
}
|
||||
|
||||
function findQueueItems(items, query) {
|
||||
const needle = String(query || '').trim().toLowerCase();
|
||||
if (!needle) return (items || []).slice();
|
||||
return (items || []).filter(item => {
|
||||
const number = Number.isInteger(item?.number) ? '#' + item.number : '';
|
||||
return [item?.repository, item?.key, number, item?.title]
|
||||
.some(value => String(value || '').toLowerCase().includes(needle));
|
||||
});
|
||||
}
|
||||
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
@@ -705,11 +847,13 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.findQueueItems = findQueueItems;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
buildMyWork.notificationIds = notificationIds;
|
||||
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
||||
buildMyWork.createNotificationSelection = createNotificationSelection;
|
||||
buildMyWork.createNotificationPager = createNotificationPager;
|
||||
buildMyWork.createWorkPager = createWorkPager;
|
||||
buildMyWork.createNotificationReader = createNotificationReader;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
function createNotificationUndo({ restore, onItems, onStatus, onOffer = () => {}, onClear = () => {} }) {
|
||||
let offered = null;
|
||||
let pending = false;
|
||||
|
||||
return {
|
||||
offer(item, items) {
|
||||
if (!item || !Number.isInteger(item.notification_id)) return false;
|
||||
offered = { item, items: Array.isArray(items) ? [...items] : [] };
|
||||
const label = item.key || 'Update';
|
||||
onStatus(label + ' marked read. Undo?');
|
||||
onOffer(item);
|
||||
return true;
|
||||
},
|
||||
async run() {
|
||||
if (!offered || pending) return false;
|
||||
const current = offered;
|
||||
const label = current.item.key || 'Update';
|
||||
pending = true;
|
||||
onStatus('Restoring ' + label + '…');
|
||||
try {
|
||||
await restore(current.item.notification_id);
|
||||
if (offered !== current) return false;
|
||||
const restored = [current.item].concat(current.items.filter(
|
||||
item => item?.notification_id !== current.item.notification_id
|
||||
));
|
||||
offered = null;
|
||||
onItems(restored, current.item);
|
||||
onStatus(label + ' is unread again.');
|
||||
onClear();
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not restore ' + label + '. Retry Undo.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
offered = null;
|
||||
onClear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function requestNotificationUnread(notificationId) {
|
||||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/unread', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Restore unread failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
function createDashboardNotificationUndo({ restore, getItems, setItems, getNotifications, setNotifications, refresh, select }) {
|
||||
let timer = null;
|
||||
const controller = createNotificationUndo({
|
||||
restore,
|
||||
onItems: (items, restored) => {
|
||||
setItems(items);
|
||||
const notifications = getNotifications();
|
||||
if (!notifications.some(item => item.id === restored.notification_id)) {
|
||||
setNotifications([{ id:restored.notification_id }].concat(notifications));
|
||||
}
|
||||
refresh();
|
||||
},
|
||||
onStatus: message => {
|
||||
select('#notification-undo-status').textContent = message;
|
||||
select('#my-work-action-status').textContent = message;
|
||||
},
|
||||
onOffer: item => {
|
||||
clearTimeout(timer);
|
||||
select('#notification-undo').hidden = false;
|
||||
select('#undo-notification').disabled = false;
|
||||
select('#undo-notification').setAttribute('aria-label', 'Undo marking ' + (item.key || 'update') + ' read');
|
||||
timer = setTimeout(() => controller.clear(), 10000);
|
||||
},
|
||||
onClear: () => { select('#notification-undo').hidden = true; },
|
||||
});
|
||||
return controller;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createNotificationUndo;
|
||||
module.exports.createDashboardNotificationUndo = createDashboardNotificationUndo;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
function createOfflineIssueBlocker({
|
||||
enqueueDurably,
|
||||
onQueued = () => {},
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
}) {
|
||||
return async function queueOfflineIssueBlocker(item, blocker, present) {
|
||||
const admission = await enqueueDurably({
|
||||
kind: 'issue-blocker',
|
||||
repository: String(item.repository || ''),
|
||||
number: Number(item.number || 0),
|
||||
blockerRepository: String(blocker.repository || ''),
|
||||
blockerNumber: Number(blocker.number || 0),
|
||||
present: present === true,
|
||||
operationId: String(createOperationId()).slice(0, 128),
|
||||
});
|
||||
onQueued(item, blocker, present === true, admission);
|
||||
return { admission };
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueBlocker;
|
||||
@@ -31,14 +31,18 @@
|
||||
}
|
||||
|
||||
const previousFailed = failed;
|
||||
const readyKeys = new Set(items.filter(item => loadSavedDetail(login, item)).map(itemKey));
|
||||
const savedDetails = new Map();
|
||||
await Promise.all(items.map(async item => {
|
||||
savedDetails.set(itemKey(item), await loadSavedDetail(login, item));
|
||||
}));
|
||||
const readyKeys = new Set(items.filter(item => savedDetails.get(itemKey(item))).map(itemKey));
|
||||
const snapshot = pending => ({
|
||||
total: items.length, ready: readyKeys.size, failed: failed.size, pending,
|
||||
});
|
||||
const candidates = items.filter(item => {
|
||||
const key = itemKey(item);
|
||||
if (onlyFailed && !previousFailed.has(key)) return false;
|
||||
const saved = loadSavedDetail(login, item);
|
||||
const saved = savedDetails.get(key);
|
||||
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
|
||||
});
|
||||
failed = new Set();
|
||||
@@ -51,8 +55,9 @@
|
||||
try {
|
||||
const detail = await loadDetail(item);
|
||||
if (runGeneration !== generation) return;
|
||||
const saved = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
|
||||
if (saved !== false) readyKeys.add(itemKey(item));
|
||||
const saved = await saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
|
||||
if (saved === false) throw new Error('Offline detail was not durably admitted.');
|
||||
readyKeys.add(itemKey(item));
|
||||
} catch (_error) {
|
||||
if (runGeneration === generation) failed.add(itemKey(item));
|
||||
}
|
||||
|
||||
+245
-2
@@ -27,6 +27,54 @@
|
||||
];
|
||||
const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at'];
|
||||
|
||||
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-offline-work-v2') {
|
||||
if (!indexedDB) return null;
|
||||
let databasePromise;
|
||||
function database() {
|
||||
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains('work')) {
|
||||
request.result.createObjectStore('work', { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
db.onversionchange = () => { db.close(); databasePromise = undefined; };
|
||||
resolve(db);
|
||||
};
|
||||
request.onerror = () => reject(request.error || new Error('Offline work database failed to open.'));
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
const requested = request => new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return async work => {
|
||||
const db = await database();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('work', 'readwrite');
|
||||
const store = transaction.objectStore('work');
|
||||
let result;
|
||||
let workError;
|
||||
transaction.oncomplete = () => workError ? undefined : resolve(result);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(workError || transaction.error || new Error('Offline work transaction aborted.'));
|
||||
Promise.resolve(work({
|
||||
get: key => requested(store.get(key)),
|
||||
getAll: () => requested(store.getAll()),
|
||||
put: value => requested(store.put(value)),
|
||||
delete: key => requested(store.delete(key)),
|
||||
clear: () => requested(store.clear()),
|
||||
})).then(value => { result = value; }).catch(error => {
|
||||
workError = error;
|
||||
try { transaction.abort(); } catch (_) { reject(error); }
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function pick(source, fields) {
|
||||
const output = {};
|
||||
fields.forEach(field => {
|
||||
@@ -36,14 +84,155 @@
|
||||
}
|
||||
|
||||
function createOfflineWorkStore({
|
||||
storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10,
|
||||
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
|
||||
maxDetails = 10, initializationTimeoutMs = 1500,
|
||||
}) {
|
||||
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
||||
let migrationPromise;
|
||||
let readinessPromise;
|
||||
let readinessGeneration = 0;
|
||||
let readinessState = transact ? { state:'initializing', reason:'' } : { state:'ready', reason:'' };
|
||||
let recordCache = null;
|
||||
let pendingDurableClear = false;
|
||||
|
||||
function hydrateLegacyCache() {
|
||||
const cache = new Map();
|
||||
try {
|
||||
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
|
||||
if (snapshot) cache.set('snapshot', { ...snapshot, id:'snapshot' });
|
||||
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||
if (Array.isArray(details)) details.forEach(record => {
|
||||
if (!record?.key || !record?.user_login || !record?.data) return;
|
||||
const id = 'detail:' + record.user_login + ':' + record.key;
|
||||
cache.set(id, { ...record, id });
|
||||
});
|
||||
} catch (_) { cache.clear(); }
|
||||
recordCache = cache;
|
||||
}
|
||||
|
||||
function migrateLegacy(generation) {
|
||||
if (!transact) return Promise.resolve(true);
|
||||
if (!migrationPromise) migrationPromise = (async () => {
|
||||
let snapshot = null;
|
||||
let details = [];
|
||||
try {
|
||||
snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
|
||||
const parsedDetails = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||
details = Array.isArray(parsedDetails) ? parsedDetails : [];
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (!snapshot && !details.length) return true;
|
||||
await transact(async records => {
|
||||
if (snapshot && !(await records.get('snapshot'))) {
|
||||
await records.put({ ...snapshot, id:'snapshot' });
|
||||
}
|
||||
for (const record of details.slice(-Math.max(1, maxDetails))) {
|
||||
if (!record?.key || !record?.user_login || !record?.data) continue;
|
||||
const id = 'detail:' + record.user_login + ':' + record.key;
|
||||
if (!(await records.get(id))) await records.put({ ...record, id });
|
||||
}
|
||||
});
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
return true;
|
||||
})().catch(() => false);
|
||||
return migrationPromise;
|
||||
}
|
||||
|
||||
async function initialize(generation) {
|
||||
const migrated = await migrateLegacy(generation);
|
||||
if (!migrated || generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
if (pendingDurableClear) {
|
||||
await transact(async records => { await records.clear(); });
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
pendingDurableClear = false;
|
||||
recordCache = new Map();
|
||||
return true;
|
||||
}
|
||||
return transact(async records => {
|
||||
const currentTime = now().getTime();
|
||||
const valid = [];
|
||||
for (const record of await records.getAll()) {
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const validShape = record?.id === 'snapshot' ?
|
||||
record.version === VERSION && record.user_login && record.data :
|
||||
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data;
|
||||
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) {
|
||||
if (record?.id) await records.delete(record.id);
|
||||
} else valid.push(record);
|
||||
}
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
recordCache = new Map(valid.map(record => [record.id, record]));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function startReady() {
|
||||
const generation = ++readinessGeneration;
|
||||
readinessState = { state:'initializing', reason:'' };
|
||||
const operation = initialize(generation).catch(() => false);
|
||||
readinessPromise = new Promise(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
if (generation !== readinessGeneration || readinessState.state !== 'initializing') return;
|
||||
hydrateLegacyCache();
|
||||
readinessState = { state:'degraded', reason:'deadline' };
|
||||
resolve(false);
|
||||
}, Math.max(1, Number(initializationTimeoutMs) || 1500));
|
||||
operation.then(available => {
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return;
|
||||
clearTimeout(timer);
|
||||
if (available) readinessState = { state:'ready', reason:'' };
|
||||
else {
|
||||
hydrateLegacyCache();
|
||||
readinessState = { state:'degraded', reason:'unavailable' };
|
||||
}
|
||||
resolve(Boolean(available));
|
||||
});
|
||||
});
|
||||
return readinessPromise;
|
||||
}
|
||||
|
||||
function ready() {
|
||||
if (!transact || readinessState.state === 'ready') return Promise.resolve(true);
|
||||
if (readinessState.state === 'degraded') return Promise.resolve(false);
|
||||
return readinessPromise || startReady();
|
||||
}
|
||||
|
||||
function status() {
|
||||
return { ...readinessState };
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (!transact) return Promise.resolve(true);
|
||||
migrationPromise = undefined;
|
||||
readinessPromise = undefined;
|
||||
return startReady();
|
||||
}
|
||||
function enabled() {
|
||||
try { return storage.getItem(ENABLED_KEY) === 'true'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (transact) {
|
||||
try {
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
} catch (_) { /* IndexedDB remains the source of truth. */ }
|
||||
if (readinessState.state === 'degraded') {
|
||||
pendingDurableClear = true;
|
||||
recordCache = new Map();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
migrationPromise = Promise.resolve(true);
|
||||
return transact(async records => {
|
||||
await records.clear();
|
||||
recordCache = new Map();
|
||||
return true;
|
||||
}).catch(() => false);
|
||||
}
|
||||
try {
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
@@ -104,6 +293,22 @@
|
||||
saved_at: now().toISOString(),
|
||||
data,
|
||||
};
|
||||
if (transact) {
|
||||
record.id = 'detail:' + login + ':' + key;
|
||||
return ready().then(available => available ? transact(async records => {
|
||||
const existing = (await records.getAll()).filter(candidate =>
|
||||
candidate?.id?.startsWith('detail:') && candidate.id !== record.id
|
||||
);
|
||||
await records.put(record);
|
||||
const overflow = existing.concat(record).sort((a, b) =>
|
||||
String(a.saved_at).localeCompare(String(b.saved_at))
|
||||
).slice(0, -Math.max(1, maxDetails));
|
||||
for (const candidate of overflow) await records.delete(candidate.id);
|
||||
overflow.forEach(candidate => recordCache.delete(candidate.id));
|
||||
recordCache.set(record.id, record);
|
||||
return true;
|
||||
}) : false).catch(() => false);
|
||||
}
|
||||
const records = readDetails().filter(candidate =>
|
||||
!(candidate?.key === key && candidate?.user_login === login)
|
||||
);
|
||||
@@ -117,6 +322,19 @@
|
||||
const key = detailKey(item);
|
||||
login = String(login || '').trim();
|
||||
if (!login || !key) return null;
|
||||
if (transact) {
|
||||
const id = 'detail:' + login + ':' + key;
|
||||
if (!recordCache) return ready().then(() => loadDetail(login, item));
|
||||
const record = recordCache.get(id);
|
||||
if (!record) return null;
|
||||
const savedAt = Date.parse(record.saved_at || '');
|
||||
if (!record.data || !Number.isFinite(savedAt) || now().getTime() - savedAt > maxAgeMs) {
|
||||
recordCache.delete(id);
|
||||
transact(async records => { await records.delete(id); }).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
const records = readDetails();
|
||||
const currentTime = now().getTime();
|
||||
const valid = records.filter(record => {
|
||||
@@ -142,6 +360,7 @@
|
||||
function save(snapshot) {
|
||||
if (!enabled() || !snapshot?.user?.login) return false;
|
||||
const record = {
|
||||
id: 'snapshot',
|
||||
version: VERSION,
|
||||
user_login: String(snapshot.user.login),
|
||||
saved_at: now().toISOString(),
|
||||
@@ -154,12 +373,36 @@
|
||||
notification_pagination: snapshot.notification_pagination || {},
|
||||
},
|
||||
};
|
||||
if (transact) {
|
||||
return ready().then(available => available ? transact(async records => {
|
||||
await records.put(record);
|
||||
recordCache.set(record.id, record);
|
||||
return true;
|
||||
}) : false).catch(() => false);
|
||||
}
|
||||
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function load(expectedLogin) {
|
||||
if (transact) {
|
||||
if (!recordCache) return ready().then(() => load(expectedLogin));
|
||||
const record = recordCache.get('snapshot');
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
|
||||
!Number.isFinite(savedAt);
|
||||
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
|
||||
if (invalid || expired) {
|
||||
if (record) {
|
||||
recordCache.delete('snapshot');
|
||||
transact(async records => { await records.delete('snapshot'); }).catch(() => {});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (expectedLogin && record.user_login !== expectedLogin) return null;
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
let record;
|
||||
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
|
||||
catch (_) { clear(); return null; }
|
||||
@@ -175,7 +418,7 @@
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
|
||||
return { enabled, setEnabled, save, load, saveDetail, loadDetail, clear };
|
||||
return { enabled, setEnabled, ready, retry, status, save, load, saveDetail, loadDetail, clear };
|
||||
}
|
||||
|
||||
return createOfflineWorkStore;
|
||||
|
||||
+115
-6
@@ -1,14 +1,26 @@
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
|
||||
let available = [];
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let loadRequest = null;
|
||||
let loadGeneration = 0;
|
||||
let query = '';
|
||||
let filters = { repositories: [], labels: [] };
|
||||
let facetView;
|
||||
let claimRequest = null;
|
||||
const previewed = new Set();
|
||||
let selecting = false;
|
||||
const selected = new Map();
|
||||
|
||||
function itemKey(item) {
|
||||
return String(item?.repository || '') + '#' + String(item?.number || '');
|
||||
}
|
||||
|
||||
function emitSelection() {
|
||||
const state = { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
onSelection(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function apply(result, append) {
|
||||
const incoming = Array.isArray(result?.items) ? result.items : [];
|
||||
if (append) {
|
||||
@@ -31,13 +43,31 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
};
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
if (result?.facets) onFacets({
|
||||
repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [],
|
||||
labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [],
|
||||
});
|
||||
if (result?.facets && typeof document !== 'undefined') {
|
||||
facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value =>
|
||||
String(value).replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character])),
|
||||
next => { filters = next; loadPage(1, false); });
|
||||
facetView(result.facets);
|
||||
}
|
||||
}
|
||||
|
||||
function loadPage(page, append) {
|
||||
if (loadRequest) return loadRequest;
|
||||
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
|
||||
function loadPage(page, append, requestedQuery = query) {
|
||||
if (loadRequest && requestedQuery === query && append) return loadRequest;
|
||||
const generation = ++loadGeneration;
|
||||
const params = new URLSearchParams();
|
||||
params.set('facets', 'true');
|
||||
if (requestedQuery) params.set('q', requestedQuery);
|
||||
filters.repositories.forEach(value => params.append('repository', value));
|
||||
filters.labels.forEach(value => params.append('label', value));
|
||||
const queryPart = params.toString() ? '&' + params.toString() : '';
|
||||
const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (generation !== loadGeneration) return result;
|
||||
apply(result, append);
|
||||
if (result?.refresh_failed === true) {
|
||||
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
|
||||
@@ -45,8 +75,9 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
onStatus('Showing saved available work while the catalog refreshes…');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { loadRequest = null; });
|
||||
return loadRequest;
|
||||
}).finally(() => { if (loadRequest === request) loadRequest = null; });
|
||||
loadRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -60,9 +91,66 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
if (!pagination.has_more) return Promise.resolve(false);
|
||||
return loadPage(pagination.page + 1, true);
|
||||
},
|
||||
search(value) {
|
||||
query = String(value || '').trim();
|
||||
return loadPage(1, false, query);
|
||||
},
|
||||
query() {
|
||||
return query;
|
||||
},
|
||||
setFilters(next) {
|
||||
filters = {
|
||||
repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10),
|
||||
labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10),
|
||||
};
|
||||
return loadPage(1, false);
|
||||
},
|
||||
filters() {
|
||||
return { repositories: filters.repositories.slice(), labels: filters.labels.slice() };
|
||||
},
|
||||
|
||||
items() {
|
||||
return available.slice();
|
||||
},
|
||||
startSelection() {
|
||||
selecting = true;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
cancelSelection() {
|
||||
selecting = false;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
toggleSelection(item) {
|
||||
if (!selecting || !item) return emitSelection();
|
||||
const key = itemKey(item);
|
||||
if (selected.has(key)) selected.delete(key);
|
||||
else selected.set(key, item);
|
||||
return emitSelection();
|
||||
},
|
||||
fillSelection(limit) {
|
||||
limit = Math.max(0, Math.floor(Number(limit) || 0));
|
||||
if (!limit) return { selected: selected.size, added: 0, limit };
|
||||
selecting = true;
|
||||
const before = selected.size;
|
||||
available.some(item => {
|
||||
if (selected.size >= limit) return true;
|
||||
selected.set(itemKey(item), item);
|
||||
return false;
|
||||
});
|
||||
emitSelection();
|
||||
return { selected: selected.size, added: selected.size - before, limit };
|
||||
},
|
||||
isSelected(item) {
|
||||
return selected.has(itemKey(item));
|
||||
},
|
||||
selectedItems() {
|
||||
return Array.from(selected.values());
|
||||
},
|
||||
selection() {
|
||||
return { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
},
|
||||
togglePreview(item) {
|
||||
const key = itemKey(item);
|
||||
if (previewed.has(key)) previewed.delete(key);
|
||||
@@ -91,6 +179,8 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
pagination.total = Math.max(available.length, pagination.total - 1);
|
||||
pagination.has_more = available.length < pagination.total;
|
||||
previewed.delete(itemKey(item));
|
||||
selected.delete(itemKey(item));
|
||||
emitSelection();
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
onStatus('Assigned ' + key + ' to you.');
|
||||
@@ -101,4 +191,23 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
};
|
||||
}
|
||||
|
||||
function attachFindWorkFacets(root, escapeHtml, onChange) {
|
||||
const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value);
|
||||
root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') });
|
||||
root.querySelector('#clear-find-work-filters').onclick = () => {
|
||||
root.querySelectorAll('input').forEach(input => { input.checked = false; });
|
||||
onChange({ repositories:[], labels:[] });
|
||||
};
|
||||
return facets => {
|
||||
const active = { repositories:values('find-work-repository'), labels:values('find-work-label') };
|
||||
const render = (id, choices, selected, name) => {
|
||||
root.querySelector(id).innerHTML = choices.map(value => '<label class="find-work-filter-option"><input type="checkbox" name="' + name + '" value="' + escapeHtml(value) + '"' + (selected.includes(value) ? ' checked' : '') + ' />' + escapeHtml(value) + '</label>').join('');
|
||||
};
|
||||
render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository');
|
||||
render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label');
|
||||
const count = active.repositories.length + active.labels.length;
|
||||
root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : '';
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
|
||||
@@ -22,8 +22,19 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function close({ add = false } = {}) {
|
||||
function setDependencies({ available, dependencies } = {}) {
|
||||
if (!current) return false;
|
||||
current.dependencies_available = available === true;
|
||||
current.dependencies = Array.isArray(dependencies) ? dependencies.filter(item => item?.state === 'open') : [];
|
||||
current.requires_override = !current.dependencies_available || current.dependencies.length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function close({ add = false, override = false } = {}) {
|
||||
if (!current) return 'closed';
|
||||
if (add && current.requires_override && !override) {
|
||||
return current.dependencies_available ? 'blocked' : 'dependencies-unavailable';
|
||||
}
|
||||
const { item, trigger, scroll } = current;
|
||||
let result = 'returned';
|
||||
if (add) {
|
||||
@@ -37,9 +48,16 @@
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return current ? { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll } : { open:false };
|
||||
if (!current) return { open:false };
|
||||
const state = { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll };
|
||||
if (Object.prototype.hasOwnProperty.call(current, 'dependencies_available')) {
|
||||
state.dependencies_available = current.dependencies_available;
|
||||
state.dependencies = current.dependencies;
|
||||
state.requires_override = current.requires_override;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
return { open, close, snapshot };
|
||||
return { open, close, setDependencies, snapshot };
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
function createPlanTodayReadiness({ identity, inspect, concurrency = 3 }) {
|
||||
const workerCount = Math.max(1, Math.floor(Number(concurrency) || 1));
|
||||
let generation = 0;
|
||||
|
||||
async function run(items) {
|
||||
const runGeneration = ++generation;
|
||||
const candidates = Array.from(items || []);
|
||||
const results = new Array(candidates.length);
|
||||
let next = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < candidates.length) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
const item = candidates[index];
|
||||
let result;
|
||||
try {
|
||||
result = await inspect(item);
|
||||
} catch (_error) {
|
||||
result = { status:'unverified' };
|
||||
}
|
||||
results[index] = [identity(item), result];
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from(
|
||||
{ length:Math.min(workerCount, candidates.length) },
|
||||
() => worker(),
|
||||
));
|
||||
if (runGeneration !== generation) return null;
|
||||
return Object.fromEntries(results);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
generation += 1;
|
||||
}
|
||||
|
||||
return { run, cancel };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanTodayReadiness;
|
||||
+119
-5
@@ -2,6 +2,13 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
let openState = false;
|
||||
let draftIds = [];
|
||||
let itemsById = new Map();
|
||||
let capacityMinutes = null;
|
||||
let estimates = {};
|
||||
let recommendations = {};
|
||||
let recommendationAware = false;
|
||||
let capacityAware = false;
|
||||
let buildState = null;
|
||||
let buildReadiness = null;
|
||||
|
||||
function cleanItems(items) {
|
||||
const unique = new Map();
|
||||
@@ -12,13 +19,29 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
return unique;
|
||||
}
|
||||
|
||||
function open(selectedItems, candidates) {
|
||||
function open(selectedItems, candidates, planning = null, actualMinutes = null) {
|
||||
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
|
||||
draftIds = [];
|
||||
for (const item of selectedItems || []) {
|
||||
const id = identity?.(item);
|
||||
if (id && itemsById.has(id) && !draftIds.includes(id) && draftIds.length < limit) draftIds.push(id);
|
||||
}
|
||||
capacityAware = Boolean(planning && ('capacity_minutes' in planning || 'estimates' in planning));
|
||||
capacityMinutes = Number.isInteger(planning?.capacity_minutes) && planning.capacity_minutes > 0
|
||||
? planning.capacity_minutes : null;
|
||||
estimates = {};
|
||||
for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
|
||||
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
|
||||
}
|
||||
recommendations = {};
|
||||
recommendationAware = actualMinutes !== null;
|
||||
buildState = null;
|
||||
buildReadiness = null;
|
||||
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
|
||||
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
|
||||
estimates[id] !== minutes) recommendations[id] = minutes;
|
||||
}
|
||||
if (Object.keys(recommendations).length) capacityAware = true;
|
||||
openState = true;
|
||||
return snapshot();
|
||||
}
|
||||
@@ -50,6 +73,13 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
openState = false;
|
||||
draftIds = [];
|
||||
itemsById = new Map();
|
||||
capacityMinutes = null;
|
||||
estimates = {};
|
||||
recommendations = {};
|
||||
recommendationAware = false;
|
||||
capacityAware = false;
|
||||
buildState = null;
|
||||
buildReadiness = null;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
@@ -57,10 +87,77 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function commit({ start: startAfterSave = false } = {}) {
|
||||
function setCapacity(minutes) {
|
||||
capacityAware = true;
|
||||
capacityMinutes = Number.isInteger(minutes) && minutes > 0 ? minutes : null;
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setEstimate(id, minutes) {
|
||||
const resolvingBuildEstimate = buildState?.needs_estimate?.includes(id);
|
||||
if (!openState || (!draftIds.includes(id) && !resolvingBuildEstimate)) return false;
|
||||
capacityAware = true;
|
||||
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) return false;
|
||||
estimates[id] = minutes;
|
||||
if (resolvingBuildEstimate && buildReadiness) buildRecommendation(buildReadiness);
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyRecommendation(id) {
|
||||
const minutes = recommendations[id];
|
||||
if (!openState || !draftIds.includes(id) || !Number.isInteger(minutes)) return false;
|
||||
estimates[id] = minutes;
|
||||
delete recommendations[id];
|
||||
capacityAware = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRecommendation(readiness = {}) {
|
||||
if (!openState) return null;
|
||||
buildReadiness = { ...readiness };
|
||||
const selected = [];
|
||||
const reasons = {};
|
||||
const skipped = [];
|
||||
const needsEstimate = [];
|
||||
let used = 0;
|
||||
for (const [id, item] of itemsById) {
|
||||
if (selected.length >= limit) break;
|
||||
const check = readiness[id] || { status:'unverified' };
|
||||
if (check.status !== 'ready') {
|
||||
skipped.push({
|
||||
id,
|
||||
status:check.status === 'blocked' ? 'blocked' : 'unverified',
|
||||
reason:check.reason || (check.status === 'blocked' ? 'Blocked' : 'Could not verify readiness'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const minutes = estimates[id];
|
||||
if (!Number.isInteger(minutes)) {
|
||||
needsEstimate.push(id);
|
||||
continue;
|
||||
}
|
||||
if (capacityMinutes !== null && used + minutes > capacityMinutes) continue;
|
||||
selected.push(id);
|
||||
reasons[id] = item.reason || item.attention_reason || 'Ranked My Work';
|
||||
used += minutes;
|
||||
}
|
||||
draftIds = selected;
|
||||
capacityAware = true;
|
||||
buildState = { selected:[...selected], reasons, skipped, needs_estimate:needsEstimate };
|
||||
return buildState;
|
||||
}
|
||||
|
||||
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
|
||||
if (!openState) return 'closed';
|
||||
const ids = [...draftIds];
|
||||
if (save?.(ids) === false) return 'unavailable';
|
||||
const state = snapshot();
|
||||
if (state.over_capacity && !confirmOverCapacity) return 'confirm-over-capacity';
|
||||
const payload = capacityAware ? {
|
||||
ids,
|
||||
capacity_minutes: capacityMinutes,
|
||||
estimates: Object.fromEntries(ids.filter(id => estimates[id]).map(id => [id, estimates[id]])),
|
||||
} : ids;
|
||||
if (save?.(payload) === false) return 'unavailable';
|
||||
const first = ids.length ? itemsById.get(ids[0]) : null;
|
||||
close();
|
||||
if (startAfterSave && first) start?.(first);
|
||||
@@ -68,7 +165,24 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { open: openState, ids: [...draftIds], count: draftIds.length, limit };
|
||||
const basic = { open: openState, ids: [...draftIds], count: draftIds.length, limit };
|
||||
if (!capacityAware) return basic;
|
||||
const selectedEstimates = Object.fromEntries(draftIds.filter(id => estimates[id]).map(id => [id, estimates[id]]));
|
||||
const plannedMinutes = Object.values(selectedEstimates).reduce((total, value) => total + value, 0);
|
||||
const remainingMinutes = capacityMinutes === null ? null : capacityMinutes - plannedMinutes;
|
||||
return {
|
||||
...basic,
|
||||
capacity_minutes: capacityMinutes,
|
||||
estimates: selectedEstimates,
|
||||
...(recommendationAware ? { recommendations:Object.fromEntries(
|
||||
draftIds.filter(id => recommendations[id]).map(id => [id, recommendations[id]])
|
||||
) } : {}),
|
||||
planned_minutes: plannedMinutes,
|
||||
remaining_minutes: remainingMinutes,
|
||||
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
|
||||
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
|
||||
...(buildState ? { build:buildState } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function item(id) {
|
||||
@@ -79,7 +193,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
|
||||
}
|
||||
|
||||
return { open, toggle, move, cancel, commit, snapshot, item, candidates };
|
||||
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, buildRecommendation, commit, snapshot, item, candidates };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
|
||||
@@ -36,11 +36,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrivateOutbox() {
|
||||
function deletePrivateDatabase(name) {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
try { request = indexedDB.deleteDatabase(name); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
@@ -52,7 +52,9 @@
|
||||
removeOwnedStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
|
||||
+173
-3
@@ -5,7 +5,11 @@ function mergeEligibility(detail, reviewState) {
|
||||
if (detail.draft) return { allowed: false, reason: 'Draft pull requests cannot be merged' };
|
||||
if (!detail.mergeable) return { allowed: false, reason: 'Resolve conflicts before merging' };
|
||||
if (detail.ci_state !== 'success') {
|
||||
return { allowed: false, reason: 'CI must succeed before merging' };
|
||||
const blockers = (detail.checks || []).filter(check =>
|
||||
['failure', 'error', 'pending'].includes(check.state)
|
||||
).map(check => check.name).filter(Boolean).slice(0, 3);
|
||||
return { allowed: false, reason: blockers.length ?
|
||||
'CI blocked by ' + blockers.join(', ') : 'CI must succeed before merging' };
|
||||
}
|
||||
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
|
||||
if (reviewState && !reviewState.complete) {
|
||||
@@ -37,9 +41,137 @@ function renderFile(file, index, reviewed, escapeHtml) {
|
||||
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
|
||||
}
|
||||
|
||||
function focusNextUnreviewed(doc, detail, state, controller) {
|
||||
if (!detail || !state) return;
|
||||
const filename = controller.nextUnreviewed(detail, state);
|
||||
const article = Array.from(doc.querySelectorAll('#pull-files .pull-file'))
|
||||
.find(file => file.dataset.pullFilename === filename);
|
||||
const toggle = article?.querySelector('.pull-file-toggle');
|
||||
const panel = toggle && doc.getElementById(toggle.getAttribute('aria-controls'));
|
||||
if (!toggle || !panel) return;
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
panel.hidden = false;
|
||||
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
toggle.focus();
|
||||
}
|
||||
|
||||
function removeFromSnapshot(data, item) {
|
||||
return { ...data, pulls:(data.pulls || []).filter(candidate =>
|
||||
candidate.repository !== item.repository || candidate.number !== item.number) };
|
||||
}
|
||||
|
||||
function sameTarget(left, right) {
|
||||
return Boolean(left && right && left.repository === right.repository && Number(left.number) === Number(right.number));
|
||||
}
|
||||
|
||||
function ownershipSelectors() {
|
||||
return ['#release-pull', '#load-pull-handoff', '#pull-handoff-recipient', '#confirm-pull-handoff'];
|
||||
}
|
||||
|
||||
function renderHandoffCandidates(select, candidates, doc) {
|
||||
select.textContent = '';
|
||||
const placeholder = doc.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
|
||||
select.appendChild(placeholder);
|
||||
candidates.forEach(candidate => {
|
||||
const option = doc.createElement('option');
|
||||
option.value = candidate.login;
|
||||
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
|
||||
select.appendChild(option);
|
||||
});
|
||||
return candidates.length;
|
||||
}
|
||||
|
||||
function ownershipExitMessage(item, action, transitionResult) {
|
||||
return item.key + ' ' + action + (transitionResult === 'opened' ?
|
||||
'. Next work item opened.' : '. Choose the next ready Today item.');
|
||||
}
|
||||
|
||||
function resetOwnershipControls(doc, item, checkpointed) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
qs('#pull-ownership').open = false;
|
||||
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
||||
qs('#pull-handoff-recipient').disabled = true;
|
||||
qs('#confirm-pull-handoff').disabled = true;
|
||||
qs('#confirm-pull-handoff').textContent = checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
|
||||
qs('#load-pull-handoff').disabled = false;
|
||||
qs('#release-pull').disabled = false;
|
||||
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
|
||||
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
|
||||
}
|
||||
|
||||
function bindOwnershipControls(doc, controller, getSelected, finish) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-handoff');
|
||||
if (load.dataset.ownershipBound === 'true') return;
|
||||
load.dataset.ownershipBound = 'true';
|
||||
load.addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
if (!selected) return;
|
||||
const select = qs('#pull-handoff-recipient');
|
||||
load.disabled = true;
|
||||
qs('#pull-handoff-status').textContent = 'Loading eligible teammates…';
|
||||
try {
|
||||
const count = renderHandoffCandidates(select, await controller.loadHandoffCandidates(selected), doc);
|
||||
select.disabled = !count;
|
||||
qs('#confirm-pull-handoff').disabled = true;
|
||||
qs('#pull-handoff-status').textContent = count ?
|
||||
'Choose who should own this pull request next.' : 'No other eligible assignees were found.';
|
||||
if (count) select.focus();
|
||||
} catch (error) {
|
||||
qs('#pull-handoff-status').textContent = error.message + ' Retry loading teammates.';
|
||||
load.disabled = false;
|
||||
load.focus();
|
||||
}
|
||||
});
|
||||
qs('#pull-handoff-recipient').addEventListener('change', event => {
|
||||
qs('#confirm-pull-handoff').disabled = !event.target.value;
|
||||
});
|
||||
qs('#confirm-pull-handoff').addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
const recipient = qs('#pull-handoff-recipient').value;
|
||||
if (!selected || !recipient || !globalThis.confirm('Hand off ' + selected.key + ' to @' + recipient + '?')) return;
|
||||
const button = qs('#confirm-pull-handoff');
|
||||
button.disabled = true;
|
||||
qs('#pull-handoff-status').textContent = 'Confirming handoff…';
|
||||
try {
|
||||
await controller.handoff(selected, recipient);
|
||||
const transition = await finish(selected);
|
||||
qs('#my-work-action-status').textContent = transition === false ?
|
||||
selected.key + ' handed off to @' + recipient + '.' :
|
||||
ownershipExitMessage(selected, 'handed off to @' + recipient, transition);
|
||||
} catch (error) {
|
||||
qs('#pull-handoff-status').textContent = error.message + ' The pull request remains in My Work; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#release-pull').addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
if (!selected || !globalThis.confirm('Release ' + selected.key + ' from your My Work?')) return;
|
||||
const button = qs('#release-pull');
|
||||
button.disabled = true;
|
||||
qs('#pull-sheet-status').textContent = 'Releasing assignment…';
|
||||
try {
|
||||
await controller.release(selected);
|
||||
const transition = await finish(selected);
|
||||
qs('#my-work-action-status').textContent = transition === false ?
|
||||
selected.key + ' released.' : ownershipExitMessage(selected, 'released', transition);
|
||||
} catch (error) {
|
||||
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let mergeRequest = null;
|
||||
let checkRequest = null;
|
||||
let ownershipRequest = null;
|
||||
let candidateRequest = null;
|
||||
const reviewRequests = new Map();
|
||||
const reviewCache = new Map();
|
||||
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
|
||||
@@ -64,9 +196,9 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
load(item) {
|
||||
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
|
||||
},
|
||||
loadReview(item, headSha) {
|
||||
loadReview(item, headSha, { refresh = false } = {}) {
|
||||
const key = item.repository + '#' + item.number + ':' + headSha;
|
||||
if (reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
|
||||
if (!refresh && reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
|
||||
if (reviewRequests.has(key)) return reviewRequests.get(key);
|
||||
const request = fetchJson(pathFor(item) + '/review-data', {
|
||||
headers: { Accept: 'application/json' },
|
||||
@@ -78,6 +210,36 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
reviewRequests.set(key, request);
|
||||
return request;
|
||||
},
|
||||
loadChecks(item) {
|
||||
if (checkRequest) return checkRequest;
|
||||
checkRequest = fetchJson(pathFor(item) + '/checks', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).finally(() => { checkRequest = null; });
|
||||
return checkRequest;
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
if (candidateRequest) return candidateRequest;
|
||||
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).finally(() => { candidateRequest = null; });
|
||||
return candidateRequest;
|
||||
},
|
||||
handoff(item, recipient) {
|
||||
if (ownershipRequest) return ownershipRequest;
|
||||
ownershipRequest = fetchJson(pathFor(item) + '/handoff', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient }),
|
||||
}).finally(() => { ownershipRequest = null; });
|
||||
return ownershipRequest;
|
||||
},
|
||||
release(item) {
|
||||
if (ownershipRequest) return ownershipRequest;
|
||||
ownershipRequest = fetchJson(pathFor(item) + '/release', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
}).finally(() => { ownershipRequest = null; });
|
||||
return ownershipRequest;
|
||||
},
|
||||
conversation(item, initialPage) {
|
||||
const pager = createConversationPager({
|
||||
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||
@@ -153,4 +315,12 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
|
||||
createPullSheet.mergeEligibility = mergeEligibility;
|
||||
createPullSheet.renderFile = renderFile;
|
||||
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
|
||||
createPullSheet.removeFromSnapshot = removeFromSnapshot;
|
||||
createPullSheet.sameTarget = sameTarget;
|
||||
createPullSheet.ownershipSelectors = ownershipSelectors;
|
||||
createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
|
||||
createPullSheet.ownershipExitMessage = ownershipExitMessage;
|
||||
createPullSheet.resetOwnershipControls = resetOwnershipControls;
|
||||
createPullSheet.bindOwnershipControls = bindOwnershipControls;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||
@@ -0,0 +1,79 @@
|
||||
(function(root, factory) {
|
||||
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,
|
||||
}) {
|
||||
let configuration = null;
|
||||
|
||||
function applicationServerKey(value) {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
||||
if (typeof atob === 'function') {
|
||||
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
|
||||
}
|
||||
return Uint8Array.from(Buffer.from(padded, 'base64'));
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
const registration = await serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||
await subscription?.unsubscribe?.();
|
||||
control.checked = false;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
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;
|
||||
}
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey(configuration.public_key),
|
||||
});
|
||||
}
|
||||
await fetchJson('api/v1/push-subscription', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(subscription.toJSON()),
|
||||
});
|
||||
control.checked = true;
|
||||
status.textContent = 'New update notifications enabled for this device.';
|
||||
}
|
||||
|
||||
async function change() {
|
||||
control.disabled = true;
|
||||
try {
|
||||
if (control.checked) await enable();
|
||||
else await disable();
|
||||
} catch (error) {
|
||||
control.checked = !control.checked;
|
||||
status.textContent = 'Could not change update notifications. Check your connection and try again.';
|
||||
} finally {
|
||||
control.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!control || !notification || !serviceWorker) return;
|
||||
control.addEventListener('change', change);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
status.textContent = configuration.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
}
|
||||
|
||||
return {init, change};
|
||||
});
|
||||
@@ -4,6 +4,7 @@ function createReviewController({
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
}) {
|
||||
let pendingSubmission = null;
|
||||
let pendingChecks = null;
|
||||
|
||||
function endpoint(item) {
|
||||
const [owner, repo] = String(item.repository || '').split('/');
|
||||
@@ -18,6 +19,14 @@ function createReviewController({
|
||||
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
|
||||
}
|
||||
|
||||
function loadChecks(item) {
|
||||
if (pendingChecks) return pendingChecks;
|
||||
pendingChecks = fetchJson(endpoint(item) + '/checks', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).finally(() => { pendingChecks = null; });
|
||||
return pendingChecks;
|
||||
}
|
||||
|
||||
function submit(item, payload) {
|
||||
if (pendingSubmission) return pendingSubmission;
|
||||
const operationKey = 'stackchain.review-submit.v1:' + item.repository + '#' + item.number;
|
||||
@@ -44,7 +53,29 @@ function createReviewController({
|
||||
return pendingSubmission;
|
||||
}
|
||||
|
||||
return { load, submit };
|
||||
return { load, loadChecks, submit };
|
||||
}
|
||||
|
||||
function createWrapPreference({ storage, mobile }) {
|
||||
const key = 'stackchain.review-wrap.v1';
|
||||
let explicit = false;
|
||||
let wrapped = Boolean(mobile);
|
||||
try {
|
||||
const saved = storage?.getItem(key);
|
||||
if (saved === 'true' || saved === 'false') {
|
||||
explicit = true;
|
||||
wrapped = saved === 'true';
|
||||
}
|
||||
} catch (_error) { /* keep the viewport default */ }
|
||||
|
||||
function snapshot() { return { wrapped, explicit }; }
|
||||
function setWrapped(value) {
|
||||
wrapped = Boolean(value);
|
||||
explicit = true;
|
||||
try { storage?.setItem(key, String(wrapped)); } catch (_error) { /* keep in memory */ }
|
||||
return snapshot();
|
||||
}
|
||||
return { snapshot, setWrapped, storageKey: key };
|
||||
}
|
||||
|
||||
function diffLineClass(line) {
|
||||
@@ -70,16 +101,19 @@ function parseDiffLines(lines) {
|
||||
return { text, kind: diffLineClass(text), commentable: false };
|
||||
}
|
||||
if (text.startsWith('-')) {
|
||||
const row = { text, kind: 'removed', commentable: true, old_position: oldLine };
|
||||
const row = { text, kind: 'removed', commentable: true, old_line: oldLine, old_position: oldLine };
|
||||
oldLine += 1;
|
||||
return row;
|
||||
}
|
||||
if (text.startsWith('+')) {
|
||||
const row = { text, kind: 'added', commentable: true, new_position: newLine };
|
||||
const row = { text, kind: 'added', commentable: true, new_line: newLine, new_position: newLine };
|
||||
newLine += 1;
|
||||
return row;
|
||||
}
|
||||
const row = { text, kind: 'context', commentable: true, new_position: newLine };
|
||||
const row = {
|
||||
text, kind: 'context', commentable: true,
|
||||
old_line: oldLine, new_line: newLine, new_position: newLine,
|
||||
};
|
||||
oldLine += 1;
|
||||
newLine += 1;
|
||||
return row;
|
||||
@@ -91,8 +125,14 @@ function renderDiffFile(file, index, escapeHtml) {
|
||||
let preview;
|
||||
if (file.diff_available) {
|
||||
const lines = parseDiffLines(file.diff_lines).map(row => {
|
||||
const oldNumber = row.old_line || row.old_position || '';
|
||||
const newNumber = row.new_line || row.new_position || '';
|
||||
const content = '<span class="review-line-numbers" aria-hidden="true">' +
|
||||
'<span class="review-line-number old">' + oldNumber + '</span>' +
|
||||
'<span class="review-line-number new">' + newNumber + '</span></span>' +
|
||||
'<span class="review-line-code">' + escapeHtml(row.text) + '</span>';
|
||||
if (!row.commentable) {
|
||||
return '<span class="review-diff-line ' + row.kind + '">' + escapeHtml(row.text) + '</span>';
|
||||
return '<span class="review-diff-line ' + row.kind + '">' + content + '</span>';
|
||||
}
|
||||
const position = row.old_position
|
||||
? ' data-old-position="' + row.old_position + '"'
|
||||
@@ -100,7 +140,7 @@ function renderDiffFile(file, index, escapeHtml) {
|
||||
return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
|
||||
'" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
|
||||
' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
|
||||
(row.old_position || row.new_position) + '">' + escapeHtml(row.text) + '</button>';
|
||||
(row.old_position || row.new_position) + '">' + content + '</button>';
|
||||
}).join('');
|
||||
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
|
||||
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
|
||||
@@ -324,7 +364,36 @@ function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderChecks(checks, escapeHtml, { offline = false } = {}) {
|
||||
const rank = { error: 0, failure: 0, pending: 1, warning: 2, unknown: 2, success: 3 };
|
||||
const items = (Array.isArray(checks) ? checks : []).filter(check =>
|
||||
check && typeof check.name === 'string' && check.name
|
||||
).map((check, index) => ({ ...check, index })).sort((left, right) =>
|
||||
(rank[left.state] ?? 2) - (rank[right.state] ?? 2) || left.index - right.index
|
||||
);
|
||||
const failed = items.filter(check => ['failure', 'error'].includes(check.state)).length;
|
||||
const pending = items.filter(check => check.state === 'pending').length;
|
||||
const passed = items.filter(check => check.state === 'success').length;
|
||||
const other = items.length - failed - pending - passed;
|
||||
const parts = [
|
||||
failed ? failed + ' failed' : '', pending ? pending + ' pending' : '',
|
||||
passed ? passed + ' passed' : '', other ? other + ' other' : '',
|
||||
].filter(Boolean);
|
||||
const summary = (offline ? 'Last known · ' : '') + (parts.join(' · ') || 'No checks reported');
|
||||
const html = items.map(check => {
|
||||
const url = typeof check.url === 'string' ? check.url : '';
|
||||
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
|
||||
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
|
||||
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
|
||||
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
|
||||
'<span class="small">' + escapeHtml(check.state || 'unknown') +
|
||||
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
|
||||
}).join('');
|
||||
return { summary, html, expanded: failed > 0 };
|
||||
}
|
||||
|
||||
createReviewController.renderDiffFile = renderDiffFile;
|
||||
createReviewController.createWrapPreference = createWrapPreference;
|
||||
createReviewController.parseDiffLines = parseDiffLines;
|
||||
createReviewController.toggleDiff = toggleDiff;
|
||||
createReviewController.createProgress = createProgress;
|
||||
@@ -332,6 +401,7 @@ createReviewController.createDraft = createDraft;
|
||||
createReviewController.formatFeedback = formatFeedback;
|
||||
createReviewController.copyAndContinue = copyAndContinue;
|
||||
createReviewController.prepareMergeContinuation = prepareMergeContinuation;
|
||||
createReviewController.renderChecks = renderChecks;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createReviewController;
|
||||
|
||||
+19
-12
@@ -3,10 +3,23 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
||||
if (root) root.createSearchPreview = createSearchPreview;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, claim, onState }) {
|
||||
return function createSearchPreview({ fetchJson, mutate, onState }) {
|
||||
let generation = 0;
|
||||
let current = null;
|
||||
let claimRequest = null;
|
||||
let mutationRequest = null;
|
||||
|
||||
function run(action, pending, success, detail) {
|
||||
if (mutationRequest) return mutationRequest;
|
||||
onState({ status: pending, item: current, detail });
|
||||
mutationRequest = mutate(detail, action).then(result => {
|
||||
onState({ status: success, item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { mutationRequest = null; });
|
||||
return mutationRequest;
|
||||
}
|
||||
|
||||
return {
|
||||
open(item) {
|
||||
@@ -32,16 +45,10 @@
|
||||
onState({ status: 'closed' });
|
||||
},
|
||||
claim(detail) {
|
||||
if (claimRequest) return claimRequest;
|
||||
onState({ status: 'claiming', item: current, detail });
|
||||
claimRequest = claim(detail).then(result => {
|
||||
onState({ status: 'claimed', item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { claimRequest = null; });
|
||||
return claimRequest;
|
||||
return run('claim', 'claiming', 'claimed', detail);
|
||||
},
|
||||
reopen(detail) {
|
||||
return run('reopen', 'reopening', 'reopened', detail);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.attachSecurityCenter = boundary => factory({ root, boundary });
|
||||
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) {
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
|
||||
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
|
||||
const enrollPasskey = root.document.getElementById('enroll-passkey');
|
||||
const activityList = root.document.getElementById('security-activity-list');
|
||||
const activityStatus = root.document.getElementById('security-activity-status');
|
||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
||||
let activityCursor = null;
|
||||
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
try {
|
||||
const devices = await boundary.listActiveDevices();
|
||||
devices.forEach(device => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'active-device';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = device.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
|
||||
details.append(label, timing);
|
||||
if (device.current) {
|
||||
const current = root.document.createElement('span');
|
||||
current.className = 'active-device-current';
|
||||
current.textContent = 'This device';
|
||||
details.append(current);
|
||||
}
|
||||
const revoke = root.document.createElement('button');
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
});
|
||||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
|
||||
const renderPasskeys = async () => {
|
||||
if (!passkeysList || !passkeysStatus) return;
|
||||
passkeysStatus.textContent = 'Loading enrolled passkeys…';
|
||||
passkeysList.replaceChildren();
|
||||
try {
|
||||
const enrolled = await boundary.listPasskeys();
|
||||
enrolled.forEach(passkey => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'enrolled-passkey';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = passkey.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
|
||||
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
|
||||
details.append(label, timing);
|
||||
const remove = root.document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Remove passkey';
|
||||
remove.addEventListener('click', async () => {
|
||||
remove.disabled = true;
|
||||
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
|
||||
try {
|
||||
const outcome = await boundary.revokePasskey(passkey);
|
||||
if (outcome) {
|
||||
passkeysStatus.textContent = outcome.current_session
|
||||
? 'Passkey removed. This session remains active; keep your recovery token available.'
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
|
||||
remove.disabled = false;
|
||||
}
|
||||
});
|
||||
row.append(details, remove);
|
||||
passkeysList.append(row);
|
||||
});
|
||||
passkeysStatus.textContent = enrolled.length
|
||||
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
|
||||
: 'No passkeys enrolled.';
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
|
||||
const renderSecurityActivity = async (append = false) => {
|
||||
if (!activityList || !activityStatus || !loadMoreActivity) return;
|
||||
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
|
||||
loadMoreActivity.hidden = true;
|
||||
if (!append) {
|
||||
activityCursor = null;
|
||||
activityList.replaceChildren();
|
||||
}
|
||||
try {
|
||||
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
|
||||
if (!append) {
|
||||
page.authentication_alerts.forEach(alert => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event authentication-alert';
|
||||
const formatted = boundary.formatAuthenticationAlert(alert);
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = formatted.title;
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
const labels = {
|
||||
sign_in: 'Signed in', sign_out: 'Signed out', passkey_enrolled: 'Passkey enrolled',
|
||||
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',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||
};
|
||||
page.events.forEach(event => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event';
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = labels[event.kind] || 'Security event';
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
const context = [event.device_label, event.method, event.target,
|
||||
event.kind === 'passkey_counter_anomaly'
|
||||
? 'Remove and re-enroll this passkey if the alert repeats'
|
||||
: null,
|
||||
event.status === 'pending' ? 'Outcome confirmation pending' : null]
|
||||
.filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
activityCursor = page.next_cursor;
|
||||
activityStatus.textContent = activityList.children.length
|
||||
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
|
||||
: 'No security activity yet.';
|
||||
loadMoreActivity.textContent = 'Load older activity';
|
||||
loadMoreActivity.hidden = !activityCursor;
|
||||
} catch (_error) {
|
||||
activityStatus.textContent = 'Security activity could not be loaded.';
|
||||
loadMoreActivity.textContent = 'Retry activity';
|
||||
loadMoreActivity.hidden = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (!devicesSheet) return;
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
|
||||
};
|
||||
devicesButton?.addEventListener('click', open);
|
||||
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
|
||||
enrollPasskey?.addEventListener('click', async () => {
|
||||
enrollPasskey.disabled = true;
|
||||
devicesStatus.textContent = 'Waiting for your device passkey…';
|
||||
try {
|
||||
await boundary.enrollPasskey();
|
||||
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
|
||||
await renderPasskeys();
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
|
||||
} finally {
|
||||
enrollPasskey.disabled = false;
|
||||
}
|
||||
});
|
||||
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||
});
|
||||
+198
-26
@@ -1,9 +1,10 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v72';
|
||||
const CACHE = 'stackchain-dashboard-shell-v98';
|
||||
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 SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
@@ -12,34 +13,52 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/security-center.js',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.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/authored-outbox.js',
|
||||
BASE + 'static/offline-issue-close.js',
|
||||
BASE + 'static/offline-issue-blocker.js',
|
||||
BASE + 'static/notification-read-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/notification-undo.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/work-selection.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
BASE + 'static/update-reply-read-next.js',
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-readiness.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/today-rollover.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
BASE + 'static/update-follow-up.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/later-sync.js',
|
||||
BASE + 'static/later-and-start.js',
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/batch-find-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/comment-actions.js',
|
||||
BASE + 'static/issue-attachment.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
@@ -52,12 +71,18 @@ const SHELL = [
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-launch.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/background-issue-sync.js',
|
||||
];
|
||||
const OPTIONAL_FEATURES = [
|
||||
];
|
||||
|
||||
async function fetchNavigation(request) {
|
||||
const controller = new AbortController();
|
||||
@@ -102,8 +127,21 @@ async function withSessionCsrf(work) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePrivateDatabase(name) {
|
||||
if (!indexedDB) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase(name); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
async function purgeRevokedSessionData() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
@@ -112,24 +150,35 @@ async function purgeRevokedSessionData() {
|
||||
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
|
||||
}
|
||||
|
||||
async function storeOfflineLease(expiresAt) {
|
||||
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
|
||||
async function notifyIdleSession() {
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' }));
|
||||
}
|
||||
|
||||
async function storeOfflineLease(expiresAt, idleExpiresAt) {
|
||||
if (
|
||||
!Number.isInteger(expiresAt) || expiresAt <= 0
|
||||
|| !Number.isInteger(idleExpiresAt) || idleExpiresAt <= 0
|
||||
) return;
|
||||
const cache = await caches.open(CACHE);
|
||||
await cache.put(OFFLINE_LEASE_URL, new Response(
|
||||
JSON.stringify({ expires_at: expiresAt }),
|
||||
JSON.stringify({ expires_at: expiresAt, idle_expires_at: idleExpiresAt }),
|
||||
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
|
||||
));
|
||||
}
|
||||
|
||||
async function validOfflineLease(cache) {
|
||||
async function offlineLeaseState(cache) {
|
||||
const response = await cache.match(OFFLINE_LEASE_URL);
|
||||
const payload = await response?.json?.().catch(() => ({})) || {};
|
||||
return Number.isInteger(payload.expires_at)
|
||||
&& payload.expires_at > Math.floor(Date.now() / 1000);
|
||||
const current = Math.floor(Date.now() / 1000);
|
||||
if (!Number.isInteger(payload.expires_at) || payload.expires_at <= current) return 'expired';
|
||||
if (!Number.isInteger(payload.idle_expires_at) || payload.idle_expires_at <= current) return 'idle';
|
||||
return 'valid';
|
||||
}
|
||||
|
||||
async function expiredOfflineResponse() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
@@ -142,8 +191,18 @@ async function expiredOfflineResponse() {
|
||||
);
|
||||
}
|
||||
|
||||
async function idleOfflineResponse() {
|
||||
await notifyIdleSession();
|
||||
return new Response(
|
||||
'Your Stackchain session is locked. Reconnect and sign in.',
|
||||
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
|
||||
async function cachedShellWithValidLease(cache) {
|
||||
if (!await validOfflineLease(cache)) return expiredOfflineResponse();
|
||||
const state = await offlineLeaseState(cache);
|
||||
if (state === 'expired') return expiredOfflineResponse();
|
||||
if (state === 'idle') return idleOfflineResponse();
|
||||
return cache.match(BASE);
|
||||
}
|
||||
|
||||
@@ -174,6 +233,9 @@ async function fetchJson(url, options = {}) {
|
||||
if (response.status === 401 && code === 'session_revoked') {
|
||||
await purgeRevokedSessionData();
|
||||
}
|
||||
if (response.status === 401 && code === 'session_idle') {
|
||||
await notifyIdleSession();
|
||||
}
|
||||
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
|
||||
error.status = response.status;
|
||||
error.code = code;
|
||||
@@ -192,11 +254,13 @@ async function flushAndNotify() {
|
||||
!await issueSync.getReceiptPreference?.(result.login)) return;
|
||||
for (const receipt of result.receipts) {
|
||||
const needsAttention = receipt.status === 'attention';
|
||||
const title = needsAttention
|
||||
const needsAuthorization = receipt.status === 'authorization';
|
||||
const title = needsAuthorization ? 'Queued review needs authorization' : needsAttention
|
||||
? 'Queued work needs attention'
|
||||
: receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent';
|
||||
await self.registration.showNotification(title, {
|
||||
body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
||||
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 },
|
||||
});
|
||||
@@ -208,10 +272,16 @@ self.addEventListener('install', event => {
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(caches.keys().then(keys => Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
)).then(() => self.clients.claim()));
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
);
|
||||
const cache = await caches.open(CACHE);
|
||||
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('sync', event => {
|
||||
@@ -224,11 +294,12 @@ self.addEventListener('message', event => {
|
||||
await flushAndNotify();
|
||||
})());
|
||||
if (event.data?.type === 'stackchain-session-lease') {
|
||||
event.waitUntil(storeOfflineLease(event.data.expiresAt));
|
||||
event.waitUntil(storeOfflineLease(event.data.expiresAt, event.data.idleExpiresAt));
|
||||
}
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||
@@ -236,18 +307,115 @@ self.addEventListener('message', event => {
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
const route = String(event.notification.data?.route || '');
|
||||
if (!route.startsWith('#/my-work/')) return;
|
||||
self.addEventListener('push', event => {
|
||||
let payload;
|
||||
try { payload = event.data?.json?.() || {}; }
|
||||
catch (_error) { return; }
|
||||
const route = String(payload.route || '');
|
||||
const tag = String(payload.tag || '');
|
||||
const notificationId = Number(payload.notification_id);
|
||||
const updateCount = Number(payload.update_count);
|
||||
if (
|
||||
route === '#/my-work/updates'
|
||||
&& tag === 'stackchain-update-digest'
|
||||
&& Number.isSafeInteger(updateCount)
|
||||
&& updateCount > 0
|
||||
&& updateCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
|
||||
const options = {
|
||||
body: 'Tap to review it in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
};
|
||||
if (
|
||||
Number.isSafeInteger(notificationId) && notificationId > 0
|
||||
&& route === '#/my-work/update/' + notificationId
|
||||
&& tag === 'stackchain-update-' + notificationId
|
||||
) {
|
||||
options.actions = [
|
||||
{ action: 'mark-read', title: 'Mark read' },
|
||||
{ action: 'tomorrow', title: 'Tomorrow' },
|
||||
];
|
||||
options.data.notificationId = notificationId;
|
||||
}
|
||||
event.waitUntil(self.registration.showNotification('New work update', options));
|
||||
});
|
||||
|
||||
async function openWorkRoute(route) {
|
||||
const target = new URL(BASE + route, self.location.origin).href;
|
||||
event.waitUntil((async () => {
|
||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (!client) return self.clients.openWindow(target);
|
||||
if (client.navigate) await client.navigate(target);
|
||||
return client.focus();
|
||||
})());
|
||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (!client) return self.clients.openWindow(target);
|
||||
if (client.navigate) await client.navigate(target);
|
||||
return client.focus();
|
||||
}
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
const route = String(event.notification.data?.route || '');
|
||||
if (event.action === 'tomorrow') {
|
||||
const notificationId = Number(event.notification.data?.notificationId);
|
||||
if (
|
||||
!Number.isSafeInteger(notificationId) || notificationId <= 0
|
||||
|| route !== '#/my-work/update/' + notificationId
|
||||
|| event.notification.tag !== 'stackchain-update-' + notificationId
|
||||
) return;
|
||||
event.waitUntil((async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
||||
try {
|
||||
const wake = new Date(self.__STACKCHAIN_NOW?.() || Date.now());
|
||||
wake.setDate(wake.getDate() + 1);
|
||||
wake.setHours(9, 0, 0, 0);
|
||||
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/later', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wake_at: wake.toISOString() }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
event.notification.close();
|
||||
} catch (_error) {
|
||||
await openWorkRoute(route);
|
||||
event.notification.close();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
if (event.action === 'mark-read') {
|
||||
const notificationId = Number(event.notification.data?.notificationId);
|
||||
if (
|
||||
!Number.isSafeInteger(notificationId) || notificationId <= 0
|
||||
|| route !== '#/my-work/update/' + notificationId
|
||||
) return;
|
||||
event.waitUntil((async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
||||
try {
|
||||
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/read', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal,
|
||||
});
|
||||
event.notification.close();
|
||||
} catch (_error) {
|
||||
await openWorkRoute(route);
|
||||
event.notification.close();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
event.notification.close();
|
||||
if (!route.startsWith('#/my-work/')) return;
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
@@ -281,5 +449,9 @@ self.addEventListener('fetch', event => {
|
||||
}
|
||||
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
}
|
||||
});
|
||||
+300
-76
@@ -13,8 +13,10 @@
|
||||
indexedDB: root.indexedDB,
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
credentials: root.navigator?.credentials,
|
||||
MessageChannel: root.MessageChannel,
|
||||
location: root.location,
|
||||
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
|
||||
confirmAction: message => root.confirm(message),
|
||||
promptAuthorization: details => root.prompt(
|
||||
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
|
||||
@@ -27,56 +29,45 @@
|
||||
});
|
||||
root.fetch = boundary.fetch;
|
||||
const attach = () => {
|
||||
boundary.startActivityHeartbeat();
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const closeDevices = root.document.getElementById('close-active-devices');
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
try {
|
||||
const devices = await boundary.listActiveDevices();
|
||||
devices.forEach(device => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'active-device';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = device.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
|
||||
details.append(label, timing);
|
||||
if (device.current) {
|
||||
const current = root.document.createElement('span');
|
||||
current.className = 'active-device-current';
|
||||
current.textContent = 'This device';
|
||||
details.append(current);
|
||||
}
|
||||
const revoke = root.document.createElement('button');
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
});
|
||||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
|
||||
const securityFeatures = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
'security-center': root.document.querySelector(
|
||||
'meta[name="stackchain-feature-security-center"]'
|
||||
)?.content || '',
|
||||
},
|
||||
});
|
||||
let loadingSecurityCenter = false;
|
||||
const openSecurityCenter = async () => {
|
||||
if (loadingSecurityCenter) return;
|
||||
loadingSecurityCenter = true;
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
renderDevices();
|
||||
});
|
||||
try {
|
||||
await securityFeatures.run('security-center', {
|
||||
trigger: devicesButton,
|
||||
status: devicesStatus,
|
||||
retryLabel: 'Tap Active devices to retry.',
|
||||
}, () => {
|
||||
devicesButton.removeEventListener('click', openSecurityCenter);
|
||||
root.attachSecurityCenter(boundary).open();
|
||||
});
|
||||
} finally {
|
||||
loadingSecurityCenter = false;
|
||||
}
|
||||
};
|
||||
if (devicesButton && devicesSheet) {
|
||||
devicesButton.addEventListener('click', openSecurityCenter);
|
||||
}
|
||||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
@@ -91,16 +82,20 @@
|
||||
root.stackchainSession = boundary;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, location, confirmAction, addActivityListener,
|
||||
promptAuthorization = () => null,
|
||||
onExpired = () => {},
|
||||
onClearError = () => {},
|
||||
requestTimeoutMs = 15000,
|
||||
activityHeartbeatIntervalMs = 60000,
|
||||
now = () => Date.now(),
|
||||
setTimer = (callback, delay) => setTimeout(callback, delay),
|
||||
}) {
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
let expirationStarted = false;
|
||||
let idleLockStarted = false;
|
||||
let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY;
|
||||
let activityHeartbeat = null;
|
||||
|
||||
async function handleRemoteRevocation() {
|
||||
if (expirationStarted) return;
|
||||
@@ -117,10 +112,19 @@
|
||||
location.replace(base + 'login?reason=session-expired');
|
||||
}
|
||||
|
||||
function handleSessionIdle() {
|
||||
if (expirationStarted || idleLockStarted) return;
|
||||
idleLockStarted = true;
|
||||
location.replace(base + 'login?reason=session-idle');
|
||||
}
|
||||
|
||||
async function handleServiceWorkerMessage(event) {
|
||||
if (event?.data?.type === 'stackchain-session-revoked') {
|
||||
await handleRemoteRevocation();
|
||||
}
|
||||
if (event?.data?.type === 'stackchain-session-idle') {
|
||||
handleSessionIdle();
|
||||
}
|
||||
if (event?.data?.type === 'stackchain-session-expired') {
|
||||
await handleSessionExpiry();
|
||||
}
|
||||
@@ -131,16 +135,32 @@
|
||||
return Number.isInteger(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function storedOfflineIdleLease() {
|
||||
const value = Number(localStorage?.getItem?.('stackchain.session-idle-expires-at'));
|
||||
return Number.isInteger(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function scheduleOfflineExpiry(expiresAt) {
|
||||
const delay = Math.max(0, expiresAt * 1000 - now());
|
||||
setTimer(() => handleSessionExpiry(), delay);
|
||||
}
|
||||
|
||||
async function publishOfflineLease(expiresAt) {
|
||||
function scheduleOfflineIdle(idleExpiresAt) {
|
||||
const delay = Math.max(0, idleExpiresAt * 1000 - now());
|
||||
setTimer(() => {
|
||||
if (storedOfflineIdleLease() * 1000 <= now()) handleSessionIdle();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async function publishOfflineLease(expiresAt, idleExpiresAt) {
|
||||
localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt));
|
||||
localStorage?.setItem?.('stackchain.session-idle-expires-at', String(idleExpiresAt));
|
||||
scheduleOfflineExpiry(expiresAt);
|
||||
scheduleOfflineIdle(idleExpiresAt);
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt });
|
||||
registration?.active?.postMessage({
|
||||
type: 'stackchain-session-lease', expiresAt, idleExpiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
@@ -150,6 +170,33 @@
|
||||
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
|
||||
}
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
|
||||
+ '='.repeat((4 - String(value).length % 4) % 4);
|
||||
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function encodeBase64Url(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
let binary = '';
|
||||
new Uint8Array(value).forEach(byte => { binary += String.fromCharCode(byte); });
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
||||
}
|
||||
|
||||
function authenticationCredentialJSON(credential) {
|
||||
return {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeBase64Url(credential.rawId),
|
||||
response: {
|
||||
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
|
||||
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
|
||||
signature: encodeBase64Url(credential.response.signature),
|
||||
userHandle: encodeBase64Url(credential.response.userHandle),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isSameOrigin(input) {
|
||||
try { return new URL(String(input?.url || input), origin).origin === origin; }
|
||||
catch (_error) { return false; }
|
||||
@@ -205,6 +252,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function authorizeWithPasskey(details) {
|
||||
if (!credentials?.get) return null;
|
||||
const headers = new Headers({ Accept: 'application/json', 'Content-Type': 'application/json' });
|
||||
const csrf = csrfToken();
|
||||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||
const optionsResponse = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/options', {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({ action: details.action, target: details.target }),
|
||||
}, 'POST', 'passkey-authorization');
|
||||
if (!optionsResponse.ok) return null;
|
||||
const publicKey = await optionsResponse.json();
|
||||
const challenge = publicKey.challenge;
|
||||
publicKey.challenge = decodeBase64Url(publicKey.challenge);
|
||||
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
|
||||
...item, id: decodeBase64Url(item.id),
|
||||
}));
|
||||
const credential = await credentials.get({ publicKey });
|
||||
const verified = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/verify', {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({
|
||||
challenge,
|
||||
credential: authenticationCredentialJSON(credential),
|
||||
action: details.action,
|
||||
target: details.target,
|
||||
}),
|
||||
}, 'POST', 'passkey-authorization');
|
||||
if (!verified.ok) return null;
|
||||
const payload = await verified.json().catch(() => ({}));
|
||||
return payload.grant || null;
|
||||
}
|
||||
|
||||
async function sessionFetch(input, options = {}, allowStepUp = true) {
|
||||
const method = String(options.method || input?.method || 'GET').toUpperCase();
|
||||
const requestOptions = { ...options };
|
||||
@@ -219,40 +297,52 @@
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
const detail = payload?.detail || {};
|
||||
if (detail.code === 'step_up_required' && detail.action && detail.target) {
|
||||
const accessToken = await promptAuthorization({
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
});
|
||||
if (!accessToken) return response;
|
||||
const authorizationHeaders = new Headers({
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
const csrf = csrfToken();
|
||||
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
|
||||
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
|
||||
method: 'POST',
|
||||
headers: authorizationHeaders,
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
let grant = null;
|
||||
try { grant = await authorizeWithPasskey(detail); }
|
||||
catch (_error) { /* Cancellation and unavailable passkeys fall back to recovery. */ }
|
||||
if (!grant) {
|
||||
const accessToken = await promptAuthorization({
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
}),
|
||||
}, 'POST', 'fresh-authorization');
|
||||
if (!authorization.ok) return authorization;
|
||||
const grant = await authorization.json().catch(() => ({}));
|
||||
if (!grant.grant) return response;
|
||||
});
|
||||
if (!accessToken) return response;
|
||||
const authorizationHeaders = new Headers({
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
const csrf = csrfToken();
|
||||
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
|
||||
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
|
||||
method: 'POST',
|
||||
headers: authorizationHeaders,
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
}),
|
||||
}, 'POST', 'fresh-authorization');
|
||||
if (!authorization.ok) return authorization;
|
||||
const authorizationPayload = await authorization.json().catch(() => ({}));
|
||||
grant = authorizationPayload.grant;
|
||||
}
|
||||
if (!grant) return response;
|
||||
const retryHeaders = new Headers(requestOptions.headers || input?.headers || {});
|
||||
retryHeaders.set('X-Step-Up-Grant', grant.grant);
|
||||
retryHeaders.set('X-Step-Up-Grant', grant);
|
||||
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
|
||||
}
|
||||
}
|
||||
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
|
||||
if (
|
||||
response.status === 401 && isSameOrigin(input)
|
||||
&& !expirationStarted && !idleLockStarted
|
||||
) {
|
||||
expirationStarted = true;
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
if (payload.code === 'session_revoked') {
|
||||
expirationStarted = false;
|
||||
await handleRemoteRevocation();
|
||||
} else if (payload.code === 'session_idle') {
|
||||
expirationStarted = false;
|
||||
handleSessionIdle();
|
||||
} else {
|
||||
expirationStarted = false;
|
||||
await handleSessionExpiry();
|
||||
@@ -261,22 +351,62 @@
|
||||
return response;
|
||||
}
|
||||
|
||||
function recordActivity() {
|
||||
const current = now();
|
||||
if (
|
||||
expirationStarted
|
||||
|| idleLockStarted
|
||||
|| activityHeartbeat
|
||||
|| current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs
|
||||
) return Promise.resolve(false);
|
||||
lastActivityHeartbeatAt = current;
|
||||
activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' })
|
||||
.then(async response => {
|
||||
if (!response.ok) return false;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const expiresAt = storedOfflineLease();
|
||||
if (expiresAt && Number.isInteger(payload.idle_expires_at)) {
|
||||
await publishOfflineLease(expiresAt, payload.idle_expires_at);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.catch(() => false)
|
||||
.finally(() => { activityHeartbeat = null; });
|
||||
return activityHeartbeat;
|
||||
}
|
||||
|
||||
function startActivityHeartbeat() {
|
||||
['pointerdown', 'keydown', 'touchstart'].forEach(type => {
|
||||
addActivityListener?.(type, recordActivity, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshOfflineLease() {
|
||||
try {
|
||||
const response = await sessionFetch(base + 'api/v1/session');
|
||||
if (!response.ok) return false;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!payload.authenticated || !Number.isInteger(payload.expires_at)) return false;
|
||||
await publishOfflineLease(payload.expires_at);
|
||||
if (
|
||||
!payload.authenticated
|
||||
|| !Number.isInteger(payload.expires_at)
|
||||
|| !Number.isInteger(payload.idle_expires_at)
|
||||
) return false;
|
||||
await publishOfflineLease(payload.expires_at, payload.idle_expires_at);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
const expiresAt = storedOfflineLease();
|
||||
if (expiresAt * 1000 > now()) {
|
||||
scheduleOfflineExpiry(expiresAt);
|
||||
return true;
|
||||
if (expiresAt * 1000 <= now()) {
|
||||
if (expiresAt) await handleSessionExpiry();
|
||||
return false;
|
||||
}
|
||||
if (expiresAt) await handleSessionExpiry();
|
||||
return false;
|
||||
const idleExpiresAt = storedOfflineIdleLease();
|
||||
if (!idleExpiresAt || idleExpiresAt * 1000 <= now()) {
|
||||
handleSessionIdle();
|
||||
return false;
|
||||
}
|
||||
scheduleOfflineExpiry(expiresAt);
|
||||
scheduleOfflineIdle(idleExpiresAt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,11 +437,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrivateOutbox() {
|
||||
function deletePrivateDatabase(name) {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
try { request = indexedDB.deleteDatabase(name); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
@@ -324,7 +454,9 @@
|
||||
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
||||
try {
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
} catch (_error) {
|
||||
const error = new Error('Could not clear private queued work from this device.');
|
||||
onClearError(error);
|
||||
@@ -336,6 +468,41 @@
|
||||
} catch (_error) { /* A later service-worker activation can clear stale caches. */ }
|
||||
}
|
||||
|
||||
async function enrollPasskey() {
|
||||
if (!credentials?.create) throw new Error('Passkeys are not supported');
|
||||
const optionsResponse = await sessionFetch(
|
||||
base + 'api/v1/passkeys/registration/options', { method: 'POST' }
|
||||
);
|
||||
if (!optionsResponse.ok) throw new Error('Could not start passkey enrollment');
|
||||
const publicKey = await optionsResponse.json();
|
||||
const challenge = publicKey.challenge;
|
||||
publicKey.challenge = decodeBase64Url(publicKey.challenge);
|
||||
publicKey.user.id = decodeBase64Url(publicKey.user.id);
|
||||
publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map(item => ({
|
||||
...item, id: decodeBase64Url(item.id),
|
||||
}));
|
||||
const credential = await credentials.create({ publicKey });
|
||||
const response = await sessionFetch(base + 'api/v1/passkeys/registration/verify', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challenge,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeBase64Url(credential.rawId),
|
||||
response: {
|
||||
attestationObject: encodeBase64Url(credential.response.attestationObject),
|
||||
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
|
||||
transports: credential.response.getTransports?.() || [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Could not verify passkey enrollment');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function listActiveDevices() {
|
||||
const response = await sessionFetch(base + 'api/v1/sessions');
|
||||
if (!response.ok) throw new Error('Could not load active devices');
|
||||
@@ -343,6 +510,56 @@
|
||||
return Array.isArray(payload.devices) ? payload.devices : [];
|
||||
}
|
||||
|
||||
async function listPasskeys() {
|
||||
const response = await sessionFetch(base + 'api/v1/passkeys');
|
||||
if (!response.ok) throw new Error('Could not load enrolled passkeys');
|
||||
const payload = await response.json();
|
||||
return Array.isArray(payload.passkeys) ? payload.passkeys : [];
|
||||
}
|
||||
|
||||
async function revokePasskey(passkey) {
|
||||
if (!passkey?.management_id) return false;
|
||||
const confirmed = confirmAction?.(
|
||||
`Remove the passkey for ${passkey.device_label}? This cannot be undone.`
|
||||
);
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(
|
||||
base + 'api/v1/passkeys/' + encodeURIComponent(passkey.management_id),
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!response.ok) throw new Error('Could not remove enrolled passkey');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function formatAuthenticationAlert(alert) {
|
||||
const failed = Math.max(0, Number(alert?.failed_count) || 0);
|
||||
const blocked = Math.max(0, Number(alert?.blocked_count) || 0);
|
||||
const method = alert?.method === 'passkey' ? 'passkey' : 'token';
|
||||
const title = `${failed} failed ${method} sign-in${failed === 1 ? '' : 's'}`
|
||||
+ (blocked ? ` · ${blocked} blocked` : '');
|
||||
const formatTime = value => new Date(Number(value) * 1000).toLocaleString(
|
||||
'en-US', { timeZone: 'UTC' },
|
||||
);
|
||||
return {
|
||||
title,
|
||||
detail: `${formatTime(alert?.first_at)} – ${formatTime(alert?.last_at)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function listSecurityEvents(cursor = null) {
|
||||
const query = new URLSearchParams({ limit: '25' });
|
||||
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
|
||||
const response = await sessionFetch(base + 'api/v1/security-events?' + query);
|
||||
if (!response.ok) throw new Error('Could not load security activity');
|
||||
const payload = await response.json();
|
||||
return {
|
||||
events: Array.isArray(payload.events) ? payload.events : [],
|
||||
authentication_alerts: Array.isArray(payload.authentication_alerts)
|
||||
? payload.authentication_alerts : [],
|
||||
next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function revokeActiveDevice(device) {
|
||||
if (!device?.management_id || device.current) return false;
|
||||
const confirmed = confirmAction?.(`Sign out ${device.device_label}?`);
|
||||
@@ -386,10 +603,17 @@
|
||||
signOut,
|
||||
signOutAllDevices,
|
||||
listActiveDevices,
|
||||
listPasskeys,
|
||||
listSecurityEvents,
|
||||
formatAuthenticationAlert,
|
||||
enrollPasskey,
|
||||
revokeActiveDevice,
|
||||
revokePasskey,
|
||||
clearPrivateDeviceData,
|
||||
handleServiceWorkerMessage,
|
||||
refreshOfflineLease,
|
||||
resumeQueuedWork,
|
||||
recordActivity,
|
||||
startActivityHeartbeat,
|
||||
};
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview']);
|
||||
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
|
||||
|
||||
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
||||
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce }) {
|
||||
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null }) {
|
||||
return function completeTodayItem(item, options = {}) {
|
||||
if (!item || !todayWork.remove(item)) {
|
||||
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
|
||||
@@ -8,7 +8,7 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war
|
||||
todaySync.flush();
|
||||
refresh();
|
||||
warm();
|
||||
workSession.complete();
|
||||
(advance || (() => workSession.complete()))();
|
||||
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createTodayReadiness = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
return function createTodayReadiness({ inspect, onOpen = () => {}, onGate = () => {}, onPreview = () => {} }) {
|
||||
let pending = null;
|
||||
let generation = 0;
|
||||
|
||||
async function classify(item) {
|
||||
if (item?.kind !== 'issue') return { status:'ready', dependencies:[] };
|
||||
try {
|
||||
const detail = await inspect(item);
|
||||
if (detail?.available !== true) return { status:'unknown', dependencies:[] };
|
||||
const dependencies = Array.isArray(detail.dependencies)
|
||||
? detail.dependencies.filter(dependency => dependency?.state === 'open')
|
||||
: [];
|
||||
return { status:dependencies.length ? 'blocked' : 'ready', dependencies };
|
||||
} catch (_error) {
|
||||
return { status:'unknown', dependencies:[] };
|
||||
}
|
||||
}
|
||||
|
||||
async function run(action, items, target = null) {
|
||||
const request = ++generation;
|
||||
const ordered = Array.isArray(items) ? items.slice() : [];
|
||||
const preferred = target || ordered[0] || null;
|
||||
if (!preferred) return 'empty';
|
||||
const preferredIndex = ordered.indexOf(preferred);
|
||||
const candidates = preferredIndex >= 0
|
||||
? ordered.slice(preferredIndex).concat(ordered.slice(0, preferredIndex))
|
||||
: [preferred].concat(ordered);
|
||||
const targetState = { item:preferred, ...(await classify(preferred)) };
|
||||
if (request !== generation) return 'superseded';
|
||||
if (targetState.status === 'ready') {
|
||||
pending = null;
|
||||
onOpen(action, preferred);
|
||||
return 'opened';
|
||||
}
|
||||
const fallbackStates = await Promise.all(candidates.slice(1).map(async item => ({
|
||||
item,
|
||||
...(await classify(item)),
|
||||
})));
|
||||
const states = [targetState, ...fallbackStates];
|
||||
if (request !== generation) return 'superseded';
|
||||
const nextReadyState = states.slice(1).find(state => state.status === 'ready');
|
||||
pending = {
|
||||
action,
|
||||
items:ordered,
|
||||
target:preferred,
|
||||
status:targetState.status,
|
||||
dependencies:targetState.dependencies,
|
||||
nextReady:nextReadyState?.item || null,
|
||||
};
|
||||
onGate({ ...pending, items:pending.items.slice() });
|
||||
return 'gated';
|
||||
}
|
||||
|
||||
function open(item) {
|
||||
if (!pending || !item) return false;
|
||||
const action = pending.action;
|
||||
pending = null;
|
||||
generation += 1;
|
||||
onOpen(action, item);
|
||||
return true;
|
||||
}
|
||||
|
||||
function previewBlocker(blocker) {
|
||||
if (!pending || !blocker) return false;
|
||||
const current = pending.dependencies.find(dependency =>
|
||||
dependency?.repository === blocker.repository && Number(dependency?.number) === Number(blocker.number)
|
||||
);
|
||||
if (!current) return false;
|
||||
onPreview(current);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
run,
|
||||
retry: () => pending ? run(pending.action, pending.items, pending.target) : Promise.resolve('closed'),
|
||||
previewBlocker,
|
||||
startNextReady: () => open(pending?.nextReady),
|
||||
workAnyway: () => open(pending?.target),
|
||||
cancel() {
|
||||
generation += 1;
|
||||
pending = null;
|
||||
},
|
||||
snapshot: () => pending ? { ...pending, items:pending.items.slice() } : { open:false },
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
function todayRecapFeedbackRows(draft, describe = () => null) {
|
||||
return (draft?.items || []).map(item => {
|
||||
const work = describe(item.identity) || {};
|
||||
const estimate = Number.isInteger(item.estimate_minutes) ? item.estimate_minutes : null;
|
||||
return {
|
||||
identity:item.identity,
|
||||
label:String(work.title || item.identity).slice(0, 180),
|
||||
context:String(work.key || work.repository || 'Work details unavailable').slice(0, 180),
|
||||
estimate_minutes:estimate,
|
||||
actual_minutes:item.actual_minutes,
|
||||
variance_minutes:estimate === null ? null : item.actual_minutes - estimate,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createTodayRecap({ save, saveAndLog = null, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) {
|
||||
const totals = items => {
|
||||
const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0);
|
||||
const actual = items.reduce((sum, item) => sum + item.actual_minutes, 0);
|
||||
return { estimated_minutes:estimated, actual_minutes:actual, variance_minutes:actual - estimated };
|
||||
};
|
||||
const storageKey = () => {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-recap-draft.v1.' + login : '';
|
||||
};
|
||||
const handoffKey = () => {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-recap-handoff.v1.' + login : '';
|
||||
};
|
||||
const validStoredDraft = saved => {
|
||||
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
|
||||
!Array.isArray(saved.items) || !saved.items.length || saved.items.length > 20) return false;
|
||||
const itemsValid = saved.items.every(item => item && typeof item.identity === 'string' && item.identity.length > 0 &&
|
||||
item.identity.length <= 500 && (item.estimate_minutes === null ||
|
||||
(Number.isInteger(item.estimate_minutes) && item.estimate_minutes >= 5 && item.estimate_minutes <= 1440)) &&
|
||||
Number.isInteger(item.actual_minutes) && item.actual_minutes >= 0 && item.actual_minutes <= 1440);
|
||||
if (!itemsValid) return false;
|
||||
const identities = new Set(saved.items.map(item => item.identity));
|
||||
if (saved.log_identities !== undefined && (!Array.isArray(saved.log_identities) ||
|
||||
saved.log_identities.length > 20 || saved.log_identities.some(identity => !identities.has(identity)))) return false;
|
||||
if (saved.time_logs !== undefined && (!saved.time_logs || typeof saved.time_logs !== 'object' ||
|
||||
Array.isArray(saved.time_logs) || Object.entries(saved.time_logs).some(([identity, status]) =>
|
||||
!identities.has(identity) || !['logged', 'retry', 'verify'].includes(status)))) return false;
|
||||
return true;
|
||||
};
|
||||
const load = () => {
|
||||
const key = storageKey();
|
||||
if (!storage || !key) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (!validStoredDraft(saved)) {
|
||||
if (saved !== null) storage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return { ...saved, ...totals(saved.items) };
|
||||
}
|
||||
catch (_error) {
|
||||
try { storage.removeItem(key); } catch (_ignored) {}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const validHandoff = saved => {
|
||||
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
|
||||
!saved.actual_minutes || typeof saved.actual_minutes !== 'object' || Array.isArray(saved.actual_minutes)) return false;
|
||||
const entries = Object.entries(saved.actual_minutes);
|
||||
return entries.length > 0 && entries.length <= 20 && entries.every(([identity, minutes]) =>
|
||||
identity.length > 0 && identity.length <= 500 && Number.isInteger(minutes) && minutes >= 0 && minutes <= 1440
|
||||
);
|
||||
};
|
||||
const pendingReplan = () => {
|
||||
const key = handoffKey();
|
||||
if (!storage || !key) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (!validHandoff(saved)) {
|
||||
if (saved !== null) storage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return { session_id:saved.session_id, actual_minutes:{...saved.actual_minutes} };
|
||||
} catch (_error) {
|
||||
try { storage.removeItem(key); } catch (_ignored) {}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const completeReplan = () => {
|
||||
const key = handoffKey();
|
||||
if (storage && key) storage.removeItem(key);
|
||||
};
|
||||
let draftKey = storageKey();
|
||||
let draft = load();
|
||||
const persist = () => {
|
||||
const key = storageKey();
|
||||
if (!storage || !key || !draft) return;
|
||||
const saved = { session_id:draft.session_id, items:draft.items };
|
||||
if (draft.log_identities) saved.log_identities = draft.log_identities;
|
||||
if (draft.time_logs) saved.time_logs = draft.time_logs;
|
||||
storage.setItem(key, JSON.stringify(saved));
|
||||
};
|
||||
const restore = () => {
|
||||
const key = storageKey();
|
||||
if (key !== draftKey) {
|
||||
draft = null;
|
||||
draftKey = key;
|
||||
}
|
||||
if (!draft) draft = load();
|
||||
return Boolean(draft);
|
||||
};
|
||||
const snapshot = () => {
|
||||
restore();
|
||||
return draft ? {
|
||||
...draft, items:draft.items.map(item => ({...item})),
|
||||
...(draft.log_identities ? {log_identities:[...draft.log_identities]} : {}),
|
||||
...(draft.time_logs ? {time_logs:{...draft.time_logs}} : {}),
|
||||
} : null;
|
||||
};
|
||||
const saveConfirmed = async includeActuals => {
|
||||
restore();
|
||||
if (!draft?.items.length) throw new Error('No timed work to save.');
|
||||
const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) };
|
||||
const actualMinutes = includeActuals ? Object.fromEntries(
|
||||
payload.items.map(item => [item.identity, item.actual_minutes])
|
||||
) : null;
|
||||
const result = await save(payload);
|
||||
if (includeActuals && storage && handoffKey()) storage.setItem(handoffKey(), JSON.stringify({
|
||||
session_id:payload.session_id, actual_minutes:actualMinutes,
|
||||
}));
|
||||
clear();
|
||||
if (storage && draftKey) storage.removeItem(draftKey);
|
||||
draft = null;
|
||||
return includeActuals ? { result, actual_minutes:actualMinutes } : result;
|
||||
};
|
||||
const saveWithTime = async identities => {
|
||||
restore();
|
||||
if (!draft?.items.length) throw new Error('No timed work to save.');
|
||||
if (typeof saveAndLog !== 'function') throw new Error('Gitea time logging is unavailable.');
|
||||
const requested = new Set(Array.isArray(identities) ? identities : []);
|
||||
const eligible = new Set(draft.items.filter(item => item.actual_minutes > 0).map(item => item.identity));
|
||||
const intended = [...new Set([...(draft.log_identities || []), ...requested])]
|
||||
.filter(identity => eligible.has(identity));
|
||||
if (!intended.length) throw new Error('Select at least one non-zero time entry.');
|
||||
draft.log_identities = intended;
|
||||
draft.time_logs = draft.time_logs || {};
|
||||
persist();
|
||||
const logIdentities = intended.filter(identity => !['logged', 'verify'].includes(draft.time_logs[identity]));
|
||||
const payload = {
|
||||
session_id:draft.session_id,
|
||||
items:draft.items.map(item => ({...item})),
|
||||
log_identities:logIdentities,
|
||||
};
|
||||
const result = await saveAndLog(payload);
|
||||
Object.assign(draft.time_logs, Object.fromEntries((result.time_logs || []).map(item => [item.identity, item.status])));
|
||||
persist();
|
||||
const logged = intended.filter(identity => draft.time_logs[identity] === 'logged').length;
|
||||
const verify = intended.filter(identity => draft.time_logs[identity] === 'verify').length;
|
||||
const retry = intended.length - logged - verify;
|
||||
if (retry || verify) {
|
||||
if (verify) throw new Error(verify + ' time ' + (verify === 1 ? 'entry needs' : 'entries need') +
|
||||
' verification in Gitea; it will not be posted again automatically.');
|
||||
throw new Error(logged + ' time ' + (logged === 1 ? 'entry' : 'entries') + ' logged; ' + retry + ' needs retry.');
|
||||
}
|
||||
clear();
|
||||
if (storage && draftKey) storage.removeItem(draftKey);
|
||||
draft = null;
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
restore,
|
||||
begin(entries, estimates = {}) {
|
||||
const items = (entries || []).filter(entry => entry?.identity).map(entry => ({
|
||||
identity:String(entry.identity),
|
||||
estimate_minutes:Number.isInteger(estimates[entry.identity]) ? estimates[entry.identity] : null,
|
||||
actual_minutes:Math.min(1440, Math.max(0, Math.round(Number(entry.elapsed_ms || 0) / 60000))),
|
||||
}));
|
||||
draftKey = storageKey();
|
||||
draft = { session_id:makeId(), items, ...totals(items) };
|
||||
persist();
|
||||
return snapshot();
|
||||
},
|
||||
correct(identity, minutes) {
|
||||
restore();
|
||||
if (!draft || !Number.isInteger(minutes) || minutes < 0 || minutes > 1440) return false;
|
||||
const item = draft.items.find(candidate => candidate.identity === identity);
|
||||
if (!item) return false;
|
||||
item.actual_minutes = minutes;
|
||||
Object.assign(draft, totals(draft.items));
|
||||
persist();
|
||||
return true;
|
||||
},
|
||||
snapshot,
|
||||
save:() => saveConfirmed(false),
|
||||
saveForReplan:() => saveConfirmed(true),
|
||||
saveAndLog:saveWithTime,
|
||||
pendingReplan,
|
||||
completeReplan,
|
||||
discardReplan:completeReplan,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveTodayRecap(payload) {
|
||||
const response = await fetch('api/v1/today/recaps', {
|
||||
method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.detail || 'Recap could not be saved.');
|
||||
return result;
|
||||
}
|
||||
|
||||
async function saveTodayRecapTime(payload) {
|
||||
const response = await fetch('api/v1/today/recaps/log-time', {
|
||||
method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.detail || 'Recap time could not be logged.');
|
||||
return result;
|
||||
}
|
||||
|
||||
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
|
||||
describeWork = () => null, adjustPlan = () => {} }) {
|
||||
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
|
||||
const render = () => {
|
||||
const draft = recap.snapshot();
|
||||
const container = qs('#today-recap-items');
|
||||
const rows = todayRecapFeedbackRows(draft, describeWork);
|
||||
container.innerHTML = rows.map(item => {
|
||||
const variance = item.variance_minutes === null ? 'No estimate comparison' :
|
||||
(item.variance_minutes === 0 ? 'On estimate' : minutes(Math.abs(item.variance_minutes)) +
|
||||
(item.variance_minutes > 0 ? ' over estimate' : ' under estimate'));
|
||||
const canonical = /^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9][0-9]*:$/.test(item.identity);
|
||||
const logStatus = draft?.time_logs?.[item.identity];
|
||||
const logControl = logStatus === 'logged' ?
|
||||
'<span class="today-recap-log today-recap-log-success">Logged to Gitea</span>' :
|
||||
logStatus === 'verify' ? '<span class="today-recap-log today-recap-log-verify">Verify this entry in Gitea</span>' :
|
||||
item.actual_minutes > 0 && canonical ?
|
||||
'<label class="today-recap-log"><input type="checkbox" data-recap-log-identity="' +
|
||||
escapeHtml(item.identity) + '"> ' + (logStatus === 'retry' ? 'Retry ' : 'Log ') +
|
||||
minutes(item.actual_minutes) + ' to Gitea</label>' :
|
||||
'<span class="small muted today-recap-log">No non-zero Gitea time to log</span>';
|
||||
return '<div class="today-recap-row"><span><strong>' + escapeHtml(item.label) + '</strong><span class="small today-recap-context">' +
|
||||
escapeHtml(item.context) + ' · ' + (item.estimate_minutes === null ? 'Not estimated' : minutes(item.estimate_minutes) + ' estimated') +
|
||||
'</span></span><span><input type="number" inputmode="numeric" min="0" max="1440" step="1" value="' +
|
||||
item.actual_minutes + '" data-recap-identity="' + escapeHtml(item.identity) + '" aria-label="Actual minutes for ' +
|
||||
escapeHtml(item.label) + '"> min</span><span class="today-recap-variance">' + variance + '</span>' + logControl + '</div>';
|
||||
}).join('') || '';
|
||||
qs('#today-recap-totals').textContent = draft ? minutes(draft.estimated_minutes) + ' estimated · ' +
|
||||
minutes(draft.actual_minutes) + ' actual · ' + minutes(Math.abs(draft.variance_minutes)) +
|
||||
(draft.variance_minutes >= 0 ? ' over' : ' under') : '';
|
||||
container.querySelectorAll('[data-recap-identity]').forEach(input => input.addEventListener('change', () => {
|
||||
if (!recap.correct(input.dataset.recapIdentity, Number(input.value))) {
|
||||
qs('#today-recap-status').textContent = 'Actual time must be a whole number from 0 to 1,440 minutes.';
|
||||
}
|
||||
render();
|
||||
}));
|
||||
};
|
||||
const loadHistory = async () => {
|
||||
qs('#today-recap-history').innerHTML = '<div class="small">Loading recent recaps…</div>';
|
||||
try {
|
||||
const result = await api('api/v1/today/recaps');
|
||||
qs('#today-recap-history').innerHTML = (result.recaps || []).map(saved =>
|
||||
'<div class="today-recap-history-row"><span>' + new Date(saved.created_at * 1000).toLocaleString() +
|
||||
'</span><strong>' + minutes(saved.actual_minutes) + ' actual · ' + minutes(Math.abs(saved.variance_minutes)) +
|
||||
(saved.variance_minutes >= 0 ? ' over' : ' under') + '</strong></div>'
|
||||
).join('') || '<div class="small">No saved recaps yet.</div>';
|
||||
} catch (_error) {
|
||||
qs('#today-recap-history').innerHTML = '<div class="small">Recent recaps are unavailable. Try again.</div>';
|
||||
}
|
||||
};
|
||||
const open = ({ begin = false } = {}) => {
|
||||
if (begin) recap.begin(timer.recapEntries(), todayWork.planning().estimates);
|
||||
else recap.restore();
|
||||
render();
|
||||
qs('#save-today-recap').hidden = !recap.snapshot()?.items.length;
|
||||
qs('#save-log-today-recap').hidden = !recap.snapshot()?.items.length;
|
||||
qs('#today-recap-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
loadHistory();
|
||||
requestAnimationFrame(() => (qs('#today-recap-items input') || qs('#close-today-recap')).focus());
|
||||
};
|
||||
const close = () => {
|
||||
qs('#today-recap-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
qs('#open-today-recaps').focus();
|
||||
};
|
||||
const saveDraft = async button => {
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Saving recap…';
|
||||
try {
|
||||
const handoff = await recap.saveForReplan();
|
||||
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(handoff.actual_minutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
||||
} finally { button.disabled = false; }
|
||||
};
|
||||
const saveTimeDraft = async button => {
|
||||
const selected = Array.from(qs('#today-recap-items').querySelectorAll('[data-recap-log-identity]:checked'))
|
||||
.map(input => input.dataset.recapLogIdentity);
|
||||
if (!selected.length) {
|
||||
qs('#today-recap-status').textContent = 'Select at least one non-zero time entry.';
|
||||
return;
|
||||
}
|
||||
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Logging selected time…';
|
||||
try {
|
||||
await recap.saveAndLog(selected);
|
||||
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(actualMinutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
|
||||
render();
|
||||
} finally { button.disabled = false; }
|
||||
};
|
||||
const bind = () => {
|
||||
qs('#open-today-recaps').addEventListener('click', () => open());
|
||||
qs('#close-today-recap').addEventListener('click', close);
|
||||
qs('#discard-today-recap').addEventListener('click', close);
|
||||
qs('#save-today-recap').addEventListener('click', event => saveDraft(event.currentTarget));
|
||||
qs('#save-log-today-recap').addEventListener('click', event => saveTimeDraft(event.currentTarget));
|
||||
};
|
||||
return {
|
||||
open, close, saveDraft, saveTimeDraft, loadHistory, render, bind,
|
||||
pendingReplan:recap.pendingReplan,
|
||||
completeReplan:recap.completeReplan,
|
||||
discardReplan:recap.discardReplan,
|
||||
};
|
||||
}
|
||||
|
||||
function endTodaySession(workSession, qs) {
|
||||
workSession.end();
|
||||
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
|
||||
}
|
||||
|
||||
function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
|
||||
timerView.finish();
|
||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
|
||||
qs('#my-work-action-status').textContent = 'Work session complete.';
|
||||
if (workFilter === 'today' && timer.recapEntries().length) view.open({ begin:true });
|
||||
else qs('#start-work-session').focus();
|
||||
}
|
||||
|
||||
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
|
||||
describeWork, adjustPlan) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
|
||||
const recap = createTodayRecap({
|
||||
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
|
||||
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
|
||||
});
|
||||
const view = createTodayRecapView({ ...options, recap });
|
||||
view.bind();
|
||||
setInterval(timerView.render, 1000);
|
||||
view.finish = workFilter => {
|
||||
closeSheets();
|
||||
updateActions();
|
||||
openTodayRecapAfterSession(view, timer, timerView, workFilter, qs);
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayRecap.createView = createTodayRecapView;
|
||||
createTodayRecap.feedbackRows = todayRecapFeedbackRows;
|
||||
module.exports = createTodayRecap;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
function createTodayRollover(options = {}) {
|
||||
const now = options.now || (() => new Date());
|
||||
const resolvedTimeZone = options.resolvedTimeZone || (() =>
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
|
||||
|
||||
function timeZone() {
|
||||
return options.timeZone?.() || resolvedTimeZone();
|
||||
}
|
||||
|
||||
function localDate() {
|
||||
if (options.localDate) return options.localDate();
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timeZone(), year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).formatToParts(now());
|
||||
const value = Object.fromEntries(parts.map(part => [part.type, part.value]));
|
||||
return `${value.year}-${value.month}-${value.day}`;
|
||||
}
|
||||
|
||||
function reviewState(plan) {
|
||||
if (!Array.isArray(plan?.ids) || !plan.ids.length) return 'empty';
|
||||
if (!plan.plan_date) return 'legacy';
|
||||
return plan.plan_date === localDate() ? 'current' : 'stale';
|
||||
}
|
||||
|
||||
function operation({ operation_id, base_revision, selected_ids, capacity_minutes, estimates = {} }) {
|
||||
const ids = [...new Set((selected_ids || []).filter(id => typeof id === 'string' && id))];
|
||||
const selected = new Set(ids);
|
||||
return {
|
||||
operation_id, action: 'rollover', item_id: 'plan', base_revision,
|
||||
plan_date: localDate(), timezone: timeZone(), ids, capacity_minutes,
|
||||
estimates: Object.fromEntries(Object.entries(estimates).filter(([id]) => selected.has(id))),
|
||||
};
|
||||
}
|
||||
|
||||
return { localDate, timeZone, reviewState, operation };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayRollover;
|
||||
+55
-5
@@ -1,4 +1,4 @@
|
||||
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
|
||||
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePlan, onStatus, createOperationId, createChannel, coordinator,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
|
||||
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
|
||||
const prefix = 'stackchain.today-sync.v1.';
|
||||
@@ -55,13 +55,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
function adopt(plan, broadcast = true) {
|
||||
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
|
||||
if (plan.revision < savedRevision()) return false;
|
||||
const snapshot = {
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
};
|
||||
if (plan.plan_date) {
|
||||
snapshot.plan_date = plan.plan_date;
|
||||
snapshot.timezone = plan.timezone || null;
|
||||
}
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify({ revision: plan.revision, ids: plan.ids }));
|
||||
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||
} catch (_error) {
|
||||
// A storage quota failure must not prevent the current tab from using server truth.
|
||||
}
|
||||
onRemoteIds?.(plan.ids);
|
||||
if (broadcast) channel?.postMessage({ revision: plan.revision, ids: plan.ids });
|
||||
onRemotePlan?.(plan);
|
||||
if (broadcast) channel?.postMessage(snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -122,7 +131,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
? record.operation.base_revision : Math.max(0, savedRevision()),
|
||||
}))
|
||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
@@ -173,6 +182,47 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
return saved;
|
||||
}
|
||||
|
||||
function enqueueConfiguration(capacityMinutes, estimates) {
|
||||
const operation = {
|
||||
operation_id: operationId(), action: 'configure', item_id: 'plan', direction: null,
|
||||
capacity_minutes: capacityMinutes, estimates: estimates || {},
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
coordinator?.notify('today');
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueRollover(proposed) {
|
||||
if (!proposed || proposed.action !== 'rollover') return false;
|
||||
const operation = {
|
||||
...proposed, operation_id: operationId(), base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
coordinator?.notify('today');
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function migrate(ids) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
@@ -253,7 +303,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, migrate, flush, pending, startLifecycle };
|
||||
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
@@ -0,0 +1,374 @@
|
||||
function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
const key = () => {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
|
||||
};
|
||||
const empty = () => ({
|
||||
version:1, active_identity:'', entries:{}, away_at:null,
|
||||
pending_interruption:null, attention_interruption:null,
|
||||
});
|
||||
const read = () => {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey || !storage) return empty();
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(ownerKey) || 'null');
|
||||
if (value?.version !== 1 || typeof value.active_identity !== 'string' ||
|
||||
!value.entries || typeof value.entries !== 'object') return empty();
|
||||
return value;
|
||||
} catch (_error) {
|
||||
return empty();
|
||||
}
|
||||
};
|
||||
const write = state => {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey || !storage) return false;
|
||||
try {
|
||||
storage.setItem(ownerKey, JSON.stringify(state));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const validPending = state => {
|
||||
const pending = state.pending_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
Number.isFinite(pending.away_ms) && pending.away_ms >= 0 ?
|
||||
{ identity:pending.identity, away_ms:pending.away_ms } : null;
|
||||
};
|
||||
const validAttention = state => {
|
||||
const pending = state.attention_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
typeof pending.resume === 'boolean' ?
|
||||
{ identity:pending.identity, resume:pending.resume } : null;
|
||||
};
|
||||
const settle = (state, at = now()) => {
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry?.running) return state;
|
||||
entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) || 0) +
|
||||
Math.max(0, at - Number(entry.started_at ?? at));
|
||||
entry.started_at = null;
|
||||
entry.running = false;
|
||||
return state;
|
||||
};
|
||||
const snapshot = (identity = '') => {
|
||||
const state = read();
|
||||
const selected = identity || state.active_identity;
|
||||
const entry = state.entries[selected];
|
||||
if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false };
|
||||
const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
|
||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0);
|
||||
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
|
||||
};
|
||||
return {
|
||||
activate(identity) {
|
||||
if (!key() || typeof identity !== 'string' || !identity) return false;
|
||||
const state = read();
|
||||
if (state.active_identity === identity && state.entries[identity]?.running) return true;
|
||||
settle(state);
|
||||
state.active_identity = identity;
|
||||
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
state.entries[identity] = entry;
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
return write(state);
|
||||
},
|
||||
pause() {
|
||||
const state = read();
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
return write(state);
|
||||
},
|
||||
resume() {
|
||||
const state = read();
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry) return false;
|
||||
if (!entry.running) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
return write(state);
|
||||
},
|
||||
stop() {
|
||||
const state = read();
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
return write(state);
|
||||
},
|
||||
beginAttention() {
|
||||
const state = read();
|
||||
const existing = validAttention(state);
|
||||
if (existing) return existing;
|
||||
const identity = state.active_identity;
|
||||
const entry = state.entries[identity];
|
||||
if (!identity || !entry) return null;
|
||||
const resume = Boolean(entry.running);
|
||||
if (resume) settle(state);
|
||||
state.away_at = null;
|
||||
state.attention_interruption = { identity, resume };
|
||||
return write(state) ? { ...state.attention_interruption } : null;
|
||||
},
|
||||
attentionInterruption() {
|
||||
return validAttention(read());
|
||||
},
|
||||
returnFromAttention() {
|
||||
const state = read();
|
||||
const pending = validAttention(state);
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry) return null;
|
||||
state.active_identity = pending.identity;
|
||||
if (pending.resume && !entry.running) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
state.attention_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed:pending.resume } : null;
|
||||
},
|
||||
markAway() {
|
||||
const state = read();
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry?.running || validPending(state)) return false;
|
||||
if (Number.isFinite(state.away_at) && state.away_at >= 0) return true;
|
||||
state.away_at = now();
|
||||
return write(state);
|
||||
},
|
||||
reconcileInterruption(thresholdMs = 5 * 60 * 1000) {
|
||||
const state = read();
|
||||
const existing = validPending(state);
|
||||
if (existing) return existing;
|
||||
state.pending_interruption = null;
|
||||
const entry = state.entries[state.active_identity];
|
||||
const awayAt = Number(state.away_at);
|
||||
const detectedAt = now();
|
||||
state.away_at = null;
|
||||
if (!entry?.running || !Number.isFinite(awayAt) || awayAt < 0 || detectedAt < awayAt ||
|
||||
detectedAt - awayAt < thresholdMs) {
|
||||
write(state);
|
||||
return null;
|
||||
}
|
||||
const awayMs = detectedAt - awayAt;
|
||||
settle(state, detectedAt);
|
||||
state.pending_interruption = { identity:state.active_identity, away_ms:awayMs };
|
||||
write(state);
|
||||
return { ...state.pending_interruption };
|
||||
},
|
||||
pendingInterruption() {
|
||||
return validPending(read());
|
||||
},
|
||||
resolveInterruption(decision) {
|
||||
if (decision !== 'count' && decision !== 'exclude') return false;
|
||||
const state = read();
|
||||
const pending = state.pending_interruption;
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!entry || !Number.isFinite(pending.away_ms) || pending.away_ms < 0) return false;
|
||||
if (decision === 'exclude') {
|
||||
entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) - pending.away_ms);
|
||||
}
|
||||
state.active_identity = pending.identity;
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
state.pending_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state);
|
||||
},
|
||||
recapEntries() {
|
||||
const state = read();
|
||||
settle(state);
|
||||
write(state);
|
||||
return Object.entries(state.entries).map(([identity, entry]) => ({
|
||||
identity,
|
||||
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0),
|
||||
})).filter(entry => entry.elapsed_ms > 0);
|
||||
},
|
||||
clearRecap() {
|
||||
return write(empty());
|
||||
},
|
||||
totalElapsed() {
|
||||
const state = read();
|
||||
return Object.entries(state.entries).reduce((total, [identity, entry]) => {
|
||||
const live = identity === state.active_identity && entry.running ?
|
||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0;
|
||||
return total + Math.max(0, Number(entry.elapsed_ms) || 0) + live;
|
||||
}, 0);
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) {
|
||||
let progress = null;
|
||||
let runway = null;
|
||||
queryAll('[data-mobile-today-open]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const identity = timer.snapshot().identity;
|
||||
if (identity) onReopen?.(identity);
|
||||
})
|
||||
);
|
||||
queryAll('[data-mobile-today-toggle]').forEach(button =>
|
||||
button.addEventListener('click', () => view.toggle())
|
||||
);
|
||||
queryAll('[data-mobile-today-complete]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const identity = timer.snapshot().identity;
|
||||
if (identity) onComplete?.(identity);
|
||||
})
|
||||
);
|
||||
if (typeof document !== 'undefined') queryAll('.work-session-nav').forEach(nav => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.hidden = true;
|
||||
button.dataset.workSessionAdjustPlan = '';
|
||||
button.textContent = 'Adjust remaining plan';
|
||||
nav.insertBefore(button, nav.querySelector('[data-work-session-complete]'));
|
||||
});
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
const plan = queryAll('#plan-today')[0];
|
||||
const sheet = queryAll('#plan-today-sheet')[0];
|
||||
if (plan && sheet) {
|
||||
const replan = createTodayBudgetReplan({ timer, openPlan:() => plan.click() });
|
||||
queryAll('[data-work-session-adjust-plan]').forEach(button =>
|
||||
button.addEventListener('click', () => replan.open())
|
||||
);
|
||||
new MutationObserver(() => {
|
||||
if (sheet.hidden && replan.restore()) render();
|
||||
}).observe(sheet, { attributes:true, attributeFilter:['hidden'] });
|
||||
}
|
||||
}
|
||||
const elapsed = milliseconds => {
|
||||
const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000));
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainder = String(seconds % 60).padStart(2, '0');
|
||||
return hours ? hours + ':' + String(minutes).padStart(2, '0') + ':' + remainder : minutes + ':' + remainder;
|
||||
};
|
||||
const render = () => {
|
||||
const snapshot = timer.snapshot();
|
||||
const active = Boolean(progress && isActive() && snapshot.identity);
|
||||
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
|
||||
queryAll('[data-mobile-today-open]').forEach(element => {
|
||||
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
|
||||
});
|
||||
queryAll('[data-mobile-today-complete]').forEach(button => {
|
||||
const itemTitle = String(getItem?.(snapshot.identity)?.title || 'current Today item');
|
||||
const finalItem = Boolean(progress && progress.index >= progress.total);
|
||||
button.hidden = !active;
|
||||
button.textContent = finalItem ? 'Done & recap' : 'Done & next';
|
||||
button.setAttribute('aria-label', finalItem ?
|
||||
'Complete ' + itemTitle + ' and open Today recap' :
|
||||
'Complete ' + itemTitle + ' and open next Today item');
|
||||
});
|
||||
if (typeof document !== 'undefined') document.body.classList.toggle('mobile-today-active', active);
|
||||
if (!progress) return;
|
||||
const sourceRunway = getRunway?.(snapshot) || runway;
|
||||
const liveRunway = sourceRunway?.future_minutes !== undefined ? (() => {
|
||||
const currentElapsed = Math.max(0, Math.ceil(snapshot.elapsed_ms / 60000));
|
||||
const currentRemaining = sourceRunway.current_minutes === null ? null :
|
||||
Math.max(0, sourceRunway.current_minutes - currentElapsed);
|
||||
const remaining = currentRemaining === null || sourceRunway.future_minutes === null ? null :
|
||||
currentRemaining + sourceRunway.future_minutes;
|
||||
const projected = remaining === null ? null : Math.ceil(timer.totalElapsed() / 60000) + remaining;
|
||||
const capacityRemaining = sourceRunway.capacity_minutes === null || projected === null ? null :
|
||||
sourceRunway.capacity_minutes - projected;
|
||||
return {
|
||||
...sourceRunway,
|
||||
remaining_minutes:remaining,
|
||||
over_estimate_minutes:sourceRunway.current_minutes === null ? 0 :
|
||||
Math.max(0, currentElapsed - sourceRunway.current_minutes),
|
||||
over_capacity_minutes:capacityRemaining === null ? 0 : Math.max(0, -capacityRemaining),
|
||||
};
|
||||
})() : sourceRunway;
|
||||
const timing = isActive() && snapshot.identity ? ' · ' + elapsed(snapshot.elapsed_ms) +
|
||||
(liveRunway?.current_minutes ? ' / ' + formatEstimate(liveRunway.current_minutes) : '') : '';
|
||||
const estimateRisk = liveRunway?.over_estimate_minutes ?
|
||||
' · ' + formatEstimate(liveRunway.over_estimate_minutes) + ' over estimate' : '';
|
||||
const capacityRisk = liveRunway?.over_capacity_minutes ?
|
||||
' · Today projected ' + formatEstimate(liveRunway.over_capacity_minutes) + ' over capacity' : '';
|
||||
queryAll('[data-work-session-progress]').forEach(element => {
|
||||
element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing + estimateRisk + capacityRisk +
|
||||
(!estimateRisk && !capacityRisk && liveRunway?.remaining_minutes ?
|
||||
' · ' + formatEstimate(liveRunway.remaining_minutes) + ' remaining' : '');
|
||||
});
|
||||
queryAll('[data-work-session-adjust-plan]').forEach(button => {
|
||||
button.hidden = !(liveRunway?.over_estimate_minutes || liveRunway?.over_capacity_minutes);
|
||||
});
|
||||
queryAll('[data-work-session-timer-toggle]').forEach(button => {
|
||||
button.hidden = !isActive();
|
||||
button.textContent = snapshot.running ? 'Pause timer' : 'Resume timer';
|
||||
button.setAttribute('aria-pressed', String(!snapshot.running));
|
||||
});
|
||||
};
|
||||
return {
|
||||
open(identity, active) {
|
||||
const current = timer.snapshot();
|
||||
if (active && current.identity !== identity) timer.activate(identity);
|
||||
else if (!active) timer.stop();
|
||||
render();
|
||||
},
|
||||
finish() { timer.stop(); progress = null; runway = null; render(); },
|
||||
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
|
||||
reset() { progress = null; runway = null; render(); },
|
||||
toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); },
|
||||
render,
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayBudgetReplan({ timer, openPlan }) {
|
||||
let active = false;
|
||||
let resume = false;
|
||||
return {
|
||||
open() {
|
||||
if (active) return false;
|
||||
const state = timer.snapshot();
|
||||
if (!state.identity) return false;
|
||||
resume = Boolean(state.running);
|
||||
if (resume && timer.pause() === false) return false;
|
||||
active = true;
|
||||
openPlan?.(state);
|
||||
return true;
|
||||
},
|
||||
restore() {
|
||||
if (!active) return false;
|
||||
active = false;
|
||||
const shouldResume = resume;
|
||||
resume = false;
|
||||
return !shouldResume || timer.resume() !== false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel, onResolved }) {
|
||||
const render = pending => {
|
||||
if (!pending) {
|
||||
sheet.hidden = true;
|
||||
return false;
|
||||
}
|
||||
const label = String(getItemLabel?.(pending.identity) || 'Current Today item');
|
||||
const minutes = Math.max(1, Math.round(pending.away_ms / 60000));
|
||||
description.textContent = label + ' · away for ' + minutes + ' minute' + (minutes === 1 ? '' : 's');
|
||||
sheet.hidden = false;
|
||||
return true;
|
||||
};
|
||||
return {
|
||||
background() { return timer.markAway(); },
|
||||
foreground() { return render(timer.reconcileInterruption()); },
|
||||
restore() { return render(timer.pendingInterruption()); },
|
||||
resolve(decision) {
|
||||
if (!timer.resolveInterruption(decision)) return false;
|
||||
sheet.hidden = true;
|
||||
onResolved?.();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayTimer.createView = createTodayTimerView;
|
||||
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
|
||||
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
|
||||
module.exports = createTodayTimer;
|
||||
}
|
||||
+84
-6
@@ -1,5 +1,6 @@
|
||||
function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
const prefix = 'stackchain.today-work.v1.';
|
||||
const planningPrefix = 'stackchain.today-planning.v1.';
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
@@ -9,9 +10,17 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
function ownerKey(keyPrefix) {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
return login ? keyPrefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
return ownerKey(prefix);
|
||||
}
|
||||
|
||||
function planningKey() {
|
||||
return ownerKey(planningPrefix);
|
||||
}
|
||||
|
||||
function read() {
|
||||
@@ -37,6 +46,40 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
}
|
||||
}
|
||||
|
||||
function planning() {
|
||||
const key = planningKey();
|
||||
if (!key || !storage) return { capacity_minutes: null, estimates: {} };
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || 'null');
|
||||
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
|
||||
? value.capacity_minutes : null;
|
||||
const ids = new Set(read());
|
||||
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
|
||||
ids.has(id) && Number.isInteger(minutes) && minutes > 0
|
||||
));
|
||||
return { capacity_minutes: capacity, estimates };
|
||||
} catch (_error) {
|
||||
return { capacity_minutes: null, estimates: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function replacePlanning(value) {
|
||||
const key = planningKey();
|
||||
if (!key || !storage) return false;
|
||||
const ids = new Set(read());
|
||||
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
|
||||
? value.capacity_minutes : null;
|
||||
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
|
||||
ids.has(id) && Number.isInteger(minutes) && minutes > 0
|
||||
));
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ capacity_minutes: capacity, estimates }));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function add(item) {
|
||||
if (!storageKey()) return 'unavailable';
|
||||
const id = identity(item);
|
||||
@@ -48,6 +91,21 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
return write(ids) ? 'added' : 'unavailable';
|
||||
}
|
||||
|
||||
function addMany(items) {
|
||||
if (!storageKey()) return { status: 'unavailable', ids: [] };
|
||||
const current = read();
|
||||
const additions = [];
|
||||
for (const item of items || []) {
|
||||
const id = identity(item);
|
||||
if (!id) return { status: 'unavailable', ids: [] };
|
||||
if (!current.includes(id) && !additions.includes(id)) additions.push(id);
|
||||
}
|
||||
if (!additions.length) return { status: 'exists', ids: [] };
|
||||
if (current.length + additions.length > limit) return { status: 'full', ids: [] };
|
||||
return write(current.concat(additions)) ?
|
||||
{ status: 'added', ids: additions } : { status: 'unavailable', ids: [] };
|
||||
}
|
||||
|
||||
function replace(ids) {
|
||||
const unique = [];
|
||||
for (const id of ids || []) {
|
||||
@@ -55,14 +113,18 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
unique.push(id);
|
||||
}
|
||||
}
|
||||
return write(unique);
|
||||
const saved = write(unique);
|
||||
if (saved && planningKey()) replacePlanning(planning());
|
||||
return saved;
|
||||
}
|
||||
|
||||
function remove(item) {
|
||||
const id = identity(item);
|
||||
const ids = read();
|
||||
const next = ids.filter(candidate => candidate !== id);
|
||||
return next.length !== ids.length && write(next);
|
||||
const saved = next.length !== ids.length && write(next);
|
||||
if (saved) replacePlanning(planning());
|
||||
return saved;
|
||||
}
|
||||
|
||||
function move(item, direction) {
|
||||
@@ -81,7 +143,10 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
const retired = ids.filter(id => !retained.includes(id));
|
||||
if (retired.length) {
|
||||
const reported = onPrune?.(retired);
|
||||
if (reported !== false) write(retained);
|
||||
if (reported !== false) {
|
||||
write(retained);
|
||||
replacePlanning(planning());
|
||||
}
|
||||
}
|
||||
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
|
||||
}
|
||||
@@ -99,7 +164,20 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, add, remove, move, reconcile, contains, position, limit };
|
||||
function runway(items, currentIndex = 0) {
|
||||
const plan = planning();
|
||||
const minutes = (items || []).slice(Math.max(0, currentIndex)).map(item => plan.estimates[identity(item)] || null);
|
||||
const total = values => values.some(value => value === null) ? null :
|
||||
values.reduce((sum, value) => sum + value, 0);
|
||||
return {
|
||||
current_minutes: minutes[0] || null,
|
||||
remaining_minutes: total(minutes),
|
||||
future_minutes: total(minutes.slice(1)),
|
||||
capacity_minutes: plan.capacity_minutes,
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
@@ -1,5 +1,6 @@
|
||||
function createUnfiledCaptures({
|
||||
storage,
|
||||
attachmentStore = null,
|
||||
getCaptureLogin = () => '',
|
||||
getCurrentLogin = () => '',
|
||||
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
@@ -29,24 +30,101 @@ function createUnfiledCaptures({
|
||||
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
function capacity() {
|
||||
const items = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
|
||||
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
|
||||
}
|
||||
|
||||
function prepare(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
if (!title) throw new Error('Add a title before saving.');
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
|
||||
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
|
||||
write(items);
|
||||
return item;
|
||||
const attachment = note?.attachment;
|
||||
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) {
|
||||
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
||||
}
|
||||
return {title, body, ownerLogin, attachment, hasAttachment};
|
||||
}
|
||||
|
||||
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} : {}),
|
||||
};
|
||||
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
|
||||
const writeItems = () => {
|
||||
try { write(items); }
|
||||
catch (error) {
|
||||
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return item;
|
||||
};
|
||||
const stage = prepared.hasAttachment
|
||||
? Promise.resolve(attachmentStore.put(item.id, {
|
||||
filename:String(prepared.attachment.filename).slice(0, 255),
|
||||
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
|
||||
}))
|
||||
: null;
|
||||
if (!stage && !removed?.hasAttachment) return writeItems();
|
||||
return Promise.resolve(stage).then(async () => {
|
||||
if (!removed?.hasAttachment) return writeItems();
|
||||
const removedAttachment = await attachmentStore.get(removed.id);
|
||||
try {
|
||||
await attachmentStore.delete(removed.id);
|
||||
} catch (error) {
|
||||
if (prepared.hasAttachment) await Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
try { return writeItems(); }
|
||||
catch (error) {
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore.put(removed.id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
const prepared = prepare(note);
|
||||
const existing = read();
|
||||
if (existing.length >= maxItems) throw new Error('Drafts full — nothing was deleted.');
|
||||
return persist(prepared, existing);
|
||||
}
|
||||
|
||||
function replaceOldest(note, expectedOldestId) {
|
||||
const prepared = prepare(note);
|
||||
const existing = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
|
||||
const oldest = existing.at(-1);
|
||||
if (existing.length < maxItems || !oldest || oldest.id !== expectedOldestId) {
|
||||
throw new Error('Drafts changed. Review them before replacing anything.');
|
||||
}
|
||||
return persist(prepared, existing.filter(item => item.id !== oldest.id), oldest);
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
const remaining = items.filter(item => item.id !== id);
|
||||
if (remaining.length === items.length) return false;
|
||||
write(remaining);
|
||||
return true;
|
||||
const removed = items.find(item => item.id === id);
|
||||
if (!removed?.hasAttachment) {
|
||||
write(remaining);
|
||||
return true;
|
||||
}
|
||||
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
|
||||
Promise.resolve(attachmentStore?.delete(id)).then(async () => {
|
||||
try { write(remaining); }
|
||||
catch (error) {
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function resume(id, confirmedLogin) {
|
||||
@@ -55,11 +133,20 @@ function createUnfiledCaptures({
|
||||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||
throw new Error('Reconnect with the account that saved this capture.');
|
||||
}
|
||||
discard(id);
|
||||
return {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
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 (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
|
||||
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
return {...draft, attachment};
|
||||
});
|
||||
}
|
||||
|
||||
return {list, save, discard, resume};
|
||||
function completeResume(id) { return discard(id); }
|
||||
|
||||
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
@@ -0,0 +1,87 @@
|
||||
function createUpdateFollowUp({ storage = null, getLogin = () => '' } = {}) {
|
||||
const sourceKey = 'stackchain.update-follow-up-source.v1';
|
||||
const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
|
||||
const safeRepository = value => {
|
||||
const candidate = String(value || '');
|
||||
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(candidate) &&
|
||||
candidate.split('/').every(part => part !== '.' && part !== '..') ? candidate : '';
|
||||
};
|
||||
const safeUrl = value => {
|
||||
try {
|
||||
const parsed = new URL(String(value || ''));
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : '';
|
||||
} catch (_error) { return ''; }
|
||||
};
|
||||
|
||||
function draft(detail = {}) {
|
||||
const source = safeUrl(detail.url || detail.latest_comment?.url);
|
||||
const context = clean(detail.latest_comment?.body || detail.subject_body, 9000);
|
||||
const title = clean(detail.title, 228);
|
||||
const sections = [];
|
||||
if (source) sections.push('Source: ' + source);
|
||||
if (context) sections.push('Latest context:\n> ' + context.replace(/\n/g, '\n> '));
|
||||
return {
|
||||
repository: safeRepository(detail.repository),
|
||||
title: ('Follow up: ' + (title || 'Unread update')).slice(0, 240),
|
||||
body: sections.join('\n\n').slice(0, 9500),
|
||||
};
|
||||
}
|
||||
|
||||
function readSource() {
|
||||
const login = String(getLogin() || '').trim();
|
||||
if (!login) return null;
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(sourceKey) || 'null');
|
||||
return record?.version === 1 && record.ownerLogin === login &&
|
||||
Number.isInteger(record.source?.notification_id) && record.source.notification_id > 0 ? record : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function writeSource(record) {
|
||||
storage?.setItem(sourceKey, JSON.stringify(record));
|
||||
}
|
||||
|
||||
function stageSource(item = {}) {
|
||||
const ownerLogin = String(getLogin() || '').trim();
|
||||
const notificationId = Number(item.notification_id);
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before continuing this follow-up.');
|
||||
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid source update.');
|
||||
const source = {
|
||||
notification_id: notificationId,
|
||||
repository: safeRepository(item.repository),
|
||||
number: Number.isInteger(item.number) && item.number > 0 ? item.number : null,
|
||||
title: clean(item.title, 240), kind: 'update', has_update: true,
|
||||
};
|
||||
writeSource({ version:1, ownerLogin, source, admission:null });
|
||||
return { ...source };
|
||||
}
|
||||
|
||||
function source() {
|
||||
const record = readSource();
|
||||
return record ? { ...record.source } : null;
|
||||
}
|
||||
|
||||
function discardSource() {
|
||||
if (!readSource()) return false;
|
||||
storage?.removeItem(sourceKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function complete({ admit, queueRead, advance }) {
|
||||
const record = readSource();
|
||||
if (!record) throw new Error('Reopen the source update before continuing.');
|
||||
let admission = record.admission;
|
||||
if (!admission) {
|
||||
admission = await admit();
|
||||
writeSource({ ...record, admission });
|
||||
}
|
||||
await queueRead(record.source.notification_id);
|
||||
const advanced = await advance({ ...record.source });
|
||||
storage?.removeItem(sourceKey);
|
||||
return { admission, advanced:Boolean(advanced) };
|
||||
}
|
||||
|
||||
return { draft, stageSource, source, discardSource, complete };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp;
|
||||
@@ -1,4 +1,6 @@
|
||||
function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
function createUpdateOwnership({
|
||||
claim, addToday, available, start, recover, onClaimed, onState, onStartState,
|
||||
}) {
|
||||
let detail = null;
|
||||
let update = null;
|
||||
let claimedItem = null;
|
||||
@@ -8,6 +10,36 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
onState?.(value);
|
||||
}
|
||||
|
||||
function startState(value) {
|
||||
onStartState?.(value);
|
||||
}
|
||||
|
||||
function claimed(result) {
|
||||
claimedItem = {
|
||||
...result,
|
||||
repository: detail.repository,
|
||||
kind: 'issue',
|
||||
notification_id: update?.notification_id,
|
||||
updated_at: update?.updated_at || result.updated_at,
|
||||
has_update: true,
|
||||
};
|
||||
detail.issue.claimable = false;
|
||||
onClaimed?.(claimedItem);
|
||||
startState({ action: 'hidden', busy: false, message: '' });
|
||||
return claimedItem;
|
||||
}
|
||||
|
||||
function recoverStartedItem(item) {
|
||||
recover?.(item);
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.',
|
||||
});
|
||||
state({ action: 'today', busy: false, message: '' });
|
||||
return 'recovery';
|
||||
}
|
||||
|
||||
function open(nextDetail, nextUpdate) {
|
||||
detail = nextDetail;
|
||||
update = nextUpdate;
|
||||
@@ -18,6 +50,11 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
busy: false,
|
||||
message: '',
|
||||
});
|
||||
startState({
|
||||
action: nextDetail?.issue?.claimable && available && start ? 'start' : 'hidden',
|
||||
busy: false,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
|
||||
function act() {
|
||||
@@ -40,21 +77,13 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
repository: detail.repository,
|
||||
number: detail.issue.number,
|
||||
})).then(result => {
|
||||
claimedItem = {
|
||||
...result,
|
||||
repository: detail.repository,
|
||||
kind: 'issue',
|
||||
notification_id: update?.notification_id,
|
||||
updated_at: update?.updated_at || result.updated_at,
|
||||
has_update: true,
|
||||
};
|
||||
detail.issue.claimable = false;
|
||||
onClaimed?.(claimedItem);
|
||||
claimed(result);
|
||||
state({ action: 'today', busy: false, message: 'Assigned to you. The update is still unread.' });
|
||||
return claimedItem;
|
||||
}).catch(error => {
|
||||
if (error?.status === 409) {
|
||||
detail.issue.claimable = false;
|
||||
startState({ action: 'hidden', busy: false, message: '' });
|
||||
state({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
@@ -72,7 +101,58 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
return request;
|
||||
}
|
||||
|
||||
return { open, act };
|
||||
function startNow() {
|
||||
if (request) return request;
|
||||
if (!detail?.issue?.claimable || !available || !start) return Promise.resolve('hidden');
|
||||
if (!available()) {
|
||||
const message = 'Today is limited to 5 items. Remove one before taking ownership.';
|
||||
startState({ action: 'start', busy: false, message });
|
||||
return Promise.resolve('full');
|
||||
}
|
||||
startState({ action: 'start', busy: true, message: 'Assigning and starting…' });
|
||||
state({ action: 'claim', busy: true, message: '' });
|
||||
request = Promise.resolve(claim({
|
||||
repository: detail.repository,
|
||||
number: detail.issue.number,
|
||||
})).then(result => {
|
||||
const item = claimed(result);
|
||||
const outcome = start(item);
|
||||
if (outcome === 'started') {
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Assigned, added to Today, and ready to work. The update is still unread.',
|
||||
});
|
||||
state({ action: 'today', busy: false, message: '' });
|
||||
return outcome;
|
||||
}
|
||||
return recoverStartedItem(item);
|
||||
}).catch(error => {
|
||||
if (claimedItem) {
|
||||
return recoverStartedItem(claimedItem);
|
||||
}
|
||||
if (error?.status === 409) {
|
||||
detail.issue.claimable = false;
|
||||
state({ action: 'hidden', busy: false, message: '' });
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Someone else claimed or closed this issue. The update is still unread.',
|
||||
});
|
||||
return 'conflict';
|
||||
}
|
||||
state({ action: 'claim', busy: false, message: '' });
|
||||
startState({
|
||||
action: 'start',
|
||||
busy: false,
|
||||
message: (error?.message || 'The issue could not be assigned.') + ' The update and your reply draft are safe; retry.',
|
||||
});
|
||||
return 'retry';
|
||||
}).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { open, act, start:startNow };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateOwnership;
|
||||
@@ -0,0 +1,64 @@
|
||||
function createUpdateReplyReadNext({
|
||||
post, markRead, queue, canQueue, deliver, accept = () => undefined, next = () => undefined,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
|
||||
async function admit(item, body, operationId, replyConfirmed) {
|
||||
const admission = await queue({
|
||||
kind: 'update-reply-read',
|
||||
notificationId: item.notification_id,
|
||||
body,
|
||||
operationId,
|
||||
replyConfirmed,
|
||||
});
|
||||
if (!admission || (!admission.item && admission.durable !== true)) {
|
||||
throw new Error('Reply and acknowledgement were not saved for delivery.');
|
||||
}
|
||||
accept(item);
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: admission.background ? 'queued' : 'saved',
|
||||
next: await next(item),
|
||||
};
|
||||
}
|
||||
|
||||
function submit(item, body, operationId, attachment = null) {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
let replyConfirmed = false;
|
||||
try {
|
||||
if (attachment) {
|
||||
const admission = await queue({
|
||||
kind:'update-reply-read', notificationId:item.notification_id, body,
|
||||
operationId, replyConfirmed:false, attachment,
|
||||
});
|
||||
if (!admission?.item) throw new Error('Reply and screenshot were not saved for delivery.');
|
||||
const outcome = deliver ? await deliver(admission.item) : null;
|
||||
const remaining = outcome?.remaining?.find(candidate => candidate.id === admission.item.id);
|
||||
if (remaining?.status === 'attention') {
|
||||
throw new Error(remaining.error || 'Reply and screenshot need attention.');
|
||||
}
|
||||
accept(item);
|
||||
return { accepted:true, delivery:outcome?.confirmed?.length ? 'posted' : 'queued', next:await next(item) };
|
||||
}
|
||||
try {
|
||||
await post(item, body, operationId);
|
||||
replyConfirmed = true;
|
||||
await markRead(item.notification_id);
|
||||
} catch (error) {
|
||||
if (!canQueue(error)) throw error;
|
||||
return await admit(item, body, operationId, replyConfirmed);
|
||||
}
|
||||
accept(item);
|
||||
return { accepted: true, delivery: 'posted', next: await next(item) };
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
return { submit, busy: () => Boolean(inFlight) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateReplyReadNext;
|
||||
+1
-14
@@ -5,19 +5,6 @@ function renderRepoMix(element, data) {
|
||||
element.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + pullRequests.length + '</div></div>';
|
||||
}
|
||||
|
||||
async function updateRepoMix(element, fetchContext = fetch) {
|
||||
try {
|
||||
const response = await fetchContext('api/v1/context', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
renderRepoMix(element, await response.json());
|
||||
} catch (error) {
|
||||
element.innerHTML = '<div class="muted">Widget unavailable.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
updateRepoMix.renderRepoMix = renderRepoMix;
|
||||
module.exports = updateRepoMix;
|
||||
module.exports = renderRepoMix;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'attention', 'update', 'later', 'draft'];
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
@@ -15,6 +16,10 @@
|
||||
function parse(fragment) {
|
||||
const parts = String(fragment || '').split('/');
|
||||
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
|
||||
const queue = parts[2]?.replace(/s$/, '');
|
||||
if (queueFilters.includes(queue) && parts.length === 3) {
|
||||
return { kind: 'queue', filter: queue };
|
||||
}
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
@@ -38,6 +43,7 @@
|
||||
}
|
||||
|
||||
function sameRoute(item, route) {
|
||||
if (route.kind === 'queue') return false;
|
||||
if (route.kind === 'update') {
|
||||
return Number(item.notification_id) === route.notification_id;
|
||||
}
|
||||
@@ -48,6 +54,7 @@
|
||||
|
||||
function createController({
|
||||
location, history, eventTarget, onOpen, onClose, onInvalid,
|
||||
onQueue = function () {},
|
||||
resolve, onResolving = function () {}, onError = function () {},
|
||||
}) {
|
||||
let items = [];
|
||||
@@ -100,6 +107,16 @@
|
||||
active = '';
|
||||
return;
|
||||
}
|
||||
if (route.kind === 'queue') {
|
||||
if (!ready) return;
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
if (active && active !== fragment) onClose();
|
||||
if (active === fragment) return;
|
||||
active = fragment;
|
||||
onQueue(route.filter);
|
||||
return;
|
||||
}
|
||||
const item = items.find(candidate => sameRoute(candidate, route));
|
||||
if (!item) {
|
||||
if (ready) resolveMissing(fragment, route);
|
||||
@@ -134,6 +151,15 @@
|
||||
onOpen(item);
|
||||
return true;
|
||||
},
|
||||
queue(filter) {
|
||||
if (!queueFilters.includes(filter)) return false;
|
||||
const name = filter + (['update', 'draft'].includes(filter) ? 's' : '');
|
||||
const fragment = '#/my-work/' + name;
|
||||
if (location.hash !== fragment) history.pushState(null, '', fragment);
|
||||
active = fragment;
|
||||
onQueue(filter);
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (parse(location.hash)) history.back();
|
||||
else {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
let active = false;
|
||||
const selected = new Set();
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function 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 select(item) {
|
||||
if (!active) return 'inactive';
|
||||
const id = identity(item);
|
||||
if (!id) return 'invalid';
|
||||
if (selected.has(id)) return 'selected';
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.add(id);
|
||||
changed();
|
||||
return 'selected';
|
||||
}
|
||||
|
||||
function toggle(item) {
|
||||
if (!active) return 'inactive';
|
||||
const id = identity(item);
|
||||
if (!id) return 'invalid';
|
||||
if (selected.has(id)) {
|
||||
selected.delete(id);
|
||||
changed();
|
||||
return 'removed';
|
||||
}
|
||||
return select(item);
|
||||
}
|
||||
|
||||
function selectMany(items) {
|
||||
if (!active) return { status: 'inactive', added: 0, count: selected.size, limit: maximum };
|
||||
let added = 0;
|
||||
let capped = false;
|
||||
for (const item of items || []) {
|
||||
const id = identity(item);
|
||||
if (!id || selected.has(id)) continue;
|
||||
if (selected.size >= maximum) {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
selected.add(id);
|
||||
added += 1;
|
||||
}
|
||||
changed();
|
||||
return { status: capped ? 'limit' : 'selected', added, count: selected.size, limit: maximum };
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!active) return snapshot();
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function retain(items) {
|
||||
const retained = new Set((items || []).map(identity).filter(Boolean));
|
||||
Array.from(selected).forEach(id => { if (!retained.has(id)) selected.delete(id); });
|
||||
return changed();
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { active, count: selected.size, ids: Array.from(selected) };
|
||||
}
|
||||
|
||||
return { identity, start, cancel, select, selectMany, clear, toggle, retain, snapshot, limit: maximum };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createWorkSelection;
|
||||
@@ -1,5 +1,9 @@
|
||||
fastapi==0.133.1
|
||||
httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
python-multipart==0.0.22
|
||||
pytest==9.1.1
|
||||
pywebpush==2.1.2
|
||||
rjsmin==1.2.5
|
||||
uvicorn==0.41.0
|
||||
webauthn==3.0.0
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Worker-shared Find Work catalog state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class RefreshLeaseLost(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AvailableIssueSnapshotState:
|
||||
items: list[dict] | None
|
||||
created_at: float | None
|
||||
retry_at: float | None
|
||||
refreshing: bool
|
||||
lease_expires_at: float | None
|
||||
|
||||
|
||||
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(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS available_issue_snapshot (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
items_json TEXT,
|
||||
created_at REAL,
|
||||
retry_at REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS available_issue_refresh_lease (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
owner TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS available_issue_claim (
|
||||
repository TEXT NOT NULL,
|
||||
number INTEGER NOT NULL,
|
||||
PRIMARY KEY (repository, number)
|
||||
);
|
||||
"""
|
||||
)
|
||||
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)
|
||||
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA busy_timeout = 1000")
|
||||
return connection
|
||||
|
||||
def try_acquire_refresh(self, *, lease_seconds: float) -> str | None:
|
||||
if lease_seconds <= 0:
|
||||
raise ValueError("refresh lease duration must be positive")
|
||||
now = self.clock()
|
||||
owner = secrets.token_hex(16)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
active = connection.execute(
|
||||
"SELECT expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if active is not None and active["expires_at"] > now:
|
||||
connection.rollback()
|
||||
return None
|
||||
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
|
||||
connection.execute(
|
||||
"INSERT INTO available_issue_refresh_lease VALUES (1, ?, ?)",
|
||||
(owner, now + lease_seconds),
|
||||
)
|
||||
connection.commit()
|
||||
return owner
|
||||
|
||||
def load(self) -> AvailableIssueSnapshotState:
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM available_issue_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
lease = connection.execute(
|
||||
"SELECT expires_at FROM available_issue_refresh_lease "
|
||||
"WHERE singleton = 1 AND expires_at > ?", (now,)
|
||||
).fetchone()
|
||||
return AvailableIssueSnapshotState(
|
||||
items=json.loads(row["items_json"]) if row["items_json"] else None,
|
||||
created_at=row["created_at"],
|
||||
retry_at=row["retry_at"],
|
||||
refreshing=lease is not None,
|
||||
lease_expires_at=lease["expires_at"] if lease else None,
|
||||
)
|
||||
|
||||
def publish(self, owner: str | None, *, items: list[dict]) -> AvailableIssueSnapshotState:
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
lease = connection.execute(
|
||||
"SELECT owner, expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if (
|
||||
owner is None
|
||||
or lease is None
|
||||
or lease["owner"] != owner
|
||||
or lease["expires_at"] <= now
|
||||
):
|
||||
connection.rollback()
|
||||
raise RefreshLeaseLost("available issue refresh lease expired or changed owner")
|
||||
claimed = {
|
||||
(row["repository"], row["number"])
|
||||
for row in connection.execute(
|
||||
"SELECT repository, number FROM available_issue_claim"
|
||||
)
|
||||
}
|
||||
items = [
|
||||
item for item in items
|
||||
if (item.get("repository"), item.get("number")) not in claimed
|
||||
]
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, "
|
||||
"retry_at = NULL WHERE singleton = 1",
|
||||
(json.dumps(items, separators=(",", ":")), now),
|
||||
)
|
||||
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
def remove_claimed(self, repository: str, number: int) -> AvailableIssueSnapshotState:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO available_issue_claim VALUES (?, ?)",
|
||||
(repository, number),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
items = json.loads(row["items_json"]) if row["items_json"] else None
|
||||
if items is not None:
|
||||
items = [
|
||||
item for item in items
|
||||
if item.get("repository") != repository or item.get("number") != number
|
||||
]
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = ? WHERE singleton = 1",
|
||||
(json.dumps(items, separators=(",", ":")),),
|
||||
)
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
def invalidate(self) -> AvailableIssueSnapshotState:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = NULL, "
|
||||
"created_at = NULL, retry_at = NULL WHERE singleton = 1"
|
||||
)
|
||||
connection.execute("DELETE FROM available_issue_claim")
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
def release_refresh(self, owner: str | None) -> None:
|
||||
if owner is None:
|
||||
return
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM available_issue_refresh_lease "
|
||||
"WHERE singleton = 1 AND owner = ?",
|
||||
(owner,),
|
||||
)
|
||||
|
||||
def fail_refresh(self, owner: str | None, *, retry_at: float) -> AvailableIssueSnapshotState:
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
lease = connection.execute(
|
||||
"SELECT owner, expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if (
|
||||
owner is None
|
||||
or lease is None
|
||||
or lease["owner"] != owner
|
||||
or lease["expires_at"] <= now
|
||||
):
|
||||
connection.rollback()
|
||||
raise RefreshLeaseLost("available issue refresh lease expired or changed owner")
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET retry_at = ? WHERE singleton = 1",
|
||||
(retry_at,),
|
||||
)
|
||||
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
|
||||
connection.commit()
|
||||
return self.load()
|
||||
+4
-2
@@ -25,7 +25,7 @@ def _quality(accept_encoding: str, coding: str) -> float:
|
||||
class NegotiatedGZipMiddleware:
|
||||
"""Compress substantial responses only when the client permits gzip."""
|
||||
|
||||
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 9):
|
||||
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 6):
|
||||
self.app = app
|
||||
self.minimum_size = minimum_size
|
||||
self.compresslevel = compresslevel
|
||||
@@ -36,7 +36,9 @@ class NegotiatedGZipMiddleware:
|
||||
return
|
||||
|
||||
accepted = Headers(scope=scope).get("Accept-Encoding", "")
|
||||
if _quality(accepted, "gzip") > 0:
|
||||
path = scope.get("path", "").rsplit("/", 1)[-1]
|
||||
immutable_runtime = path.startswith("runtime-") and path.endswith(".js")
|
||||
if _quality(accepted, "gzip") > 0 and not immutable_runtime:
|
||||
responder: ASGIApp = GZipResponder(
|
||||
self.app, self.minimum_size, compresslevel=self.compresslevel
|
||||
)
|
||||
|
||||
+92
-6
@@ -10,6 +10,7 @@ import os
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
@@ -18,6 +19,7 @@ from src.session_store import SessionStore, SessionStoreError
|
||||
SESSION_COOKIE = "stackchain_session"
|
||||
CSRF_COOKIE = "stackchain_csrf"
|
||||
DEFAULT_TTL_SECONDS = 8 * 60 * 60
|
||||
DEFAULT_IDLE_TIMEOUT_SECONDS = 15 * 60
|
||||
STEP_UP_TTL_SECONDS = 90
|
||||
MIN_SECRET_LENGTH = 24
|
||||
OPERATOR_MODE = "operator"
|
||||
@@ -29,6 +31,7 @@ class Session:
|
||||
session_id: str
|
||||
csrf: str
|
||||
expires_at: int
|
||||
idle_expires_at: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -45,6 +48,33 @@ def mode() -> str:
|
||||
return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower()
|
||||
|
||||
|
||||
def public_origin() -> str | None:
|
||||
"""Return the canonical HTTPS browser origin, or None when it is invalid."""
|
||||
configured = os.getenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "").strip()
|
||||
if not configured:
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(configured)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.path not in {"", "/"}
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
return None
|
||||
hostname = parsed.hostname.lower()
|
||||
authority = f"[{hostname}]" if ":" in hostname else hostname
|
||||
if port is not None and port != 443:
|
||||
authority += f":{port}"
|
||||
return f"https://{authority}"
|
||||
|
||||
|
||||
def configuration_error() -> str | None:
|
||||
configured_mode = mode()
|
||||
if configured_mode == INSECURE_LOCAL_MODE:
|
||||
@@ -57,6 +87,8 @@ def configuration_error() -> str | None:
|
||||
return "operator mode requires two sufficiently long secrets"
|
||||
if hmac.compare_digest(operator_secret, signing_secret):
|
||||
return "operator and session secrets must be independent"
|
||||
if public_origin() is None:
|
||||
return "operator mode requires a canonical HTTPS public origin"
|
||||
return None
|
||||
|
||||
|
||||
@@ -96,8 +128,23 @@ def _session_store(now: int | None = None) -> SessionStore:
|
||||
return SessionStore(database, clock=current)
|
||||
|
||||
|
||||
def idle_timeout_seconds() -> int:
|
||||
return max(
|
||||
1,
|
||||
int(
|
||||
os.getenv(
|
||||
"STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS",
|
||||
str(DEFAULT_IDLE_TIMEOUT_SECONDS),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def issue_session(
|
||||
now: int | None = None, *, device_label: str = "This device"
|
||||
now: int | None = None,
|
||||
*,
|
||||
device_label: str = "This device",
|
||||
management_id: str | None = None,
|
||||
) -> tuple[str, Session]:
|
||||
issued_at = int(time.time() if now is None else now)
|
||||
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
||||
@@ -114,7 +161,10 @@ def issue_session(
|
||||
encoded = _encode(payload)
|
||||
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||
_session_store(now).activate(
|
||||
session.session_id, session.expires_at, device_label=device_label
|
||||
session.session_id,
|
||||
session.expires_at,
|
||||
device_label=device_label,
|
||||
management_id=management_id,
|
||||
)
|
||||
return f"{encoded}.{signature}", session
|
||||
|
||||
@@ -146,9 +196,23 @@ def verify_session_with_reason(
|
||||
or not session.csrf
|
||||
):
|
||||
return SessionVerification(None)
|
||||
if not _session_store(now).is_active(session.session_id, session.expires_at):
|
||||
status = _session_store(now).status(
|
||||
session.session_id,
|
||||
session.expires_at,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
if status == "idle":
|
||||
return SessionVerification(None, "session_idle")
|
||||
if status != "active":
|
||||
return SessionVerification(None, "session_revoked")
|
||||
return SessionVerification(session)
|
||||
return SessionVerification(
|
||||
Session(
|
||||
session_id=session.session_id,
|
||||
csrf=session.csrf,
|
||||
expires_at=session.expires_at,
|
||||
idle_expires_at=getattr(status, "idle_expires_at", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
||||
@@ -167,6 +231,28 @@ async def active_devices(session: Session):
|
||||
return await asyncio.to_thread(_session_store().list_active, session.session_id)
|
||||
|
||||
|
||||
async def session_management_id(session: Session) -> str:
|
||||
return await asyncio.to_thread(_session_store().management_id, session.session_id)
|
||||
|
||||
|
||||
async def managed_session_active(management_id: str) -> bool:
|
||||
status = await asyncio.to_thread(
|
||||
_session_store().managed_status,
|
||||
management_id,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
return status == "active"
|
||||
|
||||
|
||||
async def touch_session(session: Session) -> bool:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().touch,
|
||||
session.session_id,
|
||||
session.expires_at,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
|
||||
|
||||
async def revoke_managed_session(management_id: str) -> bool:
|
||||
return await asyncio.to_thread(_session_store().revoke_managed, management_id)
|
||||
|
||||
@@ -218,5 +304,5 @@ def application_path(request: Request) -> str:
|
||||
|
||||
def same_origin(request: Request) -> bool:
|
||||
origin = request.headers.get("origin", "")
|
||||
expected = f"{request.url.scheme}://{request.url.netloc}"
|
||||
return bool(origin) and hmac.compare_digest(origin.rstrip("/"), expected)
|
||||
expected = public_origin()
|
||||
return bool(origin and expected) and hmac.compare_digest(origin.rstrip("/"), expected)
|
||||
+128
-15
@@ -2,23 +2,61 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import rjsmin
|
||||
|
||||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||
COMMONJS_EXPORT_LINE = re.compile(
|
||||
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$(?!\n\s*else)", re.MULTILINE
|
||||
)
|
||||
COMMONJS_BROWSER_BRANCH = re.compile(
|
||||
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*\n\s*else\s*\{",
|
||||
re.MULTILINE,
|
||||
)
|
||||
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"),
|
||||
"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/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",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeBundle:
|
||||
runtime_bytes: bytes
|
||||
runtime_gzip_bytes: bytes
|
||||
runtime_digest: str
|
||||
runtime_name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrontendBuild:
|
||||
dashboard_html: str
|
||||
runtime_bytes: bytes
|
||||
runtime_gzip_bytes: bytes
|
||||
runtime_digest: str
|
||||
runtime_name: str
|
||||
shell_digest: str
|
||||
page_sources: tuple[str, ...]
|
||||
feature_bundles: dict[str, RuntimeBundle]
|
||||
service_worker_source: str
|
||||
|
||||
|
||||
@@ -27,7 +65,25 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
||||
for source in sources:
|
||||
path = frontend_dir / source.removeprefix("static/")
|
||||
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
|
||||
return b"".join(chunks)
|
||||
source = b"".join(chunks)
|
||||
# Node-only export shims support source-level unit tests but are unreachable
|
||||
# in the browser. Strip the simple one-line form from shipped bundles.
|
||||
source = COMMONJS_BROWSER_BRANCH.sub(b"{", source)
|
||||
source = COMMONJS_EXPORT_LINE.sub(b"", source)
|
||||
revision = hashlib.sha256(source).hexdigest()
|
||||
minified = rjsmin.jsmin(source.decode()).encode()
|
||||
return minified + f';"source-sha256:{revision}";'.encode()
|
||||
|
||||
|
||||
def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> RuntimeBundle:
|
||||
content = _bundle(frontend_dir, sources)
|
||||
digest = hashlib.sha256(content).hexdigest()[:16]
|
||||
return RuntimeBundle(
|
||||
runtime_bytes=content,
|
||||
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
|
||||
runtime_digest=digest,
|
||||
runtime_name=f"{prefix}-{digest}.js",
|
||||
)
|
||||
|
||||
|
||||
def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
@@ -37,34 +93,91 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
if not sources:
|
||||
raise ValueError("dashboard entry document has no page scripts")
|
||||
|
||||
runtime = _bundle(frontend_dir, sources)
|
||||
digest = hashlib.sha256(runtime).hexdigest()[:16]
|
||||
runtime_name = f"runtime-{digest}.js"
|
||||
feature_source_names = {source for group in FEATURE_SOURCES.values() for source in group}
|
||||
core_sources = tuple(source for source in sources if source not in feature_source_names)
|
||||
core = _runtime(frontend_dir, core_sources, "runtime")
|
||||
feature_bundles = {
|
||||
name: _runtime(frontend_dir, feature_sources, f"feature-{name}")
|
||||
for name, feature_sources in FEATURE_SOURCES.items()
|
||||
}
|
||||
dashboard_html = SCRIPT_TAG.sub("", source_html)
|
||||
feature_metadata = "\n".join(
|
||||
f'<meta name="stackchain-feature-{name}" content="{bundle.runtime_name}">'
|
||||
for name, bundle in feature_bundles.items()
|
||||
)
|
||||
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>", f'<script src="{runtime_name}"></script>\n</body>'
|
||||
"</body>",
|
||||
f'<script src="{feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{core.runtime_name}"></script>\n</body>',
|
||||
)
|
||||
|
||||
worker = (frontend_dir / "service-worker.js").read_text()
|
||||
for source in sources:
|
||||
if source != WORKER_RUNTIME_SOURCE:
|
||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||
eager_feature = feature_bundles["today-timer"]
|
||||
optional_features = {
|
||||
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
|
||||
}
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n",
|
||||
" BASE + 'static/dashboard.css',\n"
|
||||
+ f" BASE + '{core.runtime_name}',\n"
|
||||
+ f" BASE + '{eager_feature.runtime_name}',\n",
|
||||
)
|
||||
worker = re.sub(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-v\d+';",
|
||||
f"const CACHE = 'stackchain-dashboard-shell-{digest}';",
|
||||
worker,
|
||||
count=1,
|
||||
worker = worker.replace(
|
||||
"const OPTIONAL_FEATURES = [\n",
|
||||
"const OPTIONAL_FEATURES = [\n"
|
||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
|
||||
)
|
||||
worker = CACHE_DECLARATION.sub(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||
)
|
||||
|
||||
shell_hasher = hashlib.sha256()
|
||||
shell_inputs = (
|
||||
("dashboard.html", dashboard_html.encode()),
|
||||
(core.runtime_name, core.runtime_bytes),
|
||||
*((bundle.runtime_name, bundle.runtime_bytes) for bundle in feature_bundles.values()),
|
||||
("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()),
|
||||
("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()),
|
||||
(
|
||||
"static/icons/stackchain-192.png",
|
||||
(frontend_dir / "icons/stackchain-192.png").read_bytes(),
|
||||
),
|
||||
(
|
||||
"static/icons/stackchain-512.png",
|
||||
(frontend_dir / "icons/stackchain-512.png").read_bytes(),
|
||||
),
|
||||
(
|
||||
WORKER_RUNTIME_SOURCE,
|
||||
(
|
||||
frontend_dir / WORKER_RUNTIME_SOURCE.removeprefix("static/")
|
||||
).read_bytes(),
|
||||
),
|
||||
("service-worker.js", worker.encode()),
|
||||
)
|
||||
for name, content in shell_inputs:
|
||||
shell_hasher.update(len(name).to_bytes(4, "big"))
|
||||
shell_hasher.update(name.encode())
|
||||
shell_hasher.update(len(content).to_bytes(8, "big"))
|
||||
shell_hasher.update(content)
|
||||
shell_digest = shell_hasher.hexdigest()[:16]
|
||||
worker = worker.replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';",
|
||||
f"const CACHE = 'stackchain-dashboard-shell-{shell_digest}';",
|
||||
1,
|
||||
)
|
||||
|
||||
return FrontendBuild(
|
||||
dashboard_html=dashboard_html,
|
||||
runtime_bytes=runtime,
|
||||
runtime_digest=digest,
|
||||
runtime_name=runtime_name,
|
||||
runtime_bytes=core.runtime_bytes,
|
||||
runtime_gzip_bytes=core.runtime_gzip_bytes,
|
||||
runtime_digest=core.runtime_digest,
|
||||
runtime_name=core.runtime_name,
|
||||
shell_digest=shell_digest,
|
||||
page_sources=sources,
|
||||
feature_bundles=feature_bundles,
|
||||
service_worker_source=worker,
|
||||
)
|
||||
)
|
||||
+676
-3
@@ -4,7 +4,7 @@ import re
|
||||
import shlex
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -20,6 +20,10 @@ class GiteaOverloadedError(RuntimeError):
|
||||
"""Raised when bounded transport admission expires before capacity is available."""
|
||||
|
||||
|
||||
class CommentMutationForbiddenError(RuntimeError):
|
||||
"""Raised when a comment is not owned by the operator or enclosing thread."""
|
||||
|
||||
|
||||
class GiteaTransport:
|
||||
"""Application-lifetime HTTP transport with single-flight concurrent reads."""
|
||||
|
||||
@@ -169,6 +173,10 @@ class IssueEditConflictError(ValueError):
|
||||
"""Raised when an issue changed after the editor loaded it."""
|
||||
|
||||
|
||||
class IssueDependencyInvalidError(ValueError):
|
||||
"""Raised when a requested blocker relationship is not valid."""
|
||||
|
||||
|
||||
def _auth() -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if GITEA_TOKEN:
|
||||
@@ -206,6 +214,35 @@ async def fetch(path: str) -> Any:
|
||||
return r.json()
|
||||
|
||||
|
||||
def issue_time_target(identity: str) -> tuple[str, str]:
|
||||
"""Return the canonical repository and issue number encoded by an identity."""
|
||||
match = re.fullmatch(
|
||||
r"(?:issue|pull):([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+):([1-9][0-9]*):",
|
||||
identity,
|
||||
)
|
||||
if match is None:
|
||||
raise ValueError("time log target is invalid")
|
||||
return match.groups()
|
||||
|
||||
|
||||
async def log_issue_time(identity: str, seconds: int) -> None:
|
||||
"""Log time to the canonical issue endpoint encoded by a dashboard identity."""
|
||||
repository, number = issue_time_target(identity)
|
||||
if not isinstance(seconds, int) or isinstance(seconds, bool) or not 1 <= seconds <= 86400:
|
||||
raise ValueError("time log duration is invalid")
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/times",
|
||||
headers={**_auth(), "Content-Type": "application/json"},
|
||||
json={"time": seconds},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def time_log_failure_is_retryable(error: Exception) -> bool:
|
||||
"""Return true only when failure proves no successful response was lost."""
|
||||
return isinstance(error, (ValueError, GiteaOverloadedError, httpx.ConnectError, httpx.HTTPStatusError))
|
||||
|
||||
|
||||
async def fetch_text(path: str, max_bytes: int) -> tuple[str, bool]:
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
@@ -301,6 +338,21 @@ async def repos() -> RepositoryItems:
|
||||
)
|
||||
|
||||
|
||||
async def search_repositories(query: str, limit: int = 20) -> list[dict]:
|
||||
"""Search repositories visible to the authenticated Gitea user."""
|
||||
response = await _get_client().get(
|
||||
"/api/v1/repos/search",
|
||||
headers=_auth(),
|
||||
params={"q": query.strip(), "limit": limit, "page": 1},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
items = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list):
|
||||
raise ValueError("Gitea repository search response was not a list")
|
||||
return [item for item in items if isinstance(item, dict)]
|
||||
|
||||
|
||||
async def repository_access(repository: str) -> dict | None:
|
||||
"""Return a repository only when the authenticated user can access it."""
|
||||
response = await _get_client().get(
|
||||
@@ -453,6 +505,7 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
"assignees": assignee_names,
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
"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),
|
||||
}
|
||||
|
||||
@@ -585,6 +638,51 @@ def _safe_web_url(value: Any) -> str:
|
||||
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
|
||||
|
||||
|
||||
def _safe_gitea_web_url(value: Any) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return ""
|
||||
resolved = urljoin(f"{GITEA_URL}/", value.strip())
|
||||
parsed = urlsplit(resolved)
|
||||
configured = urlsplit(GITEA_URL)
|
||||
base_path = configured.path.rstrip("/")
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or parsed.scheme != configured.scheme
|
||||
or parsed.netloc != configured.netloc
|
||||
or (base_path and parsed.path != base_path and not parsed.path.startswith(f"{base_path}/"))
|
||||
):
|
||||
return ""
|
||||
return resolved
|
||||
|
||||
|
||||
def _normalize_commit_checks(status: Any) -> list[dict]:
|
||||
entries = status.get("statuses") if isinstance(status, dict) else None
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
rank = {"error": 0, "failure": 0, "pending": 1, "warning": 2, "success": 3}
|
||||
checks = []
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = entry.get("context")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
state = entry.get("status", entry.get("state", "unknown"))
|
||||
state = state.lower() if isinstance(state, str) else "unknown"
|
||||
if state not in rank:
|
||||
state = "unknown"
|
||||
description = entry.get("description")
|
||||
checks.append({
|
||||
"name": name.strip()[:120],
|
||||
"state": state,
|
||||
"description": description.strip()[:240] if isinstance(description, str) else "",
|
||||
"url": _safe_gitea_web_url(entry.get("target_url")),
|
||||
"_index": index,
|
||||
})
|
||||
checks.sort(key=lambda check: (rank.get(check["state"], 2), check["_index"]))
|
||||
return [{key: value for key, value in check.items() if key != "_index"} for check in checks[:20]]
|
||||
|
||||
|
||||
def _normalize_notifications(threads: Any) -> list[dict]:
|
||||
if not isinstance(threads, list):
|
||||
raise ValueError("Gitea notification response was not a list")
|
||||
@@ -644,6 +742,41 @@ async def notifications() -> dict:
|
||||
return await notification_page(1)
|
||||
|
||||
|
||||
async def unread_notification_snapshot(
|
||||
*,
|
||||
limit: int = 50,
|
||||
max_pages: int = 20,
|
||||
max_concurrency: int = 4,
|
||||
deadline_seconds: float = 5.0,
|
||||
) -> dict:
|
||||
"""Load one complete, bounded snapshot of unread notification threads."""
|
||||
async with asyncio.timeout(deadline_seconds):
|
||||
first = await notification_page(1, limit)
|
||||
page_count = max(1, (first["total"] + limit - 1) // limit)
|
||||
if page_count > max_pages:
|
||||
raise ValueError("Unread notification snapshot exceeds the scan limit")
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def load(page: int) -> dict:
|
||||
async with semaphore:
|
||||
return await notification_page(page, limit)
|
||||
|
||||
remaining = await asyncio.gather(
|
||||
*(load(page) for page in range(2, page_count + 1))
|
||||
)
|
||||
pages = [first, *remaining]
|
||||
if any(page["total"] != first["total"] for page in pages[1:]):
|
||||
raise ValueError("Unread notification pagination changed during the scan")
|
||||
items = [item for page in pages for item in page["items"]]
|
||||
thread_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(thread_ids)) != first["total"]:
|
||||
raise ValueError("Unread notification snapshot has an incomplete thread set")
|
||||
return {"items": items, "total": first["total"], "complete": True}
|
||||
|
||||
|
||||
async def notification_page(page: int, limit: int = 50) -> dict:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/notifications?status-types=unread&limit={limit}&page={page}",
|
||||
@@ -671,6 +804,68 @@ async def mark_notification_read(thread_id: int) -> None:
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def mark_notification_unread(thread_id: int) -> None:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/notifications/threads/{thread_id}?to-status=unread",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def acknowledge_notification(thread_id: int) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
raise ValueError("Gitea notification thread response was not an object")
|
||||
repository = thread.get("repository")
|
||||
subject = thread.get("subject")
|
||||
if not isinstance(repository, dict) or not isinstance(subject, dict):
|
||||
raise ValueError("Notification does not identify a conversation")
|
||||
repository_name = repository.get("full_name")
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
comment_path = _gitea_api_path(subject.get("latest_comment_url"))
|
||||
subject_match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
|
||||
)
|
||||
comment_match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/issues/comments/(\d+)", comment_path
|
||||
)
|
||||
if (
|
||||
not subject_match
|
||||
or not comment_match
|
||||
or subject_match.group(1) != repository_name
|
||||
or comment_match.group(1) != repository_name
|
||||
or subject.get("type") not in {"Issue", "Pull"}
|
||||
):
|
||||
raise ValueError("Notification has no supported latest comment")
|
||||
|
||||
user = await current_user()
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("Authenticated Gitea user is unavailable")
|
||||
reaction_path = f"/api/v1/{comment_path}/reactions"
|
||||
response = await _get_client().get(reaction_path, headers=_auth())
|
||||
response.raise_for_status()
|
||||
reactions = response.json()
|
||||
existing = any(
|
||||
isinstance(reaction, dict)
|
||||
and reaction.get("content") == "+1"
|
||||
and isinstance(reaction.get("user"), dict)
|
||||
and reaction["user"].get("login") == login
|
||||
for reaction in (reactions if isinstance(reactions, list) else [])
|
||||
)
|
||||
if not existing:
|
||||
response = await _get_client().post(
|
||||
reaction_path, headers=_auth(), json={"content": "+1"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
await mark_notification_read(thread_id)
|
||||
return {
|
||||
"id": thread_id,
|
||||
"reaction": "existing" if existing else "created",
|
||||
"status": "read",
|
||||
}
|
||||
|
||||
|
||||
def _gitea_api_path(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
@@ -699,6 +894,9 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
conversation_match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
|
||||
)
|
||||
comment_match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/issues/comments/(\d+)", comment_path
|
||||
)
|
||||
repository_name = repository.get("full_name")
|
||||
supported_conversation = (
|
||||
conversation_match
|
||||
@@ -774,6 +972,11 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
"url": latest_url,
|
||||
},
|
||||
"issue": issue,
|
||||
"acknowledge_supported": bool(
|
||||
supported_conversation
|
||||
and comment_match
|
||||
and comment_match.group(1) == repository_name
|
||||
),
|
||||
"conversation": conversation,
|
||||
}
|
||||
|
||||
@@ -801,6 +1004,25 @@ async def notification_conversation_page(
|
||||
)
|
||||
|
||||
|
||||
async def notification_conversation_target(thread_id: int) -> tuple[str, int]:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
raise ValueError("Gitea notification thread response was not an object")
|
||||
repository = thread.get("repository")
|
||||
subject_value = thread.get("subject")
|
||||
subject = subject_value if isinstance(subject_value, dict) else {}
|
||||
repository_name = repository.get("full_name") if isinstance(repository, dict) else None
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path)
|
||||
if (
|
||||
not match
|
||||
or match.group(1) != repository_name
|
||||
or subject.get("type") not in {"Issue", "Pull"}
|
||||
):
|
||||
raise ValueError("Notification subject is not a supported conversation")
|
||||
return match.group(1), int(match.group(3))
|
||||
|
||||
|
||||
async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -832,6 +1054,43 @@ async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def upload_notification_attachment(
|
||||
thread_id: int, filename: str, content_type: str, content: bytes
|
||||
) -> dict:
|
||||
"""Upload to the exact issue or pull identified by a trusted notification."""
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
raise ValueError("Gitea notification thread response was not an object")
|
||||
repository = thread.get("repository")
|
||||
subject = thread.get("subject")
|
||||
if not isinstance(repository, dict) or not isinstance(subject, dict):
|
||||
raise ValueError("Notification does not identify a conversation")
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path)
|
||||
if (
|
||||
not match
|
||||
or match.group(1) != repository.get("full_name")
|
||||
or subject.get("type") not in {"Issue", "Pull"}
|
||||
):
|
||||
raise ValueError("Notification subject is not a supported conversation")
|
||||
# Gitea stores pull-request assets on its shared issue asset endpoint.
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{match.group(1)}/issues/{match.group(3)}/assets",
|
||||
headers=_auth(), params={"name": filename},
|
||||
files={"attachment": (filename, content, content_type)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
attachment = response.json()
|
||||
if not isinstance(attachment, dict):
|
||||
raise ValueError("Gitea attachment response was not an object")
|
||||
name = attachment.get("name")
|
||||
url = _safe_web_url(attachment.get("browser_download_url"))
|
||||
size = attachment.get("size")
|
||||
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
|
||||
raise ValueError("Gitea did not confirm the attachment")
|
||||
return {"name": name, "url": url, "size": size}
|
||||
|
||||
|
||||
async def close_issue(repository: str, number: int) -> dict:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
@@ -878,6 +1137,105 @@ async def comment_on_issue(repository: str, number: int, body: str) -> dict:
|
||||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def _owned_comment(repository: str, number: int, comment_id: int) -> dict:
|
||||
user, comment = await asyncio.gather(
|
||||
current_user(),
|
||||
fetch(f"repos/{repository}/issues/comments/{comment_id}"),
|
||||
)
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
author = comment.get("user") if isinstance(comment, dict) else None
|
||||
expected_issue_path = f"repos/{repository}/issues/{number}"
|
||||
if (
|
||||
not isinstance(login, str)
|
||||
or not login
|
||||
or not isinstance(comment, dict)
|
||||
or not isinstance(author, dict)
|
||||
or author.get("login") != login
|
||||
or _gitea_api_path(comment.get("issue_url")) != expected_issue_path
|
||||
):
|
||||
raise CommentMutationForbiddenError("Comment is not editable in this conversation")
|
||||
return comment
|
||||
|
||||
|
||||
async def edit_owned_comment(
|
||||
repository: str, number: int, comment_id: int, body: str
|
||||
) -> dict:
|
||||
await _owned_comment(repository, number, comment_id)
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/comments/{comment_id}",
|
||||
headers=_auth(),
|
||||
json={"body": body},
|
||||
)
|
||||
response.raise_for_status()
|
||||
comment = response.json()
|
||||
if not isinstance(comment, dict):
|
||||
raise ValueError("Gitea comment response was not an object")
|
||||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def delete_owned_comment(repository: str, number: int, comment_id: int) -> dict:
|
||||
await _owned_comment(repository, number, comment_id)
|
||||
response = await _get_client().delete(
|
||||
f"/api/v1/repos/{repository}/issues/comments/{comment_id}", headers=_auth()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {"id": comment_id, "deleted": True}
|
||||
|
||||
|
||||
async def upload_assigned_issue_attachment(
|
||||
repository: str,
|
||||
number: int,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
content: bytes,
|
||||
) -> dict:
|
||||
if not await is_assigned_issue(repository, number):
|
||||
raise IssueNotAvailableError("Assigned issue not found")
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/assets",
|
||||
headers=_auth(),
|
||||
params={"name": filename},
|
||||
files={"attachment": (filename, content, content_type)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
attachment = response.json()
|
||||
if not isinstance(attachment, dict):
|
||||
raise ValueError("Gitea attachment response was not an object")
|
||||
name = attachment.get("name")
|
||||
url = _safe_web_url(attachment.get("browser_download_url"))
|
||||
size = attachment.get("size")
|
||||
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
|
||||
raise ValueError("Gitea did not confirm the attachment")
|
||||
return {"name": name, "url": url, "size": size}
|
||||
|
||||
|
||||
async def upload_assigned_pull_attachment(
|
||||
repository: str,
|
||||
number: int,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
content: bytes,
|
||||
) -> dict:
|
||||
if not await is_assigned_pull(repository, number):
|
||||
raise IssueNotAvailableError("Assigned pull request not found")
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/assets",
|
||||
headers=_auth(),
|
||||
params={"name": filename},
|
||||
files={"attachment": (filename, content, content_type)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
attachment = response.json()
|
||||
if not isinstance(attachment, dict):
|
||||
raise ValueError("Gitea attachment response was not an object")
|
||||
name = attachment.get("name")
|
||||
url = _safe_web_url(attachment.get("browser_download_url"))
|
||||
size = attachment.get("size")
|
||||
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
|
||||
raise ValueError("Gitea did not confirm the attachment")
|
||||
return {"name": name, "url": url, "size": size}
|
||||
|
||||
|
||||
async def repo_labels(repository: str) -> list[dict]:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/labels",
|
||||
@@ -1055,6 +1413,66 @@ async def claim_available_issue(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def reopen_issue(repository: str, number: int) -> dict:
|
||||
"""Reopen a closed issue, assign it to the current user, and confirm both."""
|
||||
issue, user = await asyncio.gather(
|
||||
fetch(f"repos/{repository}/issues/{number}"), current_user()
|
||||
)
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if (
|
||||
not isinstance(issue, dict)
|
||||
or issue.get("number") != number
|
||||
or issue.get("pull_request") is not None
|
||||
or not isinstance(login, str)
|
||||
or not login
|
||||
):
|
||||
raise IssueNotAvailableError("Issue cannot be resumed")
|
||||
|
||||
if issue.get("state") == "open" and _login_in_users(login, issue.get("assignees")):
|
||||
confirmed = issue
|
||||
elif issue.get("state") == "closed":
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
headers=_auth(),
|
||||
json={"state": "open", "assignee": login},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
else:
|
||||
raise IssueNotAvailableError("Issue is no longer available to resume")
|
||||
assignees_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
|
||||
assignees = assignees_value if isinstance(assignees_value, list) else []
|
||||
logins = [
|
||||
assignee["login"] for assignee in assignees
|
||||
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
|
||||
]
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or confirmed.get("state") != "open"
|
||||
or login not in logins
|
||||
):
|
||||
raise ValueError("Gitea did not confirm issue reopening and assignment")
|
||||
labels_value = confirmed.get("labels")
|
||||
labels = labels_value if isinstance(labels_value, list) else []
|
||||
return {
|
||||
"id": confirmed.get("id"),
|
||||
"number": number,
|
||||
"title": confirmed.get("title", "")
|
||||
if isinstance(confirmed.get("title"), str) else "",
|
||||
"state": "open",
|
||||
"repository": repository,
|
||||
"labels": [
|
||||
label["name"] for label in labels
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||
],
|
||||
"assignees": logins,
|
||||
"updated_at": confirmed.get("updated_at", "")
|
||||
if isinstance(confirmed.get("updated_at"), str) else "",
|
||||
"url": _safe_web_url(confirmed.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
async def release_assigned_issue(repository: str, number: int) -> dict:
|
||||
login, issue = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
@@ -1129,6 +1547,40 @@ async def issue_handoff_candidates(repository: str) -> list[dict]:
|
||||
return candidates
|
||||
|
||||
|
||||
async def mention_candidates(
|
||||
repository: str, query: str, *, limit: int = 8
|
||||
) -> list[dict]:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/assignees", headers=_auth()
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea assignees response was not a list")
|
||||
needle = query.casefold()
|
||||
matches = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
login = item.get("login")
|
||||
if (
|
||||
not isinstance(login, str)
|
||||
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
|
||||
):
|
||||
continue
|
||||
full_name = item.get("full_name")
|
||||
name = full_name if isinstance(full_name, str) and full_name else login
|
||||
login_match = needle in login.casefold()
|
||||
name_match = needle in name.casefold()
|
||||
if login_match or name_match:
|
||||
matches.append((0 if login.casefold().startswith(needle) else 1, login.casefold(), {
|
||||
"login": login,
|
||||
"name": name,
|
||||
}))
|
||||
matches.sort(key=lambda item: (item[0], item[1]))
|
||||
return [item[2] for item in matches[:limit]]
|
||||
|
||||
|
||||
async def handoff_assigned_issue(
|
||||
repository: str, number: int, recipient: str
|
||||
) -> dict:
|
||||
@@ -1186,6 +1638,92 @@ async def handoff_assigned_issue(
|
||||
}
|
||||
|
||||
|
||||
async def pull_handoff_candidates(repository: str) -> list[dict]:
|
||||
candidates = await issue_handoff_candidates(repository)
|
||||
return [
|
||||
item for item in candidates
|
||||
if re.fullmatch(r"[A-Za-z0-9_.-]+", item["login"])
|
||||
][:25]
|
||||
|
||||
|
||||
async def _change_assigned_pull_owners(
|
||||
repository: str, number: int, recipient: str | None
|
||||
) -> dict:
|
||||
login, pull = await _current_login_and_target(
|
||||
f"repos/{repository}/pulls/{number}"
|
||||
)
|
||||
assignees_value = pull.get("assignees")
|
||||
assignees = assignees_value if isinstance(assignees_value, list) else []
|
||||
login_folded = login.casefold()
|
||||
assigned_to_login = any(
|
||||
isinstance(item, dict)
|
||||
and isinstance(item.get("login"), str)
|
||||
and item["login"].casefold() == login_folded
|
||||
for item in assignees
|
||||
)
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull.get("merged") is True
|
||||
or not assigned_to_login
|
||||
):
|
||||
raise IssueNotAvailableError("Pull request is not assigned to the current user")
|
||||
|
||||
if recipient is not None:
|
||||
eligible = {
|
||||
item["login"] for item in await pull_handoff_candidates(repository)
|
||||
}
|
||||
if recipient not in eligible:
|
||||
raise IssueNotAvailableError("Handoff recipient is not eligible")
|
||||
|
||||
desired = [
|
||||
item["login"] for item in assignees
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("login"), str)
|
||||
and item["login"].casefold() != login_folded
|
||||
]
|
||||
if recipient is not None and recipient not in desired:
|
||||
desired.append(recipient)
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
headers=_auth(),
|
||||
json={"assignees": desired},
|
||||
)
|
||||
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 login_folded in {item.casefold() for item in confirmed_assignees}
|
||||
or set(confirmed_assignees) != set(desired)
|
||||
or (recipient is not None and recipient not in confirmed_assignees)
|
||||
):
|
||||
raise ValueError("Gitea did not confirm pull request ownership change")
|
||||
result = {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"assignees": confirmed_assignees,
|
||||
}
|
||||
if recipient is not None:
|
||||
result["recipient"] = recipient
|
||||
return result
|
||||
|
||||
|
||||
async def handoff_assigned_pull(
|
||||
repository: str, number: int, recipient: str
|
||||
) -> dict:
|
||||
return await _change_assigned_pull_owners(repository, number, recipient)
|
||||
|
||||
|
||||
async def release_assigned_pull(repository: str, number: int) -> dict:
|
||||
return await _change_assigned_pull_owners(repository, number, None)
|
||||
|
||||
|
||||
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
@@ -1343,9 +1881,19 @@ async def issue_conversation_page(
|
||||
|
||||
async def issue_detail(repository: str, number: int) -> dict:
|
||||
base = f"repos/{repository}/issues/{number}"
|
||||
issue, conversation = await asyncio.gather(
|
||||
fetch(base), issue_conversation_page(repository, number)
|
||||
|
||||
async def load_dependencies() -> tuple[bool, list[dict]]:
|
||||
try:
|
||||
return True, await issue_dependencies(repository, number)
|
||||
except Exception:
|
||||
return False, []
|
||||
|
||||
issue, conversation, dependency_result = await asyncio.gather(
|
||||
fetch(base),
|
||||
issue_conversation_page(repository, number),
|
||||
load_dependencies(),
|
||||
)
|
||||
dependencies_available, dependencies = dependency_result
|
||||
if not isinstance(issue, dict):
|
||||
raise ValueError("Gitea issue response was not an object")
|
||||
labels_value = issue.get("labels")
|
||||
@@ -1377,11 +1925,110 @@ async def issue_detail(repository: str, number: int) -> dict:
|
||||
assignee["login"] for assignee in assignees
|
||||
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
|
||||
],
|
||||
"dependencies_available": dependencies_available,
|
||||
"dependencies": dependencies,
|
||||
"comments": normalized_comments,
|
||||
"conversation": conversation,
|
||||
}
|
||||
|
||||
|
||||
async def issue_dependencies(repository: str, number: int, limit: int = 20) -> list[dict]:
|
||||
"""Return bounded open prerequisites for an issue."""
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/dependencies",
|
||||
headers=_auth(),
|
||||
params={"limit": limit},
|
||||
)
|
||||
response.raise_for_status()
|
||||
value = response.json()
|
||||
items = value if isinstance(value, list) else []
|
||||
dependencies = []
|
||||
for item in items[:limit]:
|
||||
if not isinstance(item, dict) or item.get("state") != "open":
|
||||
continue
|
||||
repo_value = item.get("repository")
|
||||
repo = repo_value if isinstance(repo_value, dict) else {}
|
||||
dependency_repository = repo.get("full_name")
|
||||
dependency_number = item.get("number")
|
||||
if not isinstance(dependency_repository, str) or not isinstance(dependency_number, int):
|
||||
continue
|
||||
dependencies.append({
|
||||
"repository": dependency_repository,
|
||||
"number": dependency_number,
|
||||
"title": item.get("title", "") if isinstance(item.get("title"), str) else "",
|
||||
"state": "open",
|
||||
"url": _safe_web_url(item.get("html_url")),
|
||||
})
|
||||
return dependencies
|
||||
|
||||
|
||||
async def mutate_assigned_issue_dependency(
|
||||
repository: str,
|
||||
number: int,
|
||||
blocker_repository: str,
|
||||
blocker_number: int,
|
||||
remove: bool = False,
|
||||
) -> dict:
|
||||
"""Add or remove one prerequisite and return the canonical open dependency set."""
|
||||
if (repository, number) == (blocker_repository, blocker_number):
|
||||
raise IssueDependencyInvalidError("an issue cannot block itself")
|
||||
|
||||
login, source = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
if (
|
||||
source.get("state") != "open"
|
||||
or isinstance(source.get("pull_request"), dict)
|
||||
or not _login_in_users(login, source.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
|
||||
current = await issue_dependencies(repository, number)
|
||||
currently_present = any(
|
||||
item["repository"] == blocker_repository and item["number"] == blocker_number
|
||||
for item in current
|
||||
)
|
||||
if currently_present != remove:
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"dependencies_available": True,
|
||||
"dependencies": current,
|
||||
}
|
||||
|
||||
if not remove:
|
||||
candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}")
|
||||
if (
|
||||
not isinstance(candidate, dict)
|
||||
or candidate.get("number") != blocker_number
|
||||
or candidate.get("state") != "open"
|
||||
or isinstance(candidate.get("pull_request"), dict)
|
||||
):
|
||||
raise IssueDependencyInvalidError("blocker must be an accessible open issue")
|
||||
owner, repo = blocker_repository.split("/", 1)
|
||||
response = await _get_client().request(
|
||||
"DELETE" if remove else "POST",
|
||||
f"/api/v1/repos/{repository}/issues/{number}/dependencies",
|
||||
headers=_auth(),
|
||||
json={"owner": owner, "repo": repo, "index": blocker_number},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
confirmed = await issue_dependencies(repository, number)
|
||||
present = any(
|
||||
item["repository"] == blocker_repository and item["number"] == blocker_number
|
||||
for item in confirmed
|
||||
)
|
||||
if present == remove:
|
||||
raise ValueError("Gitea did not confirm the dependency change")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"dependencies_available": True,
|
||||
"dependencies": confirmed,
|
||||
}
|
||||
|
||||
|
||||
async def update_assigned_issue(
|
||||
repository: str,
|
||||
number: int,
|
||||
@@ -1596,6 +2243,7 @@ async def pull_completion_review(repository: str, number: int) -> dict:
|
||||
"mergeable": pull.get("mergeable") is True,
|
||||
"merged": pull.get("merged") is True,
|
||||
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
|
||||
"checks": _normalize_commit_checks(status),
|
||||
"files": [
|
||||
{
|
||||
"filename": item.get("filename", ""),
|
||||
@@ -1618,6 +2266,30 @@ async def pull_completion_review(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def pull_check_status(repository: str, number: int) -> dict:
|
||||
"""Load only mutable pull and CI state, without immutable review data."""
|
||||
base = f"repos/{repository}/pulls/{number}"
|
||||
pull = await fetch(base)
|
||||
if not isinstance(pull, dict):
|
||||
raise ValueError("Gitea pull request response was not an object")
|
||||
head_value = pull.get("head")
|
||||
head: dict = head_value if isinstance(head_value, dict) else {}
|
||||
sha_value = head.get("sha")
|
||||
sha = sha_value if isinstance(sha_value, str) else ""
|
||||
status = await fetch(f"repos/{repository}/commits/{sha}/status")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"head_sha": sha,
|
||||
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
|
||||
"draft": pull.get("draft") is True,
|
||||
"mergeable": pull.get("mergeable") is True,
|
||||
"merged": pull.get("merged") is True,
|
||||
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
|
||||
"checks": _normalize_commit_checks(status),
|
||||
}
|
||||
|
||||
|
||||
async def is_pull_merged_at_head(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> bool:
|
||||
@@ -1728,6 +2400,7 @@ async def pull_review_detail(repository: str, number: int) -> dict:
|
||||
"author": user.get("login", ""),
|
||||
"head_sha": sha,
|
||||
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
|
||||
"checks": _normalize_commit_checks(status),
|
||||
"files": normalized_files,
|
||||
"reviews": normalized_reviews,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Process-shared live snapshot state and refresh coordination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
SECTIONS = ("context", "events", "notifications")
|
||||
|
||||
|
||||
class RefreshLeaseLost(RuntimeError):
|
||||
"""The refresh is no longer authorized to publish shared state."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveSnapshotState:
|
||||
value: dict | None
|
||||
created_at: dict[str, float | None]
|
||||
failure_count: dict[str, int]
|
||||
retry_at: dict[str, float | None]
|
||||
revisions: dict[str, int]
|
||||
generation: str
|
||||
refreshing_sections: set[str]
|
||||
lease_expires_at: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveSnapshotMetadata:
|
||||
"""Shared snapshot coordination fields without the potentially large value."""
|
||||
|
||||
created_at: dict[str, float | None]
|
||||
failure_count: dict[str, int]
|
||||
retry_at: dict[str, float | None]
|
||||
revisions: dict[str, int]
|
||||
generation: str
|
||||
refreshing_sections: set[str]
|
||||
lease_expires_at: float | None
|
||||
|
||||
|
||||
class LiveSnapshotStore:
|
||||
"""A private SQLite snapshot with an expiring, cross-process refresh lease."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | os.PathLike[str],
|
||||
*,
|
||||
clock: Callable[[], float] | None = None,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.clock = clock or time.time
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(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)
|
||||
|
||||
def try_acquire_refresh(
|
||||
self, sections: Iterable[str], *, lease_seconds: float
|
||||
) -> str | None:
|
||||
requested = sorted(set(sections))
|
||||
if not requested or any(section not in SECTIONS for section in requested):
|
||||
raise ValueError("refresh lease requires known sections")
|
||||
if lease_seconds <= 0:
|
||||
raise ValueError("refresh lease duration must be positive")
|
||||
now = self.clock()
|
||||
owner = secrets.token_hex(16)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
active = connection.execute(
|
||||
"SELECT expires_at FROM live_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if active is not None and active["expires_at"] > now:
|
||||
connection.rollback()
|
||||
return None
|
||||
connection.execute("DELETE FROM live_refresh_lease WHERE singleton = 1")
|
||||
connection.execute(
|
||||
"INSERT INTO live_refresh_lease VALUES (1, ?, ?, ?)",
|
||||
(owner, json.dumps(requested, separators=(",", ":")), now + lease_seconds),
|
||||
)
|
||||
connection.commit()
|
||||
return owner
|
||||
|
||||
def load(self) -> LiveSnapshotState:
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM live_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
lease = connection.execute(
|
||||
"SELECT sections_json, expires_at FROM live_refresh_lease "
|
||||
"WHERE singleton = 1 AND expires_at > ?",
|
||||
(now,),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
return LiveSnapshotState(
|
||||
value=json.loads(row["value_json"]) if row["value_json"] is not None else None,
|
||||
created_at=json.loads(row["created_at_json"]),
|
||||
failure_count=json.loads(row["failure_count_json"]),
|
||||
retry_at=json.loads(row["retry_at_json"]),
|
||||
revisions=json.loads(row["revisions_json"]),
|
||||
generation=row["generation"],
|
||||
refreshing_sections=set(json.loads(lease["sections_json"])) if lease else set(),
|
||||
lease_expires_at=lease["expires_at"] if lease else None,
|
||||
)
|
||||
|
||||
def load_metadata(self) -> LiveSnapshotMetadata:
|
||||
"""Load freshness and coordination state without reading the snapshot value."""
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT created_at_json, failure_count_json, retry_at_json,
|
||||
revisions_json, generation FROM live_snapshot WHERE singleton = 1"""
|
||||
).fetchone()
|
||||
lease = connection.execute(
|
||||
"SELECT sections_json, expires_at FROM live_refresh_lease "
|
||||
"WHERE singleton = 1 AND expires_at > ?",
|
||||
(now,),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
return LiveSnapshotMetadata(
|
||||
created_at=json.loads(row["created_at_json"]),
|
||||
failure_count=json.loads(row["failure_count_json"]),
|
||||
retry_at=json.loads(row["retry_at_json"]),
|
||||
revisions=json.loads(row["revisions_json"]),
|
||||
generation=row["generation"],
|
||||
refreshing_sections=set(json.loads(lease["sections_json"])) if lease else set(),
|
||||
lease_expires_at=lease["expires_at"] if lease else None,
|
||||
)
|
||||
|
||||
def publish_refresh(
|
||||
self,
|
||||
owner: str,
|
||||
*,
|
||||
value: dict,
|
||||
created_at: dict[str, float | None],
|
||||
failure_count: dict[str, int],
|
||||
retry_at: dict[str, float | None],
|
||||
changed_sections: Iterable[str],
|
||||
) -> LiveSnapshotState:
|
||||
changed = set(changed_sections)
|
||||
if changed - set(SECTIONS):
|
||||
raise ValueError("publication contains unknown sections")
|
||||
if any(set(mapping) != set(SECTIONS) for mapping in (created_at, failure_count, retry_at)):
|
||||
raise ValueError("publication metadata must contain every section")
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
lease = connection.execute(
|
||||
"SELECT owner, expires_at FROM live_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if lease is None or lease["owner"] != owner or lease["expires_at"] <= now:
|
||||
connection.rollback()
|
||||
raise RefreshLeaseLost("live refresh lease expired or changed owner")
|
||||
row = connection.execute(
|
||||
"SELECT revisions_json FROM live_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
revisions = json.loads(row["revisions_json"])
|
||||
notifications = value.get("notifications")
|
||||
if isinstance(notifications, list):
|
||||
returned_ids = {
|
||||
item.get("id") for item in notifications if isinstance(item, dict)
|
||||
}
|
||||
read_ids = {
|
||||
item["notification_id"]
|
||||
for item in connection.execute(
|
||||
"SELECT notification_id FROM live_read_notification"
|
||||
)
|
||||
}
|
||||
value = dict(value)
|
||||
value["notifications"] = [
|
||||
item for item in notifications
|
||||
if not isinstance(item, dict) or item.get("id") not in read_ids
|
||||
]
|
||||
if returned_ids:
|
||||
placeholders = ",".join("?" for _ in returned_ids)
|
||||
connection.execute(
|
||||
f"DELETE FROM live_read_notification WHERE notification_id NOT IN ({placeholders})",
|
||||
tuple(returned_ids),
|
||||
)
|
||||
else:
|
||||
connection.execute("DELETE FROM live_read_notification")
|
||||
for section in changed:
|
||||
revisions[section] += 1
|
||||
connection.execute(
|
||||
"""UPDATE live_snapshot SET value_json = ?, created_at_json = ?,
|
||||
failure_count_json = ?, retry_at_json = ?, revisions_json = ?
|
||||
WHERE singleton = 1""",
|
||||
(
|
||||
json.dumps(value, separators=(",", ":")),
|
||||
json.dumps(created_at, separators=(",", ":")),
|
||||
json.dumps(failure_count, separators=(",", ":")),
|
||||
json.dumps(retry_at, separators=(",", ":")),
|
||||
json.dumps(revisions, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM live_refresh_lease WHERE singleton = 1 AND owner = ?", (owner,)
|
||||
)
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
def remove_notifications(self, notification_ids: Iterable[int]) -> LiveSnapshotState:
|
||||
"""Filter confirmed reads from every worker and subsequent stale refreshes."""
|
||||
read_ids = set(notification_ids)
|
||||
if not read_ids:
|
||||
return self.load()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.executemany(
|
||||
"INSERT OR IGNORE INTO live_read_notification VALUES (?)",
|
||||
((notification_id,) for notification_id in read_ids),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT value_json, revisions_json FROM live_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
value = json.loads(row["value_json"]) if row["value_json"] else None
|
||||
revisions = json.loads(row["revisions_json"])
|
||||
if value is not None and isinstance(value.get("notifications"), list):
|
||||
previous = value["notifications"]
|
||||
retained = [
|
||||
item
|
||||
for item in previous
|
||||
if not isinstance(item, dict) or item.get("id") not in read_ids
|
||||
]
|
||||
if retained != previous:
|
||||
value["notifications"] = retained
|
||||
revisions["notifications"] += 1
|
||||
connection.execute(
|
||||
"UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
|
||||
(
|
||||
json.dumps(value, separators=(",", ":")),
|
||||
json.dumps(revisions, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
def remove_notification(self, notification_id: int) -> LiveSnapshotState:
|
||||
"""Filter one confirmed read from every worker and subsequent stale refresh."""
|
||||
return self.remove_notifications([notification_id])
|
||||
|
||||
def release_refresh(self, owner: str) -> None:
|
||||
"""Relinquish a lease that became unnecessary after a coherent recheck."""
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM live_refresh_lease WHERE singleton = 1 AND owner = ?",
|
||||
(owner,),
|
||||
)
|
||||
|
||||
def fail_refresh(
|
||||
self,
|
||||
owner: str,
|
||||
*,
|
||||
failure_count: dict[str, int],
|
||||
retry_at: dict[str, float | None],
|
||||
) -> LiveSnapshotState:
|
||||
"""Publish backoff metadata and relinquish a failed refresh atomically."""
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
lease = connection.execute(
|
||||
"SELECT owner, expires_at FROM live_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if lease is None or lease["owner"] != owner or lease["expires_at"] <= now:
|
||||
connection.rollback()
|
||||
raise RefreshLeaseLost("live refresh lease expired or changed owner")
|
||||
connection.execute(
|
||||
"""UPDATE live_snapshot SET failure_count_json = ?, retry_at_json = ?
|
||||
WHERE singleton = 1""",
|
||||
(
|
||||
json.dumps(failure_count, separators=(",", ":")),
|
||||
json.dumps(retry_at, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
connection.execute("DELETE FROM live_refresh_lease WHERE singleton = 1")
|
||||
connection.commit()
|
||||
return self.load()
|
||||
+146
-1
@@ -49,6 +49,9 @@ class LoginAttemptStore:
|
||||
window_seconds: int,
|
||||
max_entries: int = 10_000,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
alert_bucket_seconds: int = 60 * 60,
|
||||
alert_retention_seconds: int = 30 * 24 * 60 * 60,
|
||||
max_alert_buckets: int = 720,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
@@ -56,6 +59,9 @@ class LoginAttemptStore:
|
||||
self.window_seconds = max(1, window_seconds)
|
||||
self.max_entries = max(1, max_entries)
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
self.alert_bucket_seconds = max(1, alert_bucket_seconds)
|
||||
self.alert_retention_seconds = max(1, alert_retention_seconds)
|
||||
self.max_alert_buckets = max(1, max_alert_buckets)
|
||||
|
||||
@staticmethod
|
||||
def _digest(source: str) -> str:
|
||||
@@ -74,6 +80,30 @@ class LoginAttemptStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_admissions (
|
||||
bucket TEXT NOT NULL,
|
||||
source_hash TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL,
|
||||
window_started_at REAL NOT NULL,
|
||||
PRIMARY KEY (bucket, source_hash)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_alerts (
|
||||
method TEXT NOT NULL,
|
||||
bucket_started_at INTEGER NOT NULL,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
blocked_count INTEGER NOT NULL DEFAULT 0,
|
||||
first_at INTEGER NOT NULL,
|
||||
last_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (method, bucket_started_at)
|
||||
)
|
||||
"""
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
@@ -97,7 +127,81 @@ class LoginAttemptStore:
|
||||
remaining = row[1] + self.window_seconds - now
|
||||
return max(0, math.ceil(remaining))
|
||||
|
||||
def record_failure(self, source: str) -> None:
|
||||
def admit(self, bucket: str, source: str, *, limit: int) -> int:
|
||||
"""Consume one fixed-window admission or return seconds until retry."""
|
||||
now = self.clock()
|
||||
source_hash = self._digest(source)
|
||||
limit = max(1, limit)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"DELETE FROM login_admissions WHERE window_started_at + ? <= ?",
|
||||
(self.window_seconds, now),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT attempts, window_started_at FROM login_admissions "
|
||||
"WHERE bucket = ? AND source_hash = ?",
|
||||
(bucket, source_hash),
|
||||
).fetchone()
|
||||
if row is not None and row[0] >= limit:
|
||||
return max(1, math.ceil(row[1] + self.window_seconds - now))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO login_admissions VALUES (?, ?, 1, ?)",
|
||||
(bucket, source_hash, now),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE login_admissions SET attempts = attempts + 1 "
|
||||
"WHERE bucket = ? AND source_hash = ?",
|
||||
(bucket, source_hash),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM login_admissions
|
||||
WHERE (bucket, source_hash) NOT IN (
|
||||
SELECT bucket, source_hash FROM login_admissions
|
||||
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
|
||||
)
|
||||
""",
|
||||
(self.max_entries,),
|
||||
)
|
||||
return 0
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def _record_alert(
|
||||
self, connection: sqlite3.Connection, method: str, *, blocked: bool, now: float
|
||||
) -> None:
|
||||
safe_method = method if method in {"token", "passkey"} else "unknown"
|
||||
occurred_at = int(now)
|
||||
bucket = occurred_at - (occurred_at % self.alert_bucket_seconds)
|
||||
failed = 0 if blocked else 1
|
||||
blocked_count = 1 if blocked else 0
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO login_alerts(
|
||||
method, bucket_started_at, failed_count, blocked_count, first_at, last_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(method, bucket_started_at) DO UPDATE SET
|
||||
failed_count = failed_count + excluded.failed_count,
|
||||
blocked_count = blocked_count + excluded.blocked_count,
|
||||
first_at = MIN(first_at, excluded.first_at),
|
||||
last_at = MAX(last_at, excluded.last_at)
|
||||
""",
|
||||
(safe_method, bucket, failed, blocked_count, occurred_at, occurred_at),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM login_alerts WHERE (method, bucket_started_at) NOT IN "
|
||||
"(SELECT method, bucket_started_at FROM login_alerts "
|
||||
"ORDER BY bucket_started_at DESC LIMIT ?)",
|
||||
(self.max_alert_buckets,),
|
||||
)
|
||||
|
||||
def record_failure(self, source: str, *, method: str = "token") -> None:
|
||||
now = self.clock()
|
||||
source_hash = self._digest(source)
|
||||
try:
|
||||
@@ -131,11 +235,52 @@ class LoginAttemptStore:
|
||||
""",
|
||||
(self.max_entries,),
|
||||
)
|
||||
self._record_alert(connection, method, blocked=False, now=now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def record_blocked(self, *, method: str = "token") -> None:
|
||||
now = self.clock()
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._record_alert(connection, method, blocked=True, now=now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def list_alerts(self, *, limit: int = 24) -> list[dict[str, int | str]]:
|
||||
bounded_limit = min(100, max(1, limit))
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM login_alerts WHERE last_at < ?",
|
||||
(now - self.alert_retention_seconds,),
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT method, failed_count, blocked_count, first_at, last_at "
|
||||
"FROM login_alerts ORDER BY bucket_started_at DESC LIMIT ?",
|
||||
(bounded_limit,),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
return [
|
||||
{
|
||||
"method": row[0],
|
||||
"failed_count": row[1],
|
||||
"blocked_count": row[2],
|
||||
"first_at": row[3],
|
||||
"last_at": row[4],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def clear(self, source: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
|
||||
+2368
-90
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,338 @@
|
||||
"""Durable, one-time WebAuthn challenges and device-bound passkey credentials."""
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.session_store import SessionStoreError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredPasskey:
|
||||
credential_id: bytes
|
||||
public_key: bytes
|
||||
sign_count: int
|
||||
device_label: str
|
||||
management_id: str
|
||||
created_at: int
|
||||
|
||||
|
||||
class PasskeyStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
clock: Callable[[], float],
|
||||
max_challenges: int = 10_000,
|
||||
max_challenges_per_source: int = 10,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self.max_challenges = max(1, max_challenges)
|
||||
self.max_challenges_per_source = max(1, max_challenges_per_source)
|
||||
|
||||
@staticmethod
|
||||
def _digest(value: bytes | str) -> str:
|
||||
raw = value if isinstance(value, bytes) else value.encode()
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=0.1)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS passkey_credentials (
|
||||
credential_id BLOB PRIMARY KEY,
|
||||
public_key BLOB NOT NULL,
|
||||
sign_count INTEGER NOT NULL,
|
||||
device_label TEXT NOT NULL,
|
||||
management_id TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS passkey_challenges (
|
||||
challenge_hash TEXT PRIMARY KEY,
|
||||
session_hash TEXT,
|
||||
source_hash TEXT,
|
||||
purpose TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(passkey_challenges)")
|
||||
}
|
||||
if "source_hash" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE passkey_challenges ADD COLUMN source_hash TEXT"
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def issue_challenge(
|
||||
self,
|
||||
challenge: bytes,
|
||||
*,
|
||||
session_id: str | None,
|
||||
purpose: str,
|
||||
action: str,
|
||||
target: str,
|
||||
source: str | None = None,
|
||||
ttl_seconds: int = 120,
|
||||
) -> None:
|
||||
now = int(self.clock())
|
||||
source_hash = self._digest(source or session_id or f"{purpose}:{action}:{target}")
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
|
||||
connection.execute(
|
||||
"INSERT INTO passkey_challenges("
|
||||
"challenge_hash, session_hash, source_hash, purpose, action, target, expires_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
self._digest(challenge),
|
||||
self._digest(session_id) if session_id else None,
|
||||
source_hash,
|
||||
purpose,
|
||||
action,
|
||||
target,
|
||||
now + max(1, ttl_seconds),
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM passkey_challenges
|
||||
WHERE source_hash = ? AND challenge_hash NOT IN (
|
||||
SELECT challenge_hash FROM passkey_challenges
|
||||
WHERE source_hash = ?
|
||||
ORDER BY expires_at DESC, rowid DESC LIMIT ?
|
||||
)
|
||||
""",
|
||||
(source_hash, source_hash, self.max_challenges_per_source),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM passkey_challenges
|
||||
WHERE challenge_hash NOT IN (
|
||||
SELECT challenge_hash FROM passkey_challenges
|
||||
ORDER BY expires_at DESC, rowid DESC LIMIT ?
|
||||
)
|
||||
""",
|
||||
(self.max_challenges,),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def consume_challenge(
|
||||
self,
|
||||
challenge: bytes,
|
||||
*,
|
||||
session_id: str | None,
|
||||
purpose: str,
|
||||
action: str,
|
||||
target: str,
|
||||
) -> bool:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM passkey_challenges WHERE challenge_hash = ? "
|
||||
"AND session_hash IS ? AND purpose = ? AND action = ? AND target = ? "
|
||||
"AND expires_at > ?",
|
||||
(
|
||||
self._digest(challenge),
|
||||
self._digest(session_id) if session_id else None,
|
||||
purpose,
|
||||
action,
|
||||
target,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def register(
|
||||
self,
|
||||
*,
|
||||
credential_id: bytes,
|
||||
public_key: bytes,
|
||||
sign_count: int,
|
||||
device_label: str,
|
||||
management_id: str,
|
||||
) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO passkey_credentials(credential_id, public_key, sign_count, "
|
||||
"device_label, management_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
credential_id,
|
||||
public_key,
|
||||
sign_count,
|
||||
device_label,
|
||||
management_id,
|
||||
int(self.clock()),
|
||||
),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def all(self) -> list[StoredPasskey]:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return [StoredPasskey(*row) for row in rows]
|
||||
|
||||
def get(self, credential_id: bytes) -> StoredPasskey | None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials WHERE credential_id = ?",
|
||||
(credential_id,),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return StoredPasskey(*row) if row else None
|
||||
|
||||
def get_management_id(self, management_id: str) -> StoredPasskey | None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return StoredPasskey(*row) if row else None
|
||||
|
||||
def advance_counter(
|
||||
self, credential_id: bytes, *, expected: int, new: int
|
||||
) -> bool:
|
||||
if expected == 0 and new == 0:
|
||||
comparison = "sign_count = 0"
|
||||
elif new > expected:
|
||||
comparison = "sign_count = ?"
|
||||
else:
|
||||
return False
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
parameters = (
|
||||
(new, credential_id)
|
||||
if expected == 0 and new == 0
|
||||
else (new, credential_id, expected)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
f"UPDATE passkey_credentials SET sign_count = ? "
|
||||
f"WHERE credential_id = ? AND {comparison}",
|
||||
parameters,
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_management_id(self, management_id: str) -> bool:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_access(
|
||||
self, management_id: str, *, preserve_session: bool
|
||||
) -> tuple[bool, bool]:
|
||||
"""Atomically remove one passkey and, when requested, its active session."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
credential = connection.execute(
|
||||
"SELECT 1 FROM passkey_credentials WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
if credential is None:
|
||||
return False, False
|
||||
session_revoked = False
|
||||
if not preserve_session:
|
||||
session = connection.execute(
|
||||
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
if session is not None:
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
)
|
||||
session_revoked = cursor.rowcount == 1
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE management_id = ?",
|
||||
(management_id,),
|
||||
)
|
||||
return cursor.rowcount == 1, session_revoked
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all(self) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM passkey_credentials")
|
||||
connection.execute("DELETE FROM passkey_challenges")
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_device_access(self, management_id: str) -> bool:
|
||||
"""Atomically remove one active session, its grants, and its linked passkey."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
session = connection.execute(
|
||||
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
if session is None:
|
||||
return False
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all_access(self) -> None:
|
||||
"""Atomically remove every passkey, challenge, grant, and active session."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM passkey_credentials")
|
||||
connection.execute("DELETE FROM passkey_challenges")
|
||||
connection.execute("DELETE FROM step_up_grants")
|
||||
connection.execute("DELETE FROM active_sessions")
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
@@ -0,0 +1,87 @@
|
||||
"""WebAuthn passkey ceremony helpers for the single dashboard operator."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from webauthn import (
|
||||
generate_authentication_options,
|
||||
generate_registration_options,
|
||||
options_to_json,
|
||||
verify_authentication_response,
|
||||
verify_registration_response,
|
||||
)
|
||||
from webauthn.helpers.structs import (
|
||||
AuthenticatorSelectionCriteria,
|
||||
PublicKeyCredentialDescriptor,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
|
||||
def encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def decode(value: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
|
||||
|
||||
def registration_options(*, rp_id: str, excluded: list[bytes] | None = None) -> tuple[dict, bytes]:
|
||||
challenge = secrets.token_bytes(32)
|
||||
options = generate_registration_options(
|
||||
rp_id=rp_id,
|
||||
rp_name="Stackchain Dashboard",
|
||||
user_name="stackchain-operator",
|
||||
user_display_name="Stackchain operator",
|
||||
challenge=challenge,
|
||||
exclude_credentials=[
|
||||
PublicKeyCredentialDescriptor(id=credential_id)
|
||||
for credential_id in (excluded or [])
|
||||
],
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.REQUIRED,
|
||||
),
|
||||
)
|
||||
return json.loads(options_to_json(options)), challenge
|
||||
|
||||
|
||||
def authentication_options(*, rp_id: str, credentials: list[bytes]) -> tuple[dict, bytes]:
|
||||
challenge = secrets.token_bytes(32)
|
||||
options = generate_authentication_options(
|
||||
rp_id=rp_id,
|
||||
challenge=challenge,
|
||||
allow_credentials=[
|
||||
PublicKeyCredentialDescriptor(id=credential_id)
|
||||
for credential_id in credentials
|
||||
],
|
||||
user_verification=UserVerificationRequirement.REQUIRED,
|
||||
)
|
||||
return json.loads(options_to_json(options)), challenge
|
||||
|
||||
|
||||
def verify_registration(
|
||||
*, credential: dict, challenge: bytes, rp_id: str, origin: str
|
||||
):
|
||||
return verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=rp_id,
|
||||
expected_origin=origin,
|
||||
require_user_verification=True,
|
||||
)
|
||||
|
||||
|
||||
def verify_authentication(
|
||||
*, credential: dict, challenge: bytes, rp_id: str, origin: str, stored
|
||||
):
|
||||
return verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=rp_id,
|
||||
expected_origin=origin,
|
||||
credential_public_key=stored.public_key,
|
||||
credential_current_sign_count=stored.sign_count,
|
||||
require_user_verification=True,
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from collections.abc import Awaitable, Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
class UnsafePushEndpoint(ValueError):
|
||||
"""Raised when a Web Push endpoint could reach a non-public service."""
|
||||
|
||||
|
||||
Resolver = Callable[[str, int], Awaitable[list[str]]]
|
||||
|
||||
|
||||
async def _resolve(host: str, port: int) -> list[str]:
|
||||
loop = asyncio.get_running_loop()
|
||||
records = await loop.getaddrinfo(
|
||||
host,
|
||||
port,
|
||||
family=socket.AF_UNSPEC,
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
return sorted({record[4][0] for record in records})
|
||||
|
||||
|
||||
async def validate_public_push_endpoint(
|
||||
endpoint: str,
|
||||
*,
|
||||
resolver: Resolver = _resolve,
|
||||
timeout_seconds: float = 2.0,
|
||||
) -> str:
|
||||
"""Return a canonical public HTTPS push endpoint or fail closed."""
|
||||
parsed = urlsplit(endpoint)
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as error:
|
||||
raise UnsafePushEndpoint(
|
||||
"Endpoint must be a canonical public Web Push service URL"
|
||||
) from error
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
or port not in {None, 443}
|
||||
):
|
||||
raise UnsafePushEndpoint("Endpoint must be a canonical public Web Push service URL")
|
||||
try:
|
||||
addresses = await asyncio.wait_for(
|
||||
resolver(parsed.hostname, port or 443),
|
||||
timeout=max(0.01, timeout_seconds),
|
||||
)
|
||||
except (OSError, asyncio.TimeoutError) as error:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
|
||||
try:
|
||||
public = addresses and all(ipaddress.ip_address(address).is_global for address in addresses)
|
||||
except ValueError as error:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
|
||||
if not public:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
|
||||
return endpoint
|
||||
@@ -0,0 +1,236 @@
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushConfiguration:
|
||||
public_key: str
|
||||
private_key: str
|
||||
subject: str
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.public_key and self.private_key and self.subject)
|
||||
|
||||
|
||||
async def send_web_push(
|
||||
subscription: dict, payload: str, configuration: PushConfiguration
|
||||
) -> None:
|
||||
import requests
|
||||
from pywebpush import webpush
|
||||
|
||||
session = requests.Session()
|
||||
session.max_redirects = 0
|
||||
|
||||
await asyncio.to_thread(
|
||||
webpush,
|
||||
subscription_info=subscription,
|
||||
data=payload,
|
||||
vapid_private_key=configuration.private_key,
|
||||
vapid_claims={"sub": configuration.subject},
|
||||
ttl=300,
|
||||
timeout=10,
|
||||
requests_session=session,
|
||||
)
|
||||
|
||||
|
||||
async def dispatch_unread_updates(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
unread: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
session_active: Callable[[str], Awaitable[bool]] | None = None,
|
||||
lease_seconds: float = 60.0,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
max_individual_notifications: int = 3,
|
||||
endpoint_validator: Callable[[str], Awaitable[str]] | None = None,
|
||||
) -> int:
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
page = await unread()
|
||||
if page.get("complete") is False:
|
||||
return 0
|
||||
thread_revisions = {
|
||||
int(item["id"]): str(item.get("updated_at") or "")
|
||||
for item in page.get("items", [])
|
||||
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
|
||||
}
|
||||
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:
|
||||
try:
|
||||
authorized = [await session_active(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:
|
||||
await asyncio.to_thread(store.delete_session, delivery.session_id)
|
||||
deliveries = [
|
||||
delivery
|
||||
for delivery, active in zip(deliveries, authorized)
|
||||
if active
|
||||
]
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
ownership_lost = asyncio.Event()
|
||||
|
||||
async def dispatch_device(delivery) -> int:
|
||||
async with semaphore:
|
||||
if ownership_lost.is_set():
|
||||
return 0
|
||||
validate_endpoint = endpoint_validator
|
||||
if validate_endpoint is None and send is None:
|
||||
validate_endpoint = validate_public_push_endpoint
|
||||
try:
|
||||
if validate_endpoint is not None:
|
||||
await validate_endpoint(delivery.subscription["endpoint"])
|
||||
except UnsafePushEndpoint:
|
||||
await asyncio.to_thread(store.delete_session, delivery.session_id)
|
||||
return 0
|
||||
count = 0
|
||||
digest_pending = set(delivery.digest_revisions)
|
||||
new_revisions = tuple(
|
||||
thread_revision
|
||||
for thread_revision in delivery.thread_revisions
|
||||
if thread_revision not in digest_pending
|
||||
)
|
||||
individual_revisions = new_revisions[
|
||||
:max(0, max_individual_notifications)
|
||||
]
|
||||
overflow_revisions = (
|
||||
delivery.digest_revisions
|
||||
+ new_revisions[len(individual_revisions):]
|
||||
)
|
||||
can_send_digest = True
|
||||
for thread_id, revision in individual_revisions:
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
if not still_owner:
|
||||
ownership_lost.set()
|
||||
return count
|
||||
payload = json.dumps(
|
||||
{
|
||||
"title": "New work update",
|
||||
"body": "Tap to review it in Stackchain.",
|
||||
"route": f"#/my-work/update/{thread_id}",
|
||||
"tag": f"stackchain-update-{thread_id}",
|
||||
"notification_id": thread_id,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
try:
|
||||
if send is None:
|
||||
operation = send_web_push(
|
||||
delivery.subscription, payload, configuration
|
||||
)
|
||||
else:
|
||||
operation = send(delivery.subscription, payload)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(
|
||||
getattr(error, "response", None), "status_code", None
|
||||
)
|
||||
if status in {404, 410}:
|
||||
await asyncio.to_thread(
|
||||
store.delete_session, delivery.session_id
|
||||
)
|
||||
can_send_digest = False
|
||||
# Leave this device's transient failures unseen for a later
|
||||
# poll instead of paying the endpoint deadline repeatedly.
|
||||
break
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivered,
|
||||
delivery.session_id,
|
||||
((thread_id, revision),),
|
||||
)
|
||||
count += 1
|
||||
if overflow_revisions and can_send_digest:
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
if not still_owner:
|
||||
ownership_lost.set()
|
||||
return count
|
||||
payload = json.dumps(
|
||||
{
|
||||
"title": f"{len(overflow_revisions)} new work updates",
|
||||
"body": "Tap to review them in Stackchain.",
|
||||
"route": "#/my-work/updates",
|
||||
"tag": "stackchain-update-digest",
|
||||
"update_count": len(overflow_revisions),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
try:
|
||||
if send is None:
|
||||
operation = send_web_push(
|
||||
delivery.subscription, payload, configuration
|
||||
)
|
||||
else:
|
||||
operation = send(delivery.subscription, payload)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(
|
||||
getattr(error, "response", None), "status_code", None
|
||||
)
|
||||
if status in {404, 410}:
|
||||
await asyncio.to_thread(
|
||||
store.delete_session, delivery.session_id
|
||||
)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_digest_pending,
|
||||
delivery.session_id,
|
||||
overflow_revisions,
|
||||
)
|
||||
return count
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivered,
|
||||
delivery.session_id,
|
||||
overflow_revisions,
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def dispatch_device_safely(delivery) -> int:
|
||||
try:
|
||||
return await dispatch_device(delivery)
|
||||
except Exception:
|
||||
# Device-specific validation, persistence, or provider failures
|
||||
# must not cancel healthy siblings. Leave any uncheckpointed
|
||||
# revisions unseen so a later poll can retry them.
|
||||
return 0
|
||||
|
||||
counts = await asyncio.gather(
|
||||
*(dispatch_device_safely(delivery) for delivery in deliveries)
|
||||
)
|
||||
return sum(counts)
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner)
|
||||
@@ -0,0 +1,285 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushDelivery:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
thread_revisions: tuple[tuple[int, str], ...]
|
||||
digest_revisions: tuple[tuple[int, str], ...] = ()
|
||||
|
||||
@property
|
||||
def thread_ids(self) -> tuple[int, ...]:
|
||||
return tuple(thread_id for thread_id, _revision in self.thread_revisions)
|
||||
|
||||
@property
|
||||
def digest_ids(self) -> tuple[int, ...]:
|
||||
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
|
||||
|
||||
|
||||
def _revisions(
|
||||
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
) -> tuple[tuple[int, str], ...]:
|
||||
if isinstance(values, Mapping):
|
||||
items = list(values.items())
|
||||
else:
|
||||
items = []
|
||||
for value in values:
|
||||
if isinstance(value, tuple):
|
||||
items.append(value)
|
||||
else:
|
||||
items.append((value, ""))
|
||||
normalized = {}
|
||||
for thread_id, revision in items:
|
||||
thread_id = int(thread_id)
|
||||
if thread_id > 0:
|
||||
normalized[thread_id] = str(revision or "")
|
||||
return tuple(sorted(normalized.items()))
|
||||
|
||||
|
||||
class PushSubscriptionStore:
|
||||
"""Durable, device-bound Web Push subscriptions and delivery deduplication."""
|
||||
|
||||
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(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
subscription_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_deliveries (
|
||||
session_id TEXT NOT NULL,
|
||||
thread_id INTEGER NOT NULL,
|
||||
revision TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (session_id, thread_id),
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_dispatch_lease (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
owner TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_digest_pending (
|
||||
session_id TEXT NOT NULL,
|
||||
thread_id INTEGER NOT NULL,
|
||||
revision TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (session_id, thread_id),
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
delivery_columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(push_deliveries)")
|
||||
}
|
||||
if "revision" not in delivery_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_deliveries ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
|
||||
)
|
||||
digest_columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(push_digest_pending)")
|
||||
}
|
||||
if "revision" not in digest_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_digest_pending ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
|
||||
)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.path, timeout=2)
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
return connection
|
||||
|
||||
def acquire_dispatch_lease(
|
||||
self, owner: str, *, 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"
|
||||
).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
|
||||
owner = excluded.owner, expires_at = excluded.expires_at""",
|
||||
(owner, now + lease_seconds),
|
||||
)
|
||||
return True
|
||||
|
||||
def release_dispatch_lease(self, owner: str) -> bool:
|
||||
with self._connect() as connection:
|
||||
result = connection.execute(
|
||||
"DELETE FROM push_dispatch_lease WHERE singleton = 1 AND owner = ?",
|
||||
(owner,),
|
||||
)
|
||||
return result.rowcount == 1
|
||||
|
||||
def upsert(self, session_id: str, subscription: dict) -> None:
|
||||
endpoint = subscription["endpoint"]
|
||||
encoded = json.dumps(subscription, separators=(",", ":"), sort_keys=True)
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,))
|
||||
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
||||
connection.execute(
|
||||
"INSERT INTO push_subscriptions(session_id, endpoint, subscription_json) VALUES (?, ?, ?)",
|
||||
(session_id, endpoint, encoded),
|
||||
)
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
||||
|
||||
def delete_all(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions")
|
||||
|
||||
def is_subscribed(self, session_id: str) -> bool:
|
||||
with self._connect() as connection:
|
||||
return connection.execute(
|
||||
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
||||
).fetchone() is not None
|
||||
|
||||
def claim_unseen(
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||
) -> list[PushDelivery]:
|
||||
candidates = _revisions(thread_revisions)
|
||||
if not candidates:
|
||||
return []
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT session_id, subscription_json FROM push_subscriptions ORDER BY session_id"
|
||||
).fetchall()
|
||||
deliveries = []
|
||||
for session_id, encoded in rows:
|
||||
delivered = {
|
||||
row[0]: row[1]
|
||||
for row in connection.execute(
|
||||
"SELECT thread_id, revision FROM push_deliveries WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
}
|
||||
for thread_id, revision in candidates:
|
||||
if delivered.get(thread_id) == "*":
|
||||
connection.execute(
|
||||
"""UPDATE push_deliveries SET revision = ?
|
||||
WHERE session_id = ? AND thread_id = ?""",
|
||||
(revision, session_id, thread_id),
|
||||
)
|
||||
delivered[thread_id] = revision
|
||||
unseen = tuple(
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if (
|
||||
candidate[0] not in delivered
|
||||
or (
|
||||
bool(candidate[1])
|
||||
and (
|
||||
not delivered[candidate[0]]
|
||||
or candidate[1] > delivered[candidate[0]]
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
if unseen:
|
||||
pending = {
|
||||
row[0]: row[1]
|
||||
for row in connection.execute(
|
||||
"SELECT thread_id, revision FROM push_digest_pending WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
}
|
||||
digest_revisions = []
|
||||
for thread_id, revision in unseen:
|
||||
if pending.get(thread_id) in {revision, "*"}:
|
||||
digest_revisions.append((thread_id, revision))
|
||||
if pending[thread_id] == "*":
|
||||
connection.execute(
|
||||
"""UPDATE push_digest_pending SET revision = ?
|
||||
WHERE session_id = ? AND thread_id = ?""",
|
||||
(revision, session_id, thread_id),
|
||||
)
|
||||
deliveries.append(
|
||||
PushDelivery(
|
||||
session_id,
|
||||
json.loads(encoded),
|
||||
unseen,
|
||||
tuple(digest_revisions),
|
||||
)
|
||||
)
|
||||
return deliveries
|
||||
|
||||
def reconcile_unread(self, thread_ids: Iterable[int]) -> None:
|
||||
"""Prune per-device checkpoints that are absent from a complete snapshot."""
|
||||
unread_ids = tuple(sorted({int(value) for value in thread_ids if int(value) > 0}))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"CREATE TEMP TABLE current_unread(thread_id INTEGER PRIMARY KEY)"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO current_unread(thread_id) VALUES (?)",
|
||||
((thread_id,) for thread_id in unread_ids),
|
||||
)
|
||||
connection.execute(
|
||||
"""DELETE FROM push_deliveries
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM current_unread
|
||||
WHERE current_unread.thread_id = push_deliveries.thread_id
|
||||
)"""
|
||||
)
|
||||
connection.execute(
|
||||
"""DELETE FROM push_digest_pending
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM current_unread
|
||||
WHERE current_unread.thread_id = push_digest_pending.thread_id
|
||||
)"""
|
||||
)
|
||||
|
||||
def mark_digest_pending(
|
||||
self,
|
||||
session_id: str,
|
||||
thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
) -> None:
|
||||
values = _revisions(thread_revisions)
|
||||
with self._connect() as connection:
|
||||
connection.executemany(
|
||||
"""INSERT INTO push_digest_pending(session_id, thread_id, revision)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id, thread_id) DO UPDATE SET
|
||||
revision = excluded.revision""",
|
||||
((session_id, thread_id, revision) for thread_id, revision in values),
|
||||
)
|
||||
|
||||
def mark_delivered(
|
||||
self,
|
||||
session_id: str,
|
||||
thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
) -> None:
|
||||
values = _revisions(thread_revisions)
|
||||
with self._connect() as connection:
|
||||
connection.executemany(
|
||||
"""INSERT INTO push_deliveries(session_id, thread_id, revision)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id, thread_id) DO UPDATE SET
|
||||
revision = excluded.revision""",
|
||||
((session_id, thread_id, revision) for thread_id, revision in values),
|
||||
)
|
||||
connection.executemany(
|
||||
"DELETE FROM push_digest_pending WHERE session_id = ? AND thread_id = ?",
|
||||
((session_id, thread_id) for thread_id, _revision in values),
|
||||
)
|
||||
@@ -5,6 +5,8 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
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
|
||||
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
||||
|
||||
|
||||
@@ -13,6 +15,15 @@ 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 == "POST"
|
||||
and (
|
||||
(path.startswith("/api/v1/repos/") and ("/issues/" in path or "/pulls/" in path))
|
||||
or path.startswith("/api/v1/notifications/")
|
||||
)
|
||||
and path.endswith("/attachments")
|
||||
):
|
||||
return ISSUE_ATTACHMENT_BODY_LIMIT
|
||||
if normalized_method in MUTATION_METHODS and path.startswith("/api/v1/"):
|
||||
return API_MUTATION_BODY_LIMIT
|
||||
return None
|
||||
@@ -42,6 +53,11 @@ class RequestBodyLimitMiddleware:
|
||||
if limit is None:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
if (
|
||||
limit == ISSUE_ATTACHMENT_BODY_LIMIT
|
||||
and self._header(scope, b"content-type").startswith(b"application/json")
|
||||
):
|
||||
limit = LEGACY_JSON_ATTACHMENT_BODY_LIMIT
|
||||
|
||||
declared_length = self._content_length(scope)
|
||||
if declared_length is not None and declared_length > limit:
|
||||
@@ -69,6 +85,13 @@ class RequestBodyLimitMiddleware:
|
||||
|
||||
await self.app(scope, replay, send)
|
||||
|
||||
@staticmethod
|
||||
def _header(scope: Scope, wanted: bytes) -> bytes:
|
||||
for name, value in scope.get("headers", []):
|
||||
if name.lower() == wanted:
|
||||
return value.lower()
|
||||
return b""
|
||||
|
||||
@staticmethod
|
||||
def _content_length(scope: Scope) -> int | None:
|
||||
for name, value in scope.get("headers", []):
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Bounded, privacy-preserving journal of operator security activity."""
|
||||
|
||||
import secrets
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class SecurityEventStoreError(RuntimeError):
|
||||
"""Raised when security activity cannot be persisted or read safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecurityEvent:
|
||||
id: int
|
||||
kind: str
|
||||
method: str | None
|
||||
device_label: str | None
|
||||
target: str | None
|
||||
created_at: int
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecurityEventPage:
|
||||
events: list[SecurityEvent]
|
||||
next_cursor: int | None
|
||||
|
||||
|
||||
class SecurityEventStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
clock: Callable[[], float],
|
||||
max_events: int = 10_000,
|
||||
retention_seconds: int = 90 * 24 * 60 * 60,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self.max_events = max(1, max_events)
|
||||
self.retention_seconds = max(1, retention_seconds)
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
|
||||
@staticmethod
|
||||
def _bounded(value: str | None, limit: int) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = " ".join(str(value).split())[:limit]
|
||||
return normalized or None
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS security_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
method TEXT,
|
||||
device_label TEXT,
|
||||
target TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(security_events)")
|
||||
}
|
||||
if "status" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'"
|
||||
)
|
||||
if "operation_id" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS security_events_created "
|
||||
"ON security_events(created_at DESC, id DESC)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
|
||||
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def _prune(self, connection: sqlite3.Connection, now: int) -> None:
|
||||
connection.execute(
|
||||
"DELETE FROM security_events WHERE created_at < ?",
|
||||
(now - self.retention_seconds,),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM security_events WHERE id NOT IN "
|
||||
"(SELECT id FROM security_events ORDER BY id DESC LIMIT ?)",
|
||||
(self.max_events,),
|
||||
)
|
||||
|
||||
def record(
|
||||
self,
|
||||
kind: str,
|
||||
*,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
) -> None:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO security_events(kind, method, device_label, target, created_at, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'completed')",
|
||||
(
|
||||
self._bounded(kind, 48) or "security_event",
|
||||
self._bounded(method, 32),
|
||||
self._bounded(device_label, 64),
|
||||
self._bounded(target, 255),
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._prune(connection, now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def reserve(
|
||||
self,
|
||||
kind: str,
|
||||
*,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
) -> str:
|
||||
now = int(self.clock())
|
||||
operation_id = secrets.token_urlsafe(24)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
|
||||
(
|
||||
self._bounded(kind, 48) or "security_event",
|
||||
self._bounded(method, 32),
|
||||
self._bounded(device_label, 64),
|
||||
self._bounded(target, 255),
|
||||
now,
|
||||
operation_id,
|
||||
),
|
||||
)
|
||||
self._prune(connection, now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
return operation_id
|
||||
|
||||
def finalize(self, operation_id: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"UPDATE security_events SET status = 'completed' WHERE operation_id = ?",
|
||||
(operation_id,),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity reservation was not found"
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def discard(self, operation_id: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM security_events WHERE operation_id = ? AND status = 'pending'",
|
||||
(operation_id,),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def list(self, *, limit: int = 50, cursor: int | None = None) -> SecurityEventPage:
|
||||
bounded_limit = min(100, max(1, limit))
|
||||
parameters: list[int] = []
|
||||
where = ""
|
||||
if cursor is not None:
|
||||
where = "WHERE id < ?"
|
||||
parameters.append(cursor)
|
||||
parameters.append(bounded_limit + 1)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT id, kind, method, device_label, target, created_at, status "
|
||||
f"FROM security_events {where} ORDER BY id DESC LIMIT ?",
|
||||
parameters,
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
has_more = len(rows) > bounded_limit
|
||||
visible = rows[:bounded_limit]
|
||||
return SecurityEventPage(
|
||||
events=[SecurityEvent(*row) for row in visible],
|
||||
next_cursor=visible[-1][0] if has_more else None,
|
||||
)
|
||||
+109
-5
@@ -12,6 +12,17 @@ class SessionStoreError(RuntimeError):
|
||||
"""Raised when session state cannot be read or changed safely."""
|
||||
|
||||
|
||||
class SessionStatus(str):
|
||||
"""String-compatible status carrying the server-confirmed idle deadline."""
|
||||
|
||||
idle_expires_at: int | None
|
||||
|
||||
def __new__(cls, value: str, idle_expires_at: int | None = None):
|
||||
instance = super().__new__(cls, value)
|
||||
instance.idle_expires_at = idle_expires_at
|
||||
return instance
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveDevice:
|
||||
management_id: str
|
||||
@@ -58,7 +69,8 @@ class SessionStore:
|
||||
expires_at INTEGER NOT NULL,
|
||||
management_id TEXT,
|
||||
device_label TEXT,
|
||||
created_at INTEGER
|
||||
created_at INTEGER,
|
||||
last_active_at INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
@@ -84,6 +96,7 @@ class SessionStore:
|
||||
"management_id": "TEXT",
|
||||
"device_label": "TEXT",
|
||||
"created_at": "INTEGER",
|
||||
"last_active_at": "INTEGER",
|
||||
}
|
||||
for name, column_type in additions.items():
|
||||
if name not in columns:
|
||||
@@ -102,6 +115,10 @@ class SessionStore:
|
||||
"UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL",
|
||||
(int(self.clock()),),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE active_sessions SET last_active_at = ? WHERE last_active_at IS NULL",
|
||||
(int(self.clock()),),
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id "
|
||||
"ON active_sessions(management_id)"
|
||||
@@ -111,7 +128,12 @@ class SessionStore:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def activate(
|
||||
self, session_id: str, expires_at: int, *, device_label: str = "This device"
|
||||
self,
|
||||
session_id: str,
|
||||
expires_at: int,
|
||||
*,
|
||||
device_label: str = "This device",
|
||||
management_id: str | None = None,
|
||||
) -> None:
|
||||
label = " ".join(str(device_label).split())[:64] or "This device"
|
||||
now = int(self.clock())
|
||||
@@ -122,14 +144,15 @@ class SessionStore:
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO active_sessions("
|
||||
"session_hash, expires_at, management_id, device_label, created_at"
|
||||
") VALUES (?, ?, ?, ?, ?)",
|
||||
"session_hash, expires_at, management_id, device_label, created_at, last_active_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
self._digest(session_id),
|
||||
expires_at,
|
||||
secrets.token_urlsafe(18),
|
||||
management_id or secrets.token_urlsafe(18),
|
||||
label,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
@@ -147,6 +170,56 @@ class SessionStore:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
return row is not None and row[0] == expires_at and expires_at > now
|
||||
|
||||
def status(
|
||||
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
|
||||
) -> str:
|
||||
now = int(self.clock())
|
||||
query = (
|
||||
"SELECT expires_at, last_active_at FROM active_sessions "
|
||||
"WHERE session_hash = ?"
|
||||
)
|
||||
parameters = (self._digest(session_id),)
|
||||
try:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(query, parameters).fetchone()
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "no such column: last_active_at" not in str(exc):
|
||||
raise
|
||||
with self._connect(initialize=True) as connection:
|
||||
row = connection.execute(query, parameters).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
if row is None or row[0] != expires_at or expires_at <= now:
|
||||
return SessionStatus("revoked")
|
||||
idle_expires_at = min(row[0], row[1] + max(1, idle_timeout_seconds))
|
||||
if idle_expires_at <= now:
|
||||
return SessionStatus("idle", idle_expires_at)
|
||||
return SessionStatus("active", idle_expires_at)
|
||||
|
||||
def touch(
|
||||
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
|
||||
) -> bool:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"UPDATE active_sessions SET last_active_at = ? "
|
||||
"WHERE session_hash = ? AND expires_at = ? AND expires_at > ? "
|
||||
"AND last_active_at + ? > ?",
|
||||
(
|
||||
now,
|
||||
self._digest(session_id),
|
||||
expires_at,
|
||||
now,
|
||||
max(1, idle_timeout_seconds),
|
||||
now,
|
||||
),
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke(self, session_id: str) -> None:
|
||||
try:
|
||||
with self._connect(initialize=True) as connection:
|
||||
@@ -185,6 +258,37 @@ class SessionStore:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def management_id(self, session_id: str) -> str:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT management_id FROM active_sessions WHERE session_hash = ?",
|
||||
(self._digest(session_id),),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
if row is None:
|
||||
raise SessionStoreError("Session is no longer active")
|
||||
return row[0]
|
||||
|
||||
def managed_status(self, management_id: str, *, idle_timeout_seconds: int) -> str:
|
||||
"""Return the authorization state for a durable device identifier."""
|
||||
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()
|
||||
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"
|
||||
|
||||
def revoke_managed(self, management_id: str) -> bool:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
|
||||
+287
-18
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -19,6 +20,7 @@ class TodayStore:
|
||||
timeout: float = 1.0,
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
recap_limit: int = 100,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
@@ -26,6 +28,7 @@ class TodayStore:
|
||||
self.timeout = timeout
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.recap_limit = recap_limit
|
||||
self.clock = clock
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
@@ -37,10 +40,23 @@ class TodayStore:
|
||||
CREATE TABLE IF NOT EXISTS today_plans (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
ids TEXT NOT NULL
|
||||
ids TEXT NOT NULL,
|
||||
capacity_minutes INTEGER,
|
||||
estimates TEXT NOT NULL DEFAULT '{}',
|
||||
plan_date TEXT,
|
||||
timezone TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
plan_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_plans)")}
|
||||
if "capacity_minutes" not in plan_columns:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN capacity_minutes INTEGER")
|
||||
if "estimates" not in plan_columns:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN estimates TEXT NOT NULL DEFAULT '{}'")
|
||||
if "plan_date" not in plan_columns:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN plan_date TEXT")
|
||||
if "timezone" not in plan_columns:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN timezone TEXT")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_operations (
|
||||
@@ -58,6 +74,33 @@ class TodayStore:
|
||||
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(self.clock(),),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_recaps (
|
||||
login TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
items TEXT NOT NULL,
|
||||
PRIMARY KEY (login, session_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS today_recaps_recent "
|
||||
"ON today_recaps(login, created_at DESC)"
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_time_logs (
|
||||
login TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
identity TEXT NOT NULL,
|
||||
actual_minutes INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
PRIMARY KEY (login, session_id, identity)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
return connection
|
||||
|
||||
@@ -88,17 +131,149 @@ class TodayStore:
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
if row is None:
|
||||
return {"revision": 0, "ids": []}
|
||||
return {"revision": int(row[0]), "ids": json.loads(row[1])}
|
||||
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}
|
||||
ids = json.loads(row[1])
|
||||
estimates = json.loads(row[3] or "{}")
|
||||
snapshot = {
|
||||
"revision": int(row[0]),
|
||||
"ids": ids,
|
||||
"capacity_minutes": row[2],
|
||||
"estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
|
||||
}
|
||||
if len(row) > 4 and row[4]:
|
||||
snapshot["plan_date"] = row[4]
|
||||
snapshot["timezone"] = row[5]
|
||||
return snapshot
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?",
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_recap_items(items: list[dict]) -> list[dict]:
|
||||
if not isinstance(items, list) or not items:
|
||||
raise ValueError("recap requires at least one item")
|
||||
if len(items) > 20:
|
||||
raise ValueError("recap is limited to 20 items")
|
||||
normalized = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
identity = item.get("identity", "") if isinstance(item, dict) else ""
|
||||
if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
|
||||
raise ValueError("recap item identity is required and bounded")
|
||||
identity = identity.strip()
|
||||
if identity in seen:
|
||||
raise ValueError("recap item identities must be unique")
|
||||
actual = item.get("actual_minutes")
|
||||
estimate = item.get("estimate_minutes")
|
||||
if not isinstance(actual, int) or isinstance(actual, bool) or actual < 0 or actual > 1440:
|
||||
raise ValueError("actual minutes must be between 0 and 1440")
|
||||
if estimate is not None and (
|
||||
not isinstance(estimate, int) or isinstance(estimate, bool)
|
||||
or estimate < 5 or estimate > 1440
|
||||
):
|
||||
raise ValueError("estimate minutes must be between 5 and 1440")
|
||||
seen.add(identity)
|
||||
normalized.append({
|
||||
"identity": identity,
|
||||
"estimate_minutes": estimate,
|
||||
"actual_minutes": actual,
|
||||
})
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _recap_snapshot(session_id: str, created_at: float, serialized: str) -> dict:
|
||||
items = json.loads(serialized)
|
||||
estimated = sum(item["estimate_minutes"] for item in items if item["estimate_minutes"] is not None)
|
||||
actual = sum(item["actual_minutes"] for item in items)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"created_at": created_at,
|
||||
"items": items,
|
||||
"estimated_minutes": estimated,
|
||||
"actual_minutes": actual,
|
||||
"variance_minutes": actual - estimated,
|
||||
}
|
||||
|
||||
def save_recap(self, login: str, session_id: str, items: list[dict]) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
if not isinstance(session_id, str) or not session_id.strip() or len(session_id) > 100:
|
||||
raise ValueError("session_id is required and bounded")
|
||||
session_id = session_id.strip()
|
||||
normalized = self._normalize_recap_items(items)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT created_at, items FROM today_recaps WHERE login = ? AND session_id = ?",
|
||||
(login, session_id),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return self._recap_snapshot(session_id, existing[0], existing[1])
|
||||
created_at = self.clock()
|
||||
connection.execute(
|
||||
"INSERT INTO today_recaps(login, session_id, created_at, items) VALUES (?, ?, ?, ?)",
|
||||
(login, session_id, created_at, serialized),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM today_recaps WHERE login = ? AND rowid NOT IN "
|
||||
"(SELECT rowid FROM today_recaps WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||
(login, login, self.recap_limit),
|
||||
)
|
||||
return self._recap_snapshot(session_id, created_at, serialized)
|
||||
|
||||
def list_recaps(self, login: str, *, limit: int = 30) -> list[dict]:
|
||||
bounded_limit = max(1, min(int(limit), self.recap_limit, 100))
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT session_id, created_at, items FROM today_recaps WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||
(self._normalize_login(login), bounded_limit),
|
||||
).fetchall()
|
||||
return [self._recap_snapshot(*row) for row in rows]
|
||||
|
||||
def begin_time_log(self, login: str, session_id: str, identity: str, actual_minutes: int) -> str:
|
||||
"""Claim one recap item for upstream logging, returning its current state."""
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT actual_minutes, status FROM today_time_logs "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(login, session_id, identity),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_time_logs(login, session_id, identity, actual_minutes, status) "
|
||||
"VALUES (?, ?, ?, ?, 'pending')",
|
||||
(login, session_id, identity, actual_minutes),
|
||||
)
|
||||
return "claimed"
|
||||
if row[0] != actual_minutes:
|
||||
raise ValueError("logged recap time cannot be changed")
|
||||
if row[1] == "failed":
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET status = 'pending' "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ?",
|
||||
(login, session_id, identity),
|
||||
)
|
||||
return "claimed"
|
||||
return row[1]
|
||||
|
||||
def finish_time_log(self, login: str, session_id: str, identity: str, *, succeeded: bool) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE today_time_logs SET status = ? "
|
||||
"WHERE login = ? AND session_id = ? AND identity = ? AND status = 'pending'",
|
||||
("logged" if succeeded else "failed", self._normalize_login(login), session_id, identity),
|
||||
)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
login: str,
|
||||
@@ -119,7 +294,8 @@ class TodayStore:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
duplicate = connection.execute(
|
||||
@@ -130,6 +306,7 @@ class TodayStore:
|
||||
return snapshot
|
||||
|
||||
ids = list(snapshot["ids"])
|
||||
estimates = dict(snapshot["estimates"])
|
||||
changed = False
|
||||
if action == "add":
|
||||
if item_id not in ids:
|
||||
@@ -140,6 +317,7 @@ class TodayStore:
|
||||
elif action == "remove":
|
||||
if item_id in ids:
|
||||
ids.remove(item_id)
|
||||
estimates.pop(item_id, None)
|
||||
changed = True
|
||||
else:
|
||||
try:
|
||||
@@ -152,18 +330,31 @@ class TodayStore:
|
||||
changed = True
|
||||
|
||||
revision = snapshot["revision"] + (1 if changed else 0)
|
||||
serialized_ids = json.dumps(ids, separators=(",", ":"))
|
||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
|
||||
(login, revision, json.dumps(ids, separators=(",", ":"))),
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates,
|
||||
snapshot.get("plan_date"), snapshot.get("timezone")),
|
||||
)
|
||||
elif changed:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
||||
(revision, json.dumps(ids, separators=(",", ":")), login),
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, estimates = ? WHERE login = ?",
|
||||
(revision, serialized_ids, serialized_estimates, login),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
return {"revision": revision, "ids": ids}
|
||||
result = {
|
||||
"revision": revision,
|
||||
"ids": ids,
|
||||
"capacity_minutes": snapshot["capacity_minutes"],
|
||||
"estimates": estimates,
|
||||
}
|
||||
if snapshot.get("plan_date"):
|
||||
result["plan_date"] = snapshot["plan_date"]
|
||||
result["timezone"] = snapshot["timezone"]
|
||||
return result
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
"""Apply an ordered batch with one lock and receipt per operation."""
|
||||
@@ -171,10 +362,15 @@ class TodayStore:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
ids = list(snapshot["ids"])
|
||||
capacity_minutes = snapshot["capacity_minutes"]
|
||||
estimates = dict(snapshot["estimates"])
|
||||
plan_date = snapshot.get("plan_date")
|
||||
timezone = snapshot.get("timezone")
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
@@ -187,7 +383,7 @@ class TodayStore:
|
||||
direction = operation.get("direction")
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"add", "remove", "move"}:
|
||||
if action not in {"add", "remove", "move", "configure", "rollover"}:
|
||||
raise ValueError("unsupported Today action")
|
||||
if action == "move" and direction not in {"up", "down"}:
|
||||
raise ValueError("move direction must be up or down")
|
||||
@@ -220,8 +416,9 @@ class TodayStore:
|
||||
elif action == "remove":
|
||||
if item_id in ids:
|
||||
ids.remove(item_id)
|
||||
estimates.pop(item_id, None)
|
||||
changed = True
|
||||
else:
|
||||
elif action == "move":
|
||||
try:
|
||||
index = ids.index(item_id)
|
||||
except ValueError:
|
||||
@@ -230,25 +427,97 @@ class TodayStore:
|
||||
if index >= 0 and 0 <= target < len(ids):
|
||||
ids[index], ids[target] = ids[target], ids[index]
|
||||
changed = True
|
||||
elif action == "configure":
|
||||
proposed_capacity = operation.get("capacity_minutes")
|
||||
if proposed_capacity is not None and (
|
||||
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
|
||||
or proposed_capacity < 15 or proposed_capacity > 1440
|
||||
):
|
||||
raise ValueError("capacity_minutes must be between 15 and 1440")
|
||||
proposed_estimates = operation.get("estimates", {})
|
||||
if not isinstance(proposed_estimates, dict):
|
||||
raise ValueError("estimates must be an object")
|
||||
normalized_estimates = {}
|
||||
for estimate_id, minutes in proposed_estimates.items():
|
||||
if estimate_id not in ids:
|
||||
continue
|
||||
if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
|
||||
raise ValueError("estimate minutes must be between 5 and 1440")
|
||||
normalized_estimates[estimate_id] = minutes
|
||||
changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
|
||||
capacity_minutes = proposed_capacity
|
||||
estimates = normalized_estimates
|
||||
else:
|
||||
proposed_date = operation.get("plan_date")
|
||||
proposed_timezone = operation.get("timezone")
|
||||
try:
|
||||
if date.fromisoformat(proposed_date or "").isoformat() != proposed_date:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("plan_date must be an ISO calendar date") from None
|
||||
if not isinstance(proposed_timezone, str) or not proposed_timezone.strip() or len(proposed_timezone) > 100:
|
||||
raise ValueError("timezone is required and bounded")
|
||||
proposed_ids = operation.get("ids")
|
||||
if not isinstance(proposed_ids, list) or len(proposed_ids) > self.limit or any(
|
||||
not isinstance(candidate, str) or not candidate or len(candidate) > 500
|
||||
for candidate in proposed_ids
|
||||
):
|
||||
raise ValueError("rollover IDs are invalid or exceed the Today limit")
|
||||
if len(set(proposed_ids)) != len(proposed_ids):
|
||||
raise ValueError("rollover IDs must be unique")
|
||||
proposed_capacity = operation.get("capacity_minutes")
|
||||
if proposed_capacity is not None and (
|
||||
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
|
||||
or proposed_capacity < 15 or proposed_capacity > 1440
|
||||
):
|
||||
raise ValueError("capacity_minutes must be between 15 and 1440")
|
||||
proposed_estimates = operation.get("estimates", {})
|
||||
if not isinstance(proposed_estimates, dict):
|
||||
raise ValueError("estimates must be an object")
|
||||
normalized_estimates = {}
|
||||
for estimate_id, minutes in proposed_estimates.items():
|
||||
if estimate_id not in proposed_ids:
|
||||
continue
|
||||
if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
|
||||
raise ValueError("estimate minutes must be between 5 and 1440")
|
||||
normalized_estimates[estimate_id] = minutes
|
||||
changed = (ids != proposed_ids or capacity_minutes != proposed_capacity or
|
||||
estimates != normalized_estimates or plan_date != proposed_date or
|
||||
timezone != proposed_timezone.strip())
|
||||
ids = list(proposed_ids)
|
||||
capacity_minutes = proposed_capacity
|
||||
estimates = normalized_estimates
|
||||
plan_date = proposed_date
|
||||
timezone = proposed_timezone.strip()
|
||||
|
||||
revision += 1 if changed else 0
|
||||
self._record_operation(connection, login, operation_id)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(ids, separators=(",", ":"))
|
||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
|
||||
(login, revision, serialized),
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone),
|
||||
)
|
||||
elif accepted:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
||||
(revision, serialized, login),
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ?, "
|
||||
"plan_date = ?, timezone = ? WHERE login = ?",
|
||||
(revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone, login),
|
||||
)
|
||||
return {
|
||||
result = {
|
||||
"revision": revision,
|
||||
"ids": ids,
|
||||
"capacity_minutes": capacity_minutes,
|
||||
"estimates": estimates,
|
||||
"accepted_operation_ids": accepted,
|
||||
"duplicate_operation_ids": duplicates,
|
||||
"rejected_operations": rejected,
|
||||
}
|
||||
if plan_date:
|
||||
result["plan_date"] = plan_date
|
||||
result["timezone"] = timezone
|
||||
return result
|
||||
+33
-6
@@ -1,9 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
from src.compression import _quality
|
||||
|
||||
router = APIRouter()
|
||||
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
|
||||
@@ -14,8 +15,8 @@ LOGIN_HTML = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sign in · Stackchain Dashboard</title>
|
||||
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
|
||||
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
|
||||
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main>
|
||||
<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>"""
|
||||
|
||||
|
||||
@@ -51,11 +52,37 @@ async def service_worker() -> Response:
|
||||
|
||||
|
||||
@router.get("/runtime-{digest}.js")
|
||||
async def runtime_bundle(digest: str) -> Response:
|
||||
async def runtime_bundle(digest: str, request: Request) -> Response:
|
||||
if digest != FRONTEND_BUILD.runtime_digest:
|
||||
raise HTTPException(status_code=404, detail="Runtime revision not found")
|
||||
accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0
|
||||
return Response(
|
||||
FRONTEND_BUILD.runtime_bytes,
|
||||
(
|
||||
FRONTEND_BUILD.runtime_gzip_bytes
|
||||
if accepts_gzip
|
||||
else FRONTEND_BUILD.runtime_bytes
|
||||
),
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Vary": "Accept-Encoding",
|
||||
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/feature-{name}-{digest}.js")
|
||||
async def feature_bundle(name: str, digest: str, request: Request) -> Response:
|
||||
bundle = FRONTEND_BUILD.feature_bundles.get(name)
|
||||
if bundle is None or digest != bundle.runtime_digest:
|
||||
raise HTTPException(status_code=404, detail="Feature revision not found")
|
||||
accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0
|
||||
return Response(
|
||||
bundle.runtime_gzip_bytes if accepts_gzip else bundle.runtime_bytes,
|
||||
media_type="application/javascript",
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Vary": "Accept-Encoding",
|
||||
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from src.passkey_store import PasskeyStore
|
||||
from src.session_store import SessionStore, SessionStoreError
|
||||
|
||||
|
||||
def test_global_access_revocation_rolls_back_every_credential_and_session_change(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
sessions.activate(
|
||||
"phone-session",
|
||||
2_000,
|
||||
management_id="phone-management-id",
|
||||
device_label="Phone",
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
"phone-session",
|
||||
action="revoke_all_sessions",
|
||||
target="all",
|
||||
ttl_seconds=90,
|
||||
)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
passkeys.register(
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
passkeys.issue_challenge(
|
||||
b"pending-challenge",
|
||||
session_id="phone-session",
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TRIGGER block_session_delete BEFORE DELETE ON active_sessions "
|
||||
"BEGIN SELECT RAISE(ABORT, 'injected failure'); END"
|
||||
)
|
||||
|
||||
with pytest.raises(SessionStoreError):
|
||||
passkeys.revoke_all_access()
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM passkey_credentials").fetchone() == (1,)
|
||||
assert connection.execute("SELECT COUNT(*) FROM passkey_challenges").fetchone() == (1,)
|
||||
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (1,)
|
||||
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
for label in ("Phone", "Laptop"):
|
||||
slug = label.lower()
|
||||
sessions.activate(
|
||||
f"{slug}-session",
|
||||
2_000,
|
||||
management_id=f"{slug}-management-id",
|
||||
device_label=label,
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
f"{slug}-session",
|
||||
action="close_issue",
|
||||
target="stackchain/dashboard#1",
|
||||
ttl_seconds=90,
|
||||
)
|
||||
passkeys.register(
|
||||
credential_id=f"{slug}-credential".encode(),
|
||||
public_key=f"{slug}-public-key".encode(),
|
||||
sign_count=0,
|
||||
device_label=label,
|
||||
management_id=f"{slug}-management-id",
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TRIGGER block_phone_delete BEFORE DELETE ON active_sessions "
|
||||
"WHEN OLD.management_id = 'phone-management-id' "
|
||||
"BEGIN SELECT RAISE(ABORT, 'injected failure'); END"
|
||||
)
|
||||
|
||||
with pytest.raises(SessionStoreError):
|
||||
passkeys.revoke_device_access("phone-management-id")
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT management_id FROM active_sessions ORDER BY management_id"
|
||||
).fetchall() == [("laptop-management-id",), ("phone-management-id",)]
|
||||
assert connection.execute(
|
||||
"SELECT management_id FROM passkey_credentials ORDER BY management_id"
|
||||
).fetchall() == [("laptop-management-id",), ("phone-management-id",)]
|
||||
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (2,)
|
||||
@@ -77,6 +77,65 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_desired_blocker_state():
|
||||
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,dependencies_available:true,dependencies:[]}};
|
||||
}},
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
kind:'issue-blocker',repository:'stackchain/dashboard',number:17,operationId:'blocker-op',
|
||||
blockerRepository:'stackchain/api',blockerNumber:9,present:false,
|
||||
}});
|
||||
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"]["blockerRepository"] == "stackchain/api"
|
||||
assert output["queued"]["blockerNumber"] == 9
|
||||
assert output["queued"]["present"] is False
|
||||
assert output["restored"]["present"] is False
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
"method": "PATCH",
|
||||
"key": "blocker-op",
|
||||
"body": {"repository": "stackchain/api", "number": 9, "present": False},
|
||||
}]
|
||||
assert output["result"]["confirmed"][0]["dependencies"] == []
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_exposes_sending_then_preserves_transient_attempt_details():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let release; const gate = new Promise(resolve => release = resolve);
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', now:()=>777,
|
||||
fetchJson:async()=>{{await gate; const error=new Error('Network unavailable');error.status=503;throw error;}},
|
||||
}});
|
||||
outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Wait',operationId:'attempt'}});
|
||||
const pending=outbox.flush('timmy');
|
||||
const sending=outbox.list()[0];
|
||||
release();
|
||||
pending.then(()=>process.stdout.write(JSON.stringify({{sending,after:outbox.list()[0]}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["sending"]["status"] == "sending"
|
||||
assert output["after"]["status"] == "queued"
|
||||
assert output["after"]["lastAttemptAt"] == 777
|
||||
assert output["after"]["lastAttemptError"] == "Network unavailable"
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_complete_pull_review_payload():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -126,7 +185,7 @@ if (queued) {{
|
||||
assert output["progress"] is None
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_idempotent_issue_closure():
|
||||
def test_authored_outbox_requires_explicit_retry_to_authorize_issue_closure():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const calls = [];
|
||||
@@ -138,13 +197,20 @@ const outbox = createAuthoredOutbox({{
|
||||
const queued = outbox.enqueue({{
|
||||
kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'
|
||||
}});
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
||||
queued,calls,result,remaining:outbox.list()
|
||||
}})));
|
||||
(async()=>{{
|
||||
const automatic = await outbox.flush('timmy');
|
||||
const awaitingAuthorization = outbox.list();
|
||||
const explicit = await outbox.retry(queued.id, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
queued,automatic,awaitingAuthorization,explicit,calls,remaining:outbox.list()
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["kind"] == "issue-close"
|
||||
assert output["automatic"]["confirmed"] == []
|
||||
assert output["awaitingAuthorization"][0]["status"] == "queued"
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/27/close",
|
||||
"options": {
|
||||
@@ -152,10 +218,50 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
||||
"headers": {"Accept": "application/json", "Idempotency-Key": "close-op"},
|
||||
},
|
||||
}]
|
||||
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["explicit"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_queued_closure_stays_durable_through_background_skip_then_closes_from_explicit_retry():
|
||||
sync = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
|
||||
script = f"""
|
||||
const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const createBackgroundIssueSync=require({json.dumps(str(sync))});
|
||||
const values=new Map();const records=new Map();let tail=Promise.resolve();const calls=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
get:async id=>records.get(id),getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'] || ''}});
|
||||
return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
const background=createBackgroundIssueSync({{store,fetchJson}});background.requestSync=async()=>{{}};
|
||||
const outbox=createAuthoredOutbox({{storage,fetchJson,getOwnerLogin:()=>'timmy',backgroundSync:background}});
|
||||
const admitted=await outbox.enqueueDurably({{kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'}});
|
||||
const automatic=await background.flush();
|
||||
outbox.reconcileBackground(await background.snapshot());
|
||||
const foregroundFlush=await outbox.flush('timmy');
|
||||
const explicit=await outbox.retry(admitted.item.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{automatic,foregroundFlush,explicit,calls,remaining:outbox.list(),snapshot:await background.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["automatic"]["confirmed"] == []
|
||||
assert output["foregroundFlush"]["confirmed"] == []
|
||||
assert output["calls"] == [
|
||||
{"url": "api/v1/background-identity", "key": ""},
|
||||
{"url": "api/v1/repos/stackchain/dashboard/issues/27/close", "key": "close-op"},
|
||||
]
|
||||
assert output["explicit"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["remaining"] == []
|
||||
assert output["snapshot"][0]["status"] == "sent"
|
||||
|
||||
|
||||
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -409,6 +515,35 @@ outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Review',ope
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authorization_hold_can_resume_in_foreground_with_original_review_identity():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const sent=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createAuthoredOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',
|
||||
backgroundSync:{{reconcile:async()=>{{}},requestSync:async()=>{{}},
|
||||
send:async item=>{{sent.push({{operationId:item.operationId,status:item.status,
|
||||
head:item.expectedHeadSha,decision:item.decision}});return{{message:{{id:91}}}};}}}},
|
||||
}});
|
||||
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,operationId:'review-stable',
|
||||
body:'Ship it',decision:'approve',expectedHeadSha:'abc123'}});
|
||||
outbox.reconcileBackground([{{id:'review-stable',kind:'pull-review',status:'authorization',
|
||||
error:'Fresh authorization required'}}]);
|
||||
(async()=>{{const held=outbox.list()[0];const result=await outbox.retry(held.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{held,sent,result,remaining:outbox.list()}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["held"]["status"] == "authorization"
|
||||
assert output["sent"] == [{
|
||||
"operationId": "review-stable", "status": "authorization",
|
||||
"head": "abc123", "decision": "approve",
|
||||
}]
|
||||
assert output["result"]["confirmed"] == [{"id": 91}]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
html = await dashboard()
|
||||
@@ -425,3 +560,82 @@ async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
assert "if (result?.queued)" in html
|
||||
assert "backgroundSync: backgroundIssueSync" in html
|
||||
assert "authoredOutbox.reconcileBackground(records)" in html
|
||||
|
||||
|
||||
def test_screenshot_comment_admits_blob_to_indexeddb_without_putting_bytes_in_localstorage():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const mirrors=[];
|
||||
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',
|
||||
backgroundSync:{{reconcile:async(items,lane)=>mirrors.push({{items,lane}}),requestSync:async()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const blob=new Blob(['PRIVATE-IMAGE-BYTES'],{{type:'image/png'}});
|
||||
const admission=await outbox.enqueueDurably({{
|
||||
kind:'issue-comment',repository:'stackchain/dashboard',number:477,
|
||||
body:'Broken at 320px',operationId:'comment-image-477',
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',blob}},
|
||||
}});
|
||||
const durable=mirrors[0].items[0].attachment;
|
||||
process.stdout.write(JSON.stringify({{admission,local:values.get('stackchain.authored-outbox.v1'),
|
||||
mirrored:{{lane:mirrors[0].lane,isBlob:durable.blob instanceof Blob,size:durable.blob?.size,text:await durable.blob?.text()}}}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert "PRIVATE-IMAGE-BYTES" not in output["local"]
|
||||
local_item = json.loads(output["local"])["items"][0]
|
||||
assert local_item["attachment"] == {
|
||||
"filename": "phone.png", "contentType": "image/png", "stored": True
|
||||
}
|
||||
assert output["mirrored"] == {
|
||||
"lane": "authored", "isBlob": True, "size": 19, "text": "PRIVATE-IMAGE-BYTES"
|
||||
}
|
||||
assert output["admission"]["durability"] == "background"
|
||||
|
||||
|
||||
def test_screenshot_comment_failed_indexeddb_admission_is_not_accepted_locally():
|
||||
script = f"""
|
||||
const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
|
||||
reconcile:async()=>{{throw new Error('IndexedDB unavailable');}},requestSync:async()=>{{}},
|
||||
}}}});
|
||||
(async()=>{{
|
||||
let error='';try{{await outbox.enqueueDurably({{kind:'issue-comment',repository:'o/r',number:1,
|
||||
body:'Keep me',operationId:'image-op',attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}});}}
|
||||
catch(caught){{error=caught.message;}}
|
||||
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output == {"error": "IndexedDB unavailable", "items": []}
|
||||
|
||||
|
||||
def test_pull_screenshot_comment_persists_blob_only_in_indexeddb():
|
||||
script = f"""
|
||||
const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const mirrors=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
|
||||
reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}},
|
||||
}}}});
|
||||
(async()=>{{const blob=new Blob(['PULL-PRIVATE-BYTES'],{{type:'image/webp'}});
|
||||
await outbox.enqueueDurably({{kind:'pull-comment',repository:'stackchain/web',number:31,
|
||||
body:'Mobile proof',operationId:'pull-image-31',
|
||||
attachment:{{filename:'proof.webp',contentType:'image/webp',blob}}}});
|
||||
const local=values.get('stackchain.authored-outbox.v1');const durable=mirrors[0][0].attachment;
|
||||
process.stdout.write(JSON.stringify({{local,metadata:JSON.parse(local).items[0].attachment,
|
||||
durable:{{isBlob:durable.blob instanceof Blob,text:await durable.blob.text()}}}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert "PULL-PRIVATE-BYTES" not in output["local"]
|
||||
assert output["metadata"] == {
|
||||
"filename": "proof.webp", "contentType": "image/webp", "stored": True
|
||||
}
|
||||
assert output["durable"] == {"isBlob": True, "text": "PULL-PRIVATE-BYTES"}
|
||||
@@ -0,0 +1,101 @@
|
||||
import threading
|
||||
import os
|
||||
|
||||
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
||||
|
||||
|
||||
def test_independent_workers_allow_only_one_catalog_refresh(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
first = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
second = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
barrier = threading.Barrier(2)
|
||||
results = []
|
||||
|
||||
def acquire(store):
|
||||
barrier.wait()
|
||||
results.append(store.try_acquire_refresh(lease_seconds=5))
|
||||
|
||||
threads = [threading.Thread(target=acquire, args=(store,)) for store in (first, second)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
assert sum(owner is not None for owner in results) == 1
|
||||
|
||||
|
||||
def test_published_catalog_and_metadata_are_visible_to_another_worker(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
writer = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
reader = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
owner = writer.try_acquire_refresh(lease_seconds=5)
|
||||
|
||||
writer.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
|
||||
|
||||
state = reader.load()
|
||||
assert state.items == [{"repository": "stackchain/api", "number": 7}]
|
||||
assert state.created_at == 100.0
|
||||
assert state.retry_at is None
|
||||
assert state.refreshing is False
|
||||
|
||||
|
||||
def test_failed_refresh_backoff_is_shared_and_lease_is_released(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
writer = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
reader = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
owner = writer.try_acquire_refresh(lease_seconds=5)
|
||||
|
||||
writer.fail_refresh(owner, retry_at=105.0)
|
||||
|
||||
state = reader.load()
|
||||
assert state.retry_at == 105.0
|
||||
assert state.refreshing is False
|
||||
assert reader.try_acquire_refresh(lease_seconds=5) is not None
|
||||
|
||||
|
||||
def test_confirmed_claim_is_removed_for_every_worker(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
writer = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
reader = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
owner = writer.try_acquire_refresh(lease_seconds=5)
|
||||
writer.publish(owner, items=[
|
||||
{"repository": "stackchain/api", "number": 7},
|
||||
{"repository": "stackchain/web", "number": 8},
|
||||
])
|
||||
|
||||
writer.remove_claimed("stackchain/api", 7)
|
||||
|
||||
assert reader.load().items == [{"repository": "stackchain/web", "number": 8}]
|
||||
|
||||
|
||||
def test_invalidation_clears_catalog_and_claim_filters(tmp_path):
|
||||
store = AvailableIssueSnapshotStore(tmp_path / "available.sqlite3", clock=lambda: 100.0)
|
||||
owner = store.try_acquire_refresh(lease_seconds=5)
|
||||
store.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
|
||||
store.remove_claimed("stackchain/api", 7)
|
||||
|
||||
store.invalidate()
|
||||
owner = store.try_acquire_refresh(lease_seconds=5)
|
||||
store.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
|
||||
|
||||
assert store.load().items == [{"repository": "stackchain/api", "number": 7}]
|
||||
|
||||
|
||||
def test_catalog_store_is_private(tmp_path):
|
||||
path = tmp_path / "state" / "available.sqlite3"
|
||||
|
||||
AvailableIssueSnapshotStore(path)
|
||||
|
||||
assert os.stat(path.parent).st_mode & 0o777 == 0o700
|
||||
assert os.stat(path).st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_cancelled_refresh_releases_its_lease_for_immediate_takeover(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
first = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
second = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
owner = first.try_acquire_refresh(lease_seconds=30)
|
||||
|
||||
first.release_refresh(owner)
|
||||
|
||||
assert second.try_acquire_refresh(lease_seconds=30) is not None
|
||||
@@ -64,6 +64,292 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item = {{
|
||||
id:'blocker-1',operationId:'blocker-1',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',number:17,blockerRepository:'stackchain/api',blockerNumber:9,present:false,
|
||||
}};
|
||||
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,dependencies_available:true,dependencies:[]}};
|
||||
}};
|
||||
(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/blockers",
|
||||
"method": "PATCH",
|
||||
"key": "blocker-1",
|
||||
"body": {"repository": "stackchain/api", "number": 9, "present": False},
|
||||
}
|
||||
assert output["state"]["completed"] == ["blocker-1"]
|
||||
assert output["result"]["confirmed"][0]["dependencies"] == []
|
||||
|
||||
|
||||
def test_closed_app_sync_keeps_unconfirmed_blocker_change_actionable():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item={{id:'blocker-2',operationId:'blocker-2',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',repository:'o/r',number:1,blockerRepository:'o/b',blockerNumber:2,present:false}};
|
||||
const state={{completed:[],failed:[]}};
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,complete:async id=>{{state.completed.push(id);item=null;}},release:async()=>{{}},fail:async(id,message)=>{{state.failed.push([id,message]);item=null;}},countBlocked:async()=>0}};
|
||||
const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:1,dependencies_available:true,dependencies:[{{repository:'o/b',number:2}}]}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["completed"] == []
|
||||
assert output["state"]["failed"] == [["blocker-2", "Blocker change was not confirmed."]]
|
||||
assert output["result"]["attention"] == 1
|
||||
|
||||
|
||||
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item = {{
|
||||
id:'capture-image',operationId:'capture-image',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',title:'Broken mobile layout',body:'At 320px',labelIds:[],
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',data:'iVBORw0KGgo='}},
|
||||
}};
|
||||
const state = {{calls:[],completed:0,released:0}};
|
||||
const store = {{
|
||||
claimNext:async()=>item ? {{...item}} : null,
|
||||
update:async(_id,transform)=>{{item=transform(item);}},
|
||||
complete:async()=>{{state.completed+=1;item=null;}},
|
||||
release:async()=>{{state.released+=1;item={{...item,status:'queued'}};}},
|
||||
fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
let uploadAttempts=0;
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
let body=null;
|
||||
if(options.body instanceof FormData){{const file=options.body.get('file');body={{filename:file.name,multipart:true}};}}
|
||||
else if(options.body)body=JSON.parse(options.body);
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
if(url.endsWith('/issues'))return{{repository:'stackchain/dashboard',number:469,title:'Broken mobile layout'}};
|
||||
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('Upload unavailable');error.status=503;throw error;}}
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
if(url.endsWith('/comments'))return{{id:91}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson}});
|
||||
let firstError='';try{{await sync.flush();}}catch(error){{firstError=error.message;}}
|
||||
const afterFirst={{...item}};
|
||||
const second=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{state,firstError,afterFirst,second}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["firstError"] == "Upload unavailable"
|
||||
assert output["afterFirst"]["deliveredIssue"]["number"] == 469
|
||||
assert [call["url"] for call in output["state"]["calls"]].count(
|
||||
"api/v1/repos/stackchain/dashboard/issues"
|
||||
) == 1
|
||||
assert output["state"]["calls"][-2]["key"] == "capture-image:attachment"
|
||||
assert output["state"]["calls"][-1]["key"] == "capture-image:attachment-comment"
|
||||
assert output["state"]["calls"][-1]["body"] == {
|
||||
"body": ""
|
||||
}
|
||||
assert output["state"]["completed"] == 1
|
||||
assert output["second"]["confirmed"][0]["number"] == 469
|
||||
|
||||
|
||||
def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const blob=new Blob(['offline-binary'],{{type:'image/png'}});
|
||||
const item={{id:'binary',operationId:'binary',ownerLogin:'timmy',repository:'o/r',title:'Visual',body:'',labelIds:[],
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',blob}}}};
|
||||
let queued=true;
|
||||
const observed={{}};
|
||||
const store={{claimNext:async()=>queued?(queued=false,{{...item}}):null,update:async()=>{{}},complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
if(url.endsWith('/issues'))return{{number:7}};
|
||||
if(url.endsWith('/attachments')){{const file=options.body.get('file');Object.assign(observed,{{
|
||||
isFormData:options.body instanceof FormData,contentTypeHeader:options.headers['Content-Type']||null,
|
||||
filename:file.name,type:file.type,size:file.size,text:await file.text()
|
||||
}});return{{markdown:''}};}}
|
||||
return{{id:8}};
|
||||
}};
|
||||
(async()=>{{await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(observed));}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"isFormData": True,
|
||||
"contentTypeHeader": None,
|
||||
"filename": "phone.png",
|
||||
"type": "image/png",
|
||||
"size": 14,
|
||||
"text": "offline-binary",
|
||||
}
|
||||
|
||||
|
||||
def test_capture_attachment_stage_keys_remain_within_idempotency_limit():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const item={{id:'long',operationId:'x'.repeat(128),ownerLogin:'timmy',repository:'o/r',title:'Bug',body:'',labelIds:[],attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}};
|
||||
const keys=[];let queued=true;
|
||||
const store={{claimNext:async()=>queued?(queued=false,item):null,update:async()=>{{}},complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};if(options.headers?.['Idempotency-Key'])keys.push(options.headers['Idempotency-Key']);if(url.endsWith('/issues'))return{{repository:'o/r',number:1}};if(url.endsWith('/attachments'))return{{markdown:''}};return{{id:1}};}};
|
||||
createBackgroundIssueSync({{store,fetchJson}}).flush().then(()=>process.stdout.write(JSON.stringify(keys)));
|
||||
"""
|
||||
keys = run_node(script)
|
||||
|
||||
assert len(keys) == 3
|
||||
assert len(set(keys)) == 3
|
||||
assert all(len(key) <= 128 for key in keys)
|
||||
|
||||
|
||||
def test_capture_renews_claim_before_every_network_stage_and_fences_completion():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const item={{id:'capture',claimToken:'claim-7',operationId:'capture',ownerLogin:'timmy',repository:'o/r',title:'Bug',body:'',labelIds:[],attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}};
|
||||
const events=[];let claimed=false;
|
||||
const store={{
|
||||
supportsClaimTokens:true,
|
||||
claimNext:async()=>claimed?null:(claimed=true,item),
|
||||
renew:async(id,token)=>{{events.push(['renew',id,token]);return item;}},
|
||||
checkpoint:async(id,token,transform)=>{{events.push(['checkpoint',id,token]);transform(item);return true;}},
|
||||
complete:async(id,token)=>events.push(['complete',id,token]),
|
||||
release:async(id,token)=>events.push(['release',id,token]),
|
||||
fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
events.push(['network',options.headers['Idempotency-Key']]);
|
||||
if(url.endsWith('/issues'))return{{number:3}};
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
return{{id:4}};
|
||||
}};
|
||||
(async()=>{{await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(events));}})();
|
||||
"""
|
||||
events = run_node(script)
|
||||
|
||||
networks = [index for index, event in enumerate(events) if event[0] == "network"]
|
||||
assert [events[index - 1][0] for index in networks] == ["renew", "renew", "renew"]
|
||||
assert [events[index][1] for index in networks] == [
|
||||
"capture", "capture:attachment", "capture:attachment-comment"
|
||||
]
|
||||
assert events[-1] == ["complete", "capture", "claim-7"]
|
||||
assert all(event[2] == "claim-7" for event in events if event[0] in {"renew", "checkpoint"})
|
||||
|
||||
|
||||
def test_screenshot_comment_retry_reuses_upload_checkpoint_and_posts_combined_body_once():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'message-image',operationId:'message-image',ownerLogin:'timmy',status:'queued',
|
||||
kind:'issue-comment',repository:'stackchain/dashboard',number:477,body:'Broken at 320px',
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',data:'iVBORw0KGgo='}}}};
|
||||
const state={{calls:[],released:0,completed:0}};let commentAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transform)=>{{item=transform(item);}},
|
||||
complete:async()=>{{state.completed++;item=null;}},release:async()=>{{state.released++;item={{...item,status:'queued'}};}},
|
||||
fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
let body=null;
|
||||
if(options.body instanceof FormData){{const file=options.body.get('file');body={{filename:file.name,multipart:true}};}}
|
||||
else if(options.body)body=JSON.parse(options.body);
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
if(commentAttempts++===0){{const error=new Error('Comment unavailable');error.status=503;throw error;}}
|
||||
return{{id:91}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let firstError='';
|
||||
try{{await sync.flush();}}catch(error){{firstError=error.message;}}
|
||||
const checkpoint={{...item}};const second=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{state,firstError,checkpoint,second}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["firstError"] == "Comment unavailable"
|
||||
assert output["checkpoint"]["attachmentMarkdown"].startswith("![phone.png]")
|
||||
assert sum(call["url"].endswith("/attachments") for call in output["state"]["calls"]) == 1
|
||||
comments = [call for call in output["state"]["calls"] if call["url"].endswith("/comments")]
|
||||
assert [call["key"] for call in comments] == ["message-image:comment"] * 2
|
||||
assert comments[-1]["body"] == {
|
||||
"body": "Broken at 320px\n\n"
|
||||
}
|
||||
assert output["state"]["completed"] == 1
|
||||
assert output["second"]["confirmed"] == [{"id": 91}]
|
||||
|
||||
|
||||
def test_pull_screenshot_retry_uploads_to_pull_and_reuses_checkpoint_before_one_comment():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'pull-image',operationId:'pull-image',ownerLogin:'timmy',status:'queued',
|
||||
kind:'pull-comment',repository:'stackchain/web',number:31,body:'Mobile proof',
|
||||
attachment:{{filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}}}};
|
||||
const calls=[];let commentAttempts=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'}};
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body instanceof FormData?'multipart':JSON.parse(options.body)}});
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
if(commentAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}return{{id:77}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});try{{await sync.flush();}}catch(_error){{}}
|
||||
const checkpoint=item.attachmentMarkdown;const result=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{calls,checkpoint,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["checkpoint"].startswith("![proof.png]")
|
||||
assert [call["url"] for call in output["calls"]] == [
|
||||
"api/v1/repos/stackchain/web/pulls/31/attachments",
|
||||
"api/v1/repos/stackchain/web/pulls/31/comments",
|
||||
"api/v1/repos/stackchain/web/pulls/31/comments",
|
||||
]
|
||||
assert [call["key"] for call in output["calls"]] == [
|
||||
"pull-image:attachment", "pull-image:comment", "pull-image:comment"
|
||||
]
|
||||
assert output["calls"][-1]["body"] == {
|
||||
"body": "Mobile proof\n\n"
|
||||
}
|
||||
assert output["result"]["confirmed"] == [{"id": 77}]
|
||||
|
||||
|
||||
def test_pull_screenshot_comment_timeout_replays_one_upload_with_durable_stage_identity():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'pull-timeout',operationId:'pull-timeout',ownerLogin:'timmy',status:'queued',
|
||||
kind:'pull-comment',repository:'stackchain/web',number:31,body:'Mobile proof',
|
||||
attachment:{{filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}}}};
|
||||
const calls=[];let commentAttempts=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'}};
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key']}});
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
if(commentAttempts++===0)return new Promise(()=>{{}});return{{id:78}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson,requestTimeoutMs:5}});
|
||||
let timedOut='';try{{await sync.flush();}}catch(error){{timedOut=error.message;}}
|
||||
const checkpoint=item.attachmentMarkdown;const replay=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{calls,timedOut,checkpoint,replay}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["timedOut"] == "Background request timed out."
|
||||
assert output["checkpoint"].startswith("![proof.png]")
|
||||
assert [call["key"] for call in output["calls"]] == [
|
||||
"pull-timeout:attachment", "pull-timeout:comment", "pull-timeout:comment"
|
||||
]
|
||||
assert sum(call["url"].endswith("/attachments") for call in output["calls"]) == 1
|
||||
assert output["replay"]["confirmed"] == [{"id": 78}]
|
||||
|
||||
|
||||
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -236,36 +522,81 @@ createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdo
|
||||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_issue_closure_and_returns_actionable_receipt():
|
||||
authored = {
|
||||
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
|
||||
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
|
||||
}
|
||||
def test_closed_app_sync_holds_consequential_review_for_foreground_authorization():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let queued = {json.dumps(authored)}; const calls=[];
|
||||
const store = {{
|
||||
claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
|
||||
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
const item={{id:'review-op',operationId:'review-op',ownerLogin:'timmy',status:'queued',
|
||||
kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
|
||||
decision:'approve',expectedHeadSha:'abc123',comments:[]}};
|
||||
const state={{authorized:[],failed:[],released:[],completed:[]}};let claimed=false;
|
||||
const store={{
|
||||
claimNext:async()=>claimed?null:(claimed=true,item),
|
||||
authorization:async(id,error)=>state.authorized.push({{id,error}}),
|
||||
fail:async(...args)=>state.failed.push(args),release:async id=>state.released.push(id),
|
||||
complete:async id=>state.completed.push(id),countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};}};
|
||||
createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
const fetchJson=async url=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
const error=new Error('Fresh authorization required');error.status=428;
|
||||
error.code='step_up_required';throw error;
|
||||
}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
mutation = output["calls"][1]
|
||||
assert mutation["url"] == "api/v1/repos/stackchain/dashboard/issues/27/close"
|
||||
assert mutation["options"]["method"] == "PATCH"
|
||||
assert mutation["options"]["headers"] == {
|
||||
"Accept": "application/json", "Idempotency-Key": "close-op"
|
||||
assert output["state"] == {
|
||||
"authorized": [{"id": "review-op", "error": "Fresh authorization required"}],
|
||||
"failed": [], "released": [], "completed": [],
|
||||
}
|
||||
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["result"]["attention"] == 0
|
||||
assert output["result"]["authorization"] == 1
|
||||
assert output["result"]["receipts"] == [{
|
||||
"id": "close-op", "status": "confirmed", "kind": "message",
|
||||
"route": "#/my-work/issue/stackchain/dashboard/27",
|
||||
"id": "review-op", "status": "authorization", "kind": "message",
|
||||
"route": "#/my-work/review/stackchain/web/8",
|
||||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_leaves_issue_closure_awaiting_foreground_authorization():
|
||||
records = [
|
||||
{
|
||||
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
|
||||
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
|
||||
},
|
||||
{
|
||||
"id": "comment-op", "operationId": "comment-op", "ownerLogin": "timmy", "status": "queued",
|
||||
"kind": "issue-comment", "repository": "stackchain/dashboard", "number": 27, "body": "Still deliver",
|
||||
},
|
||||
]
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map({json.dumps([[item["id"], item] for item in records])});let tail=Promise.resolve();const calls=[];
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
get:async id=>records.get(id),getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
calls.push({{url,options}});
|
||||
return url==='api/v1/background-identity'?{{login:'timmy'}}:{{id:45}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify({{calls,result,snapshot:await store.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
mutations = output["calls"][1:]
|
||||
assert [call["url"] for call in mutations] == [
|
||||
"api/v1/repos/stackchain/dashboard/issues/27/comments"
|
||||
]
|
||||
assert output["result"]["confirmed"] == [{"id": 45}]
|
||||
by_id = {item["id"]: item for item in output["snapshot"]}
|
||||
assert by_id["close-op"]["status"] == "queued"
|
||||
assert by_id["comment-op"]["status"] == "sent"
|
||||
|
||||
|
||||
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -289,6 +620,81 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
]
|
||||
|
||||
|
||||
def test_reconciling_a_lightweight_attachment_reference_preserves_indexeddb_blob():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
await store.reconcile([{{
|
||||
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',blob:new Blob(['first-image-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'}}}},
|
||||
]);
|
||||
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}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == {"isBlob": True, "text": "first-image-bytes"}
|
||||
assert output["second"] == "c2Vjb25kLWltYWdlLWJ5dGVz"
|
||||
|
||||
|
||||
def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map([
|
||||
['first',{{id:'first',attachment:{{filename:'one.png',contentType:'image/png',data:'first-bytes'}}}}],
|
||||
['second',{{id:'second',attachment:{{filename:'two.png',contentType:'image/png',data:'second-bytes'}}}}],
|
||||
]);
|
||||
const calls=[];
|
||||
const transaction=work=>work({{
|
||||
get:async id=>{{calls.push(['get',id]);return records.get(id);}},
|
||||
getAll:async()=>{{calls.push(['getAll']);return [...records.values()];}},
|
||||
put:async()=>{{}},delete:async()=>{{}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
const hydrated=await store.get('second');
|
||||
process.stdout.write(JSON.stringify({{hydrated,calls}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["hydrated"]["attachment"]["data"] == "second-bytes"
|
||||
assert output["calls"] == [["get", "second"]]
|
||||
|
||||
|
||||
def test_stale_foreground_upsert_preserves_confirmed_attachment_delivery_stages():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{getAll:async()=>[...records.values()].map(value=>({{...value}})),put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id)}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
await store.upsert({{id:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
|
||||
await store.update('capture',item=>({{...item,deliveredIssue:{{number:7}},attachmentMarkdown:''}}));
|
||||
const result=await store.upsert({{id:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["deliveredIssue"]["number"] == 7
|
||||
assert output["attachmentMarkdown"] == ""
|
||||
|
||||
|
||||
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -583,6 +989,52 @@ const fetchJson = async (url, options={{}}) => {{
|
||||
assert output["error"] == "Temporary outage"
|
||||
|
||||
|
||||
def test_bounded_flush_claims_just_in_time_and_keeps_unadmitted_records_queued():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
get:async id=>records.get(id),
|
||||
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;}};
|
||||
let allow=false;const waiting=[];const attempted=[];
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
attempted.push(options.headers['Idempotency-Key']);
|
||||
if(!allow)await new Promise(resolve=>waiting.push(resolve));
|
||||
return{{number:attempted.length}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,createToken:(()=>{{let n=0;return()=>`token-${{++n}}`;}})()}});
|
||||
await store.reconcile(Array.from({{length:4}},(_,i)=>({{id:`issue-${{i}}`,operationId:`issue-${{i}}`,ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Issue',labelIds:[]}})),'issue');
|
||||
await store.reconcile(Array.from({{length:2}},(_,i)=>({{id:`message-${{i}}`,operationId:`message-${{i}}`,ownerLogin:'timmy',status:'queued',kind:'issue-comment',repository:'o/r',number:i,body:'Reply'}})),'authored');
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson,maxConcurrency:2,batchSize:5}});
|
||||
const flushing=sync.flush();
|
||||
while(waiting.length<2)await new Promise(resolve=>setTimeout(resolve,0));
|
||||
const during=await store.snapshot();
|
||||
allow=true;waiting.splice(0).forEach(resolve=>resolve());
|
||||
await flushing;
|
||||
const after=await store.snapshot();
|
||||
process.stdout.write(JSON.stringify({{attempted,during,after}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["id"] for item in output["during"] if item["status"] == "sending"] == [
|
||||
"issue-0", "message-0"
|
||||
]
|
||||
assert [item["id"] for item in output["during"] if item["status"] == "queued"] == [
|
||||
"issue-1", "issue-2", "issue-3", "message-1"
|
||||
]
|
||||
assert output["attempted"] == [
|
||||
"issue-0", "message-0", "issue-1", "message-1", "issue-2"
|
||||
]
|
||||
unadmitted = next(item for item in output["after"] if item["id"] == "issue-3")
|
||||
assert unadmitted["status"] == "queued"
|
||||
assert "claimToken" not in unadmitted
|
||||
|
||||
|
||||
def test_bounded_flush_stops_admission_and_releases_unstarted_claims_on_auth_loss():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -637,18 +1089,15 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
{{id:'message-1',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'message-2',ownerLogin:'timmy',status:'queued'}},
|
||||
], 'authored');
|
||||
const claimed=await store.claimBatch('timmy', 4);
|
||||
process.stdout.write(JSON.stringify({{claimed,snapshot:await store.snapshot()}}));
|
||||
const planned=await store.planBatch('timmy', 4);
|
||||
process.stdout.write(JSON.stringify({{planned,snapshot:await store.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["id"] for item in output["claimed"]] == [
|
||||
"issue-1", "message-1", "issue-2", "message-2"
|
||||
]
|
||||
assert all(item["status"] == "sending" for item in output["claimed"])
|
||||
assert all(item["claimUntil"] == 5100 for item in output["claimed"])
|
||||
assert next(item for item in output["snapshot"] if item["id"] == "issue-3")["status"] == "queued"
|
||||
assert output["planned"] == ["issue-1", "message-1", "issue-2", "message-2"]
|
||||
assert all(item["status"] == "queued" for item in output["snapshot"])
|
||||
assert all("claimUntil" not in item for item in output["snapshot"])
|
||||
|
||||
|
||||
def test_permanent_delivery_failure_marks_issue_for_foreground_attention():
|
||||
@@ -733,6 +1182,47 @@ const transaction = work => {{
|
||||
assert output["records"][0]["claimUntil"] == 6000
|
||||
|
||||
|
||||
def test_issue_store_renews_and_fences_claim_mutations_with_unique_tokens():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();let timestamp=100;
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
get:async id=>records.get(id),
|
||||
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 tokens=['claim-a','claim-b'];
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{
|
||||
transaction,now:()=>timestamp,claimMs:30,createToken:()=>tokens.shift(),
|
||||
}});
|
||||
await store.reconcile([{{id:'lease',ownerLogin:'timmy',status:'queued'}}]);
|
||||
const first=await store.claimNext('timmy');
|
||||
timestamp=120;
|
||||
const renewed=await store.renew('lease',first.claimToken);
|
||||
timestamp=151;
|
||||
const second=await store.claimNext('timmy');
|
||||
const staleComplete=await store.complete('lease',first.claimToken);
|
||||
const staleRelease=await store.release('lease',first.claimToken);
|
||||
const staleFail=await store.fail('lease',first.claimToken,'old failure');
|
||||
const currentComplete=await store.complete('lease',second.claimToken,{{number:9}});
|
||||
process.stdout.write(JSON.stringify({{first,renewed,second,staleComplete,staleRelease,staleFail,currentComplete,record:records.get('lease')}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"]["claimToken"] == "claim-a"
|
||||
assert output["first"]["claimUntil"] == 130
|
||||
assert output["renewed"]["claimUntil"] == 150
|
||||
assert output["second"]["claimToken"] == "claim-b"
|
||||
assert output["second"]["claimUntil"] == 181
|
||||
assert output["staleComplete"] is False
|
||||
assert output["staleRelease"] is False
|
||||
assert output["staleFail"] is False
|
||||
assert output["currentComplete"] is True
|
||||
assert output["record"]["status"] == "sent"
|
||||
|
||||
|
||||
def test_foreground_and_worker_race_still_posts_one_issue():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -803,8 +1293,8 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}});
|
||||
await store.reconcile([{{id:'done',ownerLogin:'timmy',status:'queued'}}]);
|
||||
await store.claimNext('timmy');
|
||||
await store.complete('done');
|
||||
const claimed=await store.claimNext('timmy');
|
||||
await store.complete('done', claimed.claimToken);
|
||||
const replay=await store.claimNext('timmy');
|
||||
const snapshot=await store.snapshot();
|
||||
process.stdout.write(JSON.stringify({{replay,snapshot}}));
|
||||
@@ -930,5 +1420,6 @@ async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in()
|
||||
assert "await Notification.requestPermission()" in html
|
||||
assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html
|
||||
assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html
|
||||
assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html
|
||||
assert "if (window.location.hash !== '#/my-work/drafts') return;" in html
|
||||
assert "onQueue: openWorkQueueRoute" in html
|
||||
assert "workRoute.queue(filter)" in html
|
||||
assert "openDeliveryReceiptRoute" not in html
|
||||
@@ -0,0 +1,488 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BATCH_FIND_WORK = Path(__file__).parents[1] / "frontend" / "batch-find-work.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_batch_preflights_today_capacity_before_claiming_any_issue():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1,
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
onProgress:progress=>calls.push('progress:'+progress.status),
|
||||
}});
|
||||
flow.run([
|
||||
{{repository:'stackchain/dashboard',number:671}},
|
||||
{{repository:'stackchain/dashboard',number:672}},
|
||||
]).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "full",
|
||||
"selected": 2,
|
||||
"available": 1,
|
||||
"queued": [],
|
||||
"failed": [],
|
||||
},
|
||||
"calls": ["progress:full"],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_claims_in_order_continues_after_conflict_and_reports_truthfully():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
claim:item=>{{
|
||||
calls.push('claim:'+item.number);
|
||||
return item.number===672 ? Promise.reject(new Error('already claimed')) :
|
||||
Promise.resolve({{...item,assignees:['timmy']}});
|
||||
}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
onProgress:progress=>calls.push('progress:'+progress.status+':'+progress.processed),
|
||||
}});
|
||||
flow.run([671,672,673].map(number=>({{repository:'stackchain/dashboard',number}})))
|
||||
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete",
|
||||
"selected": 3,
|
||||
"available": 3,
|
||||
"queued": ["stackchain/dashboard#671", "stackchain/dashboard#673"],
|
||||
"failed": [{"key": "stackchain/dashboard#672", "reason": "already claimed"}],
|
||||
},
|
||||
"calls": [
|
||||
"claim:671", "queue:671", "progress:running:1",
|
||||
"claim:672", "progress:running:2",
|
||||
"claim:673", "queue:673", "progress:running:3",
|
||||
"progress:complete:3",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_does_not_reclaim_confirmed_issue_when_today_sync_needs_recovery():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1,
|
||||
claim:item=>{{calls.push('claim');return Promise.resolve({{...item,assignees:['timmy']}});}},
|
||||
queue:item=>{{calls.push('queue');return 'sync-unavailable';}},
|
||||
}});
|
||||
flow.run([{{repository:'stackchain/dashboard',number:671}}])
|
||||
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete",
|
||||
"selected": 1,
|
||||
"available": 1,
|
||||
"queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#671",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"calls": ["claim", "queue"],
|
||||
}
|
||||
|
||||
|
||||
def test_interrupted_batch_resumes_confirmed_assignment_without_claiming_twice():
|
||||
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 item={{repository:'stackchain/dashboard',number:679,title:'Recover me'}};
|
||||
const first=createBatchFindWork({{
|
||||
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||
claim:value=>{{calls.push('claim');return Promise.resolve({{...value,assignees:['timmy']}});}},
|
||||
queue:()=>{{calls.push('queue-failed');return 'sync-unavailable';}},
|
||||
}});
|
||||
first.run([item]).then(firstResult=>{{
|
||||
const second=createBatchFindWork({{
|
||||
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||
claim:()=>{{calls.push('duplicate-claim');return Promise.resolve(item);}},
|
||||
queue:value=>{{calls.push('queue-resumed:'+value.assignees[0]);return 'queued';}},
|
||||
}});
|
||||
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
|
||||
firstResult, resumed, calls, keys:Object.keys(values),
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"firstResult": {
|
||||
"status": "complete", "selected": 1, "available": 1, "queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#679",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"resumed": {
|
||||
"status": "complete", "selected": 1, "available": 1,
|
||||
"queued": ["stackchain/dashboard#679"], "failed": [],
|
||||
},
|
||||
"calls": ["claim", "queue-failed", "queue-resumed:timmy"],
|
||||
"keys": [],
|
||||
}
|
||||
|
||||
|
||||
def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const key='stackchain.find-work-batch.v1.timmy';
|
||||
const values={{[key]:JSON.stringify({{
|
||||
owner:'timmy', available:2, items:[
|
||||
{{item:{{repository:'stackchain/dashboard',number:678}},state:'queued'}},
|
||||
{{item:{{repository:'stackchain/dashboard',number:679}},state:'pending'}},
|
||||
],
|
||||
}})}};
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>2, owner:()=> 'timmy',
|
||||
storage:{{getItem:key=>values[key]||null,setItem:(key,value)=>values[key]=value,removeItem:key=>delete values[key]}},
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
}});
|
||||
flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 2, "available": 2,
|
||||
"queued": ["stackchain/dashboard#678", "stackchain/dashboard#679"],
|
||||
"failed": [],
|
||||
},
|
||||
"calls": ["claim:679", "queue:679"],
|
||||
}
|
||||
|
||||
|
||||
def test_time_budget_blocks_claims_until_every_estimate_fits():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
timeBudget:()=>({{capacity_minutes:90,planned_minutes:30}}),
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
}});
|
||||
const items=[701,702].map(number=>({{repository:'stackchain/dashboard',number}}));
|
||||
Promise.all([
|
||||
flow.run(items, {{'stackchain/dashboard#701':30}}),
|
||||
flow.run(items, {{'stackchain/dashboard#701':30,'stackchain/dashboard#702':45}}),
|
||||
]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"results": [
|
||||
{
|
||||
"status": "estimates-required", "selected": 2, "available": 3,
|
||||
"queued": [], "failed": [], "remaining_minutes": 60,
|
||||
"requested_minutes": 30,
|
||||
"invalid": ["stackchain/dashboard#702"],
|
||||
},
|
||||
{
|
||||
"status": "over-budget", "selected": 2, "available": 3,
|
||||
"queued": [], "failed": [], "remaining_minutes": 60,
|
||||
"requested_minutes": 75, "over_minutes": 15,
|
||||
},
|
||||
],
|
||||
"calls": [],
|
||||
}
|
||||
|
||||
|
||||
def test_time_budget_persists_estimates_only_for_successfully_queued_claims():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
timeBudget:()=>({{capacity_minutes:120,planned_minutes:30}}),
|
||||
claim:item=>item.number===702 ? Promise.reject(new Error('already claimed')) : Promise.resolve(item),
|
||||
queue:item=>'queued',
|
||||
persistEstimate:(item,minutes)=>calls.push(item.number+':'+minutes),
|
||||
}});
|
||||
flow.run([701,702].map(number=>({{repository:'stackchain/dashboard',number}})),{{
|
||||
'stackchain/dashboard#701':30,
|
||||
'stackchain/dashboard#702':45,
|
||||
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 2, "available": 3,
|
||||
"queued": ["stackchain/dashboard#701"],
|
||||
"failed": [{"key": "stackchain/dashboard#702", "reason": "already claimed"}],
|
||||
},
|
||||
"calls": ["701:30"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_has_preclaim_time_review_controls():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-estimate-review"' in html
|
||||
assert 'id="find-work-estimate-list"' in html
|
||||
assert 'id="confirm-find-work-estimates"' in html
|
||||
assert 'inputmode="numeric"' in dashboard
|
||||
assert "timeBudget:" in dashboard
|
||||
assert "persistEstimate:" in dashboard
|
||||
assert ".find-work-estimate-review" in css
|
||||
assert ".find-work-estimate-review input" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_selection_survives_loaded_pages_and_removes_confirmed_claims():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const pages={{
|
||||
1:{{items:[{{id:1,repository:'stackchain/dashboard',number:671}}],page:1,total:2,has_more:true}},
|
||||
2:{{items:[{{id:2,repository:'stackchain/dashboard',number:672}}],page:2,total:2,has_more:false}},
|
||||
}};
|
||||
const controller=createFindWork({{
|
||||
fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) :
|
||||
Promise.resolve(pages[path.includes('page=2') ? 2 : 1]),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
return controller.loadMore();
|
||||
}}).then(()=>{{
|
||||
controller.toggleSelection(controller.items()[1]);
|
||||
return controller.claim(controller.items()[0]);
|
||||
}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
selecting:controller.selection().active,
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"selected": [672], "selecting": True}
|
||||
|
||||
|
||||
def test_fill_today_selects_ranked_matches_up_to_remaining_capacity():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const states=[];
|
||||
const controller=createFindWork({{
|
||||
fetchJson:()=>Promise.resolve({{items:[
|
||||
{{id:1,repository:'stackchain/dashboard',number:701}},
|
||||
{{id:2,repository:'stackchain/dashboard',number:702}},
|
||||
{{id:3,repository:'stackchain/dashboard',number:703}},
|
||||
{{id:4,repository:'stackchain/dashboard',number:704}},
|
||||
],page:1,total:4,has_more:false}}),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}},
|
||||
onSelection:state=>states.push(state),
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[1]);
|
||||
const outcome=controller.fillSelection(3);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
outcome,
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
lastState:states.at(-1),
|
||||
}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"outcome": {"selected": 3, "added": 2, "limit": 3},
|
||||
"selected": [702, 701, 703],
|
||||
"lastState": {
|
||||
"active": True,
|
||||
"count": 3,
|
||||
"ids": [
|
||||
"stackchain/dashboard#702",
|
||||
"stackchain/dashboard#701",
|
||||
"stackchain/dashboard#703",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_fill_today_with_no_capacity_does_not_change_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const controller=createFindWork({{
|
||||
fetchJson:()=>Promise.resolve({{items:[{{id:1,repository:'stackchain/dashboard',number:701}}]}}),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
const outcome=controller.fillSelection(0);
|
||||
process.stdout.write(JSON.stringify({{outcome,selection:controller.selection()}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"outcome": {"selected": 0, "added": 0, "limit": 0},
|
||||
"selection": {"active": False, "count": 0, "ids": []},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert 'id="select-find-work"' in html
|
||||
assert 'id="batch-find-work-actions"' in html
|
||||
assert 'id="claim-selected-work"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "createBatchFindWork({" in dashboard
|
||||
assert "findWorkController.startSelection()" in dashboard
|
||||
assert "findWorkController.toggleSelection(item)" in dashboard
|
||||
assert ".find-work-batch-actions" in css
|
||||
assert "position:sticky" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert "BASE + 'static/batch-find-work.js'" in worker
|
||||
assert '<script src="static/batch-find-work.js"></script>' in html
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_touch_accessible_batch_recovery():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="resume-find-work-batch"' in html
|
||||
assert 'aria-describedby="find-work-status"' in html
|
||||
assert "mountRecovery(" in BATCH_FIND_WORK.read_text()
|
||||
assert "typeof localStorage === 'undefined'" in BATCH_FIND_WORK.read_text()
|
||||
assert ".find-work-batch-recovery" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="fill-find-work-today"' in html
|
||||
assert 'aria-describedby="find-work-selection-status"' in html
|
||||
assert "findWorkController.fillSelection(remainingSlots)" in dashboard
|
||||
assert "openFindWorkEstimateReview(findWorkController.selectedItems())" in dashboard
|
||||
assert ".fill-find-work-today" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_search_ignores_stale_response_and_preserves_cross_query_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const pending=[];
|
||||
const rendered=[];
|
||||
const fetchJson=path=>new Promise(resolve=>pending.push({{path,resolve}}));
|
||||
const controller=createFindWork({{
|
||||
fetchJson,
|
||||
onItems:items=>rendered.push(items.map(item=>item.number)),
|
||||
onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
const first=controller.search('api');
|
||||
const second=controller.search('dashboard');
|
||||
pending[1].resolve({{items:[{{id:2,repository:'stackchain/dashboard',number:673}}],page:1,total:1,has_more:false}});
|
||||
second.then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
const third=controller.search('worker');
|
||||
pending[2].resolve({{items:[{{id:3,repository:'stackchain/worker',number:674}}],page:1,total:1,has_more:false}});
|
||||
return third;
|
||||
}}).then(()=>{{
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
pending[0].resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false}});
|
||||
return first;
|
||||
}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
calls:pending.map(entry=>entry.path),
|
||||
rendered,
|
||||
items:controller.items().map(item=>item.number),
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": [
|
||||
"api/v1/available-issues?page=1&facets=true&q=api",
|
||||
"api/v1/available-issues?page=1&facets=true&q=dashboard",
|
||||
"api/v1/available-issues?page=1&facets=true&q=worker",
|
||||
],
|
||||
"rendered": [[673], [674]],
|
||||
"items": [674],
|
||||
"selected": [673, 674],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_search_has_clear_live_results_and_touch_targets():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-search"' in html
|
||||
assert 'id="clear-find-work-search"' in html
|
||||
assert 'id="find-work-match-status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "findWorkController.search(" in dashboard
|
||||
assert "#find-work-search" in dashboard
|
||||
assert ".find-work-search" in css
|
||||
assert ".find-work-search button" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_facets_serialize_filters_and_preserve_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const calls=[];
|
||||
const controller=createFindWork({{
|
||||
fetchJson:path=>{{ calls.push(path); return Promise.resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false,facets:{{repositories:['stackchain/api'],labels:['critical']}}}}); }},
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}}, onFacets:()=>{{}},
|
||||
}});
|
||||
controller.reset({{items:[{{id:9,repository:'stackchain/old',number:9}}],page:1,total:1,has_more:false}});
|
||||
controller.startSelection(); controller.toggleSelection(controller.items()[0]);
|
||||
controller.setFilters({{repositories:['stackchain/api'],labels:['critical']}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
calls, filters:controller.filters(), selected:controller.selectedItems().map(item=>item.number)
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": ["api/v1/available-issues?page=1&facets=true&repository=stackchain%2Fapi&label=critical"],
|
||||
"filters": {"repositories": ["stackchain/api"], "labels": ["critical"]},
|
||||
"selected": [9],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_accessible_touch_sized_facets():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-filters"' in html
|
||||
assert 'id="find-work-repository-filters"' in html
|
||||
assert 'id="find-work-label-filters"' in html
|
||||
assert 'id="clear-find-work-filters"' in html
|
||||
assert 'aria-label="Filter available work"' in html
|
||||
assert "setFilters(next)" in PICK_WORK.read_text()
|
||||
assert ".find-work-filters" in css
|
||||
assert ".find-work-filter-option" in css and "min-height:44px" in css
|
||||
@@ -178,7 +178,7 @@ const pending = new Map();
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)),
|
||||
claim: () => Promise.resolve(),
|
||||
mutate: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }});
|
||||
@@ -206,7 +206,7 @@ let resolveClaim;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item),
|
||||
claim: () => {{ claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
|
||||
mutate: (_detail, action) => {{ if (action !== 'claim') throw new Error('wrong action'); claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
|
||||
@@ -223,6 +223,32 @@ if (!states.some(state => state.status === 'claimed')) throw new Error('claim co
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_reopen_is_single_flight_and_reports_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let reopens = 0;
|
||||
let resolveReopen;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item),
|
||||
mutate: (_detail, action) => {{ if (action !== 'reopen') throw new Error('wrong action'); reopens += 1; return new Promise(resolve => {{ resolveReopen = resolve; }}); }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
const detail = {{ repository:'stackchain/api', number:42, reopenable:true }};
|
||||
await preview.open(detail);
|
||||
const first = preview.reopen(detail);
|
||||
const second = preview.reopen(detail);
|
||||
if (reopens !== 1 || first !== second) throw new Error('reopen was not single-flight');
|
||||
resolveReopen({{ state:'open', assignees:['timmy'] }});
|
||||
await first;
|
||||
if (!states.some(state => state.status === 'reopened')) throw new Error('reopen confirmation missing');
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
@@ -248,13 +274,31 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="start-search-result"' in html
|
||||
assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html
|
||||
assert "const searchAssignAndStart = createAssignAndStart({" in html
|
||||
assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in html
|
||||
assert "const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail))" in html
|
||||
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
|
||||
assert ".search-preview-primary-actions" in css
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
|
||||
|
||||
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 "mutate: (detail, action) => fetchReviewJson(" in html
|
||||
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-whiteboard')", 1
|
||||
)[0]
|
||||
assert "searchReopenAndStart.run(detail)" in handler
|
||||
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
|
||||
"const searchAssignAndStart", 1
|
||||
)[0]
|
||||
assert "available: createAndStart.available" in orchestrator
|
||||
assert "claim," in orchestrator
|
||||
assert "acceptClaimedIssue(confirmed)" in orchestrator
|
||||
assert "const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail))" in html
|
||||
|
||||
|
||||
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
|
||||
script = f"""
|
||||
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
|
||||
|
||||
@@ -88,6 +88,61 @@ const controller = createCommentNext({{
|
||||
}
|
||||
|
||||
|
||||
def test_update_reply_and_next_preserves_notification_identity_online_and_offline():
|
||||
script = f"""
|
||||
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||
const calls = [];
|
||||
const retryable = Object.assign(new Error('offline'), {{status:503}});
|
||||
let offline = false;
|
||||
const controller = createCommentNext({{
|
||||
queueKind: 'update-reply',
|
||||
post: (item, body, operationId) => {{
|
||||
calls.push({{post:[item.notification_id, body, operationId]}});
|
||||
return offline ? Promise.reject(retryable) : Promise.resolve({{id:17}});
|
||||
}},
|
||||
canQueue: () => true,
|
||||
queue: message => {{ calls.push({{queue:message}}); return Promise.resolve({{durable:true}}); }},
|
||||
complete: item => {{ calls.push({{complete:item.notification_id}}); return true; }},
|
||||
}});
|
||||
(async () => {{
|
||||
const item = {{kind:'issue', notification_id:91, repository:'stackchain/dashboard', number:8}};
|
||||
const posted = await controller.submit(item, 'Online reply', 'reply-91-a');
|
||||
offline = true;
|
||||
const saved = await controller.submit(item, 'Offline reply', 'reply-91-b');
|
||||
process.stdout.write(JSON.stringify({{posted, saved, calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"posted": {
|
||||
"accepted": True,
|
||||
"delivery": "posted",
|
||||
"comment": {"id": 17},
|
||||
"completed": True,
|
||||
},
|
||||
"saved": {
|
||||
"accepted": True,
|
||||
"delivery": "saved",
|
||||
"background": False,
|
||||
"completed": True,
|
||||
},
|
||||
"calls": [
|
||||
{"post": [91, "Online reply", "reply-91-a"]},
|
||||
{"complete": 91},
|
||||
{"post": [91, "Offline reply", "reply-91-b"]},
|
||||
{
|
||||
"queue": {
|
||||
"kind": "update-reply",
|
||||
"notificationId": 91,
|
||||
"body": "Offline reply",
|
||||
"operationId": "reply-91-b",
|
||||
}
|
||||
},
|
||||
{"complete": 91},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_comment_and_next_reads_retry_identity_after_the_failed_post():
|
||||
script = f"""
|
||||
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||
@@ -199,6 +254,30 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_comment_and_next_can_admit_a_complete_screenshot_message_before_advancing():
|
||||
script = f"""
|
||||
const createCommentNext=require({json.dumps(str(COMMENT_NEXT))});
|
||||
const calls=[];
|
||||
const controller=createCommentNext({{
|
||||
post:()=>{{throw new Error('must not post');}},canQueue:()=>true,
|
||||
queue:async message=>{{calls.push({{queue:message}});return{{item:{{id:'queued'}},background:true}};}},
|
||||
accept:item=>calls.push({{accept:item.number}}),
|
||||
complete:item=>{{calls.push({{complete:item.number}});return true;}},
|
||||
}});
|
||||
(async()=>{{const item={{kind:'issue',repository:'stackchain/dashboard',number:477}};
|
||||
const result=await controller.admit(item,{{kind:'issue-comment',repository:item.repository,number:item.number,
|
||||
body:'Mobile evidence',operationId:'image-op',attachment:{{filename:'phone.png',contentType:'image/png',data:'abc'}}}});
|
||||
process.stdout.write(JSON.stringify({{result,calls}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["result"] == {
|
||||
"accepted": True, "delivery": "queued", "background": True, "completed": True
|
||||
}
|
||||
assert output["calls"][0]["queue"]["attachment"]["data"] == "abc"
|
||||
assert output["calls"][1:] == [{"accept": 477}, {"complete": 477}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint():
|
||||
html = await dashboard()
|
||||
@@ -210,3 +289,18 @@ async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint
|
||||
assert "setCommentNextVisibility('pull')" in html
|
||||
assert '.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.comment-actions button { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_update_offers_reply_mark_read_and_next_independent_of_today():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="send-update-reply-read-next"' in html
|
||||
assert '>Reply, mark read & next</button>' in html
|
||||
assert "const updateReplyReadNext = createUpdateReplyReadNext({" in html
|
||||
assert "markRead: markNotificationRead" in html
|
||||
assert "next: item => notificationReader.acceptReadAndNext(lastMyWork, item)" in html
|
||||
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
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
|
||||
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
@@ -116,6 +117,185 @@ const poller = createContextPoller({{
|
||||
}
|
||||
|
||||
|
||||
def test_context_poller_waits_for_server_cooldown_when_every_section_is_degraded():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const delays = [];
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: () => Promise.resolve({{
|
||||
context: {{}}, events: [], notifications: [],
|
||||
freshness: {{
|
||||
fresh_for_seconds: 8,
|
||||
retry_in_seconds: 60,
|
||||
sections: {{
|
||||
context: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
||||
events: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
||||
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
||||
}},
|
||||
}},
|
||||
}}),
|
||||
onSnapshot: () => {{}},
|
||||
onError: error => {{ throw error; }},
|
||||
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||
clearTimer: () => {{}},
|
||||
intervalMs: 8000,
|
||||
}});
|
||||
(async () => {{
|
||||
await poller.start();
|
||||
process.stdout.write(JSON.stringify({{ delays }}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"delays": [60000]}
|
||||
|
||||
|
||||
def test_context_poller_uses_earliest_healthy_section_freshness_deadline():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const delays = [];
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: () => Promise.resolve({{
|
||||
context: {{}}, events: [], notifications: [],
|
||||
freshness: {{
|
||||
fresh_for_seconds: 8,
|
||||
retry_in_seconds: 60,
|
||||
sections: {{
|
||||
context: {{ degraded: false, age_seconds: 2 }},
|
||||
events: {{ degraded: false, age_seconds: 5 }},
|
||||
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
||||
}},
|
||||
}},
|
||||
}}),
|
||||
onSnapshot: () => {{}},
|
||||
onError: error => {{ throw error; }},
|
||||
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||
clearTimer: () => {{}},
|
||||
intervalMs: 8000,
|
||||
}});
|
||||
(async () => {{
|
||||
await poller.start();
|
||||
process.stdout.write(JSON.stringify({{ delays }}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"delays": [3000]}
|
||||
|
||||
|
||||
def test_context_poller_does_not_spin_while_stale_sections_are_revalidating():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const delays = [];
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: () => Promise.resolve({{
|
||||
context: {{}}, events: [], notifications: [],
|
||||
freshness: {{
|
||||
fresh_for_seconds: 8,
|
||||
sections: {{
|
||||
context: {{ degraded: false, revalidating: true, age_seconds: 12 }},
|
||||
events: {{ degraded: false, revalidating: true, age_seconds: 12 }},
|
||||
notifications: {{ degraded: false, revalidating: true, age_seconds: 12 }},
|
||||
}},
|
||||
}},
|
||||
}}),
|
||||
onSnapshot: () => {{}},
|
||||
onError: error => {{ throw error; }},
|
||||
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||
clearTimer: () => {{}},
|
||||
intervalMs: 8000,
|
||||
}});
|
||||
(async () => {{
|
||||
await poller.start();
|
||||
process.stdout.write(JSON.stringify({{ delays }}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"delays": [8000]}
|
||||
|
||||
|
||||
def test_context_poller_honors_http_retry_after_delay():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const delays = [];
|
||||
const errors = [];
|
||||
const unavailable = new Error('HTTP 503');
|
||||
unavailable.retryAfterMs = 45000;
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: () => Promise.reject(unavailable),
|
||||
onSnapshot: () => {{}},
|
||||
onError: error => errors.push(error.message),
|
||||
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||
clearTimer: () => {{}},
|
||||
intervalMs: 8000,
|
||||
}});
|
||||
(async () => {{
|
||||
await poller.start();
|
||||
process.stdout.write(JSON.stringify({{ delays, errors }}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"delays": [45000], "errors": ["HTTP 503"]}
|
||||
|
||||
|
||||
def test_retry_after_parser_accepts_bounded_delta_seconds():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
valid: createContextPoller.retryAfterMs('45'),
|
||||
zero: createContextPoller.retryAfterMs('0'),
|
||||
invalid: createContextPoller.retryAfterMs('later'),
|
||||
excessive: createContextPoller.retryAfterMs('999999'),
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"valid": 45000,
|
||||
"zero": None,
|
||||
"invalid": None,
|
||||
"excessive": 300000,
|
||||
}
|
||||
|
||||
|
||||
def test_context_poller_backs_off_transport_failures_and_resets_after_success():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const delays = [];
|
||||
let calls = 0;
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: () => {{
|
||||
calls += 1;
|
||||
if (calls < 6) return Promise.reject(new TypeError('network unavailable'));
|
||||
return Promise.resolve({{ context: {{}}, events: [], notifications: [] }});
|
||||
}},
|
||||
onSnapshot: () => {{}},
|
||||
onError: () => {{}},
|
||||
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||
clearTimer: () => {{}},
|
||||
intervalMs: 8000,
|
||||
maxBackoffMs: 60000,
|
||||
}});
|
||||
(async () => {{
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
process.stdout.write(JSON.stringify({{ delays }}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"delays": [8000, 16000, 32000, 60000, 60000, 8000]
|
||||
}
|
||||
|
||||
|
||||
def test_live_fetch_passes_retry_after_to_the_poller_error():
|
||||
source = DASHBOARD.read_text()
|
||||
|
||||
assert "createContextPoller.retryAfterMs(res.headers.get('Retry-After'))" in source
|
||||
assert "error.retryAfterMs" in source
|
||||
|
||||
|
||||
def test_live_revision_query_preserves_bounded_opaque_tokens():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
|
||||
+1081
-26
File diff suppressed because it is too large.
Load diff
@@ -9,12 +9,13 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SESSION_JS = ROOT / "frontend" / "session.js"
|
||||
SECURITY_CENTER_JS = ROOT / "frontend" / "security-center.js"
|
||||
|
||||
|
||||
def run_session_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, timers: [], now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
||||
const storage = {{
|
||||
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||
get length() {{ return this.values.size; }},
|
||||
@@ -57,7 +58,9 @@ const boundary = createSessionBoundary({{
|
||||
}},
|
||||
onClearError: error => state.clearErrors.push(error.message),
|
||||
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
|
||||
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
|
||||
addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }},
|
||||
now: () => state.now,
|
||||
setTimer: (callback, delay) => {{ state.leaseDelay = delay; state.timers.push({{callback, delay}}); return state.timers.length; }},
|
||||
confirmAction: message => {{ state.confirmations.push(message); return true; }},
|
||||
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
|
||||
}});
|
||||
@@ -232,25 +235,129 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
|
||||
|
||||
def test_user_activity_heartbeat_is_throttled_across_pointer_keyboard_and_touch_events():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
boundary.startActivityHeartbeat();
|
||||
await state.activityListeners.pointerdown();
|
||||
await state.activityListeners.keydown();
|
||||
state.now += 60000;
|
||||
await state.activityListeners.touchstart();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"/dashboard/api/v1/session/activity",
|
||||
"/dashboard/api/v1/session/activity",
|
||||
]
|
||||
assert all(request["method"] == "POST" for request in result["requests"])
|
||||
assert all(
|
||||
request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
for request in result["requests"]
|
||||
)
|
||||
|
||||
|
||||
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800};
|
||||
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800, idle_expires_at:4102441200};
|
||||
const valid = await boundary.refreshOfflineLease();
|
||||
state.valid = valid;
|
||||
state.savedExpiry = storage.getItem('stackchain.session-expires-at');
|
||||
state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["valid"] is True
|
||||
assert result["savedExpiry"] == "4102444800"
|
||||
assert result["savedIdleExpiry"] == "4102441200"
|
||||
assert result["workerMessages"] == [
|
||||
{"type": "stackchain-session-lease", "expiresAt": 4102444800}
|
||||
{
|
||||
"type": "stackchain-session-lease",
|
||||
"expiresAt": 4102444800,
|
||||
"idleExpiresAt": 4102441200,
|
||||
}
|
||||
]
|
||||
assert result["leaseDelay"] > 0
|
||||
|
||||
|
||||
def test_expired_idle_lease_locks_offline_without_purging_private_work():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
storage.setItem('stackchain.session-expires-at', '200');
|
||||
storage.setItem('stackchain.session-idle-expires-at', '99');
|
||||
state.failFetch = true;
|
||||
const valid = await boundary.refreshOfflineLease();
|
||||
state.valid = valid;
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["valid"] is False
|
||||
assert result["remaining"] == [
|
||||
"stackchain.private",
|
||||
"gitea.preference",
|
||||
"stackchain.session-expires-at",
|
||||
"stackchain.session-idle-expires-at",
|
||||
]
|
||||
assert result["deletedDatabases"] == []
|
||||
assert result["deletedCaches"] == []
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
|
||||
|
||||
|
||||
def test_successful_activity_heartbeat_republishes_only_confirmed_idle_deadline():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
storage.setItem('stackchain.session-expires-at', '4102444800');
|
||||
state.responsePayload = {active:true, idle_expires_at:4102441300};
|
||||
const recorded = await boundary.recordActivity();
|
||||
state.recorded = recorded;
|
||||
state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["recorded"] is True
|
||||
assert result["savedIdleExpiry"] == "4102441300"
|
||||
assert result["workerMessages"] == [
|
||||
{
|
||||
"type": "stackchain-session-lease",
|
||||
"expiresAt": 4102444800,
|
||||
"idleExpiresAt": 4102441300,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_absolute_expiry_still_purges_after_an_idle_lock():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {authenticated:true, expires_at:200, idle_expires_at:150};
|
||||
await boundary.refreshOfflineLease();
|
||||
state.now = 150000;
|
||||
await state.timers[1].callback();
|
||||
state.deletedAtIdle = [...state.deletedDatabases];
|
||||
state.now = 200000;
|
||||
await state.timers[0].callback();
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["deletedAtIdle"] == []
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
"/dashboard/login?reason=session-idle",
|
||||
"/dashboard/login?reason=session-expired",
|
||||
]
|
||||
|
||||
|
||||
def test_expired_stored_lease_purges_private_work_when_session_status_is_offline():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -266,12 +373,86 @@ process.stdout.write(JSON.stringify(state));
|
||||
|
||||
assert result["valid"] is False
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_enroll_passkey_bootstraps_with_fresh_authorization_and_web_authentication():
|
||||
script = f"""
|
||||
const createSessionBoundary=require({json.dumps(str(SESSION_JS))});
|
||||
const requests=[];
|
||||
const responses=[
|
||||
{{status:428,payload:{{detail:{{code:'step_up_required',action:'enroll_passkey',target:'current_device'}}}}}},
|
||||
{{status:201,payload:{{grant:'bootstrap-grant',expires_in:90}}}},
|
||||
{{status:201,payload:{{challenge:'AQID',rp:{{id:'forge.example',name:'Stackchain Dashboard'}},user:{{id:'BAUG',name:'stackchain-operator',displayName:'Stackchain operator'}},pubKeyCredParams:[],excludeCredentials:[],authenticatorSelection:{{userVerification:'required'}}}}}},
|
||||
{{status:201,payload:{{enrolled:true}}}},
|
||||
];
|
||||
const credential={{id:'new-passkey',type:'public-key',rawId:Uint8Array.from([1]).buffer,response:{{attestationObject:Uint8Array.from([2]).buffer,clientDataJSON:Uint8Array.from([3]).buffer,getTransports:()=>['internal']}}}};
|
||||
const boundary=createSessionBoundary({{
|
||||
cookie:()=> 'stackchain_csrf=proof',origin:'https://forge.example',base:'/dashboard/',
|
||||
credentials:{{create:async options=>{{globalThis.creation=options.publicKey;return credential;}}}},
|
||||
promptAuthorization:async()=> 'recovery-token',
|
||||
fetchImpl:async(url,options={{}})=>{{requests.push({{url:String(url),body:options.body||null,grant:new Headers(options.headers||{{}}).get('X-Step-Up-Grant')}});const item=responses.shift();return new Response(JSON.stringify(item.payload),{{status:item.status,headers:{{'Content-Type':'application/json'}}}});}},
|
||||
location:{{replace:()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{const enrolled=await boundary.enrollPasskey();process.stdout.write(JSON.stringify({{enrolled,requests,creation:globalThis.creation}}));}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["enrolled"] is True
|
||||
assert output["creation"]["challenge"] == {"0": 1, "1": 2, "2": 3}
|
||||
assert output["creation"]["user"]["id"] == {"0": 4, "1": 5, "2": 6}
|
||||
assert output["requests"][2]["grant"] == "bootstrap-grant"
|
||||
verification = json.loads(output["requests"][3]["body"])
|
||||
assert verification["challenge"] == "AQID"
|
||||
assert verification["credential"]["response"]["transports"] == ["internal"]
|
||||
|
||||
|
||||
def test_high_impact_fetch_uses_passkey_before_access_token_fallback():
|
||||
script = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const requests=[];
|
||||
const responses=[
|
||||
{{status:428,payload:{{detail:{{code:'step_up_required',action:'close_issue',target:'stackchain/api#7'}}}}}},
|
||||
{{status:200,payload:{{challenge:'AQID',rpId:'forge.example',userVerification:'required',allowCredentials:[{{type:'public-key',id:'BAUG'}}]}}}},
|
||||
{{status:201,payload:{{grant:'passkey-grant',expires_in:90}}}},
|
||||
{{status:200,payload:{{closed:true}}}},
|
||||
];
|
||||
let prompted=false;
|
||||
const credential={{id:'credential',type:'public-key',rawId:Uint8Array.from([4,5,6]).buffer,response:{{authenticatorData:Uint8Array.from([7]).buffer,clientDataJSON:Uint8Array.from([8]).buffer,signature:Uint8Array.from([9]).buffer,userHandle:null}}}};
|
||||
const boundary=createSessionBoundary({{
|
||||
cookie:()=> 'stackchain_csrf=proof',origin:'https://forge.example',base:'/dashboard/',
|
||||
credentials:{{get:async()=>credential}},
|
||||
promptAuthorization:async()=>{{prompted=true;return 'must-not-be-requested';}},
|
||||
fetchImpl:async(url,options={{}})=>{{requests.push({{url:String(url),body:options.body||null,grant:new Headers(options.headers||{{}}).get('X-Step-Up-Grant')}});const item=responses.shift();return new Response(JSON.stringify(item.payload),{{status:item.status,headers:{{'Content-Type':'application/json'}}}});}},
|
||||
location:{{replace:()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{const response=await boundary.fetch('/dashboard/api/v1/repos/stackchain/api/issues/7/close',{{method:'PATCH'}});process.stdout.write(JSON.stringify({{status:response.status,requests,prompted}}));}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["status"] == 200
|
||||
assert output["prompted"] is False
|
||||
assert output["requests"][1]["url"].endswith("/passkeys/authorization/options")
|
||||
assert json.loads(output["requests"][1]["body"]) == {
|
||||
"action": "close_issue",
|
||||
"target": "stackchain/api#7",
|
||||
}
|
||||
assert output["requests"][2]["url"].endswith("/passkeys/authorization/verify")
|
||||
assert output["requests"][3]["grant"] == "passkey-grant"
|
||||
|
||||
|
||||
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -323,7 +504,10 @@ process.stdout.write(JSON.stringify(state));
|
||||
"/dashboard/login?reason=session-expired"
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert result["expiredAfterDeletion"] is True
|
||||
@@ -343,13 +527,34 @@ process.stdout.write(JSON.stringify(state));
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_idle_response_locks_without_clearing_private_queued_work():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
state.responsePayload = {detail:'Authentication required', code:'session_idle'};
|
||||
await boundary.fetch('/dashboard/api/v1/live');
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
|
||||
assert result["deletedDatabases"] == []
|
||||
assert result["deletedCaches"] == []
|
||||
assert result["workerMessages"] == []
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
|
||||
|
||||
|
||||
def test_worker_revocation_message_clears_window_storage_before_login():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -363,6 +568,19 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
|
||||
|
||||
def test_worker_idle_message_locks_without_clearing_window_storage():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-idle'}});
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
|
||||
|
||||
|
||||
def test_worker_expiry_message_clears_window_storage_before_expired_login():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -405,7 +623,10 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert "stackchain.private" in result["removed"]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
assert result["assignedAfterDeletion"] is True
|
||||
@@ -469,6 +690,108 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["confirmations"] == ["Sign out Pixel <script>?"]
|
||||
|
||||
|
||||
def test_enrolled_passkeys_can_be_loaded_and_selectively_removed():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responses = [
|
||||
{status:200,payload:{passkeys:[
|
||||
{management_id:'passkey-id-12345678', device_label:'Old phone <script>', created_at:1000, active:false, current:false},
|
||||
]}},
|
||||
{status:200,payload:{revoked:true,current_session:false,session_revoked:false}},
|
||||
];
|
||||
const passkeys = await boundary.listPasskeys();
|
||||
const outcome = await boundary.revokePasskey(passkeys[0]);
|
||||
state.passkeys = passkeys;
|
||||
state.outcome = outcome;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["passkeys"][0]["device_label"] == "Old phone <script>"
|
||||
assert result["outcome"] == {
|
||||
"revoked": True,
|
||||
"current_session": False,
|
||||
"session_revoked": False,
|
||||
}
|
||||
assert result["requests"][0]["url"] == "/dashboard/api/v1/passkeys"
|
||||
assert result["requests"][1]["url"] == (
|
||||
"/dashboard/api/v1/passkeys/passkey-id-12345678"
|
||||
)
|
||||
assert result["requests"][1]["method"] == "DELETE"
|
||||
assert result["requests"][1]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert result["confirmations"] == [
|
||||
"Remove the passkey for Old phone <script>? This cannot be undone."
|
||||
]
|
||||
|
||||
|
||||
def test_security_activity_pages_are_loaded_with_stable_cursors():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {events:[{id:7,kind:'sign_in',method:'passkey',device_label:'Phone',target:'dashboard',created_at:1000}],next_cursor:7};
|
||||
const first = await boundary.listSecurityEvents();
|
||||
const second = await boundary.listSecurityEvents(first.next_cursor);
|
||||
state.first = first;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["first"]["events"][0]["method"] == "passkey"
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"/dashboard/api/v1/security-events?limit=25",
|
||||
"/dashboard/api/v1/security-events?limit=25&cursor=7",
|
||||
]
|
||||
|
||||
|
||||
def test_security_activity_formats_failed_sign_in_alerts_for_mobile_scanability():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.formatted = boundary.formatAuthenticationAlert({
|
||||
method:'token', failed_count:12, blocked_count:3, first_at:1000, last_at:1060
|
||||
});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["formatted"] == {
|
||||
"title": "12 failed token sign-ins · 3 blocked",
|
||||
"detail": "1/1/1970, 12:16:40 AM – 1/1/1970, 12:17:40 AM",
|
||||
}
|
||||
|
||||
|
||||
def test_security_activity_renders_authentication_alerts_before_journal_events():
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
alerts = source.index("page.authentication_alerts.forEach")
|
||||
events = source.index("page.events.forEach")
|
||||
assert alerts < events
|
||||
assert "boundary.formatAuthenticationAlert(alert)" in source
|
||||
assert "row.className = 'security-event authentication-alert'" in source
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
assert ".authentication-alert { border-color:#d69e2e;" in css
|
||||
|
||||
|
||||
def test_security_activity_explains_pending_outcome_confirmation():
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "Outcome confirmation pending" in source
|
||||
assert "event.status === 'pending'" in source
|
||||
|
||||
|
||||
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "passkey_enrolled: 'Passkey enrolled'" in source
|
||||
assert "title.textContent = labels[event.kind]" in source
|
||||
assert "details.textContent =" in source
|
||||
|
||||
|
||||
def test_security_activity_labels_consequential_pull_review_decisions():
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "pull_review_approved: 'Pull request approved'" in source
|
||||
assert "pull_review_changes_requested: 'Changes requested'" in source
|
||||
|
||||
|
||||
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -490,5 +813,17 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
assert '<button id="sign-out-all" type="button">Sign out all devices</button>' in html
|
||||
assert '<button id="active-devices" type="button">Active devices</button>' in html
|
||||
assert 'id="active-devices-sheet"' in html
|
||||
assert 'aria-label="Active devices"' in html
|
||||
assert 'aria-label="Active devices and passkeys"' in html
|
||||
assert 'aria-labelledby="enrolled-passkeys-title"' in html
|
||||
assert 'id="enrolled-passkeys-list"' in html
|
||||
assert 'A recovery token will be required after sign-out if you remove this device’s passkey.' in html
|
||||
assert 'aria-labelledby="security-activity-title"' in html
|
||||
assert 'id="security-activity-list"' in html
|
||||
assert '>Load older activity</button>' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
|
||||
|
||||
def test_security_activity_names_confirmed_gitea_time_logging():
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "gitea_time_logged: 'Gitea time logged'" in source
|
||||
+99
-11
@@ -142,25 +142,53 @@ process.stdout.write(JSON.stringify(item));
|
||||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_queued_issue_closure_for_retry_or_discard():
|
||||
def test_draft_inbox_exposes_held_review_as_awaiting_foreground_authorization():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
|
||||
id:'review-1',kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
|
||||
decision:'approve',expectedHeadSha:'abc',comments:[],ownerLogin:'timmy',
|
||||
status:'authorization',error:'Fresh authorization required',queuedAt:200
|
||||
}}]}})]]);
|
||||
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const inbox=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
|
||||
process.stdout.write(JSON.stringify({{item:inbox.list()[0],partition:inbox.partition()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["item"]["status"] == "authorization"
|
||||
assert output["item"]["label"] == "Review awaiting authorization"
|
||||
assert output["item"]["authorization_required"] is True
|
||||
assert output["partition"]["counts"]["authorization"] == 1
|
||||
assert output["partition"]["retryable"] == []
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_queued_issue_closure_as_awaiting_authorization():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
|
||||
id:'close-1',kind:'issue-close',repository:'stackchain/dashboard',number:27,body:'',
|
||||
ownerLogin:'timmy',status:'attention',error:'Closure rejected',queuedAt:200
|
||||
ownerLogin:'timmy',status:'queued',queuedAt:200
|
||||
}}]}})]]);
|
||||
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
const inbox=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
|
||||
const item=inbox.list()[0];
|
||||
process.stdout.write(JSON.stringify({{item,partition:inbox.partition()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
item = output["item"]
|
||||
|
||||
assert output["label"] == "Issue closure needs attention"
|
||||
assert output["outbox_kind"] == "issue-close"
|
||||
assert output["title"] == "stackchain/dashboard#27"
|
||||
assert output["preview"] == "Closure rejected"
|
||||
assert output["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
|
||||
assert output["quarantined"] is False
|
||||
assert item["label"] == "Awaiting authorization"
|
||||
assert item["status"] == "authorization"
|
||||
assert item["authorization_required"] is True
|
||||
assert item["outbox_kind"] == "issue-close"
|
||||
assert item["title"] == "stackchain/dashboard#27"
|
||||
assert item["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
|
||||
assert item["quarantined"] is False
|
||||
assert output["partition"]["counts"] == {
|
||||
"waiting": 0, "sending": 0, "attention": 0, "authorization": 1
|
||||
}
|
||||
assert output["partition"]["retryable"] == []
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
|
||||
@@ -225,6 +253,36 @@ process.stdout.write(JSON.stringify(drafts));
|
||||
assert by_id["mine"]["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_partitions_delivery_center_and_counts_only_safe_waiting_retries():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-comment.v1:stackchain/api#17', 'Unfinished comment'],
|
||||
['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[
|
||||
{{id:'waiting',repository:'o/r',title:'Waiting issue',body:'Body',ownerLogin:'timmy',status:'queued',queuedAt:100,lastAttemptAt:90,lastAttemptError:'Network unavailable'}},
|
||||
{{id:'sending',repository:'o/r',title:'Sending issue',body:'Body',ownerLogin:'timmy',status:'sending',queuedAt:200}},
|
||||
{{id:'attention',repository:'o/r',title:'Broken issue',body:'Body',ownerLogin:'timmy',status:'attention',queuedAt:300}},
|
||||
{{id:'other-user',repository:'o/r',title:'Private issue',body:'Body',ownerLogin:'alexander',status:'queued',queuedAt:400}}
|
||||
]}})],
|
||||
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'uncertain',kind:'issue-comment',repository:'o/r',number:4,body:'Maybe sent',ownerLogin:'timmy',status:'attention',deliveryState:'uncertain',queuedAt:500}}
|
||||
]}})],
|
||||
]);
|
||||
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const inbox = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
|
||||
process.stdout.write(JSON.stringify(inbox.partition()));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert len(output["drafts"]) == 1
|
||||
assert len(output["deliveries"]) == 5
|
||||
assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2, "authorization": 0}
|
||||
assert [item["outbox_id"] for item in output["retryable"]] == ["waiting"]
|
||||
waiting = next(item for item in output["deliveries"] if item["outbox_id"] == "waiting")
|
||||
assert waiting["last_attempt_at"] == 90
|
||||
assert waiting["last_attempt_error"] == "Network unavailable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
html = await dashboard()
|
||||
@@ -240,11 +298,41 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
|
||||
assert "Verified not posted — retry" in html
|
||||
assert "const closureOutbox = item.outbox_kind === 'issue-close';" in html
|
||||
assert "closureOutbox ? 'Open issue' : 'Open message'" in html
|
||||
assert "item.kind === 'authored-outbox' && closureOutbox" in html
|
||||
assert "payload.detail?.message" in html
|
||||
assert "error.code = payload.detail?.code" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_waiting_work():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="delivery-center"' in html
|
||||
assert 'id="retry-waiting-deliveries"' in html
|
||||
assert 'Waiting <strong data-delivery-count="waiting">' in html
|
||||
assert 'Sending <strong data-delivery-count="sending">' in html
|
||||
assert 'Needs attention <strong data-delivery-count="attention">' in html
|
||||
assert 'Authorize <strong data-delivery-count="authorization">' in html
|
||||
assert "const deliveryCenter = draftInbox.partition(displayedDrafts);" in html
|
||||
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
|
||||
assert "deliveryCenter.retryable.length" in html
|
||||
assert "item.status === 'sending' ? 'Sending'" in html
|
||||
assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html
|
||||
assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html
|
||||
assert '.delivery-center button { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_requires_an_explicit_authorize_and_close_gesture():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="draft-authorize"' in html
|
||||
assert '>Authorize & close</button>' in html
|
||||
assert "list.querySelectorAll('.draft-authorize')" in html
|
||||
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
|
||||
assert "item.status === 'authorization' ? 'Awaiting authorization'" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LOADER = Path(__file__).parents[1] / "frontend" / "feature-loader.js"
|
||||
|
||||
|
||||
def run_loader(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const fs=require('fs'); const vm=require('vm');
|
||||
const state={{appends:0, removed:0}};
|
||||
const document={{
|
||||
head:{{appendChild(node){{ state.appends++; state.node=node; }} }},
|
||||
createElement(){{ return {{remove(){{state.removed++;}}}}; }},
|
||||
}};
|
||||
const context={{document, setTimeout, clearTimeout, console}};
|
||||
context.globalThis=context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(LOADER))},'utf8'),context);
|
||||
const createFeatureLoader=context.createFeatureLoader;
|
||||
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_concurrent_feature_requests_share_one_script():
|
||||
result = run_loader("""
|
||||
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
|
||||
const first=loader.load('issue-capture'); const second=loader.load('issue-capture');
|
||||
state.node.onload(); await Promise.all([first,second]);
|
||||
console.log(JSON.stringify({appends:state.appends,same:first===second,ready:loader.ready('issue-capture')}));
|
||||
""")
|
||||
assert result == {"appends": 1, "same": True, "ready": True}
|
||||
|
||||
|
||||
def test_failed_feature_request_can_retry_without_reload():
|
||||
result = run_loader("""
|
||||
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
|
||||
const failed=loader.load('issue-capture').catch(error=>error.message); state.node.onerror();
|
||||
const message=await failed; const retry=loader.load('issue-capture'); state.node.onload(); await retry;
|
||||
console.log(JSON.stringify({appends:state.appends,removed:state.removed,message,ready:loader.ready('issue-capture')}));
|
||||
""")
|
||||
assert result == {"appends": 2, "removed": 1, "message": "Could not load issue capture.", "ready": True}
|
||||
|
||||
|
||||
def test_feature_gate_exposes_loading_failure_and_success_to_mobile_controls():
|
||||
result = run_loader("""
|
||||
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
|
||||
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
|
||||
const failed=loader.run('issue-capture',{trigger,status},()=>{opened++;});
|
||||
const loading={disabled:trigger.disabled,status:status.textContent}; state.node.onerror();
|
||||
const first=await failed;
|
||||
const failedState={disabled:trigger.disabled,status:status.textContent,opened};
|
||||
const retry=loader.run('issue-capture',{trigger,status},()=>{opened++;}); state.node.onload();
|
||||
const second=await retry;
|
||||
console.log(JSON.stringify({loading,failedState,first,second,opened,finalStatus:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"loading": {"disabled": True, "status": "Loading issue capture…"},
|
||||
"failedState": {
|
||||
"disabled": False,
|
||||
"status": "Issue capture could not load. Tap New issue to retry.",
|
||||
"opened": 0,
|
||||
},
|
||||
"first": False,
|
||||
"second": True,
|
||||
"opened": 1,
|
||||
"finalStatus": "",
|
||||
}
|
||||
|
||||
|
||||
def test_pull_workspace_failure_is_accessible_and_retryable_from_same_card():
|
||||
result = run_loader("""
|
||||
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
|
||||
const loader=createFeatureLoader({document, urls:{'pull-workflow':'feature-pull.js'}, timeoutMs:100});
|
||||
const failed=loader.run('pull-workflow',{trigger,status,retryLabel:'Tap the work card to retry.'},()=>{opened++;});
|
||||
state.node.onerror(); const first=await failed;
|
||||
const failedStatus=status.textContent;
|
||||
const retry=loader.run('pull-workflow',{trigger,status,retryLabel:'Tap the work card to retry.'},()=>{opened++;});
|
||||
state.node.onload(); const second=await retry;
|
||||
console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appends}));
|
||||
""")
|
||||
assert result == {
|
||||
"first": False,
|
||||
"second": True,
|
||||
"failedStatus": "Pull workspace could not load. Tap the work card to retry.",
|
||||
"opened": 1,
|
||||
"appends": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_security_center_failure_is_accessible_and_retryable_from_active_devices():
|
||||
result = run_loader("""
|
||||
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
|
||||
const loader=createFeatureLoader({document, urls:{'security-center':'feature-security.js'}, timeoutMs:100});
|
||||
const failed=loader.run('security-center',{trigger,status,retryLabel:'Tap Active devices to retry.'},()=>{opened++;});
|
||||
state.node.onerror(); const first=await failed; const failedStatus=status.textContent;
|
||||
const retry=loader.run('security-center',{trigger,status,retryLabel:'Tap Active devices to retry.'},()=>{opened++;});
|
||||
state.node.onload(); const second=await retry;
|
||||
console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appends}));
|
||||
""")
|
||||
assert result == {
|
||||
"first": False,
|
||||
"second": True,
|
||||
"failedStatus": "Security center could not load. Tap Active devices to retry.",
|
||||
"opened": 1,
|
||||
"appends": 2,
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import gzip
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from src import main
|
||||
from src.frontend_bundle import build_frontend
|
||||
from src.views import dashboard, runtime_bundle, service_worker
|
||||
from src.views import dashboard, feature_bundle, runtime_bundle, service_worker
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
@@ -21,8 +23,11 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
|
||||
assert first.runtime_name == second.runtime_name
|
||||
assert first.runtime_bytes == second.runtime_bytes
|
||||
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
|
||||
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
|
||||
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert first.dashboard_html.count("<script src=") == 2
|
||||
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' in first.dashboard_html
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
assert first.runtime_name.startswith("runtime-")
|
||||
assert first.runtime_name.endswith(".js")
|
||||
@@ -39,6 +44,160 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
assert changed.runtime_bytes != first.runtime_bytes
|
||||
|
||||
|
||||
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
|
||||
assert set(first.feature_bundles) == {
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
|
||||
"today-timer", "security-center",
|
||||
}
|
||||
assert (
|
||||
f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{first.runtime_name}"></script>'
|
||||
) in first.dashboard_html
|
||||
capture = first.feature_bundles["issue-capture"]
|
||||
pull_workflow = first.feature_bundles["pull-workflow"]
|
||||
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||
assert b"function createIssueCapture" in capture.runtime_bytes
|
||||
assert b"function createPullSheet" not in first.runtime_bytes
|
||||
assert b"function createReviewController" not in first.runtime_bytes
|
||||
assert b"function createPullSheet" in pull_workflow.runtime_bytes
|
||||
assert b"function createReviewController" in pull_workflow.runtime_bytes
|
||||
security_center = first.feature_bundles["security-center"]
|
||||
assert b"function attachSecurityCenter" not in first.runtime_bytes
|
||||
assert b"function attachSecurityCenter" in security_center.runtime_bytes
|
||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
|
||||
assert len(first.runtime_gzip_bytes) <= 96 * 1024
|
||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
|
||||
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
|
||||
|
||||
shell_block, optional_block = first.service_worker_source.split(
|
||||
"const OPTIONAL_FEATURES = [", 1
|
||||
)
|
||||
optional_block = optional_block.split("];", 1)[0]
|
||||
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block
|
||||
for name, bundle in first.feature_bundles.items():
|
||||
if name == "today-timer":
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
|
||||
else:
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
|
||||
assert f"BASE + '{bundle.runtime_name}'" in optional_block
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
capture_source = changed_frontend / "create-issue-sheet.js"
|
||||
capture_source.write_text(capture_source.read_text() + "\n// feature-only change\n")
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
assert changed.runtime_name == first.runtime_name
|
||||
assert changed.feature_bundles["issue-capture"].runtime_name != capture.runtime_name
|
||||
|
||||
pull_source = changed_frontend / "pull-sheet.js"
|
||||
pull_source.write_text(pull_source.read_text() + "\n// pull-workflow-only change\n")
|
||||
pull_changed = build_frontend(changed_frontend)
|
||||
assert pull_changed.runtime_name == first.runtime_name
|
||||
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
|
||||
|
||||
security_source = changed_frontend / "security-center.js"
|
||||
security_source.write_text(security_source.read_text() + "\n// security-center-only change\n")
|
||||
security_changed = build_frontend(changed_frontend)
|
||||
assert security_changed.runtime_name == first.runtime_name
|
||||
assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name
|
||||
|
||||
|
||||
def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
|
||||
build = build_frontend(FRONTEND)
|
||||
bundles = {"core.js": build.runtime_bytes}
|
||||
bundles.update({f"{name}.js": bundle.runtime_bytes for name, bundle in build.feature_bundles.items()})
|
||||
for name, source in bundles.items():
|
||||
path = tmp_path / name
|
||||
path.write_bytes(source)
|
||||
result = subprocess.run(["node", "--check", str(path)], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
|
||||
build = build_frontend(FRONTEND)
|
||||
capture = build.feature_bundles["issue-capture"]
|
||||
|
||||
request = Request(
|
||||
{"type": "http", "method": "GET", "path": f"/{capture.runtime_name}",
|
||||
"headers": [(b"accept-encoding", b"identity")]}
|
||||
)
|
||||
response = await feature_bundle("issue-capture", capture.runtime_digest, request)
|
||||
|
||||
assert response.body == capture.runtime_bytes
|
||||
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
with pytest.raises(Exception) as rejected:
|
||||
await feature_bundle("issue-capture", "missing", request)
|
||||
assert rejected.value.status_code == 404
|
||||
|
||||
|
||||
def test_css_only_release_gets_a_new_shell_generation(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
(changed_frontend / "dashboard.css").write_text(
|
||||
(changed_frontend / "dashboard.css").read_text() + "\n/* release change */\n"
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
|
||||
assert changed.runtime_name == first.runtime_name
|
||||
assert changed.runtime_bytes == first.runtime_bytes
|
||||
assert changed.shell_digest != first.shell_digest
|
||||
assert f"const CACHE = 'stackchain-dashboard-shell-{changed.shell_digest}';" in (
|
||||
changed.service_worker_source
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"asset",
|
||||
[
|
||||
"manifest.webmanifest",
|
||||
"icons/stackchain-192.png",
|
||||
"icons/stackchain-512.png",
|
||||
"background-issue-sync.js",
|
||||
"service-worker.js",
|
||||
],
|
||||
)
|
||||
def test_every_offline_shell_asset_versions_the_shell_generation(tmp_path, asset):
|
||||
first = build_frontend(FRONTEND)
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
path = changed_frontend / asset
|
||||
path.write_bytes(path.read_bytes() + b"\nrelease-change")
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
|
||||
assert changed.shell_digest != first.shell_digest
|
||||
|
||||
|
||||
def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v98';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
|
||||
assert changed.shell_digest == first.shell_digest
|
||||
assert changed.service_worker_source == first.service_worker_source
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -52,11 +211,46 @@ async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
assert response.headers["vary"] == "Accept-Encoding"
|
||||
assert int(response.headers["content-length"]) <= 100 * 1024
|
||||
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_gzip_bytes)
|
||||
assert response.content == main.FRONTEND_BUILD.runtime_bytes
|
||||
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_route_returns_the_precomputed_gzip_representation():
|
||||
build = build_frontend(FRONTEND)
|
||||
request = Request(
|
||||
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
|
||||
"headers": [(b"accept-encoding", b"gzip")]}
|
||||
)
|
||||
|
||||
response = await runtime_bundle(build.runtime_digest, request)
|
||||
|
||||
assert response.body == build.runtime_gzip_bytes
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
assert response.headers["content-length"] == str(len(build.runtime_gzip_bytes))
|
||||
assert response.headers["vary"] == "Accept-Encoding"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_requests_bypass_dynamic_gzip_compression(monkeypatch):
|
||||
class UnexpectedDynamicCompression:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise AssertionError("immutable runtime entered dynamic compression")
|
||||
|
||||
monkeypatch.setattr("src.compression.GZipResponder", UnexpectedDynamicCompression)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/{main.FRONTEND_BUILD.runtime_name}",
|
||||
headers={"Accept-Encoding": "gzip"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("accept_encoding", ["identity", "gzip;q=0, identity;q=1"])
|
||||
async def test_runtime_stays_unencoded_when_the_client_declines_gzip(accept_encoding):
|
||||
@@ -108,9 +302,13 @@ def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules()
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_asset_is_immutable_while_html_and_worker_revalidate():
|
||||
build = build_frontend(FRONTEND)
|
||||
request = Request(
|
||||
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
|
||||
"headers": [(b"accept-encoding", b"identity")]}
|
||||
)
|
||||
|
||||
html = await dashboard()
|
||||
bundle = await runtime_bundle(build.runtime_digest)
|
||||
bundle = await runtime_bundle(build.runtime_digest, request)
|
||||
worker = await service_worker()
|
||||
|
||||
assert build.runtime_name in html
|
||||
|
||||
@@ -40,6 +40,123 @@ async def test_notification_page_preserves_upstream_total_without_loading_other_
|
||||
assert [item["id"] for item in result["items"]] == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notification_snapshot_loads_every_page_with_bounded_concurrency():
|
||||
active = 0
|
||||
peak = 0
|
||||
requested_pages = []
|
||||
|
||||
async def upstream(request):
|
||||
nonlocal active, peak
|
||||
page = int(request.url.params["page"])
|
||||
requested_pages.append(page)
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
await __import__("asyncio").sleep(0)
|
||||
active -= 1
|
||||
start = (page - 1) * 50 + 1
|
||||
end = min(start + 50, 126)
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "125"},
|
||||
json=[{
|
||||
"id": thread_id,
|
||||
"updated_at": f"2026-08-11T12:{thread_id % 60:02d}:00Z",
|
||||
"subject": {},
|
||||
"repository": {},
|
||||
} for thread_id in range(start, end)],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.unread_notification_snapshot(
|
||||
max_pages=3, max_concurrency=2, deadline_seconds=1
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requested_pages[0] == 1
|
||||
assert sorted(requested_pages) == [1, 2, 3]
|
||||
assert peak == 2
|
||||
assert result["complete"] is True
|
||||
assert result["total"] == 125
|
||||
assert [item["id"] for item in result["items"]] == list(range(1, 126))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notification_snapshot_rejects_pagination_that_changes_mid_scan():
|
||||
def upstream(request):
|
||||
page = int(request.url.params["page"])
|
||||
total = 51 if page == 1 else 50
|
||||
start = (page - 1) * 50 + 1
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": str(total)},
|
||||
json=[{
|
||||
"id": thread_id,
|
||||
"subject": {},
|
||||
"repository": {},
|
||||
} for thread_id in range(start, min(start + 50, total + 1))],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="changed during the scan"):
|
||||
await gitea_proxy.unread_notification_snapshot(deadline_seconds=1)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notification_snapshot_rejects_missing_or_duplicate_threads():
|
||||
def upstream(_request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "3"},
|
||||
json=[
|
||||
{"id": 7, "subject": {}, "repository": {}},
|
||||
{"id": 7, "subject": {}, "repository": {}},
|
||||
"malformed",
|
||||
],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="incomplete thread set"):
|
||||
await gitea_proxy.unread_notification_snapshot(deadline_seconds=1)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notification_snapshot_fails_before_exceeding_the_scan_limit():
|
||||
requested_pages = []
|
||||
|
||||
def upstream(request):
|
||||
page = int(request.url.params["page"])
|
||||
requested_pages.append(page)
|
||||
start = (page - 1) * 50 + 1
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "101"},
|
||||
json=[
|
||||
{"id": thread_id, "subject": {}, "repository": {}}
|
||||
for thread_id in range(start, min(start + 50, 102))
|
||||
],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="scan limit"):
|
||||
await gitea_proxy.unread_notification_snapshot(
|
||||
max_pages=2, deadline_seconds=1
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requested_pages == [1]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notifications_are_normalized_for_mobile_handoff():
|
||||
result = gitea_proxy._normalize_notifications(
|
||||
@@ -182,6 +299,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
"issue": {"number": 7, "assignees": [], "claimable": True},
|
||||
"acknowledge_supported": True,
|
||||
"conversation": {
|
||||
"comments": [{
|
||||
"id": 9,
|
||||
|
||||
@@ -7,6 +7,26 @@ from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_log_issue_time_uses_canonical_shared_issue_endpoint_and_seconds():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(201, json={"id": 1})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
await gitea_proxy.log_issue_time("pull:stackchain/stackchain-dashboard:586:", 2520)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0].method == "POST"
|
||||
assert requests[0].url.path == "/api/v1/repos/stackchain/stackchain-dashboard/issues/586/times"
|
||||
assert requests[0].read() == b'{"time":2520}'
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_transport_is_reused_across_requests_and_closed():
|
||||
client_ids = []
|
||||
@@ -306,3 +326,79 @@ async def test_application_shutdown_cancels_available_work_scan_before_transport
|
||||
main._available_issue_snapshot_task = None
|
||||
|
||||
assert calls == ["start", "available scan cancelled", "stop (done)"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_application_shutdown_drains_authored_mutation_before_transport(monkeypatch):
|
||||
calls = []
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def authored_mutation():
|
||||
started.set()
|
||||
await release.wait()
|
||||
calls.append("mutation persisted")
|
||||
return {"id": 461}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
|
||||
context = main.app.router.lifespan_context(main.app)
|
||||
await context.__aenter__()
|
||||
task = asyncio.create_task(authored_mutation())
|
||||
main._authored_action_operations["shutdown-drain-461"] = (
|
||||
("comment", "stackchain/dashboard", 461),
|
||||
task,
|
||||
0.0,
|
||||
)
|
||||
await started.wait()
|
||||
|
||||
shutdown = asyncio.create_task(context.__aexit__(None, None, None))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert not shutdown.done()
|
||||
assert calls == ["start"]
|
||||
|
||||
release.set()
|
||||
await shutdown
|
||||
|
||||
assert calls == ["start", "mutation persisted", "stop"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_application_shutdown_bounds_and_settles_stalled_authored_mutation(monkeypatch):
|
||||
calls = []
|
||||
started = asyncio.Event()
|
||||
|
||||
async def stalled_mutation():
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
calls.append("mutation cancelled")
|
||||
|
||||
monkeypatch.setattr(main, "AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS", 0.01)
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
|
||||
context = main.app.router.lifespan_context(main.app)
|
||||
await context.__aenter__()
|
||||
task = asyncio.create_task(stalled_mutation())
|
||||
main._authored_action_operations["stalled-shutdown-461"] = (
|
||||
("review", "stackchain/dashboard", 461),
|
||||
task,
|
||||
0.0,
|
||||
)
|
||||
await started.wait()
|
||||
|
||||
await asyncio.wait_for(context.__aexit__(None, None, None), timeout=0.2)
|
||||
|
||||
assert task.cancelled()
|
||||
assert main._authored_action_operations == {}
|
||||
assert calls == ["start", "mutation cancelled", "stop"]
|
||||
@@ -6,11 +6,18 @@ import httpx
|
||||
|
||||
from src import gitea_proxy
|
||||
from src import main
|
||||
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_available_issue_snapshot():
|
||||
def reset_available_issue_snapshot(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_available_issue_snapshot_store",
|
||||
AvailableIssueSnapshotStore(tmp_path / "available-issues.sqlite3"),
|
||||
)
|
||||
main._available_issue_snapshot_task = None
|
||||
main._available_issue_snapshot_lock = asyncio.Lock()
|
||||
main._available_issue_snapshot_value = None
|
||||
main._available_issue_snapshot_created_at = None
|
||||
main._available_issue_snapshot_retry_at = None
|
||||
@@ -19,6 +26,7 @@ def reset_available_issue_snapshot():
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
main._available_issue_snapshot_task = None
|
||||
main._available_issue_snapshot_lock = asyncio.Lock()
|
||||
main._available_issue_snapshot_value = None
|
||||
main._available_issue_snapshot_created_at = None
|
||||
main._available_issue_snapshot_retry_at = None
|
||||
@@ -207,6 +215,145 @@ async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkey
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_search_filters_full_catalog_before_pagination(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{
|
||||
"repository": "stackchain/api",
|
||||
"number": number,
|
||||
"title": "Routine API maintenance",
|
||||
}
|
||||
for number in range(1, 52)
|
||||
] + [{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 673,
|
||||
"title": "Search the full Find Work catalog",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
by_repo = await client.get("/api/v1/available-issues?page=1&q=DASHBOARD")
|
||||
by_number = await client.get("/api/v1/available-issues?page=1&q=%23673")
|
||||
by_title = await client.get("/api/v1/available-issues?page=1&q=full%20find")
|
||||
|
||||
expected = [{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 673,
|
||||
"title": "Search the full Find Work catalog",
|
||||
}]
|
||||
assert by_repo.json() == {"items": expected, "page": 1, "total": 1, "has_more": False}
|
||||
assert by_number.json() == by_repo.json()
|
||||
assert by_title.json() == by_repo.json()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_filter_before_pagination_and_describe_catalog(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]},
|
||||
{"repository": "stackchain/api", "number": 2, "title": "API docs", "labels": ["docs"]},
|
||||
{"repository": "stackchain/dashboard", "number": 3, "title": "Critical UI", "labels": ["critical", "frontend"]},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("repository", "stackchain/api"), ("label", "critical")],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]}],
|
||||
"page": 1,
|
||||
"total": 1,
|
||||
"has_more": False,
|
||||
"facets": {
|
||||
"repositories": ["stackchain/api", "stackchain/dashboard"],
|
||||
"labels": ["backend", "critical", "docs", "frontend"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_or_within_each_facet_and_compose_with_search(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{"repository": "stackchain/api", "number": 1, "title": "Repair worker", "labels": ["backend"]},
|
||||
{"repository": "stackchain/web", "number": 2, "title": "Repair mobile", "labels": ["frontend"]},
|
||||
{"repository": "stackchain/docs", "number": 3, "title": "Repair guide", "labels": ["docs"]},
|
||||
{"repository": "stackchain/api", "number": 4, "title": "Routine task", "labels": ["backend"]},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("q", "repair"), ("repository", "stackchain/api"), ("repository", "stackchain/web"),
|
||||
("label", "backend"), ("label", "frontend")],
|
||||
)
|
||||
|
||||
assert [item["number"] for item in response.json()["items"]] == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_reject_excess_values_before_scanning(monkeypatch):
|
||||
async def must_not_scan():
|
||||
raise AssertionError("invalid facets must be rejected before catalog scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("label", f"label-{index}") for index in range(11)],
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch):
|
||||
async def must_not_scan():
|
||||
raise AssertionError("invalid query must be rejected before catalog scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues", params={"q": "x" * 101})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_reuses_catalog_published_by_another_worker(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
store = AvailableIssueSnapshotStore(tmp_path / "available.sqlite3", clock=lambda: 100.0)
|
||||
owner = store.try_acquire_refresh(lease_seconds=5)
|
||||
store.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
|
||||
monkeypatch.setattr(main, "_available_issue_snapshot_store", store, raising=False)
|
||||
monkeypatch.setattr(main.time, "time", lambda: 100.0)
|
||||
|
||||
async def must_not_scan():
|
||||
raise AssertionError("fresh shared catalog should avoid an upstream scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [{"repository": "stackchain/api", "number": 7}],
|
||||
"page": 1, "total": 1, "has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_coalesces_cold_scan_and_reuses_it_for_pages(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
@@ -229,6 +229,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
|
||||
"assignees": [],
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
"reopenable": False,
|
||||
"assigned_to_me": False,
|
||||
}
|
||||
|
||||
@@ -255,3 +256,26 @@ async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
|
||||
|
||||
assert preview["kind"] == "pull"
|
||||
assert preview["claimable"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_offers_reopen_only_for_closed_issues():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 42,
|
||||
"title": "Resume work",
|
||||
"state": "closed",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/api", "issue", 42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview["reopenable"] is True
|
||||
assert preview["claimable"] is False
|
||||
@@ -174,6 +174,53 @@ process.stdout.write(JSON.stringify({firstHidden:firstCard.hidden, remembered:va
|
||||
assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True}
|
||||
|
||||
|
||||
def test_setup_api_reports_install_state_without_prompting():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card:{hidden:true}, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
const before = app.state();
|
||||
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
|
||||
const after = app.state();
|
||||
process.stdout.write(JSON.stringify({before, after}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"before": {"state": "unavailable", "detail": "Installation is available from a supported browser."},
|
||||
"after": {"state": "incomplete", "detail": "Stackchain is ready to install."},
|
||||
}
|
||||
|
||||
|
||||
def test_captured_install_prompt_remains_available_until_setup_opens():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
let prevented = 0;
|
||||
const prompt = {preventDefault(){prevented += 1;}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})};
|
||||
const card = {hidden:true};
|
||||
const app = createInstallApp({
|
||||
window:new FakeTarget(), initialPrompt:prompt, card,
|
||||
installButton:new FakeButton(), dismissButton:new FakeButton(), guidance:{hidden:true},
|
||||
status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
process.stdout.write(JSON.stringify({state:app.state(), shown:!card.hidden, prevented}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"state": {"state": "incomplete", "detail": "Stackchain is ready to install."},
|
||||
"shown": True,
|
||||
"prevented": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_appinstalled_hides_an_visible_native_promotion():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
|
||||
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user