Compare commits
222
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be5603d41b | ||
|
|
4e074ae18e | ||
|
|
a4ae3e1c7f | ||
|
|
1a2297e943 | ||
|
|
611e79fc1e | ||
|
|
50817bed9a | ||
|
|
1f9f0f5c22 | ||
|
|
d776a2b003 | ||
|
|
7517d81046 | ||
|
|
091a01ba67 | ||
|
|
bad14d1b94 | ||
|
|
c4bb2f5eba | ||
|
|
d2a815c4bc | ||
|
|
3bc1d2f602 | ||
|
|
bd96c3caa3 | ||
|
|
f8ab6977ed | ||
|
|
8285b39e06 | ||
|
|
8c4969cc44 | ||
|
|
1f950f93dd | ||
|
|
505a40d077 | ||
|
|
0cabce66ed | ||
|
|
df90fb1e0e | ||
|
|
e39e92b025 | ||
|
|
07836f7176 | ||
|
|
0f36d3077b | ||
|
|
33c8761e80 | ||
|
|
157703527a | ||
|
|
5921027e80 | ||
|
|
31700d5c71 | ||
|
|
c3cb498ce1 | ||
|
|
f168a0187d | ||
|
|
877c995b47 | ||
|
|
e572b3410e | ||
|
|
a2501e47c3 | ||
|
|
2b6730288a | ||
|
|
0efd44fd90 | ||
|
|
187b0decae | ||
|
|
75846cf045 | ||
|
|
544ffb4ae7 | ||
|
|
eea9097740 | ||
|
|
35a88bb398 | ||
|
|
4e3f67df3d | ||
|
|
7131792ded | ||
|
|
41c28cc70a | ||
|
|
aa12c4ab9d | ||
|
|
c4bb7a9be1 | ||
|
|
fdf38a72ac | ||
|
|
29e3f55e07 | ||
|
|
0a18d1ac0f | ||
|
|
12c0e18dfe | ||
|
|
875ec45928 | ||
|
|
b79973fc2e | ||
|
|
6b98eb5cbe | ||
|
|
32e2a228b9 | ||
|
|
bbf397454d | ||
|
|
d3ba5975a0 | ||
|
|
6c12731215 | ||
|
|
4fe07bcd52 | ||
|
|
846c28001e | ||
|
|
5c43a5678a | ||
|
|
4b4e55dbe2 | ||
|
|
fe1049ee99 | ||
|
|
73017c1a30 | ||
|
|
9c187b5ae5 | ||
|
|
7fbbc2f942 | ||
|
|
1b448fef23 | ||
|
|
881653eef1 | ||
|
|
d8476c6f7b | ||
|
|
6eb2709477 | ||
|
|
05405bf2e8 | ||
|
|
b18a50fec5 | ||
|
|
be9de6596f | ||
|
|
71631c6731 | ||
|
|
548b95ddb6 | ||
|
|
e0d8ab9f10 | ||
|
|
a629b5a93e | ||
|
|
ce9301f145 | ||
|
|
d0e8378a02 | ||
|
|
424ab2bb6d | ||
|
|
5fb4da30a4 | ||
|
|
f78da3dfc9 | ||
|
|
14b3fb4048 | ||
|
|
d27a481d96 | ||
|
|
24eb4d1e79 | ||
|
|
3c35aa31c1 | ||
|
|
363a97aa5e | ||
|
|
306a20c37f | ||
|
|
e06bd8435d | ||
|
|
76f89df609 | ||
|
|
f0a966d3a3 | ||
|
|
9eac3bb958 | ||
|
|
fc93620118 | ||
|
|
15739f7370 | ||
|
|
1ae8533702 | ||
|
|
ea3204c78c | ||
|
|
6e72f59fcf | ||
|
|
24cadf88f8 | ||
|
|
4b645d8460 | ||
|
|
a4498fdbbc | ||
|
|
2e8efdb2bb | ||
|
|
e92e476f49 | ||
|
|
4a6a829401 | ||
|
|
c03d0a091f | ||
|
|
2f5df33020 | ||
|
|
5d517861f0 | ||
|
|
59c87f06a2 | ||
|
|
f76b2f8289 | ||
|
|
7c4e798776 | ||
|
|
317fc7371a | ||
|
|
c7d38593dd | ||
|
|
a13a13d90c | ||
|
|
666671ee59 | ||
|
|
d01f502e70 | ||
|
|
3fd5983df6 | ||
|
|
6afdad6fe4 | ||
|
|
449ab78991 | ||
|
|
95887d7493 | ||
|
|
965a73ab49 | ||
|
|
067fb76ebd | ||
|
|
d47205a561 | ||
|
|
1be9da3685 | ||
|
|
a8c1329676 | ||
|
|
d63765a9b1 | ||
|
|
c87d56c939 | ||
|
|
e234d6b6c3 | ||
|
|
edaeba4a4e | ||
|
|
9d8a362583 | ||
|
|
c63df3c46d | ||
|
|
ffba6f1212 | ||
|
|
b7dd9757aa | ||
|
|
f64ce3acc4 | ||
|
|
25597d1f05 | ||
|
|
9f5baaef6e | ||
|
|
a3861da125 | ||
|
|
2b874d8427 | ||
|
|
ba50784022 | ||
|
|
37950bd0ed | ||
|
|
bb6a4c4305 | ||
|
|
42e97b4a79 | ||
|
|
45e2798d37 | ||
|
|
218dd43764 | ||
|
|
4d9c936b86 | ||
|
|
69a3a2c568 | ||
|
|
7a9347bd2a | ||
|
|
4e275b1518 | ||
|
|
865083a835 | ||
|
|
15be0ec026 | ||
|
|
ab74426cbc | ||
|
|
f1adffef6b | ||
|
|
359d4337eb | ||
|
|
e77ad7c809 | ||
|
|
aa46196d1e | ||
|
|
e5ccfb0c5e | ||
|
|
e3c7df25c8 | ||
|
|
f11fc17a2c | ||
|
|
ecc79ce50f | ||
|
|
a10592c349 | ||
|
|
5ac90d01e6 | ||
|
|
dd3016b96b | ||
|
|
875f4eea29 | ||
|
|
78839a50fe | ||
|
|
f557f06bc5 | ||
|
|
c8998eb1ba | ||
|
|
b5b1a3b0d7 | ||
|
|
ea1b5a77f5 | ||
|
|
5c74051aab | ||
|
|
daffa894d8 | ||
|
|
67a3285412 | ||
|
|
9f40a4c5ac | ||
|
|
356f11b9c1 | ||
|
|
d5f13c4afb | ||
|
|
69532bdc1f | ||
|
|
55e1c3d6dd | ||
|
|
6718160381 | ||
|
|
e0f7c711ed | ||
|
|
43066a0822 | ||
|
|
f0706e2e6f | ||
|
|
3edc4e36b3 | ||
|
|
47cad77312 | ||
|
|
99a2f28440 | ||
|
|
53c4340d76 | ||
|
|
5a9cce4a08 | ||
|
|
4fbc9c62d9 | ||
|
|
a1b4698988 | ||
|
|
e01681af09 | ||
|
|
880d1c4a1f | ||
|
|
7c1292faa3 | ||
|
|
286e691fd2 | ||
|
|
c99d03b9bc | ||
|
|
58c2d3f5d8 | ||
|
|
988f6646c2 | ||
|
|
f56ca6373c | ||
|
|
3cfe6b9f75 | ||
|
|
65180cbb94 | ||
|
|
928184c581 | ||
|
|
e8b78de1a5 | ||
|
|
6fb547e8ce | ||
|
|
d86e84253a | ||
|
|
99b60e5f5f | ||
|
|
059722de6c | ||
|
|
16a6ee1136 | ||
|
|
f9c4938401 | ||
|
|
bdf1ddaada | ||
|
|
cf94624aa9 | ||
|
|
f42f89ca21 | ||
|
|
ae632e3562 | ||
|
|
0fd6314391 | ||
|
|
1ae49df1b0 | ||
|
|
16841f18e1 | ||
|
|
80863182d4 | ||
|
|
7c2e9861d7 | ||
|
|
7deb1c1de9 | ||
|
|
559fd2dc89 | ||
|
|
0cb7328aa1 | ||
|
|
664e5e999f | ||
|
|
37e919f43f | ||
|
|
d0780a72a7 | ||
|
|
f785134dfe | ||
|
|
de405efdbc | ||
|
|
fa47ded3ab | ||
|
|
e2be14d7c7 | ||
|
|
e2b5f6042c |
No files matched your search
@@ -57,7 +57,7 @@ jobs:
|
||||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py tests/e2e/test_mobile_authored_pull_queue_release.py
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -75,6 +75,23 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p
|
||||
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
||||
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
||||
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
||||
Eligible open issue previews also expose **Plan ahead**: choose one of the next seven local dates, review
|
||||
that day's current load and capacity, and enter a required estimate without leaving Search. Existing Week
|
||||
Ahead placement is moved only after an explicit day change, never duplicated; full days are blocked and
|
||||
over-capacity admission requires a second confirmation. The action claims unassigned work only after these
|
||||
checks, stages the canonical issue through the account-bound Week Ahead outbox, and preserves the Search
|
||||
preview through cancel or browser Back. Offline saves report **sync pending**; if assignment succeeds but
|
||||
local planning fails, the dashboard reports **Assigned, not planned** and leaves the issue recoverable in My Work.
|
||||
The live mobile Week Ahead overview also offers **Remove from week** for active planned work. It removes only the
|
||||
private planning placement—not assignment, issue state, or Gitea content—and immediately recalculates the day's
|
||||
load. A 10-second **Undo** receipt restores the exact day, list position, and estimate. Both transitions use the
|
||||
account-bound Week Ahead outbox, remain **sync pending** after delivery failure, and stay unavailable in a read-only
|
||||
offline snapshot.
|
||||
Search selection mode extends that flow across several open issues with **Plan Week Ahead**. One phone-safe
|
||||
review assigns each issue a future day and estimate, validates five-item limits and daily capacity before any
|
||||
claim, and requires a second confirmation for overload. Confirmed rows are staged into one canonical Week
|
||||
Ahead update and flushed together; partial assignment or offline sync is reported without discarding the
|
||||
selection, so claimed work remains recoverable from My Work.
|
||||
Commentable mobile Search previews support camera capture and gallery selection for up to five ordered
|
||||
photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators
|
||||
can queue photo-only, text-only, or mixed replies without leaving their Search pass, including while offline.
|
||||
@@ -90,11 +107,40 @@ device can reopen the exact Search with one tap while stale writes surface a con
|
||||
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
|
||||
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
||||
`.stackchain-state/saved-searches.sqlite3` path.
|
||||
|
||||
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
|
||||
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
|
||||
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
|
||||
after the account-scoped collection confirms the same mutation. This completes the read → watch → revisit flow without
|
||||
changing ownership, review assignment, or scheduling work.
|
||||
Following is a read-first, account-scoped collection: it is encrypted at rest, revisioned, bounded to
|
||||
50 typed items, and synchronized across signed-in devices. Opening a row reuses the correct issue or
|
||||
pull-request Search Preview; confirmed **Stop watching** removes an open item. When watched work
|
||||
closes or merges, the sequential review exposes **Stop watching & next** so the completed item can be
|
||||
retired without leaving the preview; the next captured change opens immediately, and retiring the
|
||||
final item completes the Following phase. Changed cards explain whether work closed, reopened, changed title, or
|
||||
received other activity. During review, conversation messages newer than the prior reviewed revision are highlighted,
|
||||
and the revision is acknowledged only after both detail and conversation context load; a failed conversation load keeps
|
||||
that exact change unseen for Retry or **Keep for later & next**. For open work that still needs thought, **Keep for later & next**
|
||||
restores only the loaded revision to the unseen queue and continues the captured pass without changing
|
||||
Gitea state, ownership, planning, or watch status. Failed or unconfirmed Gitea mutations leave the collection
|
||||
and current review position unchanged. Following counts never influence the recommended Work queue. Set
|
||||
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
|
||||
|
||||
Each device can explicitly opt in to **Notify me when Following changes**. Stackchain polls that
|
||||
channel independently, validates the device session immediately before delivery, and checkpoints
|
||||
the exact unseen revision set so unchanged work stays silent across workers. Lock-screen payloads
|
||||
contain only a bounded count and the `#/my-work/following` route—never repository names, titles,
|
||||
bodies, or comments. Disabling Following alerts leaves Updates, deadlines, and start-day reminders
|
||||
unchanged.
|
||||
|
||||
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
|
||||
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and
|
||||
Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day
|
||||
checkpoint: finishing Agenda, Updates, or the final Filed review returns to a focused handoff using fresh queue
|
||||
counts, while reopening the queue sheet resumes the next live phase. **Finish for now** removes only that local
|
||||
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, Filed,
|
||||
and unseen Following activity, then opens the highest-priority non-empty review queue. Following refreshes only when
|
||||
Prepare Today is opened; selecting that phase starts its changed-first sequential review directly. Starting the pass
|
||||
saves a confirmed-account, local-day checkpoint: finishing Agenda, Updates, the final Filed review, or the final
|
||||
Following revision returns to a focused handoff using fresh queue counts, while reopening the queue sheet resumes the
|
||||
next live phase. **Finish for now** removes only that local
|
||||
checkpoint. Once urgent review is clear the pass continues the existing Today plan, or opens Find Work when Today
|
||||
is empty; the briefing and checkpoint never change Gitea state.
|
||||
Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the
|
||||
@@ -120,8 +166,11 @@ explicit **Add blocked item anyway** override, while an unavailable dependency l
|
||||
as unknown rather than unblocked. Completing any Today item now exposes a 10-second, touch-safe
|
||||
**Undo** receipt. Undo restores the item's original order and estimate, queues the inverse cross-device
|
||||
plan changes, and leaves the already-advanced work session on its current item; expiry, capacity, or a
|
||||
concurrently changed plan is reported without overwriting newer work. 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. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. 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.
|
||||
concurrently changed plan is reported without overwriting newer work. Rescheduling Today work into
|
||||
Week Ahead uses a bounded account-scoped FIFO on the device: multiple offline moves survive reload,
|
||||
deliver oldest-first with stable operation IDs, and rebase each later move on the confirmed plan revisions
|
||||
without concurrent requests. 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. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. 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. Cross-device **Continue here** handoff transfers the bounded per-item timing ledger—not only the active item—so the receiving phone keeps the complete recap and optional time-log durations; legacy single-item sessions migrate automatically. **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 wrap-up, **Share day summary** opens a private review of the exact worked-on and tomorrow selections. Every row is opt-in adjustable, actual time is excluded by default, and an optional bounded note is previewed before the native share sheet or clipboard fallback. Canceling or closing keeps the account-scoped device draft; successful sharing or **Discard draft** removes it.
|
||||
|
||||
A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. When work is blocked, **Post blocker & move on** requires a future return time, admits the comment before any planning change, defers the item to Later, and continues the existing Today session. An admitted planning transition remains checkpointed for retry, so a storage or Today-removal failure cannot post the blocker twice. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable.
|
||||
@@ -149,7 +198,46 @@ failure preserves both the reply draft and checkpoint. Finishing or choosing **E
|
||||
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
|
||||
offline operations. Planning edits can remain offline for up to 30 days. After that, the
|
||||
offline operations. **Plan Tomorrow** remains independent from active Today work and durably admits the exact
|
||||
ordered plan, capacity, estimates, next local date, timezone, and server revision to account-bound browser
|
||||
storage before closing on a phone. The Queues summary marks it **sync pending** while offline; reconnect,
|
||||
foreground, and midnight lifecycle checks share one delivery flight. A successful account receipt removes
|
||||
the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for
|
||||
review. Unsynced Tomorrow work is never promoted into Today.
|
||||
**Week Ahead** opens as a read-first seven-day mobile overview, so operators can inspect the next planned day,
|
||||
work titles and references, load versus capacity, overloads, and pending sync without staging a change. When Today
|
||||
is empty and no work session is active, **Start this day early** confirms the next planned date, item count, minutes,
|
||||
and capacity before atomically moving only that day into Today; offline, pending, stale, or non-empty plans remain
|
||||
unchanged. **Edit day** enters one date and returns to the refreshed overview; **Edit week** starts the continuous
|
||||
planning pass. Closing an issue from its Week Ahead detail retires that identity and estimate from every future
|
||||
week day in one durable update while preserving sibling order and daily capacity. The open overview recalculates
|
||||
immediately; an unavailable save remains visibly **sync pending** and retries through the existing lifecycle.
|
||||
**Plan Week Ahead** continues through seven local dates and now finishes on a mobile review step instead of
|
||||
closing after the seventh save. The review shows planned minutes against each day’s capacity, marks overloads,
|
||||
and flags work assigned to more than one date. Operators can move an item to another date without copying it;
|
||||
the estimate follows the item and the server rejects duplicate cross-day assignments without advancing the
|
||||
week revision. When capacity changes create overload, **Reflow remaining week** previews a deterministic,
|
||||
order-preserving redistribution across the seven visible dates. No day exceeds its capacity or five-item limit;
|
||||
work that cannot fit is named and remains in My Work, while missing estimates block apply. Cancel performs no
|
||||
write, and apply stages one durable whole-week transition through the existing conflict-safe sync path.
|
||||
Confirmation remains disabled while duplicates exist or the account-bound week is still syncing.
|
||||
Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days
|
||||
are not stranded behind an earlier request. From the same review, **Refresh from calendar** reads a local
|
||||
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and compares every current
|
||||
capacity with refreshed availability and planned load. If refreshed availability creates an overload, the review
|
||||
previews the deterministic destination of moved work and reports anything that cannot fit. One explicit
|
||||
**Apply capacities & reflow** action stages the complete seven-day plan once—there is no transient overloaded
|
||||
save—and preserves consented free windows alongside the redistributed work. A refresh that already fits keeps
|
||||
the direct one-write capacity path. IANA `TZID` values from Google and Outlook calendars are converted
|
||||
to the device's local workday, including daylight-saving transitions. Daily and weekly recurrence (`COUNT`,
|
||||
`UNTIL`, `INTERVAL`, and weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated with work bounded
|
||||
to the seven review dates; cancelled and transparent events do not consume capacity. If a time zone is unknown,
|
||||
or a recurring event that could affect the week uses another frequency or unsupported rule part, the review
|
||||
reports only an affected-event count and disables apply rather than understating busy time. Export a simpler
|
||||
seven-day calendar to proceed. Raw calendar data and event
|
||||
metadata are never persisted, rendered in diagnostics, or sent; only the reviewed capacity-minute totals use
|
||||
the existing encrypted, account-bound Week Ahead sync.
|
||||
Planning edits can remain offline for up to 30 days. After that, the
|
||||
expired edit is discarded visibly and the account plan is kept rather than replaying stale
|
||||
intent. The server retains no more than 4,096 operation receipts per account and removes
|
||||
receipts older than the same 30-day window; client base revisions keep a pruned replay from
|
||||
@@ -199,10 +287,12 @@ each envelope to its operation key and field purpose so rows and fields cannot b
|
||||
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
||||
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
||||
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
||||
likewise migrate on first read. Synchronized Saved Search collections use the private-state key and
|
||||
authenticate each envelope to its normalized account, preventing rows from being substituted between
|
||||
operators. Existing plaintext Saved Searches migrate atomically on first read without advancing their
|
||||
revision; missing, wrong, or modified key material returns no saved-view content. Other private stores
|
||||
likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
|
||||
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
|
||||
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
|
||||
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
|
||||
startup without changing order or acknowledgement state. Missing, wrong, or modified key material
|
||||
returns no saved-view or receipt content. Other private stores
|
||||
are not encrypted at the application layer. Web
|
||||
Push subscriptions use a third, independent
|
||||
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
|
||||
@@ -275,13 +365,24 @@ 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'
|
||||
# Required for worker-shared live/Find Work snapshots, synchronized Today/Later
|
||||
# planning and Saved Search state, and the Security activity journal. Keep this key independent
|
||||
# planning, Saved Search state, completed Filed review receipts, and the Security activity journal. Keep this key independent
|
||||
# from the Draft key and inject the
|
||||
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit
|
||||
# it. Missing, malformed, wrong-key, or modified state fails closed without
|
||||
# returning content. Legacy Today/Later, Saved Search, and Security activity rows
|
||||
# migrate atomically on first use without changing logical revisions or journal IDs.
|
||||
# The single-key setting remains the compatible first deployment and writes v1 envelopes.
|
||||
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||
# After all workers run keyring-capable code, configure at most four named keys and
|
||||
# select the active write key. Keep the old key present during the rolling deployment.
|
||||
# Key IDs use 1-32 letters, digits, underscores, or hyphens.
|
||||
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS='{"legacy":"<old-base64-key>","2026-08":"<new-base64-key>"}'
|
||||
export STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID='2026-08'
|
||||
# New writes use authenticated v2 envelopes carrying the active key ID. Rewrap every
|
||||
# shared store with the restart-safe command; output contains store-level counts only.
|
||||
# Run it again until every store reports failed=0, migrated=0, and current=total,
|
||||
# then remove the old key from every worker. Retain all keys and investigate if it exits 1.
|
||||
python3 scripts/rotate_private_state.py
|
||||
# Required for cross-device unfiled Draft sync. The single-key setting remains
|
||||
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
|
||||
# Inject the base64 encoding of exactly 32 random bytes from a secret manager.
|
||||
@@ -309,7 +410,8 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
|
||||
# three individual alerts followed by one private digest that opens Updates. A later
|
||||
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent.
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent. Opt-in
|
||||
# Following alerts use generic copy and deep-link to the changed-first Following review.
|
||||
# Browser push services must resolve exclusively to public IP addresses. Stackchain
|
||||
# validates endpoints at enrollment and again before delivery, rejects redirects,
|
||||
# and removes legacy subscriptions that resolve to private or reserved networks.
|
||||
|
||||
@@ -23,3 +23,20 @@
|
||||
- API contracts + versioning
|
||||
- independent deployability
|
||||
- observability: metrics, traces, structured logs
|
||||
|
||||
# Disk capacity incident
|
||||
|
||||
Root filesystem usage at or above 85% is an operations incident.
|
||||
|
||||
1. Capture the initial state with `df -h / /var/lib/gitea`.
|
||||
2. Inspect directory sizes and active processes before cleanup. Reclaim only
|
||||
disposable caches and abandoned temporary environments. Responders must not
|
||||
delete Gitea data, repositories, databases, secrets, or active release
|
||||
artifacts.
|
||||
3. Re-run `df -h / /var/lib/gitea`; usage must be below 85%.
|
||||
4. Confirm the release path with `systemctl is-active gitea act_runner`, then run
|
||||
the repository test suite.
|
||||
|
||||
Record the before and after usage in the incident ticket. If safe cleanup cannot
|
||||
restore capacity below 85%, keep the incident open and escalate storage
|
||||
expansion rather than removing durable data.
|
||||
+262
-4
@@ -50,6 +50,20 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
#open-insights { display:none; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.search-week-plan-sheet { position:fixed; inset:0; z-index:118; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.search-week-plan-sheet[hidden] { display:none; }
|
||||
.search-week-plan-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.search-week-plan-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.search-week-plan-panel h2 { margin:.2rem 0; }
|
||||
.search-week-plan-panel fieldset { min-width:0; margin:12px 0; padding:0; border:0; }
|
||||
.search-week-plan-days { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.search-week-plan-days button { display:grid; min-width:0; min-height:44px; text-align:left; overflow-wrap:anywhere; }
|
||||
.search-week-plan-days button[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 1px #60a5fa inset; }
|
||||
.search-week-plan-days small { color:#9fb3c8; }
|
||||
.search-week-plan-estimate { display:grid; gap:6px; }
|
||||
.search-week-plan-estimate input, #confirm-search-week-plan, #cancel-search-week-plan { box-sizing:border-box; min-height:44px; }
|
||||
#confirm-search-week-plan { width:100%; }
|
||||
@media (max-width:359px) { .search-week-plan-days { grid-template-columns:1fr; } }
|
||||
.notification-undo { position:fixed; }
|
||||
.notification-undo button { min-height:44px; }
|
||||
.notification-undo, .today-completion-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; }
|
||||
@@ -198,6 +212,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.search-batch-estimate-review input { min-height:44px; width:76px; }
|
||||
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-batch-estimate-actions button { min-height:44px; }
|
||||
.search-week-batch-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; overflow-x:hidden; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.search-week-batch-review[hidden] { display:none; }
|
||||
.search-week-batch-review > div:first-child, #search-week-batch-list { display:grid; gap:10px; }
|
||||
.search-week-batch-row { display:grid; gap:8px; padding:10px 0; border-bottom:1px solid #2a496e; overflow-wrap:anywhere; }
|
||||
.search-week-batch-row-controls { display:grid; grid-template-columns:minmax(0,1fr) minmax(96px,.45fr); gap:8px; }
|
||||
.search-week-batch-row select, .search-week-batch-row input, .search-week-batch-actions button { min-height:44px; width:100%; }
|
||||
.search-week-batch-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.search-release-review[hidden] { display:none; }
|
||||
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
|
||||
@@ -220,6 +241,8 @@ 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; }
|
||||
.app-badge-setting { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.app-badge-control { min-height:44px; }
|
||||
.push-update-control { min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
@@ -241,6 +264,179 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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 { min-width:44px; min-height:44px; }
|
||||
.week-plan-progress { margin:8px 0 4px; color:#bfdbfe; font-weight:700; }
|
||||
.week-plan-dates { display:flex; gap:8px; margin:8px 0 12px; padding:2px 0 8px; overflow-x:auto; overscroll-behavior-inline:contain; }
|
||||
.week-plan-dates[hidden] { display:none; }
|
||||
.week-plan-dates button { min-height:44px; min-width:86px; flex:0 0 auto; padding:6px 10px; }
|
||||
.week-plan-dates button[aria-current="date"] { color:#bfdbfe; background:#17365a; border-color:#60a5fa; }
|
||||
.today-week-reschedule { box-sizing:border-box; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:inherit; }
|
||||
.today-week-reschedule::backdrop { background:rgba(5,12,21,.82); }
|
||||
.today-week-reschedule-panel { width:min(100%,560px); box-sizing:border-box; min-height:100%; margin-left:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; overflow-x:hidden; }
|
||||
.today-week-reschedule-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-week-reschedule-panel h2, .today-week-reschedule-panel header p { margin-top:0; }
|
||||
.today-week-reschedule-panel input { box-sizing:border-box; width:100%; min-height:44px; margin:6px 0 10px; font-size:16px; }
|
||||
.today-week-reschedule-days { display:grid; gap:8px; margin:14px 0; }
|
||||
.today-week-reschedule-days button { min-height:44px; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; text-align:left; overflow-wrap:anywhere; }
|
||||
.today-week-reschedule-days button[aria-checked="true"] { border-color:#60a5fa; background:#17365a; color:#eff6ff; }
|
||||
.today-week-reschedule-days button:disabled { opacity:.58; }
|
||||
#cancel-today-week-reschedule { min-height:44px; }
|
||||
#confirm-today-week-reschedule { position:sticky; bottom:0; width:100%; min-height:48px; }
|
||||
#today-week-reschedule-status { min-height:1.4em; color:#fde68a; }
|
||||
.back-to-week-review { width:100%; min-height:44px; margin:4px 0 12px; }
|
||||
.tomorrow-conflict-review { margin-top:14px; }
|
||||
.tomorrow-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||||
.tomorrow-conflict-plans > section { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.tomorrow-conflict-plans h4 { margin:0 0 8px; }
|
||||
.tomorrow-conflict-plan-summary { margin:0 0 8px; color:#bfdbfe; }
|
||||
.tomorrow-conflict-plan-list { margin:0; padding-left:22px; overflow-wrap:anywhere; }
|
||||
.tomorrow-conflict-plan-list li + li { margin-top:6px; }
|
||||
.tomorrow-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
||||
.tomorrow-conflict-actions button { min-height:44px; flex:1 1 0; }
|
||||
.tomorrow-conflict-status { min-height:1.4em; margin-top:8px; }
|
||||
.plan-today-sheet.tomorrow-conflict-mode .mobile-plan-today-nav,
|
||||
.plan-today-sheet.tomorrow-conflict-mode #plan-today-fit,
|
||||
.plan-today-sheet.tomorrow-conflict-mode #plan-today-selected,
|
||||
.plan-today-sheet.tomorrow-conflict-mode #plan-today-available-work,
|
||||
.plan-today-sheet.tomorrow-conflict-mode .plan-today-actions { display:none; }
|
||||
@media (max-width:480px) {
|
||||
.tomorrow-conflict-plans { grid-template-columns:1fr; }
|
||||
.tomorrow-conflict-actions { flex-direction:column; }
|
||||
}
|
||||
.week-conflict-review { margin-top:14px; }
|
||||
.week-conflict-plans { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||||
.week-conflict-plans > section { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.week-conflict-plans h4 { margin:0 0 8px; }
|
||||
.week-conflict-day { margin:0 0 10px; padding-bottom:10px; border-bottom:1px solid #31577f; }
|
||||
.week-conflict-day:last-child { margin-bottom:0; padding-bottom:0; border-bottom:0; }
|
||||
.week-conflict-day strong { display:block; color:#bfdbfe; }
|
||||
.week-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
||||
.week-conflict-actions button { min-height:44px; flex:1 1 0; }
|
||||
.week-conflict-days { display:grid; gap:12px; }
|
||||
.week-conflict-date { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.week-conflict-date h4 { margin:0 0 8px; color:#bfdbfe; }
|
||||
.week-conflict-choice { min-height:44px; display:flex; align-items:center; gap:10px; padding:8px; border-radius:8px; }
|
||||
.week-conflict-choice:has(input:checked) { background:#17365a; outline:1px solid #60a5fa; }
|
||||
.week-conflict-choice input { min-width:20px; min-height:20px; }
|
||||
.week-conflict-choice span { display:grid; min-width:0; }
|
||||
.week-conflict-choice small { color:#a9bdd3; }
|
||||
#save-merged-week { width:100%; min-height:44px; margin-top:14px; }
|
||||
.week-conflict-status { min-height:1.4em; margin-top:8px; }
|
||||
.plan-today-sheet.week-conflict-mode .week-plan-dates,
|
||||
.plan-today-sheet.week-conflict-mode .mobile-plan-today-nav,
|
||||
.plan-today-sheet.week-conflict-mode #plan-today-fit,
|
||||
.plan-today-sheet.week-conflict-mode #plan-today-selected,
|
||||
.plan-today-sheet.week-conflict-mode #plan-today-available-work,
|
||||
.plan-today-sheet.week-conflict-mode .plan-today-actions { display:none; }
|
||||
@media (max-width:480px) {
|
||||
.week-conflict-plans { grid-template-columns:1fr; }
|
||||
.week-conflict-actions { flex-direction:column; }
|
||||
}
|
||||
.week-pull-conflict-review { min-width:0; margin-top:14px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
||||
.week-pull-conflict-item { margin-bottom:6px; color:#eff6ff; font-weight:700; }
|
||||
.week-pull-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
||||
.week-pull-conflict-actions button { min-height:44px; flex:1 1 0; }
|
||||
.week-pull-conflict-status { min-height:1.4em; margin-top:8px; }
|
||||
@media (max-width:480px) { .week-pull-conflict-actions { flex-direction:column; } }
|
||||
.week-review { margin-top:14px; }
|
||||
.week-review h2 { margin-bottom:6px; }
|
||||
.week-review-days { display:grid; gap:12px; }
|
||||
.week-review-day { padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.week-review-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; }
|
||||
.week-review-day.is-next-up { border-color:#60a5fa; box-shadow:0 0 0 1px #60a5fa inset; }
|
||||
.week-next-up { display:inline-block; margin-left:6px; padding:2px 7px; border-radius:999px; background:#1d4f7a; color:#dbeafe; font-size:11px; vertical-align:middle; }
|
||||
.week-review-day header { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||||
.week-review-day header button { flex:0 0 auto; min-height:44px; }
|
||||
.week-review-day h3, .week-review-day p { margin:0 0 8px; }
|
||||
.week-review-load { color:#bfdbfe; font-weight:700; }
|
||||
.week-review-day ul { display:grid; gap:10px; margin:0; padding:0; list-style:none; }
|
||||
.week-review-day li { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; padding-top:8px; border-top:1px solid #31577f; overflow-wrap:anywhere; }
|
||||
.week-review-item-copy { display:grid; min-width:0; gap:3px; }
|
||||
.week-review-item-open { display:block; width:100%; min-width:0; min-height:44px; padding:8px; border:0; border-radius:8px; background:transparent; color:inherit; text-align:left; }
|
||||
.week-review-item-open:hover { background:#173453; }
|
||||
.week-review-item-open:focus-visible { outline:3px solid #93c5fd; outline-offset:2px; }
|
||||
.week-review-unplan { width:100%; min-height:44px; border-color:#6b87a6; background:transparent; color:#d7e5f5; }
|
||||
.week-review-pull { grid-column:1/-1; width:100%; min-height:44px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
|
||||
.week-review-unplan { grid-column:1/-1; }
|
||||
.week-unplan-receipt { margin:12px 0 6px; padding:10px 12px; border:1px solid #60a5fa; border-radius:10px; background:#112d4d; color:#dbeafe; }
|
||||
#undo-week-unplan { width:100%; min-height:44px; margin-bottom:8px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
|
||||
.week-start-early { display:block; width:100%; min-height:44px; margin-top:12px; border-color:#60a5fa; background:#1d4f7a; color:#eff6ff; font-weight:800; }
|
||||
.week-start-early:focus-visible { outline:3px solid #bfdbfe; outline-offset:2px; }
|
||||
.week-review-item-copy strong, .week-review-item-copy small { overflow-wrap:anywhere; }
|
||||
.week-review-item-copy small { color:#a9bdd3; }
|
||||
.week-review-move { display:flex; align-items:end; gap:8px; }
|
||||
.week-review-day label { display:grid; gap:4px; font-size:12px; }
|
||||
.week-review-day select, .week-review-day button { min-height:44px; }
|
||||
.week-review-day button { min-height:44px; }
|
||||
.week-review-duplicates { margin:12px 0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; }
|
||||
.week-review-status { min-height:1.4em; margin:10px 0; }
|
||||
.week-offline-snapshot { margin:10px 0 8px; padding:10px 12px; border:1px solid #f59e0b; border-radius:10px; background:#2a1c12; color:#fde68a; overflow-wrap:anywhere; }
|
||||
#retry-week-live { min-height:44px; width:100%; margin-bottom:8px; border-color:#f59e0b; }
|
||||
#confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
#edit-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
#export-saved-week-calendar { width:100%; min-height:48px; margin:0 0 8px; }
|
||||
#open-week-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; }
|
||||
#open-week-reflow { width:100%; min-height:48px; margin:0 0 12px; border-color:#60a5fa; }
|
||||
.week-reflow-review { box-sizing:border-box; width:100%; margin:0 0 14px; padding:14px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; overflow-x:hidden; }
|
||||
.week-reflow-review h3 { margin:.25rem 0; }
|
||||
.week-reflow-days { display:grid; gap:6px; margin:12px 0; }
|
||||
.week-reflow-day { display:flex; justify-content:space-between; gap:10px; min-width:0; padding:8px; border-radius:8px; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.week-reflow-unscheduled { min-height:1.4em; color:#fde68a; overflow-wrap:anywhere; }
|
||||
.week-reflow-actions { display:grid; grid-template-columns:1fr 2fr; gap:8px; margin-top:12px; }
|
||||
.week-reflow-actions button { min-width:0; min-height:48px; }
|
||||
.week-capacity-import { position:fixed; z-index:121; inset:0; box-sizing:border-box; width:100%; max-width:560px; margin-inline:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; overflow:auto; overflow-x:hidden; }
|
||||
.week-capacity-import[hidden] { display:none; }
|
||||
.week-capacity-import > header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.week-capacity-import h2 { margin:.2rem 0; }
|
||||
.week-capacity-import > label, .week-capacity-hours label { display:grid; min-width:0; gap:6px; margin:12px 0; }
|
||||
.week-capacity-import input, .week-capacity-import button { box-sizing:border-box; min-width:0; min-height:44px; max-width:100%; }
|
||||
.week-capacity-hours { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
|
||||
.week-capacity-days { display:grid; gap:8px; margin:10px 0 14px; }
|
||||
.week-capacity-day { display:grid; min-width:0; gap:3px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.week-capacity-day span { color:#bfdbfe; font-weight:700; }
|
||||
.week-capacity-day small { color:#a9bdd3; }
|
||||
.week-capacity-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; }
|
||||
.week-capacity-day .week-capacity-over { color:#fde68a; font-weight:700; }
|
||||
.week-free-times-consent { display:flex; align-items:flex-start; gap:10px; min-width:0; margin:12px 0; padding:10px; border:1px solid #31577f; border-radius:10px; }
|
||||
.week-free-times-consent input { flex:0 0 44px; width:44px; margin:0; }
|
||||
.week-free-times-consent span { display:grid; min-width:0; gap:4px; overflow-wrap:anywhere; }
|
||||
.week-free-times-consent small { color:#a9bdd3; }
|
||||
#apply-week-capacities { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
.week-availability-editor { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
#open-week-availability { min-height:44px; }
|
||||
.week-availability-editor h3 { margin-top:0; }
|
||||
.week-availability-grid { display:grid; grid-template-columns:1fr; gap:8px; }
|
||||
.week-availability-grid label { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,120px); gap:10px; align-items:center; }
|
||||
.week-availability-grid input { min-height:44px; min-width:0; box-sizing:border-box; }
|
||||
.week-availability-actions { display:grid; grid-template-columns:1fr; gap:8px; margin-top:12px; }
|
||||
.week-availability-actions button { min-height:44px; }
|
||||
@media (min-width:600px) { .week-availability-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .week-availability-actions { grid-template-columns:repeat(3,minmax(0,1fr)); } }
|
||||
@media (max-width:359px) { .week-capacity-hours { grid-template-columns:1fr; } }
|
||||
.week-calendar-handoff h2 { margin-bottom:6px; }
|
||||
.week-calendar-days { display:grid; gap:12px; margin:14px 0; }
|
||||
.week-calendar-day { min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.week-calendar-day header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.week-calendar-day h3 { margin:0; }
|
||||
.week-calendar-day input[type="time"] { min-height:44px; font-size:16px; }
|
||||
.week-calendar-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:center; min-width:0; padding:8px 0; overflow-wrap:anywhere; }
|
||||
.week-calendar-choice { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-height:44px; }
|
||||
.week-calendar-choice input { width:22px; height:22px; }
|
||||
.week-calendar-choice span { display:grid; gap:3px; }
|
||||
.week-calendar-time { display:grid; gap:2px; font-size:12px; font-weight:700; }
|
||||
.week-calendar-time input { box-sizing:border-box; width:7.5rem; min-height:44px; font-size:16px; }
|
||||
.week-calendar-item small { color:#a9bdd3; }
|
||||
.week-calendar-status { min-height:1.4em; margin:10px 0; }
|
||||
.week-calendar-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#0b1526; }
|
||||
.week-calendar-actions button { min-width:0; min-height:48px; }
|
||||
@media(max-width:359px) { .week-calendar-item, .week-calendar-actions { grid-template-columns:1fr; } .week-calendar-time input { width:100%; } }
|
||||
.plan-today-sheet.week-review-mode .week-plan-dates,
|
||||
.plan-today-sheet.week-review-mode .mobile-plan-today-nav,
|
||||
.plan-today-sheet.week-review-mode #plan-today-fit,
|
||||
.plan-today-sheet.week-review-mode #plan-today-selected,
|
||||
.plan-today-sheet.week-review-mode #plan-today-available-work,
|
||||
.plan-today-sheet.week-review-mode .plan-today-actions { display:none; }
|
||||
@media (max-width:480px) {
|
||||
.week-review-day li { grid-template-columns:1fr; }
|
||||
}
|
||||
.mobile-plan-today-nav { position:sticky; top:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:4px; margin:0 -18px 10px; padding:4px 18px; background:rgba(11,21,38,.98); border-block:1px solid #2a496e; }
|
||||
.mobile-plan-today-nav button { min-width:0; min-height:44px; padding:4px; border-color:transparent; font-size:12px; }
|
||||
.mobile-plan-today-nav button[aria-current="location"] { color:#bfdbfe; background:#17365a; border-color:#31577f; }
|
||||
@@ -630,6 +826,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-decision-bar button, .update-decision-bar summary { min-height:44px; min-width:0; padding-inline:6px; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.task-overlay-open .issue-sheet.open, .task-overlay-open .pull-sheet.open { z-index:76; }
|
||||
.issue-sheet.open { display:flex; }
|
||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
@@ -758,16 +955,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.filed-claim-actions button { min-width:0; min-height:44px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions #watch-issue-detail, .pull-sheet-actions #watch-pull-detail { min-width:0; min-height:44px; }
|
||||
.detail-watch-status:not(:empty) { margin-top:8px; overflow-wrap:anywhere; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-handoff, .pull-ownership { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select, .pull-ownership select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff, .pull-ownership, .pull-review-request { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div, .pull-review-request > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select, .pull-ownership select, .pull-review-request select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff select, .issue-handoff button { min-height:44px; }
|
||||
.pull-ownership select, .pull-ownership button { min-height:44px; }
|
||||
.pull-review-request select, .pull-review-request button { min-height:44px; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.plan-tomorrow { min-height:44px; }
|
||||
.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(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; }
|
||||
@@ -840,7 +1041,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.search-preview-conversation { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-conversation h2 { margin:0; font-size:1rem; }
|
||||
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||
.search-preview-comment.new-since-review { padding-left:10px; border-left:3px solid var(--accent); background:#10233a; }
|
||||
.search-preview-conversation button { min-height:44px; width:100%; }
|
||||
.search-preview-review { display:grid; gap:8px; min-width:0; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-review[hidden] { display:none; }
|
||||
.search-preview-review h2 { margin:0; font-size:1rem; }
|
||||
.search-preview-review button { min-height:44px; width:100%; }
|
||||
.search-preview-file { min-width:0; margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; overflow:hidden; }
|
||||
.search-preview-file > strong, .search-preview-file > .small { display:block; overflow-wrap:anywhere; }
|
||||
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-reply[hidden] { display:none; }
|
||||
.search-preview-reply h2 { margin:0; font-size:1rem; }
|
||||
@@ -852,10 +1060,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.search-preview-reply .conversation-photo-actions { max-width:100%; }
|
||||
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
#keep-following-status { display:block; min-height:20px; overflow-wrap:anywhere; }
|
||||
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
|
||||
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.search-preview-primary-actions.following-disposition-mode { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
@media (max-width:420px) {
|
||||
.search-preview-primary-actions { grid-template-columns:1fr; }
|
||||
.search-preview-primary-actions.following-disposition-mode { grid-template-columns:1fr; }
|
||||
}
|
||||
@media (max-width:600px) {
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-top:max(12px,env(safe-area-inset-top)); }
|
||||
@@ -879,12 +1090,25 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
padding-bottom:calc(6px + env(safe-area-inset-bottom));
|
||||
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
|
||||
}
|
||||
#search-preview-overview, #search-preview-conversation,
|
||||
#search-preview-overview, #search-preview-conversation, #search-preview-review,
|
||||
#search-preview-reply-workspace, #search-preview-actions { scroll-margin-top:72px; }
|
||||
#search-preview-actions { position:static; }
|
||||
}
|
||||
@media (min-width:601px) { .mobile-search-preview-nav { display:none; } }
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.following-sheet { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto 0 0 auto; padding:0; border:1px solid #2a496e; color:var(--text); background:#102641; }
|
||||
.following-sheet::backdrop { background:rgba(3,9,18,.74); }
|
||||
.following-panel { box-sizing:border-box; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
||||
.following-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.following-panel h2, .following-panel p { margin:0; }
|
||||
.following-panel header button, #retry-following { min-height:44px; }
|
||||
.following-review-action { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.following-list { display:grid; gap:8px; min-width:0; max-height:70dvh; overflow:auto; }
|
||||
.following-card { box-sizing:border-box; display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; min-width:0; min-height:52px; padding:10px 12px; text-align:left; }
|
||||
.following-card span:first-child { min-width:0; display:grid; gap:3px; }
|
||||
.following-card em { color:var(--accent); font-size:.75rem; font-style:normal; font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.following-card.has-unseen-change { border-color:var(--accent); }
|
||||
.following-card strong, .following-card small { overflow-wrap:anywhere; }
|
||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||
.markdown-content > :first-child { margin-top:0; }
|
||||
.markdown-content > :last-child { margin-bottom:0; }
|
||||
@@ -1035,6 +1259,34 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
|
||||
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
|
||||
.pull-review-tools button { min-height:44px; }
|
||||
.pull-reviewer-summary { margin:10px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
|
||||
.pull-reviewer-summary h3 { margin:0 0 6px; font-size:14px; }
|
||||
.pull-reviewer-status { padding:8px 0; border-top:1px solid #203a5c; overflow-wrap:anywhere; }
|
||||
.pull-reviewer-status:first-child { border-top:0; }
|
||||
.pull-reviewer-status-heading { display:flex; justify-content:space-between; gap:10px; }
|
||||
.pull-reviewer-status-heading span { text-align:right; }
|
||||
.cancel-review-request { min-height:44px; width:100%; margin-top:8px; }
|
||||
.pull-review-feedback { margin-top:8px; border:1px solid #2a496e; border-radius:8px; padding:0 8px 8px; }
|
||||
.pull-review-feedback > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
|
||||
.pull-review-feedback-summary { margin:4px 0 10px; white-space:pre-wrap; }
|
||||
.pull-review-feedback-file { margin-top:8px; padding-top:8px; border-top:1px solid #203a5c; }
|
||||
.pull-review-feedback-file h4 { margin:0 0 4px; overflow-wrap:anywhere; }
|
||||
.pull-review-feedback-file ul { margin:0; padding-left:20px; }
|
||||
.pull-review-feedback-file li { margin:6px 0; }
|
||||
.pull-review-feedback-file small { display:block; color:#93c5fd; }
|
||||
.pull-review-feedback-file button { min-height:44px; width:100%; margin-top:4px; }
|
||||
.address-review-feedback, [data-load-review-feedback] { min-height:44px; width:100%; margin-top:8px; }
|
||||
.pull-feedback-pass { margin-top:10px; padding:10px; border:1px solid #3b82f6; border-radius:10px; overflow-wrap:anywhere; }
|
||||
.pull-feedback-pass-heading { display:flex; align-items:start; justify-content:space-between; gap:8px; }
|
||||
.pull-feedback-pass-heading h4 { margin:2px 0 8px; }
|
||||
.pull-feedback-pass p { white-space:pre-wrap; }
|
||||
.pull-feedback-pass textarea { width:100%; min-height:88px; box-sizing:border-box; }
|
||||
.pull-feedback-dispositions { display:grid; grid-template-columns:1fr; gap:6px; margin:10px 0; padding:8px; }
|
||||
.pull-feedback-dispositions button[aria-pressed="true"] { border-color:#60a5fa; background:#173b63; }
|
||||
.pull-feedback-navigation { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:8px; }
|
||||
.pull-feedback-pass button { min-height:44px; }
|
||||
#finish-pull-feedback, #request-feedback-review { width:100%; margin-top:8px; }
|
||||
#request-updated-pull-review { min-height:44px; width:100%; margin-top:8px; }
|
||||
.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; }
|
||||
.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
|
||||
.pull-review summary h2 { margin:0; font-size:16px; }
|
||||
@@ -1072,6 +1324,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
@media (max-width: 600px) {
|
||||
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
||||
body.mobile-today-active { padding-bottom:calc(var(--mobile-today-clearance, 166px) + env(safe-area-inset-bottom)); }
|
||||
body:has(.mobile-today-hud:not([hidden]):not([data-overlay-hidden="true"])) { padding-bottom:calc(var(--mobile-today-clearance, 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; }
|
||||
@@ -1168,6 +1421,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.following-sheet { width:100%; max-width:none; border:0; border-radius:18px 18px 0 0; }
|
||||
|
||||
.mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-first-task::backdrop { background:rgba(3,9,18,.78); }
|
||||
@@ -1206,6 +1460,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-update-outcome p { margin:0 0 10px; }
|
||||
.mobile-update-outcome button { width:100%; min-height:44px; }
|
||||
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:4px; 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-first-task-coach { grid-column:1 / -1; display:grid; gap:2px; padding:8px 10px; border-left:3px solid #60a5fa; border-radius:6px; background:#172f4d; }
|
||||
.mobile-first-task-coach[hidden] { display:none; }
|
||||
.mobile-first-task-receipt { position:fixed; left:12px; right:12px; bottom:calc(var(--mobile-today-clearance, 166px) + 8px + env(safe-area-inset-bottom)); z-index:46; padding:12px; border:1px solid #34d399; border-radius:10px; background:#0d3b35; font-weight:700; }
|
||||
.mobile-first-task-receipt[hidden] { display:none; }
|
||||
.mobile-today-summary { grid-column:1 / 4; 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-column:4 / 7; align-self:center; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.mobile-today-hud [data-mobile-today-complete] { grid-column:1 / 3; }
|
||||
|
||||
+498
-92
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,292 @@
|
||||
(function (root, factory) {
|
||||
const exports = factory();
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = exports.createFollowing;
|
||||
module.exports.attachFollowing = exports.attachFollowing;
|
||||
module.exports.planningDisposition = exports.planningDisposition;
|
||||
} else {
|
||||
root.createFollowing = exports.createFollowing;
|
||||
root.attachFollowing = exports.attachFollowing;
|
||||
root.followingPlanningDisposition = exports.planningDisposition;
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
function planningDisposition(detail, controls) {
|
||||
const active = detail?.following === true && detail.kind === 'issue' && detail.state === 'open' &&
|
||||
Boolean(detail.claimable || detail.assigned_to_me);
|
||||
const result = active ? {active:true,today:'Add to Today & next',later:'Later & next',week:'Week Ahead & next'} : {active:false};
|
||||
if (!controls && globalThis.document) {
|
||||
const query = selector => globalThis.document.querySelector(selector);
|
||||
controls = {root:query('.search-preview-primary-actions'),claim:query('#claim-search-result'),
|
||||
today:query('#queue-search-result'),later:query('#defer-search-result'),week:query('#plan-search-result'),
|
||||
start:query('#start-search-result'),watch:query('#watch-search-result')};
|
||||
}
|
||||
controls?.root?.classList.toggle('following-disposition-mode', active);
|
||||
if (active && controls) {
|
||||
controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true;
|
||||
controls.today.textContent = result.today;
|
||||
controls.later.textContent = result.later;
|
||||
controls.week.textContent = result.week;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createFollowing(options) {
|
||||
let generation = 0;
|
||||
let snapshot = {revision:0, items:[]};
|
||||
let review = null;
|
||||
|
||||
function sameItem(left, right) {
|
||||
return (left?.kind || 'issue') === (right?.kind || 'issue') &&
|
||||
left?.repository === right?.repository && Number(left?.number) === Number(right?.number);
|
||||
}
|
||||
|
||||
function publish(status, error) {
|
||||
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
|
||||
state.degraded = snapshot.degraded === true;
|
||||
state.refreshFailures = Number(snapshot.refreshFailures) || 0;
|
||||
if (review?.acknowledged.size) state.reviewSummary = {
|
||||
reviewed:review.acknowledged.size,
|
||||
remaining:snapshot.items.filter(item => item.has_unseen_change === true).length,
|
||||
};
|
||||
if (error) state.error = error;
|
||||
options.render?.(state);
|
||||
if (status === 'ready') options.onCount?.(
|
||||
snapshot.items.filter(item => item.has_unseen_change === true).length);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const requestGeneration = ++generation;
|
||||
publish('loading');
|
||||
try {
|
||||
const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}});
|
||||
if (requestGeneration !== generation) return snapshot;
|
||||
snapshot = {
|
||||
revision:Number(result?.revision) || 0,
|
||||
items:Array.isArray(result?.items) ? result.items.slice(0, 50)
|
||||
.map(item => ({...item, kind:item.kind === 'pull' ? 'pull' : 'issue'})) : [],
|
||||
degraded:result?.degraded === true,
|
||||
refreshFailures:Number(result?.refresh_failures) || 0,
|
||||
};
|
||||
publish('ready');
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (requestGeneration === generation) publish('error', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledge(item) {
|
||||
const current = snapshot.items.find(candidate => sameItem(candidate, item) &&
|
||||
candidate.updated_at === item?.updated_at);
|
||||
if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
|
||||
await options.onAcknowledge(current);
|
||||
current.has_unseen_change = false;
|
||||
review?.acknowledged.add(current.kind + ':' + current.repository + '#' + current.number + '@' + current.updated_at);
|
||||
publish('ready');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function keepForLater(item) {
|
||||
const exact = candidate => sameItem(candidate, item) && candidate.updated_at === item?.updated_at;
|
||||
const current = snapshot.items.find(exact);
|
||||
const index = review?.active ? review.items.findIndex(exact) : -1;
|
||||
if (!current || index < 0 || typeof options.onKeep !== 'function') return null;
|
||||
await options.onKeep(current);
|
||||
current.has_unseen_change = true;
|
||||
review.acknowledged.delete(current.kind + ':' + current.repository + '#' +
|
||||
current.number + '@' + current.updated_at);
|
||||
review.items.splice(index, 1);
|
||||
const next = review.items[index] || null;
|
||||
if (!next) review.active = false;
|
||||
publish('ready');
|
||||
if (next) await open(snapshot.items.findIndex(candidate => sameItem(candidate, next)));
|
||||
return next;
|
||||
}
|
||||
|
||||
async function open(index) {
|
||||
const item = snapshot.items[Number(index)];
|
||||
if (!item) return false;
|
||||
await options.onOpen?.({...item, following:true});
|
||||
await acknowledge(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function startReview() {
|
||||
const items = snapshot.items
|
||||
.filter(item => item.has_unseen_change === true)
|
||||
.map(item => ({...item, following:true}));
|
||||
if (!items.length) return false;
|
||||
review = {items, more:false, active:true, acknowledged:new Set()};
|
||||
await open(snapshot.items.indexOf(snapshot.items.find(item => sameItem(item, items[0]))));
|
||||
return true;
|
||||
}
|
||||
|
||||
function finishReview() {
|
||||
const completed = Boolean(review?.completed || (review?.active &&
|
||||
!snapshot.items.some(item => item.has_unseen_change === true)));
|
||||
if (review) review.active = false;
|
||||
if (completed && !review.completed) {
|
||||
review.completed = true;
|
||||
options.onReviewComplete?.();
|
||||
}
|
||||
publish('ready');
|
||||
return completed;
|
||||
}
|
||||
|
||||
function retire(item) {
|
||||
const same = candidate => sameItem(candidate, item);
|
||||
const index = review?.active ? review.items.findIndex(same) : -1;
|
||||
snapshot.items = snapshot.items.filter(candidate => !same(candidate));
|
||||
if (index < 0) {
|
||||
publish('ready');
|
||||
return null;
|
||||
}
|
||||
review.items.splice(index, 1);
|
||||
const next = review.items[index] || null;
|
||||
if (!next) {
|
||||
review.active = false;
|
||||
if (!review.completed) {
|
||||
review.completed = true;
|
||||
options.onReviewComplete?.();
|
||||
}
|
||||
}
|
||||
publish('ready');
|
||||
return next ? {...next} : null;
|
||||
}
|
||||
|
||||
return {
|
||||
load, open, startReview, previewLoaded:acknowledge, keepForLater, finishReview, retire,
|
||||
session:() => review?.active ? {items:[...review.items], more:false} : null,
|
||||
items:() => snapshot.items.map(item => ({...item})),
|
||||
count:() => snapshot.items.length,
|
||||
};
|
||||
}
|
||||
|
||||
function attachFollowing(onOpen, hooks = {}) {
|
||||
const document = globalThis.document;
|
||||
const query = selector => document.querySelector(selector);
|
||||
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''})[character]);
|
||||
const formatTime = value => new Date(value).toLocaleString();
|
||||
const fetchJson = async (url, options) => {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
|
||||
return payload;
|
||||
};
|
||||
const changeRevision = (item, action) => {
|
||||
const [owner, repo] = item.repository.split('/');
|
||||
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
|
||||
encodeURIComponent(repo) + '/issues/' + item.number + '/' + action +
|
||||
'?kind=' + encodeURIComponent(item.kind), {
|
||||
method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
|
||||
body:JSON.stringify({updated_at:item.updated_at}),
|
||||
});
|
||||
};
|
||||
let feature;
|
||||
let activeReviewItem = null;
|
||||
const show = () => query('#following-sheet').open || query('#following-sheet').showModal();
|
||||
function render(state) {
|
||||
hooks.onStatus?.(state.status);
|
||||
const list = query('#following-list');
|
||||
const status = query('#following-status');
|
||||
const reviewButton = query('#review-following');
|
||||
query('#retry-following').hidden = state.status !== 'error';
|
||||
if (state.status === 'loading') return void (status.textContent = 'Loading watched items…');
|
||||
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
|
||||
const unseen = state.items.filter(item => item.has_unseen_change === true).length;
|
||||
reviewButton.hidden = unseen === 0;
|
||||
reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
|
||||
status.textContent = (state.reviewSummary
|
||||
? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. '
|
||||
: '') + (state.degraded ? 'Some watched items could not be refreshed. Showing last known details. ' : '') + (state.items.length
|
||||
? state.items.length + (state.items.length === 1 ? ' watched item.' : ' watched items.')
|
||||
: 'No watched items yet. Watch an issue or pull request from Search to keep it here.');
|
||||
list.innerHTML = state.items.map((item, index) =>
|
||||
'<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
|
||||
'" type="button" data-following-index="' + index + '"><span>' +
|
||||
(item.has_unseen_change ? '<em>' + escapeHtml(item.change_summary || 'New activity') + '</em>' : '') + '<strong>' +
|
||||
escapeHtml(item.title) + '</strong><small>' + escapeHtml(
|
||||
(item.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + item.repository + ' #' + item.number +
|
||||
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
|
||||
'</small></span><span aria-hidden="true">›</span></button>').join('');
|
||||
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
|
||||
query('#following-sheet').close();
|
||||
feature.open(Number(button.dataset.followingIndex)).catch(() => {});
|
||||
}));
|
||||
}
|
||||
feature = createFollowing({
|
||||
fetchJson, render,
|
||||
onCount:count => {
|
||||
const value = query('[data-mobile-queue-count="following"]');
|
||||
value.textContent = count;
|
||||
value.closest('button').setAttribute('aria-label', 'Following, ' + count +
|
||||
(count === 1 ? ' unseen change' : ' unseen changes'));
|
||||
hooks.onCount?.(count, feature.items());
|
||||
},
|
||||
onOpen:item => {
|
||||
activeReviewItem = item;
|
||||
return onOpen(item);
|
||||
},
|
||||
onReviewComplete:hooks.onReviewComplete,
|
||||
onAcknowledge:item => changeRevision(item, 'seen'),
|
||||
onKeep:item => changeRevision(item, 'keep'),
|
||||
});
|
||||
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
|
||||
query('#review-following').addEventListener('click', () => {
|
||||
query('#following-sheet').close();
|
||||
feature.startReview().catch(() => {
|
||||
show();
|
||||
});
|
||||
});
|
||||
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
|
||||
const keepButton = query('#keep-following-for-later');
|
||||
keepButton.addEventListener('click', async () => {
|
||||
const status = query('#keep-following-status');
|
||||
keepButton.disabled = true;
|
||||
status.textContent = 'Keeping this change for later…';
|
||||
try {
|
||||
if (!await feature.keepForLater(activeReviewItem)) query('#close-search-preview').click();
|
||||
status.textContent = 'Kept for later.';
|
||||
} catch (_) {
|
||||
status.textContent = 'Could not keep this change for later. Retry when ready.';
|
||||
keepButton.disabled = false;
|
||||
}
|
||||
});
|
||||
return {
|
||||
load:feature.load,
|
||||
review:feature.startReview,
|
||||
async route() {
|
||||
await feature.load().then(feature.startReview).catch(() => false) || show();
|
||||
},
|
||||
open() {
|
||||
show();
|
||||
feature.load().catch(() => {});
|
||||
return 'opened-following';
|
||||
},
|
||||
session:feature.session,
|
||||
previewLoaded:feature.previewLoaded,
|
||||
keepForLater:feature.keepForLater,
|
||||
retire:feature.retire,
|
||||
preview(state) {
|
||||
const disposition = planningDisposition(state?.detail || state?.item);
|
||||
keepButton.hidden = state?.item?.following !== true || disposition.active;
|
||||
keepButton.disabled = false;
|
||||
if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = '';
|
||||
},
|
||||
returnToFollowing() {
|
||||
const completed = feature.finishReview();
|
||||
if (completed) return 'completed-following';
|
||||
show();
|
||||
globalThis.requestAnimationFrame?.(() => {
|
||||
const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following');
|
||||
target?.focus();
|
||||
});
|
||||
return 'returned-following';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {createFollowing, attachFollowing, planningDisposition};
|
||||
});
|
||||
+301
-3
@@ -133,6 +133,10 @@
|
||||
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step" id="device-setup-app-badge-step">
|
||||
<div><strong>Show unread Updates on the app icon</strong><p class="small" id="device-setup-app-badge-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-app-badge" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
|
||||
<div class="device-setup-deadline-controls">
|
||||
@@ -143,6 +147,14 @@
|
||||
<button class="device-setup-action" id="device-setup-deadline" type="button">Enable</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Start my planned day</strong><p class="small" id="device-setup-start-day-status" role="status">Get one private reminder when Tomorrow is ready.</p></div>
|
||||
<div class="device-setup-deadline-controls">
|
||||
<label for="device-setup-start-day-hour">Local hour</label>
|
||||
<select id="device-setup-start-day-hour" aria-label="Start-day reminder local hour"></select>
|
||||
<button class="device-setup-action" id="device-setup-start-day" type="button">Enable</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<section class="device-storage" aria-labelledby="device-storage-heading">
|
||||
<div>
|
||||
@@ -185,6 +197,7 @@
|
||||
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">0</span></button>
|
||||
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
|
||||
<button class="work-filter" data-work-filter="filed" aria-pressed="false">Filed <span data-work-count="filed">0</span></button>
|
||||
<button class="work-filter" data-work-filter="authored" aria-pressed="false">My PRs <span data-work-count="authored">0</span></button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||
@@ -192,6 +205,7 @@
|
||||
<button class="work-filter" data-work-filter="later" aria-pressed="false">Later <span data-work-count="later">0</span></button>
|
||||
<button class="work-filter" data-work-filter="draft" aria-pressed="false">Drafts <span data-work-count="draft">0</span></button>
|
||||
</div>
|
||||
<button class="plan-tomorrow" id="plan-tomorrow" type="button">Plan Tomorrow</button>
|
||||
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
|
||||
<select class="work-milestone-filter" id="work-milestone-filter">
|
||||
<option value="all">All milestones</option>
|
||||
@@ -201,13 +215,22 @@
|
||||
<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>
|
||||
<span class="app-badge-setting" id="app-badge-setting">
|
||||
<label class="app-badge-control" for="app-badge-control"><input id="app-badge-control" type="checkbox" /> Show review attention on the app icon</label>
|
||||
<span class="small" id="app-badge-status" role="status" aria-live="polite"></span>
|
||||
</span>
|
||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
|
||||
<label class="push-update-control" for="push-following"><input id="push-following" type="checkbox" /> Notify me when Following changes</label>
|
||||
<span class="small" id="push-following-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
|
||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-start-day"><input id="push-start-day" type="checkbox" /> Remind me when my planned day is ready</label>
|
||||
<label for="push-start-day-hour">Start-day hour <select id="push-start-day-hour" aria-label="Start-day reminder local hour"></select></label>
|
||||
<span class="small" id="push-start-day-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="today-lock-screen"><input id="today-lock-screen" type="checkbox" /> Show active Today controls on my lock screen</label>
|
||||
<span class="small" id="today-lock-screen-status" role="status" aria-live="polite"></span>
|
||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||
@@ -448,6 +471,143 @@
|
||||
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
|
||||
<button id="cancel-plan-today" type="button">Cancel</button>
|
||||
</div>
|
||||
<p class="week-plan-progress" id="week-plan-progress" aria-live="polite" hidden></p>
|
||||
<nav class="week-plan-dates" id="week-plan-dates" aria-label="Week Ahead dates" hidden></nav>
|
||||
<button class="back-to-week-review" id="back-to-week-review" type="button" hidden>Back to week review</button>
|
||||
<section class="tomorrow-conflict-review" id="tomorrow-conflict-review" aria-labelledby="tomorrow-conflict-title" hidden>
|
||||
<div class="small">Cross-device change</div>
|
||||
<h3 id="tomorrow-conflict-title">Choose which Tomorrow plan to keep</h3>
|
||||
<p class="small muted">Both plans are preserved until one choice succeeds. Today will not change.</p>
|
||||
<div class="tomorrow-conflict-plans">
|
||||
<section aria-labelledby="tomorrow-conflict-phone-title">
|
||||
<h4 id="tomorrow-conflict-phone-title">This phone</h4>
|
||||
<div id="tomorrow-conflict-phone-plan"></div>
|
||||
</section>
|
||||
<section aria-labelledby="tomorrow-conflict-server-title">
|
||||
<h4 id="tomorrow-conflict-server-title">Saved on your account</h4>
|
||||
<div id="tomorrow-conflict-server-plan"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="tomorrow-conflict-actions">
|
||||
<button id="keep-phone-tomorrow" type="button">Keep this phone’s plan</button>
|
||||
<button id="use-server-tomorrow" type="button">Use saved account plan</button>
|
||||
</div>
|
||||
<div class="small tomorrow-conflict-status" id="tomorrow-conflict-status" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="week-conflict-review" id="week-conflict-review" aria-labelledby="week-conflict-title" hidden>
|
||||
<div class="small">Cross-device change</div>
|
||||
<h3 id="week-conflict-title">Resolve Week Ahead changes</h3>
|
||||
<p class="small muted">Changes on different days are combined automatically. Choose only where both devices changed the same day.</p>
|
||||
<div class="week-conflict-days" id="week-conflict-days" hidden></div>
|
||||
<button id="save-merged-week" type="button" hidden disabled>Save combined week</button>
|
||||
<div class="week-conflict-plans" id="week-conflict-legacy-plans">
|
||||
<section aria-labelledby="week-conflict-phone-title">
|
||||
<h4 id="week-conflict-phone-title">This phone</h4>
|
||||
<div id="week-conflict-phone-plan"></div>
|
||||
</section>
|
||||
<section aria-labelledby="week-conflict-server-title">
|
||||
<h4 id="week-conflict-server-title">Saved on your account</h4>
|
||||
<div id="week-conflict-server-plan"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="week-conflict-actions" id="week-conflict-legacy-actions">
|
||||
<button id="keep-phone-week" type="button">Keep this phone’s week</button>
|
||||
<button id="use-server-week" type="button">Use saved account week</button>
|
||||
</div>
|
||||
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="week-pull-conflict-review" id="week-pull-conflict-review" aria-labelledby="week-pull-conflict-title" tabindex="-1" hidden>
|
||||
<div class="small">Saved transfer needs review</div>
|
||||
<h3 id="week-pull-conflict-title">Resolve Today transfer</h3>
|
||||
<p id="week-pull-conflict-item" class="week-pull-conflict-item"></p>
|
||||
<p id="week-pull-conflict-context" class="small muted"></p>
|
||||
<div class="week-pull-conflict-actions">
|
||||
<button id="rebase-week-pull" type="button">Add using current plans</button>
|
||||
<button id="keep-week-pull" type="button">Keep in Week Ahead</button>
|
||||
</div>
|
||||
<div class="small week-pull-conflict-status" id="week-pull-conflict-status" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="week-review" id="week-review" aria-labelledby="week-review-title" hidden>
|
||||
<div class="small">Seven-day overview</div>
|
||||
<h2 id="week-review-title">Week Ahead</h2>
|
||||
<p class="small muted">See what is next and check each day’s load. Edit only when you are ready.</p>
|
||||
<p class="week-offline-snapshot" id="week-offline-snapshot" role="status" aria-live="polite" hidden></p>
|
||||
<button id="retry-week-live" type="button" hidden>Retry live Week Ahead</button>
|
||||
<button id="open-week-capacity-import" type="button">Refresh from calendar</button>
|
||||
<button id="open-week-availability" type="button">Set weekly availability</button>
|
||||
<section class="week-availability-editor" id="week-availability-editor" aria-labelledby="week-availability-title" hidden>
|
||||
<h3 id="week-availability-title">Weekly availability</h3>
|
||||
<p class="small muted">New Week Ahead dates use these defaults. Capacity set on a specific date remains an override.</p>
|
||||
<div class="week-availability-grid">
|
||||
<label>Monday <input data-weekday-capacity="0" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Tuesday <input data-weekday-capacity="1" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Wednesday <input data-weekday-capacity="2" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Thursday <input data-weekday-capacity="3" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Friday <input data-weekday-capacity="4" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Saturday <input data-weekday-capacity="5" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Sunday <input data-weekday-capacity="6" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
</div>
|
||||
<div class="week-availability-actions">
|
||||
<button id="cancel-week-availability" type="button">Cancel</button>
|
||||
<button id="disable-week-availability" type="button">Disable defaults</button>
|
||||
<button id="save-week-availability" type="button">Save weekly availability</button>
|
||||
</div>
|
||||
<div id="week-availability-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<button id="open-week-reflow" type="button" hidden>Reflow remaining week</button>
|
||||
<section class="week-reflow-review" id="week-reflow-review" aria-labelledby="week-reflow-title" hidden>
|
||||
<div class="small">Capacity-safe preview</div>
|
||||
<h3 id="week-reflow-title">Reflow remaining week?</h3>
|
||||
<p class="small muted">Work keeps its current order. Anything that cannot fit stays unscheduled in My Work.</p>
|
||||
<div class="week-reflow-days" id="week-reflow-days"></div>
|
||||
<div class="week-reflow-unscheduled" id="week-reflow-unscheduled" role="status"></div>
|
||||
<div class="week-reflow-actions">
|
||||
<button id="cancel-week-reflow" type="button">Cancel</button>
|
||||
<button id="apply-week-reflow" type="button">Apply reflow</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="week-review-duplicates" id="week-review-duplicates" role="alert" hidden></div>
|
||||
<div class="week-review-days" id="week-review-days"></div>
|
||||
<div class="week-unplan-receipt" id="week-unplan-receipt" role="status" aria-live="polite" hidden></div>
|
||||
<button id="undo-week-unplan" type="button" hidden>Undo</button>
|
||||
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
|
||||
<button id="export-saved-week-calendar" type="button" hidden>Add saved week to calendar</button>
|
||||
<button id="edit-week-plan" type="button" hidden>Edit week</button>
|
||||
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
|
||||
</section>
|
||||
<section class="week-capacity-import" id="week-capacity-import" aria-labelledby="week-capacity-title" hidden>
|
||||
<header><div><div class="small">Private calendar refresh</div><h2 id="week-capacity-title">Refresh Week Ahead availability</h2></div>
|
||||
<button id="cancel-week-capacity-import" type="button">Cancel</button></header>
|
||||
<p class="small muted">Compare refreshed availability with planned load before anything changes. Your calendar is read only on this device; event titles, people, locations, and the file are never saved or uploaded.</p>
|
||||
<label>Calendar file
|
||||
<input id="week-capacity-file" type="file" accept=".ics,text/calendar" />
|
||||
</label>
|
||||
<div class="week-capacity-hours">
|
||||
<label>Workday starts <input id="week-capacity-start" type="time" value="09:00" required /></label>
|
||||
<label>Workday ends <input id="week-capacity-end" type="time" value="17:00" required /></label>
|
||||
</div>
|
||||
<div id="week-capacity-status" class="small" role="status" aria-live="assertive"></div>
|
||||
<section id="week-capacity-review" aria-labelledby="week-capacity-review-title" hidden>
|
||||
<h3 id="week-capacity-review-title">Review available time</h3>
|
||||
<div id="week-capacity-days" class="week-capacity-days"></div>
|
||||
<label class="week-free-times-consent"><input id="keep-week-free-times" type="checkbox" />
|
||||
<span><strong>Keep these free times with Week Ahead</strong><small>Only seven days of start/end times are encrypted and synced. Calendar events and the file stay on this device.</small></span>
|
||||
</label>
|
||||
<button id="apply-week-capacities" type="button">Apply seven capacities</button>
|
||||
</section>
|
||||
</section>
|
||||
<section class="week-calendar-handoff" id="week-calendar-handoff" aria-labelledby="week-calendar-title" hidden>
|
||||
<div class="small">Calendar handoff</div>
|
||||
<h2 id="week-calendar-title">Add Week Ahead to calendar</h2>
|
||||
<p class="small muted">Titles, references, dates, and times are included in a local calendar file; nothing is uploaded. Imported busy time is used only until this page reloads unless you consent to keep free times with the encrypted Week Ahead plan.</p>
|
||||
<div class="week-calendar-days" id="week-calendar-days"></div>
|
||||
<div class="small week-calendar-status" id="week-calendar-status" role="status" aria-live="assertive"></div>
|
||||
<div class="week-calendar-actions">
|
||||
<button id="back-to-week-review-from-calendar" type="button">Back to review</button>
|
||||
<button id="save-week-calendar-times" type="button">Save exact times</button>
|
||||
<button id="share-week-calendar" type="button">Share calendar blocks</button>
|
||||
</div>
|
||||
</section>
|
||||
<nav class="mobile-plan-today-nav" aria-label="Plan Today sections">
|
||||
<button type="button" data-plan-today-section="fit" aria-current="location">Fit</button>
|
||||
<button type="button" data-plan-today-section="today">Today</button>
|
||||
@@ -535,7 +695,7 @@
|
||||
<div><div class="small">End your day intentionally</div><h2 id="today-wrap-up-title">Wrap up Today</h2></div>
|
||||
<button id="close-today-wrap-up" type="button">Not now</button>
|
||||
</div>
|
||||
<p class="small muted">Choose unfinished work to schedule for tomorrow at 09:00. Unchecked work stays in Today.</p>
|
||||
<p class="small muted">Carry selected work into the ordered Tomorrow plan. Unselected work stays in Today.</p>
|
||||
<div id="today-wrap-up-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="today-wrap-up-items" class="today-wrap-up-items"></div>
|
||||
<div class="today-wrap-up-actions">
|
||||
@@ -672,6 +832,7 @@
|
||||
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
|
||||
<button id="cancel-search-selection" type="button">Cancel</button>
|
||||
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
|
||||
<button id="week-selected-search-results" type="button" disabled>Plan Week Ahead</button>
|
||||
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
|
||||
<button id="queue-selected-search-results" type="button" disabled>Assign & add to Today</button>
|
||||
</div>
|
||||
@@ -685,6 +846,16 @@
|
||||
<button id="confirm-search-batch-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="search-week-batch-review" id="search-week-batch-review" role="dialog" aria-modal="true"
|
||||
aria-labelledby="search-week-batch-heading" hidden>
|
||||
<div><strong id="search-week-batch-heading">Plan selected issues into Week Ahead</strong>
|
||||
<span class="small" id="search-week-batch-summary" aria-live="polite"></span></div>
|
||||
<div id="search-week-batch-list"></div>
|
||||
<div class="search-week-batch-actions">
|
||||
<button id="cancel-search-week-batch" type="button">Back</button>
|
||||
<button id="confirm-search-week-batch" type="button">Assign & plan</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="search-release-review" id="search-release-review"
|
||||
aria-labelledby="search-release-heading" hidden>
|
||||
<div><strong id="search-release-heading">Plan selected issues into a release</strong>
|
||||
@@ -716,6 +887,7 @@
|
||||
<nav class="mobile-search-preview-nav" aria-label="Search preview sections">
|
||||
<button type="button" data-search-preview-section="overview">Overview</button>
|
||||
<button type="button" data-search-preview-section="conversation">Conversation</button>
|
||||
<button type="button" data-search-preview-section="changes" hidden>Changes</button>
|
||||
<button type="button" data-search-preview-section="reply">Reply</button>
|
||||
<button type="button" data-search-preview-section="actions">Act</button>
|
||||
</nav>
|
||||
@@ -731,6 +903,12 @@
|
||||
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
|
||||
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
|
||||
</section>
|
||||
<section id="search-preview-review" class="search-preview-review" aria-labelledby="search-preview-review-title" hidden>
|
||||
<h2 id="search-preview-review-title">Changes</h2>
|
||||
<div id="search-preview-review-status" class="small" aria-live="polite"></div>
|
||||
<div id="search-preview-files"></div>
|
||||
<button id="retry-search-preview-review" type="button" hidden>Retry changes</button>
|
||||
</section>
|
||||
<section class="search-preview-reply" id="search-preview-reply-workspace" aria-labelledby="search-preview-reply-title" hidden>
|
||||
<h2 id="search-preview-reply-title">Reply</h2>
|
||||
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
@@ -787,15 +965,35 @@
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<button id="defer-search-result" type="button" hidden>Assign & defer</button>
|
||||
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
||||
<button id="plan-search-result" type="button" hidden>Plan ahead</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
|
||||
<button id="watch-search-result" type="button" hidden>Watch issue</button>
|
||||
<button id="keep-following-for-later" type="button" hidden>Keep for later & next</button>
|
||||
</div>
|
||||
<span id="keep-following-status" class="small" role="status" aria-live="polite"></span>
|
||||
<button id="share-search-result" type="button" disabled>Share result</button>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="search-week-plan-sheet" id="search-week-plan-sheet" role="dialog" aria-modal="true"
|
||||
aria-labelledby="search-week-plan-title" hidden>
|
||||
<div class="search-week-plan-panel">
|
||||
<header>
|
||||
<div><div class="small">Week Ahead</div><h2 id="search-week-plan-title">Plan this issue</h2></div>
|
||||
<button id="cancel-search-week-plan" type="button">Cancel</button>
|
||||
</header>
|
||||
<p id="search-week-plan-copy" class="muted"></p>
|
||||
<fieldset><legend>Choose a day</legend><div class="search-week-plan-days" id="search-week-plan-days"></div></fieldset>
|
||||
<label class="search-week-plan-estimate" for="search-week-plan-estimate">Estimate in minutes
|
||||
<input id="search-week-plan-estimate" type="number" inputmode="numeric" min="1" max="1440" step="5" required />
|
||||
</label>
|
||||
<p id="search-week-plan-status" class="small" aria-live="polite"></p>
|
||||
<button id="confirm-search-week-plan" type="button">Plan</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="whiteboard-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-actions">
|
||||
@@ -995,11 +1193,13 @@
|
||||
</section>
|
||||
<div class="issue-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="watch-issue-detail" type="button">Watch issue</button>
|
||||
<button id="release-issue" type="button">Release assignment</button>
|
||||
<button id="close-issue" type="button">Close issue</button>
|
||||
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<div id="issue-watch-status" class="detail-watch-status small" aria-live="polite"></div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
@@ -1452,6 +1652,16 @@
|
||||
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
|
||||
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
|
||||
<div class="pull-sheet-content markdown-content" id="pull-sheet-body"></div>
|
||||
<button id="edit-pull-content" type="button" hidden>Edit review context</button>
|
||||
<form class="issue-edit-form" id="pull-edit-form" hidden>
|
||||
<label>Title<input id="pull-edit-title" maxlength="255" required /></label>
|
||||
<label>Description<textarea id="pull-edit-body" maxlength="10000"></textarea></label>
|
||||
<div class="issue-edit-actions">
|
||||
<button id="save-pull-content" type="submit">Save context</button>
|
||||
<button id="cancel-pull-edit" type="button">Cancel</button>
|
||||
</div>
|
||||
<div id="pull-edit-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</form>
|
||||
</div>
|
||||
<section id="pull-conversation" tabindex="-1">
|
||||
<h2>Full conversation</h2><div id="pull-comments"></div>
|
||||
@@ -1512,6 +1722,36 @@
|
||||
<div id="pull-review-status" class="small" aria-live="polite">Expand to load changed files and merge readiness.</div>
|
||||
<button class="pull-retry" id="pull-review-retry" type="button" hidden>Retry review data</button>
|
||||
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Review data not loaded</span></div>
|
||||
<section class="pull-reviewer-summary" aria-labelledby="pull-reviewer-heading">
|
||||
<h3 id="pull-reviewer-heading">Reviewer status</h3>
|
||||
<div id="pull-reviewer-status" class="small" aria-live="polite">Reviewer status not loaded.</div>
|
||||
<div id="pull-reviewer-statuses"></div>
|
||||
<button id="request-updated-pull-review" type="button" hidden>Request updated review</button>
|
||||
<section class="pull-feedback-pass" id="pull-feedback-pass" aria-labelledby="pull-feedback-heading" hidden>
|
||||
<div class="pull-feedback-pass-heading">
|
||||
<div><span class="small" id="pull-feedback-progress"></span><h4 id="pull-feedback-heading">Address review feedback</h4></div>
|
||||
<button id="close-pull-feedback" type="button">Close</button>
|
||||
</div>
|
||||
<p class="small" id="pull-feedback-location"></p>
|
||||
<p id="pull-feedback-body"></p>
|
||||
<div class="small" id="pull-feedback-context" aria-live="polite"></div>
|
||||
<fieldset class="pull-feedback-dispositions">
|
||||
<legend>Disposition</legend>
|
||||
<button type="button" data-feedback-disposition="addressed">Addressed</button>
|
||||
<button type="button" data-feedback-disposition="discussion">Needs discussion</button>
|
||||
<button type="button" data-feedback-disposition="skipped">Skip for now</button>
|
||||
</fieldset>
|
||||
<label for="pull-feedback-note">Response note (optional)</label>
|
||||
<textarea id="pull-feedback-note" maxlength="1000" placeholder="What changed or needs discussion?"></textarea>
|
||||
<div class="pull-feedback-navigation">
|
||||
<button id="previous-pull-feedback" type="button">Previous</button>
|
||||
<button id="next-pull-feedback" type="button">Next</button>
|
||||
</div>
|
||||
<button id="finish-pull-feedback" type="button" disabled>Post feedback response</button>
|
||||
<button id="request-feedback-review" type="button" hidden>Request updated review from this reviewer</button>
|
||||
<div id="pull-feedback-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
</section>
|
||||
<details class="ci-checks" id="pull-checks">
|
||||
<summary><span>Checks</span><span class="small" id="pull-checks-summary">Not loaded</span></summary>
|
||||
<button class="ci-check-refresh" id="refresh-pull-checks" type="button">Refresh checks</button>
|
||||
@@ -1521,6 +1761,18 @@
|
||||
<div id="pull-files"></div>
|
||||
<button id="merge-pull" type="button" disabled>Merge</button>
|
||||
</details>
|
||||
<details class="pull-review-request" id="pull-review-request">
|
||||
<summary>Request review</summary>
|
||||
<div>
|
||||
<button id="publish-draft-pull" type="button" hidden>Ready for review</button>
|
||||
<div id="publish-draft-pull-status" class="small" aria-live="assertive"></div>
|
||||
<button id="load-pull-reviewers" type="button">Choose reviewer</button>
|
||||
<label for="pull-review-recipient" class="small">Eligible repository reviewer</label>
|
||||
<select id="pull-review-recipient" disabled><option value="">Select a teammate</option></select>
|
||||
<button id="confirm-pull-review" type="button" disabled>Request review</button>
|
||||
<div id="pull-review-request-status" class="small" aria-live="assertive">Load teammates to request a review.</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="pull-ownership" id="pull-ownership">
|
||||
<summary>Ownership</summary>
|
||||
<div>
|
||||
@@ -1533,10 +1785,12 @@
|
||||
</details>
|
||||
<div class="pull-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="watch-pull-detail" type="button">Watch pull request</button>
|
||||
<button id="release-pull" type="button">Release assignment</button>
|
||||
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<div id="pull-watch-status" class="detail-watch-status small" aria-live="polite"></div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
@@ -1655,12 +1909,19 @@
|
||||
</section>
|
||||
|
||||
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
|
||||
<div class="mobile-first-task-coach small" data-mobile-first-task-coach hidden>
|
||||
<strong>Complete your first task</strong>
|
||||
<span>Open it, then use Done & next after a real outcome.</span>
|
||||
</div>
|
||||
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
|
||||
<div class="small" data-work-session-progress aria-live="polite"></div>
|
||||
<button data-mobile-today-complete type="button" hidden>Done & next</button>
|
||||
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
|
||||
<button data-mobile-today-more type="button" aria-controls="mobile-today-actions" aria-haspopup="dialog">More</button>
|
||||
</section>
|
||||
<div class="mobile-first-task-receipt" id="mobile-first-task-receipt" role="status" aria-live="polite" hidden>
|
||||
First task completed · continue Today
|
||||
</div>
|
||||
<dialog class="mobile-today-actions" id="mobile-today-actions" aria-modal="true" aria-labelledby="mobile-today-actions-title">
|
||||
<section class="mobile-today-actions-panel">
|
||||
<header><h2 id="mobile-today-actions-title">Today session</h2><button data-mobile-today-actions-close type="button">Close</button></header>
|
||||
@@ -1673,10 +1934,25 @@
|
||||
<button class="mobile-today-blocked" data-mobile-today-blocked type="button" hidden>Report blocker</button>
|
||||
<button data-today-break-open type="button">Take break</button>
|
||||
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
|
||||
<button data-work-session-reschedule-week type="button">Reschedule to Week Ahead</button>
|
||||
<button data-mobile-today-end type="button">End session</button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog class="today-week-reschedule" id="today-week-reschedule" aria-labelledby="today-week-reschedule-title">
|
||||
<section class="today-week-reschedule-panel">
|
||||
<header>
|
||||
<div><p class="small muted">Active Today item</p><h2 id="today-week-reschedule-title">Reschedule to Week Ahead</h2></div>
|
||||
<button id="cancel-today-week-reschedule" type="button">Cancel</button>
|
||||
</header>
|
||||
<p class="small">Choose a future day after reviewing its current planned load. Today will continue with the next ready item.</p>
|
||||
<div class="today-week-reschedule-days" id="today-week-reschedule-days" role="radiogroup" aria-label="Week Ahead destination"></div>
|
||||
<label for="today-week-reschedule-estimate">Estimate in minutes</label>
|
||||
<input id="today-week-reschedule-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" required />
|
||||
<p id="today-week-reschedule-status" class="small" role="status" aria-live="assertive"></p>
|
||||
<button id="confirm-today-week-reschedule" type="button" disabled>Move to Week Ahead & continue</button>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog class="today-progress-sheet" id="today-progress-sheet" aria-labelledby="today-progress-title">
|
||||
<section class="today-progress-panel">
|
||||
<header><div><p class="small muted">Active Today item</p><h2 id="today-progress-title">Add progress update</h2></div><button id="cancel-today-progress" type="button">Cancel</button></header>
|
||||
@@ -1795,18 +2071,22 @@
|
||||
<div>
|
||||
<h3 id="mobile-start-day-heading">Prepare Today</h3>
|
||||
<p id="mobile-start-day-summary" class="small" aria-live="polite">Reviewing urgent queues…</p>
|
||||
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, and Filed</p>
|
||||
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, Filed, and Following</p>
|
||||
</div>
|
||||
<button class="mobile-start-day-action" id="mobile-start-day-action" type="button">Prepare Today</button>
|
||||
<button class="mobile-start-day-finish" id="finish-mobile-start-day" type="button" hidden>Finish for now</button>
|
||||
</section>
|
||||
<div class="mobile-queue-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<button data-mobile-queue="tomorrow" type="button"><span><strong>Tomorrow</strong><small id="mobile-tomorrow-summary" aria-live="polite">Nothing planned</small></span></button>
|
||||
<button data-mobile-queue="week" type="button"><span><strong>Week Ahead</strong><small id="mobile-week-summary" aria-live="polite">Nothing planned</small></span></button>
|
||||
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
||||
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="following" type="button" aria-label="Following, 0 unseen changes"><span><strong>Following</strong><small>Issues and pull requests you watch</small></span><span data-mobile-queue-count="following">0</span></button>
|
||||
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
||||
<button data-mobile-queue="authored" type="button"><span><strong>My PRs</strong><small>Pull requests you authored</small></span><span data-mobile-queue-count="authored">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
|
||||
@@ -1815,6 +2095,16 @@
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog class="following-sheet" id="following-sheet" aria-labelledby="following-title">
|
||||
<section class="following-panel">
|
||||
<header><div><p class="small muted">Read without taking ownership</p><h2 id="following-title">Following</h2></div><button id="close-following" type="button">Close</button></header>
|
||||
<p id="following-status" class="small" role="status" aria-live="polite"></p>
|
||||
<button id="review-following" class="following-review-action" type="button" hidden>Review new activity</button>
|
||||
<div id="following-list" class="following-list"></div>
|
||||
<button id="retry-following" type="button" hidden>Retry</button>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mobile-delivery-recovery" class="mobile-delivery-recovery" aria-labelledby="mobile-delivery-recovery-title">
|
||||
<section class="mobile-delivery-recovery-panel">
|
||||
<header>
|
||||
@@ -1866,6 +2156,7 @@
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/saved-searches.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/following.js"></script>
|
||||
<script src="static/search-reply-draft-store.js"></script>
|
||||
<script src="static/conversation-reply-draft-store.js"></script>
|
||||
<script src="static/conversation-photo-drafts.js"></script>
|
||||
@@ -1911,6 +2202,12 @@
|
||||
<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/tomorrow-plan.js"></script>
|
||||
<script src="static/week-calendar.js"></script>
|
||||
<script src="static/week-calendar-import.js"></script>
|
||||
<script src="static/week-plan.js"></script>
|
||||
<script src="static/today-week-reschedule.js"></script>
|
||||
<script src="static/search-week-plan.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/today-rollover.js"></script>
|
||||
<script src="static/update-ownership.js"></script>
|
||||
@@ -1965,6 +2262,7 @@
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/mobile-insights.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/mobile-app-badge.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/private-device-data.js"></script>
|
||||
<script src="static/device-storage.js"></script>
|
||||
|
||||
@@ -572,6 +572,7 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
globalThis.dispatchEvent?.(new CustomEvent('stackchain:issue-closed',{detail:item}));
|
||||
return result;
|
||||
}).finally(() => { closeRequest = null; });
|
||||
return closeRequest;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
(function(root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileAppBadge = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileAppBadge({
|
||||
control,
|
||||
status,
|
||||
container,
|
||||
navigator,
|
||||
storage,
|
||||
serviceWorker,
|
||||
}) {
|
||||
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
|
||||
let enabled = false;
|
||||
const confirmedCounts = {updates:0, following:0};
|
||||
let renderedCount = null;
|
||||
|
||||
|
||||
function available() {
|
||||
return typeof navigator?.setAppBadge === 'function'
|
||||
&& typeof navigator?.clearAppBadge === 'function';
|
||||
}
|
||||
|
||||
async function syncPreference() {
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({type:'stackchain-app-badge-preference', enabled});
|
||||
return Boolean(registration?.active);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCount(channel, count) {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({type:'stackchain-app-badge-count', channel, count});
|
||||
} catch (_error) { /* A later refresh can retry. */ }
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const confirmedCount = Math.min(9999, confirmedCounts.updates + confirmedCounts.following);
|
||||
if (!enabled || !available() || renderedCount === confirmedCount) return true;
|
||||
try {
|
||||
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
|
||||
else await navigator.clearAppBadge();
|
||||
renderedCount = confirmedCount;
|
||||
if (status) status.textContent = 'App icon badge is on and up to date.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) status.textContent = 'Could not update the app icon badge. Stackchain still has the confirmed count.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function change() {
|
||||
enabled = Boolean(control?.checked);
|
||||
if (enabled) storage?.setItem(ENABLED_KEY, 'true');
|
||||
else storage?.removeItem(ENABLED_KEY);
|
||||
await syncPreference();
|
||||
if (enabled) {
|
||||
if (confirmedCounts.updates > 0) await syncCount('updates', confirmedCounts.updates);
|
||||
if (confirmedCounts.following > 0) await syncCount('following', confirmedCounts.following);
|
||||
}
|
||||
if (!enabled && available()) {
|
||||
confirmedCounts.updates = 0;
|
||||
confirmedCounts.following = 0;
|
||||
try {
|
||||
await navigator.clearAppBadge();
|
||||
renderedCount = null;
|
||||
if (status) status.textContent = 'App icon badge is off.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) status.textContent = 'App icon badge is off, but the browser could not clear the old count.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return render();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!available()) {
|
||||
if (container) container.hidden = true;
|
||||
if (control) control.disabled = true;
|
||||
if (status) status.textContent = 'App icon badges are unavailable in this browser.';
|
||||
return false;
|
||||
}
|
||||
enabled = storage?.getItem(ENABLED_KEY) === 'true';
|
||||
if (enabled) void syncPreference();
|
||||
if (control) {
|
||||
control.checked = enabled;
|
||||
control.addEventListener('change', change);
|
||||
}
|
||||
if (status) status.textContent = enabled ? 'App icon badge is on.' : 'App icon badge is off.';
|
||||
return true;
|
||||
}
|
||||
|
||||
function readiness() {
|
||||
if (!available()) return {state:'unavailable', detail:'App icon badges are unavailable in this browser.'};
|
||||
return enabled
|
||||
? {state:'complete', detail:'The app icon shows review attention.'}
|
||||
: {state:'incomplete', detail:'Show review attention without opening Stackchain.'};
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
if (!available()) return false;
|
||||
if (control) control.checked = true;
|
||||
await change();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reconcile(channel, count, authoritative = false) {
|
||||
if (!Object.hasOwn(confirmedCounts, channel)
|
||||
|| authoritative !== true || !Number.isSafeInteger(count) || count < 0 || count > 9999) return false;
|
||||
confirmedCounts[channel] = count;
|
||||
await syncCount(channel, count);
|
||||
return render();
|
||||
}
|
||||
|
||||
return {start, change, reconcile, readiness, enable};
|
||||
});
|
||||
@@ -9,8 +9,9 @@
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
|
||||
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
|
||||
['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable'],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
|
||||
];
|
||||
].filter(([_name, button, status, action]) => button && status && action);
|
||||
let trigger = options.launcher;
|
||||
let ownsDetour = false;
|
||||
let backgroundInert = null;
|
||||
@@ -167,10 +168,12 @@ function mountMobileDeviceSetup(options) {
|
||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
protectionButton:qs('#device-setup-protection'),
|
||||
appBadgeButton:qs('#device-setup-app-badge'),
|
||||
deadlineButton:qs('#device-setup-deadline'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
protectionStatus:qs('#device-setup-protection-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
||||
appBadgeStatus:qs('#device-setup-app-badge-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'),
|
||||
@@ -193,12 +196,14 @@ function mountMobileDeviceSetup(options) {
|
||||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||
protection:options.storageProtectionReadiness(),
|
||||
push:options.notificationReadiness(),
|
||||
appBadge:options.appBadgeReadiness(),
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
protectStorage:options.protectStorage,
|
||||
enablePush:options.enablePush,
|
||||
enableAppBadge:options.enableAppBadge,
|
||||
enableDeadline:options.enableDeadline,
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
isOnline: () => typeof navigator === 'undefined' || navigator.onLine,
|
||||
mediaQuery: win?.matchMedia('(max-width: 600px)') || {matches:false},
|
||||
eventTarget: win,
|
||||
setTimer: (...args) => globalThis.setTimeout(...args),
|
||||
sheet: doc?.querySelector('#mobile-first-task'),
|
||||
title: doc?.querySelector('#mobile-first-task-title'),
|
||||
findButton: doc?.querySelector('#mobile-first-task-find'),
|
||||
@@ -16,6 +17,8 @@
|
||||
setupButton: doc?.querySelector('#mobile-first-task-setup'),
|
||||
closeButton: doc?.querySelector('#close-mobile-first-task'),
|
||||
status: doc?.querySelector('#mobile-first-task-status'),
|
||||
coach: doc?.querySelector('[data-mobile-first-task-coach]'),
|
||||
receipt: doc?.querySelector('#mobile-first-task-receipt'),
|
||||
isTodayActive: () => false,
|
||||
onFind: () => doc?.querySelector('#find-work')?.click(),
|
||||
onCreate: () => doc?.querySelector('#new-issue')?.click(),
|
||||
@@ -32,22 +35,37 @@
|
||||
return login ? prefix + login : '';
|
||||
}
|
||||
|
||||
function completed() {
|
||||
function state() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return false;
|
||||
try { return options.storage.getItem(currentKey) === 'complete'; }
|
||||
catch (_error) { return false; }
|
||||
if (!currentKey) return '';
|
||||
try { return options.storage.getItem(currentKey) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
}
|
||||
|
||||
function store(value) {
|
||||
const currentKey = key();
|
||||
const current = state();
|
||||
if (!currentKey || current === value || current === 'complete') return false;
|
||||
try {
|
||||
options.storage.setItem(currentKey, value);
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function adoptRemote(value) {
|
||||
return ['coaching', 'complete'].includes(value) && store(value);
|
||||
}
|
||||
|
||||
function required() {
|
||||
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !completed());
|
||||
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !state());
|
||||
}
|
||||
|
||||
function markComplete() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return;
|
||||
try { options.storage.setItem(currentKey, 'complete'); }
|
||||
catch (_error) {}
|
||||
function renderCoach() {
|
||||
if (!options.coach) return;
|
||||
const visible = options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive();
|
||||
options.coach.hidden = !visible;
|
||||
const host = options.coach.closest?.('[data-mobile-today-hud]');
|
||||
if (visible && host) host.hidden = false;
|
||||
}
|
||||
|
||||
function render() {
|
||||
@@ -72,16 +90,30 @@
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (account() && options.isTodayActive() && !completed()) {
|
||||
markComplete();
|
||||
if (account() && options.isTodayActive() && state() !== 'complete') {
|
||||
store('coaching');
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
return 'completed';
|
||||
renderCoach();
|
||||
return 'coaching';
|
||||
}
|
||||
renderCoach();
|
||||
if (options.sheet.open) render();
|
||||
if (required() && options.hasWork()) return 'awaiting-start';
|
||||
return required() ? 'required' : 'inactive';
|
||||
}
|
||||
|
||||
function completeOutcome() {
|
||||
if (state() !== 'coaching') return false;
|
||||
store('complete');
|
||||
options.eventTarget?.dispatchEvent?.(new CustomEvent('stackchain:first-task-complete'));
|
||||
renderCoach();
|
||||
if (options.receipt) {
|
||||
options.receipt.hidden = false;
|
||||
options.setTimer?.(() => { options.receipt.hidden = true; }, 6000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handoff(callback, requiresOnline = false) {
|
||||
if (requiresOnline && !options.isOnline()) return;
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
@@ -99,5 +131,6 @@
|
||||
options.eventTarget?.addEventListener('offline', render);
|
||||
}
|
||||
|
||||
return {required, open, refresh, render, start};
|
||||
globalThis.stackchainFirstTaskCompleting = () => state() === 'coaching';
|
||||
return {required, open, refresh, render, start, completeOutcome, adoptRemote};
|
||||
});
|
||||
@@ -33,6 +33,7 @@
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
if (name === 'following' && options.openFollowing) return options.openFollowing();
|
||||
if (name === 'filed' && options.openFiled) return options.openFiled();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
|
||||
@@ -98,12 +98,14 @@ function attachMobileSearchPreviewNavigation({document, window}) {
|
||||
buttons:{
|
||||
overview:bySection('overview'),
|
||||
conversation:bySection('conversation'),
|
||||
changes:bySection('changes'),
|
||||
reply:bySection('reply'),
|
||||
actions:bySection('actions'),
|
||||
},
|
||||
targets:{
|
||||
overview:document.getElementById('search-preview-overview'),
|
||||
conversation:document.getElementById('search-preview-conversation'),
|
||||
changes:document.getElementById('search-preview-review'),
|
||||
reply,
|
||||
actions:document.getElementById('search-preview-actions'),
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
['attention', 'Attention'],
|
||||
['update', 'Updates'],
|
||||
['filed', 'Filed'],
|
||||
['following', 'Following'],
|
||||
];
|
||||
const anonymousItems = new WeakMap();
|
||||
let anonymousItemSequence = 0;
|
||||
@@ -102,19 +103,27 @@
|
||||
|
||||
function briefing() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const phases = reviewPhases(counts);
|
||||
let phases = reviewPhases(counts);
|
||||
const followingUnavailable = counts.followingUnavailable === true;
|
||||
if (followingUnavailable) {
|
||||
phases = phases.filter(phase => phase.name !== 'following');
|
||||
phases.push({name:'following', label:'Following unavailable', count:count(counts.following)});
|
||||
}
|
||||
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
|
||||
const today = count(counts.today);
|
||||
const delivery = phases.find(phase => phase.name === 'delivery')?.count || 0;
|
||||
const other = total - delivery;
|
||||
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
|
||||
const nextLabel = next === 'delivery' ? 'Review Delivery' :
|
||||
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'));
|
||||
(next === 'following' && followingUnavailable ? 'Retry Following' :
|
||||
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work')));
|
||||
const followingRetry = next === 'following' && followingUnavailable;
|
||||
return {
|
||||
total,
|
||||
next,
|
||||
label: nextLabel,
|
||||
summary: delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
|
||||
summary: followingRetry ? 'Following needs retry before Today · ' + today + ' planned' :
|
||||
delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
|
||||
' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') +
|
||||
' · ' + today + ' planned' :
|
||||
total ? total + ' items before Today · ' + today + ' planned' :
|
||||
|
||||
+6
-1
@@ -71,6 +71,7 @@ function buildMyWork(data, now = new Date()) {
|
||||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
const isFiled = (item.work_reasons || []).includes('created_by_me');
|
||||
const isAuthored = (item.work_reasons || []).includes('authored_by_me');
|
||||
const isCompleted = isFiled && item.state === 'closed';
|
||||
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||
const normalized = {
|
||||
@@ -78,6 +79,7 @@ function buildMyWork(data, now = new Date()) {
|
||||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
is_filed: isFiled,
|
||||
is_authored: isAuthored,
|
||||
is_completed: isCompleted,
|
||||
is_assigned: assigned,
|
||||
has_update: false,
|
||||
@@ -85,7 +87,8 @@ function buildMyWork(data, now = new Date()) {
|
||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||
(due && due.priority < 4 ? due.label :
|
||||
(isReview ? 'Needs your review' : (isCompleted ? 'Completed · review outcome' :
|
||||
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work'))))),
|
||||
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' :
|
||||
(isAuthored ? 'Authored by you' : 'Open work')))))),
|
||||
_priority: priorityLabel ? 0 :
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))),
|
||||
};
|
||||
@@ -657,6 +660,7 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
let filtered = items;
|
||||
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
|
||||
else if (selectedFilter === 'filed') filtered = items.filter((item) => item.is_filed);
|
||||
else if (selectedFilter === 'authored') filtered = items.filter((item) => item.is_authored);
|
||||
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||
@@ -1080,6 +1084,7 @@ function countMyWork(items) {
|
||||
all: items.length,
|
||||
attention: items.filter(needsAttention).length,
|
||||
filed: items.filter((item) => item.is_filed).length,
|
||||
authored: items.filter((item) => item.is_authored).length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
'stackchain-voice-transcripts-v1',
|
||||
'stackchain-search-reply-drafts-v1',
|
||||
'stackchain-conversation-reply-drafts-v1',
|
||||
'stackchain-today-action-mailbox-v1',
|
||||
'stackchain-app-badge-preference-v1',
|
||||
]);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
|
||||
else root.stackchainPrivateDatabases = databases;
|
||||
|
||||
+628
-5
@@ -12,12 +12,107 @@ function mergeEligibility(detail, reviewState) {
|
||||
'CI blocked by ' + blockers.join(', ') : 'CI must succeed before merging' };
|
||||
}
|
||||
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
|
||||
const reviewer = (detail.reviewers || []).find(item => item?.blocking);
|
||||
if (reviewer?.status === 'waiting') {
|
||||
return { allowed: false, reason: 'Waiting for @' + reviewer.login + '’s review' };
|
||||
}
|
||||
if (reviewer?.status === 'changes_requested') {
|
||||
return { allowed: false, reason: '@' + reviewer.login + ' requested changes' };
|
||||
}
|
||||
if (reviewer?.status === 'outdated') {
|
||||
return { allowed: false, reason: '@' + reviewer.login + '’s review is outdated after new commits' };
|
||||
}
|
||||
if (reviewState && !reviewState.complete) {
|
||||
return { allowed: false, reason: 'Review every changed file before merging' };
|
||||
}
|
||||
return { allowed: true, reason: 'Ready to merge' };
|
||||
}
|
||||
|
||||
function renderReviewerFeedback(reviewer, escapeHtml) {
|
||||
const groups = new Map();
|
||||
(Array.isArray(reviewer?.comments) ? reviewer.comments : []).forEach(comment => {
|
||||
if (!comment?.path || !comment?.body) return;
|
||||
if (!groups.has(comment.path)) groups.set(comment.path, []);
|
||||
groups.get(comment.path).push(comment);
|
||||
});
|
||||
const summary = reviewer?.summary ? '<p class="pull-review-feedback-summary">' +
|
||||
escapeHtml(reviewer.summary) + '</p>' : '';
|
||||
const files = Array.from(groups.entries()).map(([path, comments]) =>
|
||||
'<section class="pull-review-feedback-file"><h4>' + escapeHtml(path) + '</h4><ul>' +
|
||||
comments.map(comment => '<li>' + escapeHtml(comment.body) +
|
||||
(comment.line ? '<small>Line ' + Number(comment.line) + '</small>' : '') + '</li>').join('') +
|
||||
'</ul><button type="button" data-review-feedback-file="' + escapeHtml(path) +
|
||||
'">View in changes</button></section>'
|
||||
).join('');
|
||||
const loadable = Number.isInteger(reviewer?.review_id) && reviewer.review_id > 0 &&
|
||||
['commented', 'changes_requested', 'outdated'].includes(reviewer?.status);
|
||||
const feedbackState = Array.isArray(reviewer?.comments) ? 'loaded' : reviewer?.feedback_state;
|
||||
const feedbackAction = !loadable || feedbackState === 'loaded' ? '' :
|
||||
feedbackState === 'loading' ? '<p class="small" role="status">Loading inline feedback…</p>' :
|
||||
'<p class="small" role="status">' + (feedbackState === 'error' ? 'Feedback unavailable. ' : '') +
|
||||
'<button type="button" data-load-review-feedback="' + Number(reviewer.review_id) + '">' +
|
||||
(feedbackState === 'error' ? 'Retry feedback' : 'Load inline feedback') + '</button></p>';
|
||||
const emptyLoaded = loadable && feedbackState === 'loaded' && !files ?
|
||||
'<p class="small">No inline comments on this review.</p>' : '';
|
||||
if (!summary && !files && !feedbackAction && !emptyLoaded) return '';
|
||||
const reviewedHead = reviewer?.status === 'outdated' && reviewer?.head_sha ?
|
||||
'<p class="small">Reviewed head ' + escapeHtml(reviewer.head_sha.slice(0, 8)) + '</p>' : '';
|
||||
const addressable = Number.isInteger(reviewer?.review_id) && reviewer.review_id > 0 &&
|
||||
['changes_requested', 'outdated'].includes(reviewer?.status) && feedbackState === 'loaded' &&
|
||||
(reviewer.comments || []).some(comment => Number.isInteger(comment?.id) && comment.id > 0);
|
||||
const address = addressable ? '<button type="button" class="address-review-feedback" data-address-review-feedback="' +
|
||||
Number(reviewer.review_id) + '">Address feedback</button>' : '';
|
||||
return '<details class="pull-review-feedback"><summary>Review feedback</summary>' +
|
||||
reviewedHead + summary + feedbackAction + emptyLoaded + files + address + '</details>';
|
||||
}
|
||||
|
||||
function renderReviewerStatuses(reviewers, escapeHtml) {
|
||||
const labels = {
|
||||
waiting: 'Waiting for review',
|
||||
approved: 'Approved current head',
|
||||
changes_requested: 'Changes requested',
|
||||
commented: 'Commented',
|
||||
outdated: 'Outdated after new commits',
|
||||
};
|
||||
return (Array.isArray(reviewers) ? reviewers : []).map(reviewer => {
|
||||
const status = labels[reviewer?.status] || 'Status unavailable';
|
||||
const cancel = reviewer?.status === 'waiting' && reviewer?.login ?
|
||||
'<button type="button" class="cancel-review-request" data-cancel-review-request="' +
|
||||
escapeHtml(reviewer.login) + '">Cancel request</button>' : '';
|
||||
return '<article class="pull-reviewer-status" data-reviewer-status="' +
|
||||
escapeHtml(reviewer?.status || 'unknown') + '" data-review-id="' +
|
||||
(Number.isInteger(reviewer?.review_id) ? Number(reviewer.review_id) : '') +
|
||||
'"><div class="pull-reviewer-status-heading"><strong>@' +
|
||||
escapeHtml(reviewer?.login || 'unknown') + '</strong><span>' + escapeHtml(status) + '</span></div>' +
|
||||
renderReviewerFeedback(reviewer, escapeHtml) + cancel + '</article>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderReviewerPanel(doc, detail, escapeHtml = value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')) {
|
||||
const reviewers = Array.isArray(detail?.reviewers) ? detail.reviewers : [];
|
||||
const openFeedback = new Set(Array.from(doc.querySelectorAll('.pull-review-feedback[open]'))
|
||||
.map(panel => panel.closest('.pull-reviewer-status')?.dataset.reviewId).filter(Boolean));
|
||||
doc.querySelector('#pull-reviewer-statuses').innerHTML = renderReviewerStatuses(reviewers, escapeHtml);
|
||||
openFeedback.forEach(reviewId => {
|
||||
const panel = doc.querySelector('.pull-reviewer-status[data-review-id="' + reviewId + '"] .pull-review-feedback');
|
||||
if (panel) panel.open = true;
|
||||
});
|
||||
doc.querySelectorAll('[data-review-feedback-file]').forEach(button => {
|
||||
button.addEventListener('click', () => focusPullFile(doc, button.dataset.reviewFeedbackFile));
|
||||
});
|
||||
doc.querySelector('#pull-reviewer-status').textContent = reviewers.length ?
|
||||
reviewers.length + ' reviewer status' + (reviewers.length === 1 ? '' : 'es') + ' for this pull request.' :
|
||||
'No review has been requested; existing merge policy is unchanged.';
|
||||
doc.querySelector('#request-updated-pull-review').hidden =
|
||||
!reviewers.some(reviewer => reviewer.status === 'outdated');
|
||||
}
|
||||
|
||||
function reviewEligibility(detail, reviewState, doc) {
|
||||
renderReviewerPanel(doc, detail);
|
||||
return mergeEligibility(detail, reviewState);
|
||||
}
|
||||
|
||||
function renderFile(file, index, reviewed, escapeHtml) {
|
||||
const filename = escapeHtml(file.filename || 'Unknown file');
|
||||
const panelId = 'pull-diff-' + index;
|
||||
@@ -41,18 +136,22 @@ function renderFile(file, index, reviewed, escapeHtml) {
|
||||
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
|
||||
}
|
||||
|
||||
function focusNextUnreviewed(doc, detail, state, controller) {
|
||||
if (!detail || !state) return;
|
||||
const filename = controller.nextUnreviewed(detail, state);
|
||||
function focusPullFile(doc, filename) {
|
||||
const article = Array.from(doc.querySelectorAll('#pull-files .pull-file'))
|
||||
.find(file => file.dataset.pullFilename === filename);
|
||||
const toggle = article?.querySelector('.pull-file-toggle');
|
||||
const panel = toggle && doc.getElementById(toggle.getAttribute('aria-controls'));
|
||||
if (!toggle || !panel) return;
|
||||
if (!toggle || !panel) return false;
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
panel.hidden = false;
|
||||
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
toggle.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function focusNextUnreviewed(doc, detail, state, controller) {
|
||||
if (!detail || !state) return;
|
||||
focusPullFile(doc, controller.nextUnreviewed(detail, state));
|
||||
}
|
||||
|
||||
function removeFromSnapshot(data, item) {
|
||||
@@ -99,9 +198,374 @@ function resetOwnershipControls(doc, item, checkpointed) {
|
||||
qs('#release-pull').disabled = false;
|
||||
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
|
||||
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
|
||||
qs('#edit-pull-content').hidden = true;
|
||||
qs('#pull-edit-form').hidden = true;
|
||||
qs('#pull-edit-status').textContent = '';
|
||||
}
|
||||
|
||||
function bindOwnershipControls(doc, controller, getSelected, finish) {
|
||||
function resetReviewRequestControls(doc, detail) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
qs('#pull-review-request').open = false;
|
||||
qs('#pull-review-request').dataset.headSha = detail?.head_sha || '';
|
||||
qs('#pull-review-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
||||
qs('#pull-review-recipient').disabled = true;
|
||||
qs('#confirm-pull-review').disabled = true;
|
||||
qs('#load-pull-reviewers').disabled = !detail?.head_sha;
|
||||
qs('#pull-review-request-status').textContent = detail?.head_sha ?
|
||||
'Load teammates to request a review.' : 'Load the current pull request before requesting review.';
|
||||
qs('#pull-reviewer-status').textContent = 'Reviewer status not loaded.';
|
||||
qs('#pull-reviewer-statuses').textContent = '';
|
||||
qs('#request-updated-pull-review').hidden = true;
|
||||
qs('#pull-feedback-pass').hidden = true;
|
||||
qs('#pull-feedback-status').textContent = '';
|
||||
}
|
||||
|
||||
function bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-reviewers');
|
||||
if (load.dataset.reviewRequestBound === 'true') return;
|
||||
load.dataset.reviewRequestBound = 'true';
|
||||
controller.setReviewDetail = detail => {
|
||||
resetReviewRequestControls(doc, detail);
|
||||
controller.edit?.setDetail(detail, Boolean(detail?.saved_at));
|
||||
const publishable = detail?.state === 'open' && detail?.draft === true &&
|
||||
detail?.author === getLogin?.() && detail?.head_sha;
|
||||
qs('#publish-draft-pull').hidden = !publishable;
|
||||
if (publishable) qs('#pull-review-request').open = true;
|
||||
qs('#publish-draft-pull-status').textContent = '';
|
||||
};
|
||||
resetReviewRequestControls(doc, null);
|
||||
qs('#request-updated-pull-review').addEventListener('click', () => {
|
||||
qs('#pull-review-request').open = true;
|
||||
qs('#pull-review-request').scrollIntoView({ block:'center', behavior:'smooth' });
|
||||
load.focus();
|
||||
});
|
||||
controller.onReviewRequested = result => {
|
||||
const detail = getDetail?.();
|
||||
if (!detail || result.head_sha !== detail.head_sha) return;
|
||||
detail.reviewers = (detail.reviewers || []).filter(reviewer => reviewer.login !== result.reviewer);
|
||||
detail.reviewers.push({login:result.reviewer, status:'waiting', head_sha:result.head_sha, blocking:true});
|
||||
renderReviewerPanel(doc, detail);
|
||||
qs('#pull-merge-state').textContent = 'Waiting for @' + result.reviewer + '’s review';
|
||||
qs('#merge-pull').disabled = true;
|
||||
};
|
||||
qs('#publish-draft-pull').addEventListener('click', async () => {
|
||||
const selected = getSelected?.();
|
||||
const detail = getDetail?.();
|
||||
if (!selected || !detail?.head_sha || detail.draft !== true ||
|
||||
!globalThis.confirm('Publish ' + selected.key + ' as ready for review?')) return;
|
||||
const button = qs('#publish-draft-pull');
|
||||
button.disabled = true;
|
||||
qs('#publish-draft-pull-status').textContent = 'Publishing draft…';
|
||||
try {
|
||||
const result = await controller.publishReady(selected, detail.head_sha);
|
||||
if (getSelected?.() !== selected || getDetail?.() !== detail) return;
|
||||
Object.assign(detail, result);
|
||||
selected.title = result.title;
|
||||
qs('#pull-sheet-title').textContent = result.title;
|
||||
controller.setReviewDetail(detail);
|
||||
qs('#publish-draft-pull-status').textContent = 'Published and ready for review.';
|
||||
qs('#pull-review-request').open = true;
|
||||
load.focus();
|
||||
} catch (error) {
|
||||
qs('#publish-draft-pull-status').textContent = error.message + ' The pull request remains a draft; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#pull-reviewer-statuses').addEventListener('click', async event => {
|
||||
const button = event.target.closest?.('[data-cancel-review-request]');
|
||||
if (!button || button.disabled) return;
|
||||
const selected = getSelected?.();
|
||||
const detail = getDetail?.();
|
||||
const reviewer = button.dataset.cancelReviewRequest;
|
||||
if (!selected || !detail?.head_sha || !reviewer ||
|
||||
!globalThis.confirm('Cancel @' + reviewer + '’s review request for ' + selected.key + '?')) return;
|
||||
button.disabled = true;
|
||||
qs('#pull-reviewer-status').textContent = 'Cancelling @' + reviewer + '’s review request…';
|
||||
try {
|
||||
const result = await controller.cancelReview(selected, reviewer, detail.head_sha);
|
||||
if (result?.head_sha !== detail.head_sha || result?.requested_reviewers?.includes(reviewer)) {
|
||||
throw new Error('Cancellation was not confirmed.');
|
||||
}
|
||||
detail.reviewers = (detail.reviewers || []).filter(candidate =>
|
||||
candidate.login !== reviewer || candidate.status !== 'waiting');
|
||||
renderReviewerPanel(doc, detail);
|
||||
const eligibility = mergeEligibility(detail, controller.reviewState(selected, detail));
|
||||
qs('#pull-merge-state').textContent = eligibility.reason;
|
||||
qs('#merge-pull').disabled = !eligibility.allowed;
|
||||
qs('#pull-review-request').open = true;
|
||||
qs('#pull-review-request-status').textContent = 'Review request cancelled. Choose a replacement reviewer.';
|
||||
load.disabled = false;
|
||||
load.focus();
|
||||
} catch (error) {
|
||||
qs('#pull-reviewer-status').textContent = error.message + ' Refresh review data before retrying.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
load.addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
if (!selected) return;
|
||||
const select = qs('#pull-review-recipient');
|
||||
load.disabled = true;
|
||||
qs('#pull-review-request-status').textContent = 'Loading eligible reviewers…';
|
||||
try {
|
||||
const count = renderHandoffCandidates(select, await controller.loadReviewCandidates(selected), doc);
|
||||
select.disabled = !count;
|
||||
qs('#confirm-pull-review').disabled = true;
|
||||
qs('#pull-review-request-status').textContent = count ?
|
||||
'Choose a teammate to review the current head.' : 'No eligible reviewers were found.';
|
||||
if (count) select.focus();
|
||||
else load.disabled = false;
|
||||
} catch (error) {
|
||||
qs('#pull-review-request-status').textContent = error.message + ' Retry loading reviewers.';
|
||||
load.disabled = false;
|
||||
load.focus();
|
||||
}
|
||||
});
|
||||
qs('#pull-review-recipient').addEventListener('change', event => {
|
||||
qs('#confirm-pull-review').disabled = !event.target.value;
|
||||
});
|
||||
qs('#confirm-pull-review').addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
const detail = { head_sha: qs('#pull-review-request').dataset.headSha };
|
||||
const reviewer = qs('#pull-review-recipient').value;
|
||||
if (!selected || !detail?.head_sha || !reviewer ||
|
||||
!globalThis.confirm('Request review of ' + selected.key + ' at ' + detail.head_sha.slice(0, 8) + ' from @' + reviewer + '?')) return;
|
||||
const button = qs('#confirm-pull-review');
|
||||
button.disabled = true;
|
||||
qs('#pull-review-request-status').textContent = 'Requesting review…';
|
||||
try {
|
||||
await controller.requestReview(selected, reviewer, detail.head_sha);
|
||||
qs('#pull-review-request-status').textContent = 'Review requested from @' + reviewer + '.';
|
||||
} catch (error) {
|
||||
qs('#pull-review-request-status').textContent = error.message + ' Selection kept; retry the request.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const panel = qs('#pull-feedback-pass');
|
||||
if (panel.dataset.feedbackBound === 'true') return;
|
||||
panel.dataset.feedbackBound = 'true';
|
||||
let active = null;
|
||||
const comments = () => (active?.reviewer?.comments || [])
|
||||
.filter(comment => Number.isInteger(comment?.id) && comment.id > 0);
|
||||
const current = () => comments()[active?.state?.index || 0];
|
||||
const save = () => {
|
||||
if (active) controller.saveFeedbackPass(active.item, active.detail, active.reviewer, active.login, active.state);
|
||||
};
|
||||
const render = () => {
|
||||
if (!active) return;
|
||||
const items = comments();
|
||||
active.state.index = Math.max(0, Math.min(Number(active.state.index) || 0, items.length - 1));
|
||||
const comment = current();
|
||||
const response = active.state.responses[String(comment.id)] || {};
|
||||
qs('#pull-feedback-progress').textContent = 'Comment ' + (active.state.index + 1) + ' of ' + items.length;
|
||||
qs('#pull-feedback-heading').textContent = 'Address @' + active.reviewer.login + '’s feedback';
|
||||
qs('#pull-feedback-location').textContent = comment.path + (comment.line ? ' · line ' + Number(comment.line) : '');
|
||||
qs('#pull-feedback-body').textContent = comment.body;
|
||||
qs('#pull-feedback-note').value = response.note || '';
|
||||
panel.querySelectorAll('[data-feedback-disposition]').forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.feedbackDisposition === response.disposition));
|
||||
});
|
||||
qs('#previous-pull-feedback').disabled = active.state.index === 0;
|
||||
qs('#next-pull-feedback').disabled = active.state.index >= items.length - 1;
|
||||
qs('#finish-pull-feedback').disabled = active.state.posted === true ||
|
||||
items.some(item => !active.state.responses[String(item.id)]?.disposition);
|
||||
qs('#request-feedback-review').hidden = active.state.posted !== true;
|
||||
const found = focusPullFile(doc, comment.path);
|
||||
qs('#pull-feedback-context').textContent = found ?
|
||||
'Matching change opened below.' : 'Matching change is unavailable in this preview.';
|
||||
panel.scrollIntoView({ block:'center', behavior:'smooth' });
|
||||
};
|
||||
qs('#pull-reviewer-statuses').addEventListener('click', async event => {
|
||||
const loadButton = event.target.closest?.('[data-load-review-feedback]');
|
||||
if (loadButton && !loadButton.disabled) {
|
||||
const detail = getDetail?.();
|
||||
const item = getSelected?.();
|
||||
const reviewId = Number(loadButton.dataset.loadReviewFeedback);
|
||||
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
|
||||
if (!detail || !item || !reviewer) return;
|
||||
const refresh = reviewer.feedback_state === 'error';
|
||||
reviewer.feedback_state = 'loading';
|
||||
renderReviewerPanel(doc, detail);
|
||||
try {
|
||||
const result = await controller.loadFeedback(item, detail, reviewer, { refresh });
|
||||
if (getDetail?.() !== detail || getSelected?.() !== item ||
|
||||
result.head_sha !== detail.head_sha || Number(result.review_id) !== reviewId) return;
|
||||
reviewer.comments = Array.isArray(result.comments) ? result.comments : [];
|
||||
reviewer.feedback_state = 'loaded';
|
||||
renderReviewerPanel(doc, detail);
|
||||
doc.querySelector('[data-address-review-feedback="' + reviewId + '"]')?.focus();
|
||||
} catch (error) {
|
||||
if (getDetail?.() !== detail || getSelected?.() !== item) return;
|
||||
reviewer.feedback_state = 'error';
|
||||
renderReviewerPanel(doc, detail);
|
||||
doc.querySelector('[data-load-review-feedback="' + reviewId + '"]')?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const button = event.target.closest?.('[data-address-review-feedback]');
|
||||
if (!button) return;
|
||||
const detail = getDetail?.();
|
||||
const item = getSelected?.();
|
||||
const reviewId = Number(button.dataset.addressReviewFeedback);
|
||||
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
|
||||
if (!detail || !item || !reviewer) return;
|
||||
const login = getLogin?.() || '';
|
||||
active = { item, detail, reviewer, login,
|
||||
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
|
||||
panel.hidden = false;
|
||||
qs('#pull-feedback-status').textContent = active.state.posted ?
|
||||
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
|
||||
render();
|
||||
});
|
||||
panel.querySelector('.pull-feedback-dispositions').addEventListener('click', event => {
|
||||
const disposition = event.target.dataset?.feedbackDisposition;
|
||||
const comment = current();
|
||||
if (!active || !comment || !['addressed', 'discussion', 'skipped'].includes(disposition)) return;
|
||||
const key = String(comment.id);
|
||||
active.state.responses[key] = { ...(active.state.responses[key] || {}), disposition };
|
||||
save();
|
||||
render();
|
||||
});
|
||||
qs('#pull-feedback-note').addEventListener('input', event => {
|
||||
const comment = current();
|
||||
if (!active || !comment) return;
|
||||
const key = String(comment.id);
|
||||
active.state.responses[key] = { ...(active.state.responses[key] || {}), note:event.target.value.slice(0, 1000) };
|
||||
save();
|
||||
});
|
||||
qs('#previous-pull-feedback').addEventListener('click', () => {
|
||||
if (!active) return;
|
||||
active.state.index -= 1; save(); render();
|
||||
});
|
||||
qs('#next-pull-feedback').addEventListener('click', () => {
|
||||
if (!active) return;
|
||||
active.state.index += 1; save(); render();
|
||||
});
|
||||
qs('#close-pull-feedback').addEventListener('click', () => {
|
||||
panel.hidden = true;
|
||||
doc.querySelector('[data-address-review-feedback="' + active?.reviewer?.review_id + '"]')?.focus();
|
||||
});
|
||||
qs('#finish-pull-feedback').addEventListener('click', async () => {
|
||||
if (!active || qs('#finish-pull-feedback').disabled) return;
|
||||
const latest = getDetail?.();
|
||||
if (!latest || latest.head_sha !== active.state.headSha) {
|
||||
qs('#pull-feedback-status').textContent = 'The pull request changed. Refresh review data before posting.';
|
||||
return;
|
||||
}
|
||||
if (!globalThis.confirm('Post one feedback response to ' + active.item.key + '?')) return;
|
||||
qs('#finish-pull-feedback').disabled = true;
|
||||
qs('#pull-feedback-status').textContent = 'Posting feedback response…';
|
||||
try {
|
||||
await controller.postFeedbackResponse(active.item, active.detail, active.reviewer, active.login, active.state);
|
||||
active.state.posted = true;
|
||||
save();
|
||||
qs('#pull-feedback-status').textContent = 'Response posted. Request an updated review when ready.';
|
||||
render();
|
||||
} catch (error) {
|
||||
qs('#pull-feedback-status').textContent = error.message + ' Progress kept; retry without duplicating the response.';
|
||||
qs('#finish-pull-feedback').disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#request-feedback-review').addEventListener('click', async () => {
|
||||
if (!active?.state?.posted) return;
|
||||
const button = qs('#request-feedback-review');
|
||||
button.disabled = true;
|
||||
qs('#pull-feedback-status').textContent = 'Requesting updated review…';
|
||||
try {
|
||||
await controller.requestReview(active.item, active.reviewer.login, active.detail.head_sha);
|
||||
qs('#pull-feedback-status').textContent = 'Response posted; updated review requested from @' + active.reviewer.login + '.';
|
||||
button.hidden = true;
|
||||
} catch (error) {
|
||||
qs('#pull-feedback-status').textContent = 'Response posted; review not requested. ' + error.message;
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const form = qs('#pull-edit-form');
|
||||
const controls = () => form.querySelectorAll('input,textarea,button');
|
||||
const draft = () => ({
|
||||
title:qs('#pull-edit-title').value.trim(), body:qs('#pull-edit-body').value,
|
||||
expectedHeadSha:getDetail()?.head_sha || '',
|
||||
});
|
||||
qs('#edit-pull-content').addEventListener('click', () => {
|
||||
const item = getSelected(), detail = getDetail();
|
||||
if (!item || !detail) return;
|
||||
const saved = controller.loadEditDraft(item) || {
|
||||
title:detail.title || '', body:detail.body || '', expectedHeadSha:detail.head_sha || '',
|
||||
};
|
||||
qs('#pull-edit-title').value = saved.title;
|
||||
qs('#pull-edit-body').value = saved.body;
|
||||
form.hidden = false;
|
||||
qs('#pull-edit-status').textContent = '';
|
||||
qs('#pull-edit-title').focus();
|
||||
});
|
||||
qs('#cancel-pull-edit').addEventListener('click', () => {
|
||||
form.hidden = true;
|
||||
qs('#edit-pull-content').focus();
|
||||
});
|
||||
['#pull-edit-title', '#pull-edit-body'].forEach(selector => qs(selector).addEventListener('input', () => {
|
||||
const item = getSelected();
|
||||
if (item && getDetail()) controller.saveEditDraft(item, draft());
|
||||
}));
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const item = getSelected(), update = draft();
|
||||
if (!item || !getDetail()) return;
|
||||
if (!update.title) {
|
||||
qs('#pull-edit-status').textContent = 'Title is required.';
|
||||
qs('#pull-edit-title').focus();
|
||||
return;
|
||||
}
|
||||
controls().forEach(control => { control.disabled = true; });
|
||||
qs('#pull-edit-status').textContent = 'Saving review context…';
|
||||
try {
|
||||
const result = await controller.updateContent(item, update);
|
||||
if (getSelected() !== item) return;
|
||||
const detail = getDetail();
|
||||
Object.assign(detail, result);
|
||||
item.title = result.title;
|
||||
qs('#pull-sheet-title').textContent = result.title;
|
||||
qs('#pull-sheet-body').innerHTML = renderMarkdown(result.body || 'No description provided.');
|
||||
controller.setReviewDetail(detail);
|
||||
qs('#pull-sheet-status').textContent = 'Review context updated · ready to request review';
|
||||
form.hidden = true;
|
||||
qs('#pull-edit-status').textContent = '';
|
||||
qs('#edit-pull-content').focus();
|
||||
} catch (error) {
|
||||
qs('#pull-edit-status').textContent = error.message + ' Your change is still here; retry.';
|
||||
qs('#pull-edit-title').focus();
|
||||
} finally {
|
||||
controls().forEach(control => { control.disabled = false; });
|
||||
}
|
||||
});
|
||||
return {
|
||||
reset() {
|
||||
qs('#edit-pull-content').hidden = true;
|
||||
form.hidden = true;
|
||||
qs('#pull-edit-status').textContent = '';
|
||||
},
|
||||
setDetail(detail, offline = false) {
|
||||
const editable = !offline && detail?.state === 'open' && detail.author === getLogin();
|
||||
qs('#edit-pull-content').hidden = !editable;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) {
|
||||
bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-handoff');
|
||||
if (load.dataset.ownershipBound === 'true') return;
|
||||
@@ -172,13 +636,24 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
let checkRequest = null;
|
||||
let ownershipRequest = null;
|
||||
let candidateRequest = null;
|
||||
let reviewCandidateRequest = null;
|
||||
let reviewRequestMutation = null;
|
||||
let reviewCancelMutation = null;
|
||||
let readyMutation = null;
|
||||
let editRequest = null;
|
||||
let feedbackRequest = null;
|
||||
const reviewRequests = new Map();
|
||||
const reviewCache = new Map();
|
||||
const feedbackRequests = new Map();
|
||||
const feedbackCache = new Map();
|
||||
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
|
||||
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
|
||||
const operationKey = item => draftKey(item) + ':operation';
|
||||
const editDraftKey = item => 'stackchain.pull-edit.v1:' + item.repository + '#' + item.number;
|
||||
const reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha;
|
||||
const feedbackKey = (item, detail, reviewer, login) => 'stackchain.feedback-pass.v1:' +
|
||||
item.repository + '#' + item.number + ':' + reviewer.review_id + ':' + reviewer.head_sha + ':' + detail.head_sha + ':' + login;
|
||||
const fileNames = detail => (detail?.files || []).map(file => file.filename).filter(Boolean);
|
||||
|
||||
function reviewState(item, detail) {
|
||||
@@ -210,6 +685,25 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
reviewRequests.set(key, request);
|
||||
return request;
|
||||
},
|
||||
loadFeedback(item, detail, reviewer, { refresh = false } = {}) {
|
||||
const reviewId = Number(reviewer?.review_id);
|
||||
const headSha = detail?.head_sha || '';
|
||||
const key = item.repository + '#' + item.number + ':' + headSha + ':' + reviewId;
|
||||
if (!refresh && feedbackCache.has(key)) return Promise.resolve(feedbackCache.get(key));
|
||||
if (feedbackRequests.has(key)) return feedbackRequests.get(key);
|
||||
const request = fetchJson(pathFor(item) + '/reviews/' + encodeURIComponent(reviewId) +
|
||||
'/feedback?expected_head_sha=' + encodeURIComponent(headSha), {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (result?.head_sha !== headSha || Number(result?.review_id) !== reviewId) {
|
||||
throw new Error('Review feedback changed. Reload review data before retrying.');
|
||||
}
|
||||
feedbackCache.set(key, result);
|
||||
return result;
|
||||
}).finally(() => feedbackRequests.delete(key));
|
||||
feedbackRequests.set(key, request);
|
||||
return request;
|
||||
},
|
||||
loadChecks(item) {
|
||||
if (checkRequest) return checkRequest;
|
||||
checkRequest = fetchJson(pathFor(item) + '/checks', {
|
||||
@@ -224,6 +718,129 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
}).finally(() => { candidateRequest = null; });
|
||||
return candidateRequest;
|
||||
},
|
||||
loadReviewCandidates(item) {
|
||||
if (reviewCandidateRequest) return reviewCandidateRequest;
|
||||
reviewCandidateRequest = fetchJson(pathFor(item) + '/review-candidates', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).finally(() => { reviewCandidateRequest = null; });
|
||||
return reviewCandidateRequest;
|
||||
},
|
||||
requestReview(item, reviewer, expectedHeadSha) {
|
||||
if (reviewRequestMutation) return reviewRequestMutation;
|
||||
reviewRequestMutation = fetchJson(pathFor(item) + '/request-review', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reviewer, expected_head_sha: expectedHeadSha }),
|
||||
}).then(result => {
|
||||
this.onReviewRequested?.(result);
|
||||
return result;
|
||||
}).finally(() => { reviewRequestMutation = null; });
|
||||
return reviewRequestMutation;
|
||||
},
|
||||
cancelReview(item, reviewer, expectedHeadSha) {
|
||||
if (reviewCancelMutation) return reviewCancelMutation;
|
||||
reviewCancelMutation = fetchJson(pathFor(item) + '/request-review', {
|
||||
method: 'DELETE',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reviewer, expected_head_sha: expectedHeadSha }),
|
||||
}).then(result => {
|
||||
this.onReviewCancelled?.(result);
|
||||
return result;
|
||||
}).finally(() => { reviewCancelMutation = null; });
|
||||
return reviewCancelMutation;
|
||||
},
|
||||
publishReady(item, expectedHeadSha) {
|
||||
if (readyMutation) return readyMutation;
|
||||
readyMutation = fetchJson(pathFor(item) + '/ready', {
|
||||
method:'PATCH',
|
||||
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify({ expected_head_sha:expectedHeadSha }),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || result?.head_sha !== expectedHeadSha ||
|
||||
result?.state !== 'open' || result?.draft !== false || !result?.title) {
|
||||
throw new Error('Draft publication was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { readyMutation = null; });
|
||||
return readyMutation;
|
||||
},
|
||||
loadEditDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
|
||||
return value && typeof value.title === 'string' && typeof value.body === 'string' &&
|
||||
typeof value.expectedHeadSha === 'string' ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
},
|
||||
saveEditDraft(item, draft) {
|
||||
try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); }
|
||||
catch (_error) { /* The edit fields remain the fallback. */ }
|
||||
},
|
||||
loadFeedbackPass(item, detail, reviewer, login) {
|
||||
const fresh = { headSha:detail.head_sha, index:0, responses:{}, posted:false };
|
||||
try {
|
||||
const saved = JSON.parse(storage?.getItem(feedbackKey(item, detail, reviewer, login)) || 'null');
|
||||
return saved && saved.headSha === detail.head_sha && saved.responses && typeof saved.responses === 'object'
|
||||
? { ...fresh, ...saved } : fresh;
|
||||
} catch (_error) { return fresh; }
|
||||
},
|
||||
saveFeedbackPass(item, detail, reviewer, login, state) {
|
||||
try { storage?.setItem(feedbackKey(item, detail, reviewer, login), JSON.stringify(state)); }
|
||||
catch (_error) { /* The visible pass remains usable without storage. */ }
|
||||
},
|
||||
feedbackSummary(reviewer, state) {
|
||||
const labels = { addressed:'Addressed', discussion:'Needs discussion', skipped:'Skip for now' };
|
||||
const lines = (reviewer.comments || []).filter(comment => Number.isInteger(comment?.id) && comment.id > 0)
|
||||
.map(comment => {
|
||||
const response = state.responses[String(comment.id)] || {};
|
||||
const path = String(comment.path || '').replaceAll('`', "'").replaceAll('\n', ' ').slice(0, 300);
|
||||
const body = String(comment.body || '').replaceAll('\n', ' ').slice(0, 180);
|
||||
const note = String(response.note || '').replaceAll('\n', ' ').slice(0, 1000);
|
||||
return '- `' + path + '` · comment #' + comment.id + ' · **' + (labels[response.disposition] || 'Unresolved') +
|
||||
'** — ' + body + (note ? '\n - Response: ' + note : '');
|
||||
});
|
||||
return ('Addressed review feedback from @' + reviewer.login + ' (review #' + reviewer.review_id + ').\n\n' +
|
||||
lines.join('\n')).slice(0, 10000);
|
||||
},
|
||||
postFeedbackResponse(item, detail, reviewer, login, state) {
|
||||
if (feedbackRequest) return feedbackRequest;
|
||||
const key = feedbackKey(item, detail, reviewer, login);
|
||||
let operationId;
|
||||
try {
|
||||
operationId = storage?.getItem(key + ':operation') || String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(key + ':operation', operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
feedbackRequest = fetchJson(pathFor(item) + '/comments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Content-Type':'application/json', 'Idempotency-Key':operationId },
|
||||
body:JSON.stringify({ body:this.feedbackSummary(reviewer, state) }),
|
||||
}).then(result => {
|
||||
try { storage?.removeItem(key + ':operation'); } catch (_error) { /* Confirmed upstream. */ }
|
||||
return result;
|
||||
}).finally(() => { feedbackRequest = null; });
|
||||
return feedbackRequest;
|
||||
},
|
||||
updateContent(item, draft) {
|
||||
if (editRequest) return editRequest;
|
||||
this.saveEditDraft(item, draft);
|
||||
editRequest = fetchJson(pathFor(item) + '/content', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
body: draft.body,
|
||||
expected_head_sha: draft.expectedHeadSha,
|
||||
}),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || result?.title !== draft.title ||
|
||||
result?.body !== draft.body || result?.head_sha !== draft.expectedHeadSha) {
|
||||
throw new Error('Pull request update was not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(editDraftKey(item)); }
|
||||
catch (_error) { /* Confirmed upstream content is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { editRequest = null; });
|
||||
return editRequest;
|
||||
},
|
||||
handoff(item, recipient) {
|
||||
if (ownershipRequest) return ownershipRequest;
|
||||
ownershipRequest = fetchJson(pathFor(item) + '/handoff', {
|
||||
@@ -314,6 +931,8 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
}
|
||||
|
||||
createPullSheet.mergeEligibility = mergeEligibility;
|
||||
createPullSheet.review = reviewEligibility;
|
||||
createPullSheet.renderReviewerStatuses = renderReviewerStatuses;
|
||||
createPullSheet.renderFile = renderFile;
|
||||
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
|
||||
createPullSheet.removeFromSnapshot = removeFromSnapshot;
|
||||
@@ -323,4 +942,8 @@ createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
|
||||
createPullSheet.ownershipExitMessage = ownershipExitMessage;
|
||||
createPullSheet.resetOwnershipControls = resetOwnershipControls;
|
||||
createPullSheet.bindOwnershipControls = bindOwnershipControls;
|
||||
createPullSheet.bindContextEditor = bindContextEditor;
|
||||
createPullSheet.resetReviewRequestControls = resetReviewRequestControls;
|
||||
createPullSheet.bindReviewRequestControls = bindReviewRequestControls;
|
||||
createPullSheet.bindFeedbackControls = bindFeedbackControls;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||
@@ -3,6 +3,8 @@
|
||||
else root.createPushNotifications = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
followingControl, followingStatus,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
|
||||
notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
@@ -127,11 +129,16 @@
|
||||
control.checked = false;
|
||||
if (testControl) testControl.hidden = true;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
if (startDayControl) startDayControl.checked = false;
|
||||
if (followingControl) followingControl.checked = false;
|
||||
configuration.subscribed = false;
|
||||
configuration.deadline_enabled = false;
|
||||
configuration.start_day_enabled = false;
|
||||
configuration.following_enabled = false;
|
||||
pendingIntent = null;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
if (followingStatus) followingStatus.textContent = 'Following change alerts are off for this device.';
|
||||
}
|
||||
|
||||
async function ensureSubscription() {
|
||||
@@ -225,13 +232,83 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStartDay() {
|
||||
startDayControl.disabled = true;
|
||||
if (startDayHour) startDayHour.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (startDayControl.checked) pendingIntent = 'start-day';
|
||||
if (startDayControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (startDayControl.checked && !subscription) {
|
||||
startDayControl.checked = false;
|
||||
startDayStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const reminderHour = Number(startDayHour?.value ?? configuration?.start_day_reminder_hour ?? 9);
|
||||
await fetchJson('api/v1/push-subscription/start-day', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:startDayControl.checked, timezone, reminder_hour:reminderHour}),
|
||||
});
|
||||
configuration.start_day_enabled = startDayControl.checked;
|
||||
configuration.start_day_timezone = timezone;
|
||||
configuration.start_day_reminder_hour = reminderHour;
|
||||
pendingIntent = null;
|
||||
startDayStatus.textContent = startDayControl.checked
|
||||
? `Start-day reminder enabled for ${formattedHour(reminderHour)} local time.`
|
||||
: 'Start-day reminders are off for this device.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
startDayControl.checked = !startDayControl.checked;
|
||||
startDayStatus.textContent = 'Could not change start-day reminders. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
startDayControl.disabled = false;
|
||||
if (startDayHour) startDayHour.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeFollowing() {
|
||||
followingControl.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (followingControl.checked) pendingIntent = 'following';
|
||||
if (followingControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (followingControl.checked && !subscription) {
|
||||
followingControl.checked = false;
|
||||
followingStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
await fetchJson('api/v1/push-subscription/following', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:followingControl.checked}),
|
||||
});
|
||||
configuration.following_enabled = followingControl.checked;
|
||||
pendingIntent = null;
|
||||
followingStatus.textContent = followingControl.checked
|
||||
? 'Following change alerts enabled for this device.'
|
||||
: 'Following change alerts are off for this device.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
followingControl.checked = !followingControl.checked;
|
||||
followingStatus.textContent = 'Could not change Following alerts. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
followingControl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDeadline() {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function recoverPermission(intent = null) {
|
||||
if (!pendingIntent && (intent === 'updates' || intent === 'deadline')) pendingIntent = intent;
|
||||
if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following'].includes(intent)) pendingIntent = intent;
|
||||
if (!pendingIntent || notification.permission !== 'granted') return false;
|
||||
if (recoveryPromise) return recoveryPromise;
|
||||
recoveryPromise = (async () => {
|
||||
@@ -239,6 +316,14 @@
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
if (pendingIntent === 'start-day') {
|
||||
startDayControl.checked = true;
|
||||
return changeStartDay();
|
||||
}
|
||||
if (pendingIntent === 'following') {
|
||||
followingControl.checked = true;
|
||||
return changeFollowing();
|
||||
}
|
||||
return Boolean(await enable());
|
||||
})();
|
||||
try {
|
||||
@@ -253,24 +338,37 @@
|
||||
control.addEventListener('change', change);
|
||||
testControl?.addEventListener('click', testDelivery);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
startDayControl?.addEventListener('change', changeStartDay);
|
||||
followingControl?.addEventListener('change', changeFollowing);
|
||||
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
if (deadlineControl) deadlineControl.disabled = true;
|
||||
if (startDayControl) startDayControl.disabled = true;
|
||||
if (followingControl) followingControl.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
|
||||
if (followingControl) followingControl.checked = Boolean(configuration.following_enabled);
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
|
||||
renderDeliveryHealth();
|
||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled
|
||||
? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.`
|
||||
: 'Start-day reminders are off for this device.';
|
||||
if (followingStatus) followingStatus.textContent = configuration.following_enabled
|
||||
? 'Following change alerts enabled for this device.'
|
||||
: 'Following change alerts are off for this device.';
|
||||
renderDeadlineSnooze();
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
return {init, change, changeDeadline, changeStartDay, changeFollowing, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
});
|
||||
@@ -64,10 +64,100 @@
|
||||
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
|
||||
}
|
||||
|
||||
function createWeekBatchPlan({
|
||||
week,
|
||||
prepare = item => Promise.resolve(item),
|
||||
accept = item => item,
|
||||
identity = item => String(item?.repository || '') + '#' + String(item?.number || ''),
|
||||
maxItems = 5,
|
||||
onProgress = () => {},
|
||||
} = {}) {
|
||||
let running = null;
|
||||
const load = async () => {
|
||||
await week.load();
|
||||
return week.dates().map(day => ({ ...day, ...week.day(day.date) }));
|
||||
};
|
||||
function validate(items, assignments, days, confirmOverload = false) {
|
||||
const allowed = new Set(days.map(day => day.date));
|
||||
const errors = [];
|
||||
const simulated = new Map(days.map(day => [day.date, {
|
||||
ids:[...(day.ids || [])], estimates:{...(day.estimates || {})},
|
||||
capacity_minutes:Number(day.capacity_minutes) || 0,
|
||||
}]));
|
||||
items.forEach(item => {
|
||||
const id = identity(item), choice = assignments?.[id] || {};
|
||||
const estimate = Number(choice.estimate);
|
||||
if (!allowed.has(choice.date)) errors.push({ id, reason:'date-required' });
|
||||
if (!Number.isFinite(estimate) || estimate <= 0) errors.push({ id, reason:'estimate-required' });
|
||||
if (!allowed.has(choice.date) || !Number.isFinite(estimate) || estimate <= 0) return;
|
||||
simulated.forEach((day, date) => {
|
||||
if (day.ids.includes(id) && choice.date !== date) {
|
||||
day.ids = day.ids.filter(value => value !== id); delete day.estimates[id];
|
||||
}
|
||||
});
|
||||
const destination = simulated.get(choice.date);
|
||||
if (!destination.ids.includes(id) && destination.ids.length >= maxItems) {
|
||||
errors.push({ id, reason:'day-full', date:choice.date, limit:maxItems }); return;
|
||||
}
|
||||
if (!destination.ids.includes(id)) destination.ids.push(id);
|
||||
destination.estimates[id] = estimate;
|
||||
});
|
||||
const overloads = [];
|
||||
simulated.forEach((day, date) => {
|
||||
const planned = day.ids.reduce((sum, id) => sum + (Number(day.estimates[id]) || 0), 0);
|
||||
if (day.capacity_minutes > 0 && planned > day.capacity_minutes) {
|
||||
overloads.push({ date, planned_minutes:planned, capacity_minutes:day.capacity_minutes });
|
||||
}
|
||||
});
|
||||
if (errors.length) return { status:'invalid', errors, overloads:[] };
|
||||
if (overloads.length && !confirmOverload) return { status:'overload-confirmation-required', errors:[], overloads };
|
||||
return { status:'ready', errors:[], overloads };
|
||||
}
|
||||
async function preview(items) {
|
||||
const days = await load();
|
||||
return { days, placements:Object.fromEntries(items.map(item => [identity(item), week.placement(identity(item))])) };
|
||||
}
|
||||
function run(items, assignments, { confirmOverload = false } = {}) {
|
||||
if (running) return running;
|
||||
running = (async () => {
|
||||
const selected = Array.isArray(items) ? items.slice() : [];
|
||||
const days = await load();
|
||||
const checked = validate(selected, assignments, days, confirmOverload);
|
||||
if (checked.status !== 'ready') return checked;
|
||||
const failed = [], planned = [];
|
||||
for (let index = 0; index < selected.length; index += 1) {
|
||||
const source = selected[index], originalId = identity(source), choice = assignments[originalId];
|
||||
try {
|
||||
const confirmed = accept(await prepare(source)) || source;
|
||||
const id = identity(confirmed);
|
||||
const existing = week.placement(id);
|
||||
if (!week.place(id, choice.date, Number(choice.estimate), {move:Boolean(existing && existing.date !== choice.date)})) {
|
||||
failed.push({id:originalId, reason:'assigned-not-planned'});
|
||||
} else {
|
||||
week.rememberPendingItem?.(id, confirmed);
|
||||
planned.push(id);
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({id:originalId, reason:error?.message || 'assignment failed'});
|
||||
}
|
||||
onProgress({status:'running',processed:index + 1,selected:selected.length});
|
||||
}
|
||||
if (planned.length) {
|
||||
try { await week.flush(); }
|
||||
catch (_error) { return {status:'planned-pending',planned:planned.length,failed}; }
|
||||
}
|
||||
return {status:failed.length?'partial':'planned',planned:planned.length,failed};
|
||||
})().finally(() => { running = null; });
|
||||
return running;
|
||||
}
|
||||
return { preview, validate, run, pending:() => Boolean(running) };
|
||||
}
|
||||
createSearchBatchPlan.createWeekBatchPlan = createWeekBatchPlan;
|
||||
|
||||
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
|
||||
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
|
||||
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute,
|
||||
laterWork = null, laterPicker = null
|
||||
laterWork = null, laterPicker = null, weekPlan = null, storage = null
|
||||
) {
|
||||
const get = selector => document.querySelector(selector);
|
||||
let processor;
|
||||
@@ -87,6 +177,7 @@
|
||||
get('#queue-selected-search-results').disabled = state.count === 0;
|
||||
get('#defer-selected-search-results').disabled = state.count === 0;
|
||||
get('#plan-selected-search-results').disabled = state.count === 0;
|
||||
if (get('#week-selected-search-results')) get('#week-selected-search-results').disabled = state.count === 0;
|
||||
get('#search-selection-status').textContent = state.count ?
|
||||
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
|
||||
render();
|
||||
@@ -194,6 +285,81 @@
|
||||
}
|
||||
},
|
||||
});
|
||||
const weekProcessor = weekPlan ? createWeekBatchPlan({
|
||||
week:weekPlan,prepare:item=>plan.prepare(item),accept:acceptIssue,
|
||||
identity:item=>todayWork.identity(item),
|
||||
onProgress:progress=>{
|
||||
if (progress.status === 'running') get('#search-week-batch-summary').textContent =
|
||||
'Planning ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
},
|
||||
storage,
|
||||
}) : null;
|
||||
let weekOverload = false;
|
||||
function weekAssignments() {
|
||||
const values = {};
|
||||
document.querySelectorAll('[data-search-week-batch-date]').forEach(select => {
|
||||
const id = select.dataset.searchWeekBatchDate;
|
||||
const estimate = document.querySelector('[data-search-week-batch-estimate="' + id + '"]');
|
||||
values[id] = {date:select.value,estimate:Number(estimate?.value)};
|
||||
});
|
||||
return values;
|
||||
}
|
||||
function closeWeekReview() {
|
||||
if (!get('#search-week-batch-review')) return;
|
||||
get('#search-week-batch-review').hidden = true;
|
||||
get('#search-batch-actions').hidden = !plan.snapshot().active;
|
||||
weekOverload = false;
|
||||
get('#confirm-search-week-batch').textContent = 'Assign & plan';
|
||||
}
|
||||
async function openWeekReview() {
|
||||
const items = plan.snapshot().items;
|
||||
if (!weekProcessor || !items.length) return;
|
||||
const button = get('#week-selected-search-results');
|
||||
button.disabled = true;
|
||||
get('#search-selection-status').textContent = 'Loading Week Ahead…';
|
||||
try {
|
||||
const preview = await weekProcessor.preview(items);
|
||||
const options = selected => preview.days.map(day => '<option value="' + escapeAttribute(day.date) + '"' +
|
||||
(day.date === selected ? ' selected' : '') + '>' + escapeHtml(day.label + ' · ' + (day.ids || []).length + ' planned') + '</option>').join('');
|
||||
get('#search-week-batch-list').innerHTML = items.map(item => {
|
||||
const id = todayWork.identity(item), existing = preview.placements[id];
|
||||
return '<div class="search-week-batch-row"><div><span class="small">' + escapeHtml(id) + '</span><strong>' +
|
||||
escapeHtml(item.title || 'Untitled work') + '</strong></div><div class="search-week-batch-row-controls"><label>Day<select data-search-week-batch-date="' +
|
||||
escapeAttribute(id) + '">' + options(existing?.date || preview.days[0]?.date) + '</select></label><label>Minutes<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
|
||||
escapeAttribute(existing?.estimate || '') + '" data-search-week-batch-estimate="' + escapeAttribute(id) +
|
||||
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes"></label></div></div>';
|
||||
}).join('');
|
||||
get('#search-week-batch-summary').textContent = items.length + ' selected · review every day and estimate before assignment.';
|
||||
get('#search-batch-actions').hidden = true;
|
||||
get('#search-week-batch-review').hidden = false;
|
||||
document.querySelector('[data-search-week-batch-date]')?.focus();
|
||||
} catch (error) {
|
||||
get('#search-selection-status').textContent = error?.message || 'Week Ahead is unavailable. Retry when connected.';
|
||||
} finally { button.disabled = false; }
|
||||
}
|
||||
async function confirmWeekReview() {
|
||||
if (!weekProcessor || weekProcessor.pending()) return;
|
||||
const button = get('#confirm-search-week-batch');
|
||||
button.disabled = true;
|
||||
const outcome = await weekProcessor.run(plan.snapshot().items, weekAssignments(), {confirmOverload:weekOverload});
|
||||
if (outcome.status === 'invalid') {
|
||||
const reasons = [...new Set(outcome.errors.map(error => error.reason))];
|
||||
get('#search-week-batch-summary').textContent = reasons.includes('day-full') ?
|
||||
'A day already has five items. Choose another day.' : 'Choose a valid day and estimate for every issue.';
|
||||
} else if (outcome.status === 'overload-confirmation-required') {
|
||||
weekOverload = true;
|
||||
button.textContent = 'Confirm over capacity';
|
||||
get('#search-week-batch-summary').textContent = outcome.overloads.map(day =>
|
||||
day.planned_minutes + ' of ' + day.capacity_minutes + ' min on ' + day.date).join(' · ') + '. Confirm to plan anyway.';
|
||||
} else {
|
||||
const pending = outcome.status === 'planned-pending';
|
||||
get('#cmd-search-action-status').textContent = outcome.planned + ' planned' +
|
||||
(pending ? ' · sync pending' : '') + (outcome.failed.length ? ' · ' + outcome.failed.length + ' need attention.' : '.');
|
||||
closeWeekReview();
|
||||
if (!outcome.failed.length) plan.cancel();
|
||||
}
|
||||
button.disabled = false;
|
||||
}
|
||||
function estimateValues() {
|
||||
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
|
||||
[input.dataset.searchBatchEstimate, Number(input.value)]
|
||||
@@ -274,6 +440,9 @@
|
||||
) }, event.currentTarget, 'search-batch');
|
||||
});
|
||||
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
|
||||
get('#week-selected-search-results')?.addEventListener('click', openWeekReview);
|
||||
get('#cancel-search-week-batch')?.addEventListener('click', closeWeekReview);
|
||||
get('#confirm-search-week-batch')?.addEventListener('click', confirmWeekReview);
|
||||
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
|
||||
get('#confirm-search-release').addEventListener('click', () => {
|
||||
const milestoneSelect = get('#search-release-milestone');
|
||||
@@ -322,7 +491,7 @@
|
||||
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
|
||||
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
|
||||
}
|
||||
return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
|
||||
return { plan, processor, laterProcessor, releaseProcessor, weekProcessor, restore, resultHtml };
|
||||
};
|
||||
|
||||
return createSearchBatchPlan;
|
||||
|
||||
+237
-10
@@ -30,8 +30,82 @@
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview/conversation?' + query.toString();
|
||||
};
|
||||
root.followingPullReviewPath = item => {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return 'api/v1/following/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review-data';
|
||||
};
|
||||
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||
'/comments?kind=' + encodeURIComponent(item.kind);
|
||||
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||
'/subscription?kind=' + encodeURIComponent(item.kind);
|
||||
root.searchPreviewSubscriptionOptions = fetchJson => {
|
||||
const options = {
|
||||
load:async detail => {
|
||||
if (['issue', 'pull'].includes(detail.kind) && detail.state === 'closed' && detail.following === true) {
|
||||
return {...detail, watching:true};
|
||||
}
|
||||
if (!(['issue', 'pull'].includes(detail.kind) && detail.state === 'open')) return detail;
|
||||
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
|
||||
return {...detail, watching:result.watching === true};
|
||||
},
|
||||
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
|
||||
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
|
||||
}),
|
||||
review:item => fetchJson(root.followingPullReviewPath(item)),
|
||||
};
|
||||
options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), {
|
||||
headers:{Accept:'application/json'},
|
||||
})});
|
||||
return options;
|
||||
};
|
||||
root.createDetailWatch = ({fetchJson,refreshFollowing,onState}) => {
|
||||
const api=root.searchPreviewSubscriptionOptions(fetchJson);
|
||||
let item,watching=false,mutation;
|
||||
return {
|
||||
async open(next) {
|
||||
item=next; onState('loading',watching);
|
||||
const result=await api.load(next).catch(error=>{
|
||||
if(item===next)onState('error',watching,error);throw error;
|
||||
});
|
||||
if (item !== next) return;
|
||||
watching=result.watching === true; onState('ready',watching);
|
||||
},
|
||||
toggle() {
|
||||
if (mutation) return mutation;
|
||||
const next=!watching;
|
||||
onState(next?'watching':'unwatching',watching);
|
||||
mutation=api.watch(item,next).then(async result => {
|
||||
if (result?.watching !== next || result?.following_synced !== true)
|
||||
throw new Error(result?.error || 'Unconfirmed.');
|
||||
watching=next; await refreshFollowing();
|
||||
onState(watching?'watched':'unwatched',watching);
|
||||
}).catch(error => {onState('error',watching,error);throw error;})
|
||||
.finally(()=>{mutation=null;});
|
||||
return mutation;
|
||||
},
|
||||
};
|
||||
};
|
||||
root.searchPreviewWatchStatus = state => ({
|
||||
watching:'Starting watch…', unwatching:'Stopping watch…',
|
||||
watched:'Watching · available in Following. Future activity will appear in Updates.',
|
||||
unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.',
|
||||
'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch ' +
|
||||
(state.detail?.kind === 'pull' ? 'pull request' : 'issue') + ' to repair.',
|
||||
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
|
||||
})[state.status] || '';
|
||||
root.renderSearchPreviewWatch = (detail, state, button) => {
|
||||
const watchableKind = ['issue', 'pull'].includes(detail.kind);
|
||||
const retiring = watchableKind && detail.state === 'closed' &&
|
||||
detail.following === true && detail.watching === true;
|
||||
button.hidden = !(watchableKind && detail.state === 'open') && !retiring;
|
||||
button.textContent = retiring ? 'Stop watching & next' :
|
||||
(detail.watching ? 'Stop watching' : 'Watch ' + (detail.kind === 'pull' ? 'pull request' : 'issue'));
|
||||
button.disabled = state.status === 'watching' || state.status === 'unwatching';
|
||||
};
|
||||
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
|
||||
const detail = getDetail();
|
||||
if (detail) preview.setWatching(detail.watching !== true).catch(() => {});
|
||||
});
|
||||
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
|
||||
storage,
|
||||
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
@@ -50,10 +124,15 @@
|
||||
retry.hidden = older.hidden = true;
|
||||
return;
|
||||
}
|
||||
const isNew = comment => Boolean(conversation.reviewedAt && comment.created_at &&
|
||||
new Date(comment.created_at).getTime() > new Date(conversation.reviewedAt).getTime());
|
||||
const newCount = (conversation.comments || []).filter(isNew).length;
|
||||
comments.innerHTML = (conversation.comments || []).map(comment =>
|
||||
'<article class="search-preview-comment" data-comment-id="' + Number(comment.id || 0) + '">' +
|
||||
'<article class="search-preview-comment' + (isNew(comment) ? ' new-since-review' : '') +
|
||||
'" data-comment-id="' + Number(comment.id || 0) + '">' +
|
||||
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '</div>' +
|
||||
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') +
|
||||
(isNew(comment) ? ' · <strong>New since last review</strong>' : '') + '</div>' +
|
||||
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
|
||||
).join('');
|
||||
retry.hidden = conversation.status !== 'error';
|
||||
@@ -65,7 +144,8 @@
|
||||
'Conversation unavailable. Preview and planning actions still work.';
|
||||
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
|
||||
else status.textContent = conversation.comments.length +
|
||||
(conversation.comments.length === 1 ? ' message.' : ' messages.');
|
||||
(conversation.comments.length === 1 ? ' message' : ' messages') +
|
||||
(newCount ? ' · ' + newCount + ' new since last review' : '') + '.';
|
||||
};
|
||||
root.renderSearchPreviewReply = (state, detail, preview, document) => {
|
||||
const section = document.querySelector('.search-preview-reply');
|
||||
@@ -90,9 +170,58 @@
|
||||
else if (state.status === 'reply-error') status.textContent =
|
||||
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
|
||||
};
|
||||
root.renderSearchPreviewReview = (review, document, escapeHtml) => {
|
||||
const section = document.querySelector('#search-preview-review');
|
||||
const status = document.querySelector('#search-preview-review-status');
|
||||
const files = document.querySelector('#search-preview-files');
|
||||
const retry = document.querySelector('#retry-search-preview-review');
|
||||
section.hidden = !review;
|
||||
retry.hidden = review?.status !== 'error';
|
||||
files.innerHTML = '';
|
||||
if (!review) {
|
||||
status.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (review.status === 'loading') {
|
||||
status.textContent = 'Loading CI and changed files…';
|
||||
return;
|
||||
}
|
||||
if (review.status === 'error') {
|
||||
status.textContent = 'Changes unavailable. This revision has not been marked reviewed.';
|
||||
return;
|
||||
}
|
||||
const data = review.data || {};
|
||||
const changed = Array.isArray(data.files) ? data.files : [];
|
||||
const ci = ({success:'CI passed', failure:'CI failed', error:'CI failed', pending:'CI pending'})[
|
||||
data.ci_state
|
||||
] || 'CI status unavailable';
|
||||
status.textContent = ci + ' · ' + changed.length + ' changed ' +
|
||||
(changed.length === 1 ? 'file.' : 'files.');
|
||||
files.innerHTML = changed.map(file => {
|
||||
const lines = (Array.isArray(file.diff_lines) ? file.diff_lines : []).map(raw => {
|
||||
const line = String(raw);
|
||||
const kind = line.startsWith('@@') ? 'hunk' : line.startsWith('+') ? 'added' :
|
||||
line.startsWith('-') ? 'removed' : 'context';
|
||||
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(line) + '</span>';
|
||||
}).join('');
|
||||
const diff = file.diff_available
|
||||
? '<pre class="pull-diff">' + lines +
|
||||
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
|
||||
: '<div class="pull-diff-empty">' +
|
||||
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') + '</div>';
|
||||
return '<article class="search-preview-file"><strong>' + escapeHtml(file.filename || 'Unknown file') +
|
||||
'</strong><span class="small">' + escapeHtml(file.status || 'changed') + ' · +' +
|
||||
Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span>' + diff + '</article>';
|
||||
}).join('');
|
||||
};
|
||||
root.renderSearchPreviewWorkspaces = (state, detail, preview, document, escapeHtml) => {
|
||||
root.renderSearchPreviewReply(state, detail, preview, document);
|
||||
root.renderSearchPreviewReview(state.review, document, escapeHtml);
|
||||
document.querySelector('[data-search-preview-section="changes"]').hidden = !state.review;
|
||||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, fetchReview, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
|
||||
if (Array.isArray(session)) {
|
||||
getSession = session[0];
|
||||
loadMore = () => session[1].loadMore();
|
||||
@@ -105,13 +234,24 @@
|
||||
let shareRequest = null;
|
||||
let moveRequest = null;
|
||||
let replyRequest = null;
|
||||
let watchRequest = null;
|
||||
let conversation = null;
|
||||
let review = null;
|
||||
let openedRevision = null;
|
||||
|
||||
function sameItem(left, right) {
|
||||
return left && right && left.kind === right.kind && left.repository === right.repository &&
|
||||
Number(left.number) === Number(right.number);
|
||||
}
|
||||
|
||||
async function notifyOpened(item, requestGeneration) {
|
||||
const revision = [item?.kind, item?.repository, item?.number, item?.updated_at].join(':');
|
||||
if (requestGeneration !== generation || openedRevision === revision) return false;
|
||||
await onOpened?.({...item});
|
||||
if (requestGeneration === generation) openedRevision = revision;
|
||||
return true;
|
||||
}
|
||||
|
||||
function replyKey(item, suffix) {
|
||||
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
|
||||
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
|
||||
@@ -143,6 +283,10 @@
|
||||
}
|
||||
|
||||
function publish(state) {
|
||||
if (conversation && !Object.prototype.hasOwnProperty.call(state, 'conversation')) {
|
||||
state = {...state, conversation};
|
||||
}
|
||||
if (review && !Object.prototype.hasOwnProperty.call(state, 'review')) state = {...state, review};
|
||||
const position = navigation(state.item || current);
|
||||
if (navigationRoot) {
|
||||
const bar = navigationRoot.querySelector('.search-preview-navigation');
|
||||
@@ -176,7 +320,10 @@
|
||||
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
|
||||
const previousComments = page && Array.isArray(conversation?.comments)
|
||||
? conversation.comments : [];
|
||||
conversation = { status:'loading', comments:previousComments, olderPage:page ?? null };
|
||||
conversation = {
|
||||
status:'loading', comments:previousComments, olderPage:page ?? null,
|
||||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail, conversation });
|
||||
return fetchConversation(detail, page).then(result => {
|
||||
if (requestGeneration !== generation) return result;
|
||||
@@ -188,6 +335,7 @@
|
||||
status:'ready',
|
||||
comments,
|
||||
olderPage:result?.older_page ?? null,
|
||||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
return result;
|
||||
@@ -196,6 +344,7 @@
|
||||
conversation = {
|
||||
status:'error', comments:previousComments,
|
||||
olderPage:page ?? conversation?.olderPage ?? null, error,
|
||||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
}
|
||||
@@ -203,6 +352,37 @@
|
||||
});
|
||||
}
|
||||
|
||||
function loadReview(detail, requestGeneration) {
|
||||
if (!(detail?.following === true && detail?.kind === 'pull' && typeof fetchReview === 'function')) {
|
||||
review = null;
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
review = {status:'loading', data:null};
|
||||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||
return fetchReview(detail).then(data => {
|
||||
if (requestGeneration === generation) {
|
||||
review = {status:'ready', data};
|
||||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||
}
|
||||
return data;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
review = {status:'error', data:null, error};
|
||||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async function acknowledgeWhenContextReady(requestGeneration) {
|
||||
const conversationReady = typeof fetchConversation !== 'function' || conversation?.status === 'ready';
|
||||
const reviewRequired = current?.following === true && current?.kind === 'pull' &&
|
||||
typeof fetchReview === 'function';
|
||||
if (conversationReady && (!reviewRequired || review?.status === 'ready')) {
|
||||
await notifyOpened(current, requestGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
hasReplyAttachments() {
|
||||
return Boolean(hasAttachments?.());
|
||||
@@ -213,14 +393,24 @@
|
||||
const requestGeneration = generation;
|
||||
current = { ...item };
|
||||
conversation = null;
|
||||
review = null;
|
||||
openedRevision = null;
|
||||
publish({ status: 'loading', item: current });
|
||||
return fetchJson(current).then(detail => {
|
||||
return fetchJson(current).then(async detail => {
|
||||
if (requestGeneration === generation) {
|
||||
current = { ...current, ...detail };
|
||||
if (typeof fetchConversation === 'function') {
|
||||
loadConversation(current, requestGeneration);
|
||||
const context = loadConversation(current, requestGeneration);
|
||||
if (current.following === true) {
|
||||
const reviewContext = loadReview(current, requestGeneration);
|
||||
await Promise.all([context, reviewContext]);
|
||||
await acknowledgeWhenContextReady(requestGeneration);
|
||||
} else {
|
||||
await notifyOpened(current, requestGeneration);
|
||||
}
|
||||
} else {
|
||||
publish({ status: 'ready', item: current, detail });
|
||||
await notifyOpened(current, requestGeneration);
|
||||
}
|
||||
}
|
||||
return detail;
|
||||
@@ -269,9 +459,19 @@
|
||||
})().finally(() => { moveRequest = null; });
|
||||
return moveRequest;
|
||||
},
|
||||
retryConversation() {
|
||||
if (!current) return Promise.resolve(null);
|
||||
return loadConversation(current, generation);
|
||||
async retryConversation() {
|
||||
if (!current) return null;
|
||||
const requestGeneration = generation;
|
||||
const result = await loadConversation(current, requestGeneration);
|
||||
if (current?.following === true) await acknowledgeWhenContextReady(requestGeneration);
|
||||
return result;
|
||||
},
|
||||
async retryReview() {
|
||||
if (!current) return null;
|
||||
const requestGeneration = generation;
|
||||
const result = await loadReview(current, requestGeneration);
|
||||
await acknowledgeWhenContextReady(requestGeneration);
|
||||
return result;
|
||||
},
|
||||
loadOlderConversation() {
|
||||
if (!current || !conversation?.olderPage) return Promise.resolve(null);
|
||||
@@ -348,11 +548,38 @@
|
||||
reopen(detail) {
|
||||
return run('reopen', 'reopening', 'reopened', detail);
|
||||
},
|
||||
setWatching(watching) {
|
||||
if (watchRequest) return watchRequest;
|
||||
if (!current || typeof watch !== 'function') {
|
||||
return Promise.reject(new Error('Watching is unavailable.'));
|
||||
}
|
||||
const detail = current;
|
||||
publish({ status:watching ? 'watching' : 'unwatching', item:current, detail });
|
||||
watchRequest = watch(detail, watching).then(async result => {
|
||||
current = { ...current, watching:result?.watching === true };
|
||||
const status = result?.following_synced === false
|
||||
? 'watch-partial' : (watching ? 'watched' : 'unwatched');
|
||||
publish({ status, item:current, detail:current, result });
|
||||
if (!watching && result?.watching === false && typeof afterUnwatch === 'function') {
|
||||
const next = await afterUnwatch({...current});
|
||||
if (next) {
|
||||
onNavigate?.(next);
|
||||
await api.open(next);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}).catch(error => {
|
||||
publish({ status:'watch-error', item:current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { watchRequest = null; });
|
||||
return watchRequest;
|
||||
},
|
||||
};
|
||||
if (navigationRoot) {
|
||||
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
|
||||
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
|
||||
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
|
||||
navigationRoot.querySelector('#retry-search-preview-review').addEventListener('click', () => api.retryReview());
|
||||
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
|
||||
const reply = navigationRoot.querySelector('#search-preview-reply');
|
||||
reply?.addEventListener('input', event => {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5}={}) {
|
||||
let running=null;
|
||||
const eligible=detail=>Boolean(detail&&detail.kind==='issue'&&detail.state==='open'&&
|
||||
(detail.claimable||detail.assigned_to_me));
|
||||
const loadDay=date=>{
|
||||
const value=week.day(date),estimates=value.estimates||{};
|
||||
return {...value,planned_minutes:(value.ids||[]).reduce((total,id)=>total+(Number(estimates[id])||0),0)};
|
||||
};
|
||||
async function preview(detail) {
|
||||
if(!eligible(detail))return {eligible:false,days:[]};
|
||||
await week.load();
|
||||
const id=identity(detail),existing=week.placement(id);
|
||||
return {eligible:true,existing,days:week.dates().map(item=>({...item,...loadDay(item.date)}))};
|
||||
}
|
||||
function plan(detail,options={}) {
|
||||
if(running)return running;
|
||||
const perform=async()=>{
|
||||
if(!eligible(detail))return {status:'ineligible'};
|
||||
const estimate=Number(options.estimate);
|
||||
if(!Number.isFinite(estimate)||estimate<=0)return {status:'estimate-required'};
|
||||
await week.load();
|
||||
if(!week.dates().some(item=>item.date===options.date))return {status:'date-required'};
|
||||
const originalId=identity(detail),existing=week.placement(originalId);
|
||||
if(existing&&existing.date!==options.date&&!options.move)
|
||||
return {status:'already-planned',date:existing.date,estimate:existing.estimate};
|
||||
const destination=loadDay(options.date);
|
||||
const alreadyThere=(destination.ids||[]).includes(originalId);
|
||||
if(!alreadyThere&&(destination.ids||[]).length>=maxItems)return {status:'day-full',limit:maxItems};
|
||||
const previous=alreadyThere?(Number(destination.estimates?.[originalId])||0):0;
|
||||
const plannedMinutes=destination.planned_minutes-previous+estimate;
|
||||
if(Number(destination.capacity_minutes)>0&&plannedMinutes>Number(destination.capacity_minutes)&&!options.confirmOverload)
|
||||
return {status:'overload-confirmation-required',planned_minutes:plannedMinutes,
|
||||
capacity_minutes:Number(destination.capacity_minutes)};
|
||||
let item=detail,assigned=false;
|
||||
if(detail.claimable){item=await claim(detail);assigned=true;}
|
||||
item=accept(item)||item;
|
||||
const id=identity(item);
|
||||
if(!week.place(id,options.date,estimate,{move:Boolean(options.move)}))
|
||||
return {status:assigned?'assigned-not-planned':'not-planned',item};
|
||||
week.rememberPendingItem?.(id,item);
|
||||
try {
|
||||
await week.flush();
|
||||
return {status:'planned',date:options.date,estimate,item};
|
||||
} catch(_error) {
|
||||
return {status:'planned-pending',date:options.date,estimate,item};
|
||||
}
|
||||
};
|
||||
running=perform().finally(()=>{running=null;});
|
||||
return running;
|
||||
}
|
||||
return {eligible,preview,plan,pending:()=>Boolean(running)};
|
||||
}
|
||||
function createSearchWeekPlanUI({planner,document,window,getDetail,onPlanned=()=>{},announce=()=>{},escapeHtml,escapeAttribute}={}) {
|
||||
const qs=selector=>document.querySelector(selector);
|
||||
let trigger=null,detail=null,preview=null,overload=false;
|
||||
function close(navigate=false) {
|
||||
if(navigate&&window.history.state?.searchWeekPlan){window.history.back();return;}
|
||||
qs('#search-week-plan-sheet').hidden=true;
|
||||
detail=null;preview=null;overload=false;trigger?.focus?.();trigger=null;
|
||||
}
|
||||
function selectDate(date) {
|
||||
qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(button=>
|
||||
button.setAttribute('aria-pressed',String(button.dataset.searchWeekDate===date)));
|
||||
qs('#search-week-plan-days').dataset.selected=date;
|
||||
overload=false;qs('#confirm-search-week-plan').textContent='Plan';
|
||||
}
|
||||
async function open(button) {
|
||||
const selected=getDetail();
|
||||
if(!selected||planner.pending())return false;
|
||||
trigger=button;detail=selected;overload=false;
|
||||
const sheet=qs('#search-week-plan-sheet'),status=qs('#search-week-plan-status');
|
||||
sheet.hidden=false;status.textContent='Loading Week Ahead…';qs('#confirm-search-week-plan').disabled=true;
|
||||
window.history.pushState({...window.history.state,searchWeekPlan:true},'',window.location.href);
|
||||
qs('#search-week-plan-copy').textContent=detail.title||'Untitled issue';
|
||||
try {
|
||||
preview=await planner.preview(detail);
|
||||
const existing=preview.existing;
|
||||
qs('#search-week-plan-days').innerHTML=preview.days.map((day,index)=>
|
||||
'<button type="button" data-search-week-date="'+escapeAttribute(day.date)+'" aria-pressed="'+String(index===0)+'"><strong>'+escapeHtml(day.label)+
|
||||
'</strong><small>'+escapeHtml(day.planned_minutes+(day.capacity_minutes?' of '+day.capacity_minutes:'')+' min · '+day.ids.length+' planned')+'</small></button>').join('');
|
||||
qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(dayButton=>
|
||||
dayButton.addEventListener('click',()=>selectDate(dayButton.dataset.searchWeekDate)));
|
||||
selectDate(existing?.date||preview.days[0].date);
|
||||
qs('#search-week-plan-estimate').value=existing?.estimate||'';
|
||||
status.textContent=existing?'Already planned for '+existing.date+'. Choose another day to move it.':'Choose a day and add an estimate.';
|
||||
qs('#confirm-search-week-plan').disabled=false;qs('#search-week-plan-estimate').focus();return true;
|
||||
} catch(error) {status.textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;}
|
||||
}
|
||||
async function confirm() {
|
||||
if(!detail||planner.pending())return;
|
||||
const date=qs('#search-week-plan-days').dataset.selected,estimate=Number(qs('#search-week-plan-estimate').value);
|
||||
const existing=preview?.existing,move=Boolean(existing&&existing.date!==date);
|
||||
const button=qs('#confirm-search-week-plan'),status=qs('#search-week-plan-status');button.disabled=true;
|
||||
const outcome=await planner.plan(detail,{date,estimate,move,confirmOverload:overload});
|
||||
if(outcome.status==='overload-confirmation-required'){
|
||||
overload=true;button.textContent='Confirm over capacity';
|
||||
status.textContent=outcome.planned_minutes+' min exceeds '+outcome.capacity_minutes+' min capacity. Confirm to plan anyway.';
|
||||
} else if(outcome.status==='estimate-required')status.textContent='Enter an estimate in minutes.';
|
||||
else if(outcome.status==='day-full')status.textContent='That day already has '+outcome.limit+' items. Choose another day.';
|
||||
else if(outcome.status==='already-planned')status.textContent='Already planned for '+outcome.date+'. Choose Move to change the day.';
|
||||
else if(outcome.status==='assigned-not-planned')status.textContent='Assigned, not planned. Find it in My Work and retry.';
|
||||
else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');await onPlanned(outcome.item||detail,true,outcome);close(true);}
|
||||
else if(outcome.status==='planned'){announce('Planned for '+date+'.');await onPlanned(outcome.item||detail,false,outcome);close(true);}
|
||||
else status.textContent='Could not plan this issue. Retry.';
|
||||
button.disabled=false;
|
||||
}
|
||||
qs('#plan-search-result').addEventListener('click',event=>open(event.currentTarget));
|
||||
qs('#cancel-search-week-plan').addEventListener('click',()=>close(true));
|
||||
qs('#confirm-search-week-plan').addEventListener('click',confirm);
|
||||
window.addEventListener('popstate',()=>{if(!qs('#search-week-plan-sheet').hidden)close();});
|
||||
document.addEventListener('keydown',event=>{
|
||||
if(event.key==='Escape'&&!qs('#search-week-plan-sheet').hidden){event.preventDefault();close(true);}
|
||||
});
|
||||
return {open,close,confirm};
|
||||
}
|
||||
if(typeof module!=='undefined'&&module.exports){module.exports=createSearchWeekPlan;module.exports.UI=createSearchWeekPlanUI;}
|
||||
+260
-17
@@ -1,12 +1,151 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v123';
|
||||
const CACHE = 'stackchain-dashboard-shell-v138';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
|
||||
const TODAY_ACTION_TTL_MS = 2 * 60 * 1000;
|
||||
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
|
||||
|
||||
function createAppBadgePreference() {
|
||||
const dbName = 'stackchain-app-badge-preference-v1';
|
||||
const storeName = 'preferences';
|
||||
const open = () => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
|
||||
});
|
||||
return {
|
||||
async get() {
|
||||
const database = await open();
|
||||
const enabled = await new Promise((resolve, reject) => {
|
||||
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('enabled');
|
||||
request.onsuccess = () => resolve(request.result === true);
|
||||
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
|
||||
});
|
||||
database.close();
|
||||
return enabled;
|
||||
},
|
||||
async set(enabled) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put(enabled === true, 'enabled');
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('App badge preference could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
async getCounts() {
|
||||
const database = await open();
|
||||
const counts = {};
|
||||
for (const channel of ['updates', 'following']) {
|
||||
counts[channel] = await new Promise((resolve, reject) => {
|
||||
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('count:' + channel);
|
||||
request.onsuccess = () => resolve(Number.isSafeInteger(request.result) ? request.result : 0);
|
||||
request.onerror = () => reject(request.error || new Error('App badge count unavailable.'));
|
||||
});
|
||||
}
|
||||
database.close();
|
||||
return counts;
|
||||
},
|
||||
async setCount(channel, count) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put(count, 'count:' + channel);
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('App badge count could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
async clearCounts() {
|
||||
await this.setCount('updates', 0);
|
||||
await this.setCount('following', 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBadgePreference();
|
||||
let renderedBackgroundBadgeCount = null;
|
||||
|
||||
async function reconcileBackgroundAppBadge(channel, count) {
|
||||
if (!['updates', 'following'].includes(channel)
|
||||
|| !Number.isSafeInteger(count) || count < 0 || count > 9999
|
||||
|| typeof self.registration.setAppBadge !== 'function'
|
||||
|| typeof self.registration.clearAppBadge !== 'function') return false;
|
||||
let enabled = false;
|
||||
try { enabled = await appBadgePreference.get(); } catch (_error) { return false; }
|
||||
if (!enabled) return false;
|
||||
try {
|
||||
await appBadgePreference.setCount(channel, count);
|
||||
const counts = await appBadgePreference.getCounts();
|
||||
const total = Math.min(9999, counts.updates + counts.following);
|
||||
if (renderedBackgroundBadgeCount === total) return false;
|
||||
if (total > 0) await self.registration.setAppBadge(total);
|
||||
else await self.registration.clearAppBadge();
|
||||
renderedBackgroundBadgeCount = total;
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function createTodayActionStore() {
|
||||
const dbName = 'stackchain-today-action-mailbox-v1';
|
||||
const storeName = 'commands';
|
||||
const open = () => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(storeName, {keyPath:'clientId'});
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Today action mailbox unavailable.'));
|
||||
});
|
||||
return {
|
||||
async put(clientId, command) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put({...command, clientId});
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Today action could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
async claim(clientId, now) {
|
||||
const database = await open();
|
||||
let command = null;
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.get(clientId);
|
||||
request.onsuccess = () => {
|
||||
command = request.result || null;
|
||||
if (command) store.delete(clientId);
|
||||
};
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Today action could not be claimed.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
return command && command.expiresAt > now ? command : null;
|
||||
},
|
||||
async purge() {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).clear();
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Today actions could not be purged.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
const todayActionStore = self.__STACKCHAIN_TODAY_ACTION_STORE || createTodayActionStore();
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
@@ -24,6 +163,7 @@ const SHELL = [
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/saved-searches.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/following.js',
|
||||
BASE + 'static/search-reply-draft-store.js',
|
||||
BASE + 'static/conversation-reply-draft-store.js',
|
||||
BASE + 'static/conversation-photo-drafts.js',
|
||||
@@ -68,6 +208,12 @@ const SHELL = [
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-readiness.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/tomorrow-plan.js',
|
||||
BASE + 'static/week-calendar.js',
|
||||
BASE + 'static/week-calendar-import.js',
|
||||
BASE + 'static/week-plan.js',
|
||||
BASE + 'static/today-week-reschedule.js',
|
||||
BASE + 'static/search-week-plan.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/today-rollover.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
@@ -129,6 +275,7 @@ const SHELL = [
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-insights.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/mobile-app-badge.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/private-data-inventory.js',
|
||||
BASE + 'static/private-device-data.js',
|
||||
@@ -462,6 +609,35 @@ async function updateTodayLockScreen(active, running, rawActionToken = '', rawBr
|
||||
}
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-app-badge-count') {
|
||||
event.waitUntil((async () => {
|
||||
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
const channel = event.data.channel;
|
||||
const count = event.data.count;
|
||||
if (!['updates', 'following'].includes(channel)
|
||||
|| !Number.isSafeInteger(count) || count < 0 || count > 9999) return;
|
||||
try {
|
||||
if (await appBadgePreference.get()) {
|
||||
await appBadgePreference.setCount(channel, count);
|
||||
renderedBackgroundBadgeCount = null;
|
||||
}
|
||||
} catch (_error) { /* A later authoritative refresh can restore the count. */ }
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-app-badge-preference') {
|
||||
event.waitUntil((async () => {
|
||||
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
const enabled = event.data.enabled === true;
|
||||
try {
|
||||
await appBadgePreference.set(enabled);
|
||||
renderedBackgroundBadgeCount = null;
|
||||
if (!enabled && typeof self.registration.clearAppBadge === 'function') {
|
||||
await appBadgePreference.clearCounts();
|
||||
await self.registration.clearAppBadge();
|
||||
}
|
||||
} catch (_error) { /* Page preference remains authoritative on next launch. */ }
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
|
||||
await issueSync.resume();
|
||||
await flushAndNotify();
|
||||
@@ -472,6 +648,7 @@ self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
await todayActionStore.purge();
|
||||
await deletePrivateDatabases();
|
||||
await updateTodayLockScreen(false, false);
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
@@ -480,12 +657,33 @@ self.addEventListener('message', event => {
|
||||
}
|
||||
})());
|
||||
if (event.data?.type === 'stackchain-today-lock-screen') {
|
||||
event.waitUntil(updateTodayLockScreen(
|
||||
event.data.active === true,
|
||||
event.data.running === true,
|
||||
String(event.data.actionToken || ''),
|
||||
Number(event.data.breakDeadlineAt || 0)
|
||||
));
|
||||
event.waitUntil((async () => {
|
||||
const active = event.data.active === true;
|
||||
if (!active) await todayActionStore.purge();
|
||||
await updateTodayLockScreen(
|
||||
active,
|
||||
event.data.running === true,
|
||||
String(event.data.actionToken || ''),
|
||||
Number(event.data.breakDeadlineAt || 0)
|
||||
);
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-claim-today-action') {
|
||||
event.waitUntil((async () => {
|
||||
const source = event.source;
|
||||
if (!source?.id || !String(source.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
let command;
|
||||
try { command = await todayActionStore.claim(source.id, Date.now()); }
|
||||
catch (_error) { return; }
|
||||
if (!command || !['pause', 'resume', 'complete'].includes(command.action)) return;
|
||||
if (command.actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(command.actionToken)) return;
|
||||
if (command.action === 'complete' && !command.actionToken) return;
|
||||
source.postMessage?.({
|
||||
type:'stackchain-today-timer-action',
|
||||
action:command.action,
|
||||
...(command.actionToken ? {actionToken:command.actionToken} : {}),
|
||||
});
|
||||
})());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -498,7 +696,23 @@ self.addEventListener('push', event => {
|
||||
const tag = String(payload.tag || '');
|
||||
const notificationId = Number(payload.notification_id);
|
||||
const updateCount = Number(payload.update_count);
|
||||
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
const followingCount = Number(payload.following_count);
|
||||
const planDate = String(payload.plan_date || '');
|
||||
if (
|
||||
route === '#/my-work/start-day'
|
||||
&& /^\d{4}-\d{2}-\d{2}$/.test(planDate)
|
||||
&& tag === 'stackchain-start-day-' + planDate
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification('Your planned day is ready', {
|
||||
body: 'Open Stackchain to prepare Today.',
|
||||
tag,
|
||||
actions: [{ action: 'prepare-today', title: 'Prepare Today' }],
|
||||
data: {route},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/agenda'
|
||||
&& protectRoute === '#/my-work/agenda/protect-today'
|
||||
@@ -521,6 +735,24 @@ self.addEventListener('push', event => {
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/following'
|
||||
&& /^stackchain-following-[0-9a-f]{16}$/.test(tag)
|
||||
&& Number.isSafeInteger(followingCount)
|
||||
&& followingCount > 0
|
||||
&& followingCount <= 50
|
||||
) {
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge('following', followingCount),
|
||||
self.registration.showNotification(
|
||||
followingCount + ' watched item' + (followingCount === 1 ? '' : 's') + ' changed', {
|
||||
body: 'Open Following to review the latest activity.',
|
||||
tag,
|
||||
data: {route},
|
||||
}),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/updates'
|
||||
&& tag === 'stackchain-update-digest'
|
||||
@@ -528,11 +760,12 @@ self.addEventListener('push', event => {
|
||||
&& updateCount > 0
|
||||
&& updateCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
}));
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge('updates', unreadCount),
|
||||
self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.', tag, data: {route},
|
||||
}),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
|
||||
@@ -552,7 +785,10 @@ self.addEventListener('push', event => {
|
||||
];
|
||||
options.data.notificationId = notificationId;
|
||||
}
|
||||
event.waitUntil(self.registration.showNotification('New work update', options));
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge('updates', unreadCount),
|
||||
self.registration.showNotification('New work update', options),
|
||||
]));
|
||||
});
|
||||
|
||||
async function openWorkRoute(route) {
|
||||
@@ -593,10 +829,17 @@ async function applyTodayTimerAction(action, actionToken = '') {
|
||||
});
|
||||
return client.focus?.();
|
||||
}
|
||||
const query = '?today_timer_action=' + action +
|
||||
(actionToken ? '&today_action_token=' + encodeURIComponent(actionToken) : '');
|
||||
const target = new URL(BASE + query + route, self.location.origin).href;
|
||||
return self.clients.openWindow(target);
|
||||
const target = new URL(BASE + route, self.location.origin).href;
|
||||
const opened = await self.clients.openWindow(target);
|
||||
if (!opened?.id) return opened;
|
||||
try {
|
||||
await todayActionStore.put(opened.id, {
|
||||
action,
|
||||
...(actionToken ? {actionToken} : {}),
|
||||
expiresAt:Date.now() + TODAY_ACTION_TTL_MS,
|
||||
});
|
||||
} catch (_error) { /* The clean Today route remains safe; fail the action closed. */ }
|
||||
return opened;
|
||||
}
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null,
|
||||
completeActivation = () => globalThis.stackchainFirstTaskCompleting?.(),
|
||||
now = Date.now, ttlMs = 10000, onOffer = null, onClear = null,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout,
|
||||
undo = [globalThis.document?.getElementById('today-completion-undo'),
|
||||
@@ -23,7 +24,18 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war
|
||||
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
|
||||
return false;
|
||||
}
|
||||
todaySync.enqueue('remove', todayWork.identity(item));
|
||||
const operations = [
|
||||
{action:'remove', item_id:identity},
|
||||
...(completeActivation() ? [{action:'activate', item_id:'first-task', activation_state:'complete'}] : []),
|
||||
];
|
||||
const admitted = todaySync.enqueueBatch ? todaySync.enqueueBatch(operations) :
|
||||
todaySync.enqueue('remove', identity);
|
||||
if (!admitted) {
|
||||
todayWork.restore?.(snapshot);
|
||||
announce(options.admissionFailureMessage ||
|
||||
'Device storage is full. Free space and try again.');
|
||||
return false;
|
||||
}
|
||||
todaySync.flush();
|
||||
refresh();
|
||||
warm();
|
||||
|
||||
@@ -149,16 +149,9 @@ function createTodayLockScreen({
|
||||
},
|
||||
consumeAction,
|
||||
async consumeLaunchAction() {
|
||||
let url;
|
||||
try { url = new URL(locationRef?.href || ''); }
|
||||
catch (_error) { return false; }
|
||||
const action = url.searchParams.get('today_timer_action');
|
||||
if (!['pause', 'resume', 'complete'].includes(action)) return false;
|
||||
const token = url.searchParams.get('today_action_token') || '';
|
||||
url.searchParams.delete('today_timer_action');
|
||||
url.searchParams.delete('today_action_token');
|
||||
historyRef?.replaceState?.(null, '', url.pathname + url.search + url.hash);
|
||||
return await consumeAction(action, token);
|
||||
if (!enabled()) return false;
|
||||
await post({type:'stackchain-claim-today-action'});
|
||||
return true;
|
||||
},
|
||||
render,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,13 @@ function createTodayRollover(options = {}) {
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
|
||||
|
||||
function timeZone() {
|
||||
return options.timeZone?.() || resolvedTimeZone();
|
||||
const candidate = options.timeZone?.() || resolvedTimeZone();
|
||||
try {
|
||||
new Intl.DateTimeFormat('en', { timeZone: candidate }).format(now());
|
||||
return candidate;
|
||||
} catch (_error) {
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
function localDate() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
function createTodaySessionSync({
|
||||
fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {},
|
||||
onOwnedRestore = () => {},
|
||||
setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval,
|
||||
}) {
|
||||
let current = null;
|
||||
@@ -25,6 +26,8 @@ function createTodaySessionSync({
|
||||
current = session;
|
||||
if (session.device_id === deviceId()) {
|
||||
ownedRevision = session.revision;
|
||||
if (Number.isFinite(session.break_deadline_at) &&
|
||||
timer?.restoreBreak?.(session.identity, session.break_deadline_at)) onOwnedRestore(session);
|
||||
onRemote(null);
|
||||
} else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) {
|
||||
if (previousOwned) {
|
||||
@@ -60,7 +63,7 @@ function createTodaySessionSync({
|
||||
onStatus('syncing');
|
||||
const body = {
|
||||
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
|
||||
elapsed_ms:current.elapsed_ms, running:true,
|
||||
elapsed_ms:current.elapsed_ms, entries:current.entries, running:true,
|
||||
};
|
||||
if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null;
|
||||
const session = await fetchJson(endpoint, {
|
||||
@@ -68,7 +71,7 @@ function createTodaySessionSync({
|
||||
body:JSON.stringify(body),
|
||||
});
|
||||
adopt(session);
|
||||
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
|
||||
timer?.adopt?.(session.identity, session.elapsed_ms, session.running, session.entries);
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error?.status === 409 || error?.code === 'session_changed') {
|
||||
@@ -89,6 +92,7 @@ function createTodaySessionSync({
|
||||
const body = {
|
||||
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
|
||||
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
|
||||
entries:Array.isArray(snapshot.entries) ? snapshot.entries : undefined,
|
||||
running:Boolean(snapshot.running),
|
||||
};
|
||||
if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) {
|
||||
@@ -112,7 +116,7 @@ function createTodaySessionSync({
|
||||
}
|
||||
}
|
||||
|
||||
function publish(snapshot = timer?.snapshot?.()) {
|
||||
function publish(snapshot = timer?.sessionSnapshot?.() || timer?.snapshot?.()) {
|
||||
snapshot = snapshot?.identity ? snapshot : latestSnapshot;
|
||||
if (!snapshot?.identity || !deviceId()) return Promise.resolve(null);
|
||||
latestSnapshot = snapshot;
|
||||
@@ -163,6 +167,10 @@ function todaySessionHandoffSummary(session, item) {
|
||||
return `On break until ${end} · ready to resume here`;
|
||||
}
|
||||
const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000));
|
||||
if (Array.isArray(session?.entries) && session.entries.length > 1) {
|
||||
const total = session.entries.reduce((sum, entry) => sum + Math.max(0, Number(entry?.elapsed_ms) || 0), 0);
|
||||
return `${session.entries.length} tracked items · ${Math.floor(total / 60000)} min total`;
|
||||
}
|
||||
return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
|
||||
}
|
||||
|
||||
@@ -186,6 +194,7 @@ function attachTodaySessionHandoff({
|
||||
};
|
||||
const sync = createTodaySessionSync({
|
||||
fetchJson, getDeviceId, timer,
|
||||
onOwnedRestore:renderTimer,
|
||||
onStatus:state => showSessionStatus(
|
||||
state === 'syncing' ? 'Session syncing…' :
|
||||
(state === 'offline' ? 'Session offline · will retry.' : '')
|
||||
|
||||
+65
-22
@@ -61,12 +61,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
};
|
||||
if (['coaching', 'complete'].includes(plan.first_task_state)) {
|
||||
snapshot.first_task_state = plan.first_task_state;
|
||||
}
|
||||
if (plan.plan_date) {
|
||||
snapshot.plan_date = plan.plan_date;
|
||||
snapshot.timezone = plan.timezone || null;
|
||||
}
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
const activationKey = login && 'stackchain.first-task.v1:' + login;
|
||||
const current = activationKey && storage?.getItem(activationKey);
|
||||
const rank = {'': 0, coaching: 1, complete: 2};
|
||||
if (activationKey && rank[snapshot.first_task_state] > (rank[current] || 0)) {
|
||||
storage?.setItem(activationKey, snapshot.first_task_state);
|
||||
}
|
||||
} catch (_error) {
|
||||
// A storage quota failure must not prevent the current tab from using server truth.
|
||||
}
|
||||
@@ -121,11 +131,14 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
discardedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const operation = record?.operation;
|
||||
const valid = operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) &&
|
||||
typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at));
|
||||
if (valid) records.push({ ...record, recordKey });
|
||||
const operations = Array.isArray(record?.operations) ? record.operations : [record?.operation];
|
||||
const valid = operations.length && operations.every(operation => operation &&
|
||||
typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) &&
|
||||
typeof operation.item_id === 'string') && Number.isFinite(Number(record.queued_at));
|
||||
if (valid) operations.forEach((operation, index) => {
|
||||
records.push({operation, queued_at:Number(record.queued_at) + index / 1000, recordKey});
|
||||
});
|
||||
else {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
@@ -151,7 +164,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
? record.operation.base_revision : Math.max(0, savedRevision()),
|
||||
}))
|
||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
@@ -176,7 +189,25 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, direction = null) {
|
||||
function saveOperations(operations, queuedAt = now()) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const batched = operations.length > 1;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operations[0].operation_id);
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify(batched ?
|
||||
{operations, queued_at:queuedAt} : {operation:operations[0], queued_at:queuedAt}));
|
||||
knownOperationKeys.add(recordKey);
|
||||
coordinator?.notify('today');
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, direction = null, fields = {}) {
|
||||
const operations = pending();
|
||||
if (action === 'remove' && operations.some(operation =>
|
||||
operation.action === 'remove' && operation.item_id === itemId
|
||||
@@ -186,20 +217,17 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
}
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, direction,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
base_revision: Math.max(0, savedRevision()), ...fields,
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() + operations.length }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('today');
|
||||
} catch (_error) { /* Report the persistence failure below. */ }
|
||||
onStatus?.(saved ? 'pending' : 'error');
|
||||
return saved;
|
||||
return saveOperations([operation], now() + operations.length);
|
||||
}
|
||||
|
||||
function enqueueBatch(specifications) {
|
||||
const operations = specifications.map(specification => ({
|
||||
operation_id: operationId(), direction: specification.direction ?? null,
|
||||
base_revision: Math.max(0, savedRevision()), ...specification,
|
||||
}));
|
||||
return saveOperations(operations);
|
||||
}
|
||||
|
||||
function enqueueConfiguration(capacityMinutes, estimates) {
|
||||
@@ -223,6 +251,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueActivation(state) {
|
||||
if (!['coaching', 'complete'].includes(state)) return false;
|
||||
const rank = {coaching: 1, complete: 2};
|
||||
const queued = pending().filter(operation => operation.action === 'activate');
|
||||
if (queued.some(operation => rank[operation.activation_state] >= rank[state])) return true;
|
||||
queued.forEach(operation => removeOperation(operation.operation_id));
|
||||
return enqueue('activate', 'first-task', null, {activation_state: state});
|
||||
}
|
||||
|
||||
function enqueueRollover(proposed) {
|
||||
if (!proposed || proposed.action !== 'rollover') return false;
|
||||
const operation = {
|
||||
@@ -266,7 +303,10 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
let operations = pending();
|
||||
let plan;
|
||||
let hadConflict = false;
|
||||
if (!operations.length) plan = await fetchJson('api/v1/today');
|
||||
if (!operations.length) {
|
||||
plan = await fetchJson('api/v1/today');
|
||||
operations = pending();
|
||||
}
|
||||
while (operations.length) {
|
||||
if (key() !== ownerKey) return false;
|
||||
const batch = operations.slice(0, 50);
|
||||
@@ -323,6 +363,9 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
|
||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||
windowObject?.addEventListener?.('online', flush);
|
||||
windowObject?.addEventListener?.('stackchain:first-task-complete', () => {
|
||||
if (enqueueActivation('complete')) flush();
|
||||
});
|
||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||
documentObject.hidden ? false : flush()
|
||||
);
|
||||
@@ -332,7 +375,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
return { enqueue, enqueueBatch, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
+42
-10
@@ -75,8 +75,9 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
entry.running = false;
|
||||
return state;
|
||||
};
|
||||
const snapshot = (identity = '') => {
|
||||
const snapshot = (identity = '', includeEntries = false) => {
|
||||
const state = read();
|
||||
const sampledAt = now();
|
||||
const selected = identity || state.active_identity;
|
||||
const entry = state.entries[selected];
|
||||
const result = !selected || !entry ?
|
||||
@@ -84,31 +85,48 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
{
|
||||
identity:selected,
|
||||
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
|
||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0),
|
||||
Math.max(0, sampledAt - Number(entry.started_at ?? sampledAt)) : 0),
|
||||
running:Boolean(entry.running),
|
||||
};
|
||||
const timedBreak = validBreak(state);
|
||||
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
|
||||
if (includeEntries) {
|
||||
result.entries = Object.entries(state.entries).slice(0, 20).map(([entryIdentity, value]) => ({
|
||||
identity:entryIdentity,
|
||||
elapsed_ms:Math.max(0, Math.floor(Number(value.elapsed_ms) || 0) + (value.running ?
|
||||
Math.max(0, sampledAt - Number(value.started_at ?? sampledAt)) : 0)),
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
adopt(identity, elapsedMs, running) {
|
||||
adopt(identity, elapsedMs, running, entries = null) {
|
||||
if (!key() || typeof identity !== 'string' || !identity ||
|
||||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
|
||||
const incoming = entries === null ? [{identity, elapsed_ms:elapsedMs}] : entries;
|
||||
if (!Array.isArray(incoming) || incoming.length > 20) return false;
|
||||
const seen = new Set();
|
||||
const ledger = {};
|
||||
for (const entry of incoming) {
|
||||
if (typeof entry?.identity !== 'string' || !entry.identity || entry.identity.length > 500 ||
|
||||
seen.has(entry.identity) || !Number.isFinite(Number(entry.elapsed_ms)) || Number(entry.elapsed_ms) < 0) return false;
|
||||
seen.add(entry.identity);
|
||||
ledger[entry.identity] = {
|
||||
elapsed_ms:Math.floor(Number(entry.elapsed_ms)), started_at:null, running:false,
|
||||
};
|
||||
}
|
||||
if (!ledger[identity] || ledger[identity].elapsed_ms !== Math.floor(Number(elapsedMs))) return false;
|
||||
const state = read();
|
||||
settle(state);
|
||||
state.active_identity = identity;
|
||||
state.entries[identity] = {
|
||||
elapsed_ms:Math.floor(Number(elapsedMs)),
|
||||
started_at:running ? now() : null,
|
||||
running:Boolean(running),
|
||||
};
|
||||
state.entries = ledger;
|
||||
state.entries[identity].started_at = running ? now() : null;
|
||||
state.entries[identity].running = Boolean(running);
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
state.search_interruption = null;
|
||||
state.detour_interruption = null;
|
||||
state.timed_break = null;
|
||||
if(running || validBreak(state)?.identity !== identity) state.timed_break=null;
|
||||
return write(state);
|
||||
},
|
||||
activate(identity) {
|
||||
@@ -159,6 +177,15 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
state.timed_break = { identity, deadline_at:now() + duration * 60000 };
|
||||
return write(state) ? validBreak(state) : false;
|
||||
},
|
||||
restoreBreak(identity, deadlineAt) {
|
||||
const deadline = Number(deadlineAt);
|
||||
if (typeof identity !== 'string' || !identity || !Number.isFinite(deadline) || deadline <= now()) return false;
|
||||
const state = read();
|
||||
const entry = state.entries[identity];
|
||||
if (state.active_identity !== identity || !entry || entry.running || validBreak(state)) return false;
|
||||
state.timed_break = { identity, deadline_at:Math.floor(deadline) };
|
||||
return write(state);
|
||||
},
|
||||
breakSnapshot() {
|
||||
return validBreak(read());
|
||||
},
|
||||
@@ -388,6 +415,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
}, 0);
|
||||
},
|
||||
snapshot,
|
||||
sessionSnapshot:(identity = '') => snapshot(identity, true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -542,6 +570,10 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||
},
|
||||
finishCapture() { return captureView.finish(); },
|
||||
transferCapture() { return captureView.transfer(); },
|
||||
restore(recovery) {
|
||||
render();
|
||||
return Promise.resolve(recovery).catch(() => false).then(() => { render(); return !!timer.breakSnapshot?.(); });
|
||||
},
|
||||
render,
|
||||
};
|
||||
queryAll('#new-issue').forEach(button => button.addEventListener('click', () => {
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
function createTodayWeekReschedule({
|
||||
week,
|
||||
api,
|
||||
getToday,
|
||||
adoptToday = () => {},
|
||||
operationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()),
|
||||
storage = null,
|
||||
getLogin = () => '',
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
let current = null;
|
||||
let flushing = null;
|
||||
const storagePrefix = 'stackchain.today-week-reschedule.v1.';
|
||||
|
||||
function storageKey() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? storagePrefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function validRecord(value) {
|
||||
return typeof value?.body?.operation_id === 'string' &&
|
||||
Array.isArray(value?.today?.ids) && Array.isArray(value?.week?.days);
|
||||
}
|
||||
|
||||
function readQueue() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return [];
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (Array.isArray(value?.records)) return value.records.filter(validRecord);
|
||||
return validRecord(value) ? [value] : [];
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function saveQueue(records) {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return false;
|
||||
try {
|
||||
if (!records.length) storage.removeItem(key);
|
||||
else storage.setItem(key, JSON.stringify({version:2, records}));
|
||||
return true;
|
||||
}
|
||||
catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function persist(record) { return saveQueue([...readQueue(), record]); }
|
||||
|
||||
function pending() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return null;
|
||||
return readQueue()[0] || null;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
const records = readQueue();
|
||||
const record = records[records.length - 1];
|
||||
if (!record) return null;
|
||||
adoptToday(record.today);
|
||||
week.adopt(record.week);
|
||||
return {...record, sync_pending:true, pending_count:records.length};
|
||||
}
|
||||
|
||||
async function restoreConflict(error) {
|
||||
if (error?.status !== 409) throw error;
|
||||
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
||||
adoptToday(today);
|
||||
week.adopt(weekState);
|
||||
const conflict = new Error('Plans changed on another device. Review and retry this saved move.');
|
||||
conflict.status = 409;
|
||||
throw conflict;
|
||||
}
|
||||
|
||||
function flush(retryConflict = false) {
|
||||
if (flushing) return flushing;
|
||||
if (!pending()) return Promise.resolve(false);
|
||||
flushing = (async () => {
|
||||
let result = null;
|
||||
let conflictRetried = false;
|
||||
while (true) {
|
||||
const records = readQueue();
|
||||
const record = records[0];
|
||||
if (!record) return result ? {...result, sync_pending:false, pending_count:0} : false;
|
||||
try {
|
||||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status === 409 && retryConflict && !conflictRetried) {
|
||||
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
||||
adoptToday(today); week.adopt(weekState);
|
||||
const latest = readQueue();
|
||||
if (today?.ids?.includes(record.body.identity) &&
|
||||
latest[0]?.body?.operation_id === record.body.operation_id) {
|
||||
latest[0] = {...latest[0], body:{
|
||||
...latest[0].body,
|
||||
today_revision:today.revision,
|
||||
week_revision:weekState.revision,
|
||||
}};
|
||||
if (!saveQueue(latest)) throw new Error('Could not update this saved move after plans changed.');
|
||||
conflictRetried = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await restoreConflict(error);
|
||||
}
|
||||
adoptToday(result.today);
|
||||
week.adopt(result.week);
|
||||
const latest = readQueue();
|
||||
if (latest[0]?.body?.operation_id !== record.body.operation_id) continue;
|
||||
const remaining = latest.slice(1).map((entry, index) => index ? entry : ({
|
||||
...entry,
|
||||
body:{...entry.body, today_revision:result.today.revision, week_revision:result.week.revision},
|
||||
}));
|
||||
if (!saveQueue(remaining)) throw new Error('Move synced, but its local queue could not be updated.');
|
||||
}
|
||||
})().finally(() => { flushing = null; });
|
||||
return flushing;
|
||||
}
|
||||
|
||||
function optimisticSnapshots(planDate, estimate) {
|
||||
const today = {
|
||||
...current.today,
|
||||
ids: current.today.ids.filter(id => id !== current.identity),
|
||||
estimates: {...(current.today.estimates || {})},
|
||||
};
|
||||
delete today.estimates[current.identity];
|
||||
const days = (current.week.days || []).map(day => {
|
||||
const estimates = {...(day.estimates || {})}; delete estimates[current.identity];
|
||||
return {...day, ids:(day.ids || []).filter(id => id !== current.identity), estimates};
|
||||
});
|
||||
let destination = days.find(day => day.plan_date === planDate);
|
||||
if (!destination) {
|
||||
destination = {plan_date:planDate, ids:[], capacity_minutes:null, estimates:{}};
|
||||
days.push(destination);
|
||||
}
|
||||
destination.ids.push(current.identity);
|
||||
destination.estimates[current.identity] = estimate;
|
||||
return {today, week:{revision:current.week_revision, timezone:current.week.timezone || null, days}};
|
||||
}
|
||||
|
||||
async function open(identity) {
|
||||
let today;
|
||||
const queued = readQueue().length;
|
||||
let offline = Boolean(queued);
|
||||
if (queued) {
|
||||
today = getToday?.();
|
||||
} else {
|
||||
try { today = await api('api/v1/today'); }
|
||||
catch (error) {
|
||||
if (error?.status === 401 || error?.status === 403) throw error;
|
||||
today = getToday?.(); offline = true;
|
||||
}
|
||||
}
|
||||
if (!identity || !today?.ids?.includes(identity)) {
|
||||
throw new Error('The active Today item changed. Reopen rescheduling.');
|
||||
}
|
||||
const loaded = queued ? week.state?.() : await week.load();
|
||||
if (!loaded) throw new Error('Reload Today before rescheduling another saved move.');
|
||||
if (loaded?.sync_pending) {
|
||||
throw new Error('Reconnect before rescheduling Today into Week Ahead.');
|
||||
}
|
||||
offline = offline || Boolean(loaded?.offline_snapshot);
|
||||
const estimate = Number(today.estimates?.[identity]);
|
||||
const estimateMinutes = Number.isFinite(estimate) && estimate > 0 ? estimate : null;
|
||||
const days = week.review().days.map(day => {
|
||||
const ids = (day.ids || []).filter(id => id !== identity);
|
||||
const planned = Number(day.planned_minutes) || 0;
|
||||
const existing = Number(day.estimates?.[identity]) || 0;
|
||||
return {
|
||||
...day,
|
||||
ids,
|
||||
planned_minutes: Math.max(0, planned - existing),
|
||||
load: `${Math.max(0, planned - existing)} / ${Number(day.capacity_minutes) || 0} min`,
|
||||
eligible: ids.length < 5,
|
||||
};
|
||||
});
|
||||
current = {
|
||||
identity,
|
||||
today_revision: today.revision,
|
||||
week_revision: loaded.revision,
|
||||
operation_id: operationId(),
|
||||
estimate_minutes: estimateMinutes,
|
||||
days,
|
||||
today,
|
||||
week: loaded,
|
||||
offline,
|
||||
};
|
||||
return {...current, days:days.map(day => ({...day, ids:[...day.ids]}))};
|
||||
}
|
||||
|
||||
async function confirm(planDate, estimateMinutes, {allowOverload = false} = {}) {
|
||||
if (!current) throw new Error('Open rescheduling before choosing a day.');
|
||||
const day = current.days.find(value => value.plan_date === planDate);
|
||||
if (!day) throw new Error('Choose one of the next seven days.');
|
||||
const estimate = Number(estimateMinutes);
|
||||
if (!Number.isFinite(estimate) || estimate < 5 || estimate > 1440) {
|
||||
throw new Error('Estimate must be between 5 and 1440 minutes.');
|
||||
}
|
||||
if (!day.eligible) throw new Error('That Week Ahead day already has five items.');
|
||||
const capacity = Number(day.capacity_minutes) || 0;
|
||||
if (capacity && day.planned_minutes + estimate > capacity && !allowOverload) {
|
||||
throw new Error("That move exceeds the day's capacity. Confirm overload before rescheduling.");
|
||||
}
|
||||
const body = {
|
||||
operation_id:current.operation_id,
|
||||
identity:current.identity,
|
||||
estimate_minutes:estimate,
|
||||
plan_date:planDate,
|
||||
today_revision:current.today_revision,
|
||||
week_revision:current.week_revision,
|
||||
allow_over_capacity:allowOverload,
|
||||
};
|
||||
const optimistic = optimisticSnapshots(planDate, estimate);
|
||||
const durable = Boolean(storageKey() && storage);
|
||||
const admitted = durable && persist({body, ...optimistic, queued_at:now()});
|
||||
if (durable && !admitted) {
|
||||
throw new Error('Could not save this move on this device. Nothing changed.');
|
||||
}
|
||||
if (admitted) {
|
||||
adoptToday(optimistic.today);
|
||||
week.adopt(optimistic.week);
|
||||
}
|
||||
if (current.offline) {
|
||||
current = null;
|
||||
return {...optimistic, sync_pending:true, pending_count:readQueue().length};
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
if (admitted) {
|
||||
result = await flush(true);
|
||||
} else {
|
||||
try {
|
||||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 409) throw error;
|
||||
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
||||
adoptToday(today); week.adopt(weekState);
|
||||
if (!today?.ids?.includes(body.identity)) throw error;
|
||||
body.today_revision = today.revision; body.week_revision = weekState.revision;
|
||||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch(error) {
|
||||
if (admitted && error?.status === 409) await restoreConflict(error);
|
||||
if (admitted && error?.status !== 401 && error?.status !== 403) {
|
||||
current = null;
|
||||
return {...optimistic, sync_pending:true};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
adoptToday(result.today);
|
||||
week.adopt(result.week);
|
||||
current = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
function cancel() { current = null; }
|
||||
return {open, confirm, cancel, state:() => current, pending, resume, flush};
|
||||
}
|
||||
|
||||
function mountTodayWeekReschedule({
|
||||
qs, document=globalThis.document, window=globalThis.window, week, api, getToday, adoptToday, currentTarget, closeActions,
|
||||
refresh, warm, continueToday, announce, storage=null, getLogin=()=>'', schedule=callback=>requestAnimationFrame(callback),
|
||||
}={}) {
|
||||
const dialog=qs('#today-week-reschedule'),daysRoot=qs('#today-week-reschedule-days');
|
||||
const estimate=qs('#today-week-reschedule-estimate'),status=qs('#today-week-reschedule-status');
|
||||
const confirm=qs('#confirm-today-week-reschedule'),launcher=qs('[data-work-session-reschedule-week]');
|
||||
let selectedDate=null,allowOverload=false;
|
||||
const controller=createTodayWeekReschedule({week,api,getToday,adoptToday,storage,getLogin});
|
||||
async function flushPending() {
|
||||
if(!controller.pending())return false;
|
||||
try {
|
||||
const result=await controller.flush();
|
||||
announce('Today-to-Week move synced to your account.');
|
||||
await refresh();warm();
|
||||
return result;
|
||||
} catch(error) {
|
||||
announce(`${error.message||'Sync unavailable.'} Move saved on this device · sync pending.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function resumePending() {
|
||||
const restored=controller.resume();
|
||||
if(!restored)return false;
|
||||
announce('Today-to-Week move saved on this device · sync pending.');
|
||||
flushPending();
|
||||
return restored;
|
||||
}
|
||||
window?.addEventListener('online', flushPending);
|
||||
document?.addEventListener('visibilitychange',()=>document.hidden?false:flushPending());
|
||||
let restoreAttempts=0;
|
||||
function restoreWhenOwned(){
|
||||
if(resumePending()||getLogin()||restoreAttempts++>=20)return;
|
||||
window?.setTimeout(restoreWhenOwned,250);
|
||||
}
|
||||
restoreWhenOwned();
|
||||
function close() {
|
||||
controller.cancel();selectedDate=null;allowOverload=false;
|
||||
if(dialog.open)dialog.close();schedule(()=>{
|
||||
qs('[data-mobile-today-more]').click();launcher.focus();
|
||||
});
|
||||
}
|
||||
function render(opened) {
|
||||
daysRoot.replaceChildren(...opened.days.map(day=>{
|
||||
const button=document.createElement('button');
|
||||
button.type='button';button.dataset.planDate=day.plan_date;button.setAttribute('role','radio');
|
||||
button.setAttribute('aria-checked','false');button.disabled=!day.eligible;
|
||||
const label=document.createElement('strong');label.textContent=day.label;
|
||||
const load=document.createElement('span');load.className='small';
|
||||
load.textContent=day.eligible?day.load:'5 items · full';button.append(label,load);
|
||||
button.addEventListener('click',()=>{
|
||||
selectedDate=day.plan_date;allowOverload=false;
|
||||
daysRoot.querySelectorAll('button').forEach(choice=>choice.setAttribute(
|
||||
'aria-checked',String(choice===button)
|
||||
));
|
||||
status.textContent='';confirm.disabled=false;confirm.textContent='Move to Week Ahead & continue';
|
||||
});
|
||||
return button;
|
||||
}));
|
||||
}
|
||||
launcher.addEventListener('click',async()=>{
|
||||
const target=currentTarget();
|
||||
if(!target){announce('Start or resume a checkpointed Today item before rescheduling.');return;}
|
||||
closeActions();status.textContent='Loading Week Ahead…';confirm.disabled=true;dialog.showModal();
|
||||
try{
|
||||
const opened=await controller.open(target.identity);render(opened);
|
||||
estimate.value=opened.estimate_minutes||'';
|
||||
status.textContent=opened.estimate_minutes?'Choose a future day.':'Add an estimate, then choose a future day.';
|
||||
schedule(()=>daysRoot.querySelector('button:not(:disabled)')?.focus());
|
||||
}catch(error){status.textContent=error.message;}
|
||||
});
|
||||
qs('#cancel-today-week-reschedule').addEventListener('click',close);
|
||||
dialog.addEventListener('cancel',event=>{event.preventDefault();close();});
|
||||
confirm.addEventListener('click',async()=>{
|
||||
if(!selectedDate)return;
|
||||
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';
|
||||
try{
|
||||
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
||||
dialog.close();
|
||||
if(result.sync_pending){
|
||||
announce('Moved locally. Saved on this device · sync pending.');warm();
|
||||
}else{
|
||||
await refresh();warm();announce('Moved to Week Ahead. Continuing Today.');
|
||||
}
|
||||
await continueToday();
|
||||
}catch(error){
|
||||
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
||||
status.textContent=error.message;
|
||||
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
|
||||
confirm.disabled=false;
|
||||
}
|
||||
});
|
||||
return {controller,close,flushPending,resumePending};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createTodayWeekReschedule;
|
||||
module.exports.mount = mountTodayWeekReschedule;
|
||||
}
|
||||
+42
-12
@@ -1,4 +1,4 @@
|
||||
function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
function createTodayWrapUp({ todayWork, tomorrowPlan, todaySync, limit = 5 }) {
|
||||
let items = [];
|
||||
const selected = new Set();
|
||||
|
||||
@@ -28,16 +28,39 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
const wake = laterWork.presetUntil('tomorrow');
|
||||
let scheduled = 0;
|
||||
for (const item of items) {
|
||||
const loaded = await tomorrowPlan.load();
|
||||
const currentIds = Array.isArray(loaded?.ids) ? [...loaded.ids] : [];
|
||||
const carried = items.filter(item => selected.has(todayWork.identity(item)) && todayWork.contains(item));
|
||||
if (!carried.length) {
|
||||
return {
|
||||
scheduled:0,
|
||||
left:items.filter(item => todayWork.contains(item)).length,
|
||||
plan_count:currentIds.length,
|
||||
plan_date:loaded?.plan_date,
|
||||
sync_pending:Boolean(loaded?.sync_pending),
|
||||
};
|
||||
}
|
||||
const combinedIds = [...currentIds];
|
||||
for (const item of carried) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (!combinedIds.includes(identity)) combinedIds.push(identity);
|
||||
}
|
||||
if (combinedIds.length > limit) {
|
||||
throw new Error(`Tomorrow can hold ${limit} items. Keep ${combinedIds.length - limit} in Today or edit Tomorrow first.`);
|
||||
}
|
||||
const estimates = {...(loaded?.estimates || {})};
|
||||
const capacityMinutes = loaded?.capacity_minutes ?? null;
|
||||
const plannedMinutes = combinedIds.reduce((total, identity) => total + (Number(estimates[identity]) || 0), 0);
|
||||
if (capacityMinutes !== null && plannedMinutes > capacityMinutes) {
|
||||
throw new Error('Tomorrow is over capacity. Edit the plan before finishing wrap-up.');
|
||||
}
|
||||
const staged = tomorrowPlan.stage({ids:combinedIds, capacity_minutes:capacityMinutes, estimates});
|
||||
if (!staged) throw new Error('Tomorrow could not be saved on this device. Your Today plan is unchanged.');
|
||||
let scheduled = 0;
|
||||
for (const item of carried) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (!selected.has(identity) || !todayWork.contains(item)) continue;
|
||||
const deferred = laterWork.defer(item, wake, {handoff:'today'});
|
||||
if (deferred !== 'deferred') throw new Error('Tomorrow could not be saved. Your Today plan is unchanged.');
|
||||
if (!todaySync.enqueue('remove', identity)) {
|
||||
laterWork.restore?.(item);
|
||||
throw new Error('Today sync could not be queued. Your Today plan is unchanged.');
|
||||
throw new Error('Today sync could not be queued. The item remains in Today.');
|
||||
}
|
||||
if (todayWork.contains(item) && !todayWork.remove(item)) {
|
||||
throw new Error('Today could not be updated. Retry wrap-up.');
|
||||
@@ -45,7 +68,13 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
scheduled += 1;
|
||||
}
|
||||
await todaySync.flush();
|
||||
return { scheduled, left:items.length - scheduled, wake_at:wake.toISOString() };
|
||||
return {
|
||||
scheduled,
|
||||
left:items.length - scheduled,
|
||||
plan_count:combinedIds.length,
|
||||
plan_date:staged.plan_date,
|
||||
sync_pending:Boolean(staged.sync_pending),
|
||||
};
|
||||
}
|
||||
|
||||
return { open, choose, snapshot, finish };
|
||||
@@ -69,7 +98,7 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
|
||||
return '<div class="today-wrap-up-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
||||
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
||||
'data-wrap-up-identity="' + escapeHtml(row.identity) + '"' + (row.schedule ? ' checked' : '') +
|
||||
'> Tomorrow 09:00</label></div>';
|
||||
'> Carry to Tomorrow</label></div>';
|
||||
}).join('') || '<p class="small">Nothing unfinished remains in Today.</p>';
|
||||
qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => {
|
||||
input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked));
|
||||
@@ -97,7 +126,8 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
|
||||
context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180),
|
||||
}));
|
||||
const result = await controller.finish();
|
||||
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
|
||||
qs('#my-work-action-status').textContent = result.scheduled + ' carried to Tomorrow · ' + result.left +
|
||||
' left in Today' + (result.sync_pending ? ' · sync pending.' : '.');
|
||||
close();
|
||||
onComplete(result, actualMinutes, workedItems, tomorrowItems);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
function createTomorrowPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||
let plan={revision:0,ids:[],capacity_minutes:null,estimates:{}};
|
||||
let flushing=null;
|
||||
let lastConflict=null;
|
||||
const storagePrefix='stackchain.tomorrow-sync.v1.';
|
||||
const state=()=>({...plan,ids:[...plan.ids],estimates:{...plan.estimates}});
|
||||
function storageKey() {
|
||||
const login=String(getLogin?.()||'').trim().toLowerCase();
|
||||
return login?storagePrefix+encodeURIComponent(login):'';
|
||||
}
|
||||
function pending() {
|
||||
const key=storageKey();
|
||||
if(!key||!storage) return false;
|
||||
try {
|
||||
const value=JSON.parse(storage.getItem(key)||'null');
|
||||
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.ids)?
|
||||
{...value,ids:[...value.ids],estimates:{...(value.estimates||{})},sync_pending:true}:false;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
function adopt(value) {
|
||||
if (!Number.isInteger(value?.revision)||!Array.isArray(value?.ids)) return false;
|
||||
plan={revision:value.revision,ids:[...value.ids],capacity_minutes:value.capacity_minutes??null,
|
||||
estimates:{...(value.estimates||{})},...(value.plan_date?{plan_date:value.plan_date,timezone:value.timezone||null}:{})};
|
||||
return state();
|
||||
}
|
||||
function nextLocalDate() {
|
||||
const [y,m,d]=localDate().split('-').map(Number);
|
||||
return new Date(Date.UTC(y,m-1,d+1)).toISOString().slice(0,10);
|
||||
}
|
||||
async function load(){
|
||||
const queued=pending();
|
||||
return queued?(plan={...queued},state()):adopt(await fetchJson('api/v1/tomorrow'));
|
||||
}
|
||||
function stage(value) {
|
||||
const key=storageKey();
|
||||
if(!key||!storage) return false;
|
||||
const queued={base_revision:plan.revision,revision:plan.revision,ids:[...(value.ids||[])],
|
||||
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
|
||||
plan_date:nextLocalDate(),timezone:timeZone(),sync_pending:true};
|
||||
try { storage.setItem(key,JSON.stringify(queued)); }
|
||||
catch (_error) { return false; }
|
||||
lastConflict=null;
|
||||
plan=queued;
|
||||
return state();
|
||||
}
|
||||
function deliveryBody(value) {
|
||||
return {base_revision:value.base_revision,ids:[...value.ids],
|
||||
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
|
||||
plan_date:value.plan_date,timezone:value.timezone};
|
||||
}
|
||||
function flush() {
|
||||
if(flushing) return flushing;
|
||||
const queued=pending();
|
||||
const key=storageKey();
|
||||
if(!queued||!key) return Promise.resolve(false);
|
||||
const body=deliveryBody(queued);
|
||||
flushing=fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body)}).then(saved=>{
|
||||
const current=pending();
|
||||
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key);
|
||||
if(!pending()) adopt(saved);
|
||||
return saved;
|
||||
}).catch(async error=>{
|
||||
if(error?.status===409) {
|
||||
const remote=await fetchJson('api/v1/tomorrow');
|
||||
lastConflict={key,local:pending(),remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}};
|
||||
}
|
||||
throw error;
|
||||
}).finally(()=>{flushing=null;});
|
||||
return flushing;
|
||||
}
|
||||
async function keepLocal() {
|
||||
if(!lastConflict||lastConflict.key!==storageKey()) return false;
|
||||
const conflictKey=lastConflict.key;
|
||||
const local={...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}};
|
||||
const body=deliveryBody({...local,base_revision:lastConflict.remote.revision});
|
||||
try {
|
||||
const saved=await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body)});
|
||||
const current=pending();
|
||||
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
|
||||
if(!pending()) adopt(saved);
|
||||
lastConflict=null;
|
||||
return saved;
|
||||
} catch(error) {
|
||||
if(error?.status===409) {
|
||||
const remote=await fetchJson('api/v1/tomorrow');
|
||||
lastConflict={key:conflictKey,local,remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function useRemote() {
|
||||
if(!lastConflict||lastConflict.key!==storageKey()) return false;
|
||||
const remote=lastConflict.remote;
|
||||
const key=storageKey();
|
||||
if(key&&storage) storage.removeItem(key);
|
||||
const adopted=adopt(remote);
|
||||
lastConflict=null;
|
||||
return adopted;
|
||||
}
|
||||
async function save(value) {
|
||||
const body={base_revision:plan.revision,ids:[...(value.ids||[])],
|
||||
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
|
||||
plan_date:nextLocalDate(),timezone:timeZone()};
|
||||
return adopt(await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
|
||||
}
|
||||
async function promote(today_revision) {
|
||||
if (pending()||!plan.plan_date||plan.plan_date>localDate()||!plan.ids.length) return false;
|
||||
const promotion_id=`tomorrow-${plan.plan_date}-r${plan.revision}`;
|
||||
return fetchJson('api/v1/tomorrow/promote',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({promotion_id,tomorrow_revision:plan.revision,today_revision})});
|
||||
}
|
||||
function millisecondsUntilNextDay() {
|
||||
const now=Date.now();
|
||||
const zone=timeZone();
|
||||
let formatter;
|
||||
try {
|
||||
formatter=new Intl.DateTimeFormat('en-CA',{timeZone:zone,year:'numeric',month:'2-digit',day:'2-digit',
|
||||
hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'});
|
||||
} catch (_error) {
|
||||
const next=new Date(now);
|
||||
next.setHours(24,0,0,250);
|
||||
return Math.max(1,next.getTime()-now);
|
||||
}
|
||||
const zoned=value=>Object.fromEntries(formatter.formatToParts(new Date(value))
|
||||
.filter(part=>part.type!=='literal').map(part=>[part.type,Number(part.value)]));
|
||||
const current=zoned(now);
|
||||
const targetWall=Date.UTC(current.year,current.month-1,current.day+1);
|
||||
let candidate=targetWall;
|
||||
for(let attempt=0;attempt<2;attempt+=1){
|
||||
const parts=zoned(candidate);
|
||||
const offset=Date.UTC(parts.year,parts.month-1,parts.day,parts.hour,parts.minute,parts.second)-candidate;
|
||||
candidate=targetWall-offset;
|
||||
}
|
||||
return Math.max(1,candidate-now+250);
|
||||
}
|
||||
function summary(value=plan) {
|
||||
const ids=Array.isArray(value?.ids)?value.ids:[];
|
||||
const suffix=value?.sync_pending?' · sync pending':'';
|
||||
if(!ids.length) return 'Nothing planned'+suffix;
|
||||
const label=`${ids.length} planned`;
|
||||
const estimates=value.estimates||{};
|
||||
const estimated=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0);
|
||||
const capacity=Number(value.capacity_minutes)||0;
|
||||
return (estimated&&capacity?`${label} · ${estimated} of ${capacity} min`:label)+suffix;
|
||||
}
|
||||
function startLifecycle({windowObject,documentObject,check,setTimer=setTimeout,clearTimer=clearTimeout,
|
||||
nextDelay=millisecondsUntilNextDay}={}) {
|
||||
let flight=null;
|
||||
let timer=null;
|
||||
const schedule=()=>{
|
||||
if(timer!==null) clearTimer(timer);
|
||||
timer=setTimer(run,nextDelay());
|
||||
};
|
||||
function run(){
|
||||
if(!flight) flight=Promise.resolve().then(check).finally(()=>{flight=null;schedule();});
|
||||
return flight;
|
||||
}
|
||||
windowObject?.addEventListener?.('online',run);
|
||||
documentObject?.addEventListener?.('visibilitychange',()=>documentObject.hidden?false:run());
|
||||
run();
|
||||
return {run,stop(){if(timer!==null)clearTimer(timer);timer=null;}};
|
||||
}
|
||||
function conflict() {
|
||||
return lastConflict?.key===storageKey()?{local:{...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}},
|
||||
remote:{...lastConflict.remote,ids:[...lastConflict.remote.ids],estimates:{...lastConflict.remote.estimates}}}:null;
|
||||
}
|
||||
return {adopt,load,save,stage,pending,flush,conflict,keepLocal,useRemote,promote,state,summary,nextLocalDate,startLifecycle};
|
||||
}
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=createTomorrowPlan;
|
||||
@@ -0,0 +1,298 @@
|
||||
function createWeekCalendarImport() {
|
||||
const maxBytes=1024*1024;
|
||||
const zonedInstantFormatters=new Map(),zonePartFormatters=new Map(),supportedTimeZones=new Map();
|
||||
function clock(value) {
|
||||
const match=/^(\d{2}):(\d{2})$/.exec(String(value||''));
|
||||
if(!match)return null;
|
||||
const minutes=Number(match[1])*60+Number(match[2]);
|
||||
return Number(match[1])<24&&Number(match[2])<60?minutes:null;
|
||||
}
|
||||
function instant(value) {
|
||||
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(value||'');
|
||||
if(!match)return null;
|
||||
const parts=match.slice(1,7).map(Number);
|
||||
const milliseconds=match[7]?Date.UTC(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]):
|
||||
new Date(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]).getTime();
|
||||
return Number.isFinite(milliseconds)?milliseconds:null;
|
||||
}
|
||||
function zonedInstant(value,timeZone) {
|
||||
if(!timeZone)return instant(value);
|
||||
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/.exec(value||'');
|
||||
if(!match)return null;
|
||||
try {
|
||||
const wanted=match.slice(1).map(Number);
|
||||
if(!zonedInstantFormatters.has(timeZone))zonedInstantFormatters.set(timeZone,new Intl.DateTimeFormat('en-CA',{
|
||||
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23',
|
||||
}));
|
||||
const formatter=zonedInstantFormatters.get(timeZone);
|
||||
let result=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5]);
|
||||
for(let attempt=0;attempt<2;attempt+=1) {
|
||||
const shown=Object.fromEntries(formatter.formatToParts(new Date(result)).map(part=>[part.type,part.value]));
|
||||
const represented=Date.UTC(Number(shown.year),Number(shown.month)-1,Number(shown.day),Number(shown.hour),Number(shown.minute),Number(shown.second));
|
||||
result+=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5])-represented;
|
||||
}
|
||||
return result;
|
||||
} catch(error) { return null; }
|
||||
}
|
||||
function supportedTimeZone(timeZone) {
|
||||
if(!timeZone)return true;
|
||||
if(supportedTimeZones.has(timeZone))return supportedTimeZones.get(timeZone);
|
||||
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);supportedTimeZones.set(timeZone,true);return true;}
|
||||
catch(error) {supportedTimeZones.set(timeZone,false);return false;}
|
||||
}
|
||||
function zoneParts(value,timeZone) {
|
||||
const date=new Date(value);
|
||||
if(!timeZone)return {year:date.getFullYear(),month:date.getMonth()+1,day:date.getDate(),weekday:date.getDay(),
|
||||
hour:date.getHours(),minute:date.getMinutes(),second:date.getSeconds()};
|
||||
if(!zonePartFormatters.has(timeZone))zonePartFormatters.set(timeZone,new Intl.DateTimeFormat('en-US',{
|
||||
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',weekday:'short',hourCycle:'h23',
|
||||
}));
|
||||
const formatter=zonePartFormatters.get(timeZone);
|
||||
const parts=Object.fromEntries(formatter.formatToParts(date).map(part=>[part.type,part.value]));
|
||||
return {year:Number(parts.year),month:Number(parts.month),day:Number(parts.day),
|
||||
weekday:['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(parts.weekday),hour:Number(parts.hour),
|
||||
minute:Number(parts.minute),second:Number(parts.second)};
|
||||
}
|
||||
function addCalendarDays(value,days,timeZone) {
|
||||
if(!timeZone) {const date=new Date(value);date.setDate(date.getDate()+days);return date.getTime();}
|
||||
const parts=zoneParts(value,timeZone),day=new Date(Date.UTC(parts.year,parts.month-1,parts.day+days));
|
||||
const wall=[day.getUTCFullYear(),String(day.getUTCMonth()+1).padStart(2,'0'),String(day.getUTCDate()).padStart(2,'0')].join('')+
|
||||
'T'+[parts.hour,parts.minute,parts.second].map(part=>String(part).padStart(2,'0')).join('');
|
||||
return zonedInstant(wall,timeZone);
|
||||
}
|
||||
function dateInstant(value) {
|
||||
const match=/^(\d{4})(\d{2})(\d{2})$/.exec(value||'');
|
||||
return match?new Date(Number(match[1]),Number(match[2])-1,Number(match[3])).getTime():null;
|
||||
}
|
||||
function events(source) {
|
||||
if(typeof source!=='string'||!source.includes('BEGIN:VCALENDAR'))throw new Error('Choose a valid .ics calendar file.');
|
||||
if(new TextEncoder().encode(source).length>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
|
||||
const lines=source.replace(/\r\n[ \t]/g,'').split(/\r?\n/);
|
||||
const result=[];let current=null;
|
||||
lines.forEach(line=>{
|
||||
if(line==='BEGIN:VEVENT'){current={};return;}
|
||||
if(line==='END:VEVENT'){
|
||||
if((current?.unsupportedTimezone||(current?.start!=null&¤t?.end>current.start))&&
|
||||
current.status!=='CANCELLED'&¤t.transparency!=='TRANSPARENT')result.push(current);
|
||||
current=null;return;
|
||||
}
|
||||
if(!current)return;
|
||||
const separator=line.indexOf(':');if(separator<0)return;
|
||||
const property=line.slice(0,separator),parts=property.split(';'),name=parts[0],value=line.slice(separator+1);
|
||||
const parameters=Object.fromEntries(parts.slice(1).map(part=>part.split('=')));
|
||||
if(parameters.TZID&&!supportedTimeZone(parameters.TZID))current.unsupportedTimezone=true;
|
||||
const allDay=parameters.VALUE==='DATE',parsed=allDay?dateInstant(value):zonedInstant(value,parameters.TZID);
|
||||
if(name==='DTSTART'){current.start=parsed;current.timeZone=parameters.TZID||null;}
|
||||
if(name==='DTEND')current.end=parsed;
|
||||
if(name==='RRULE')current.rule=value;
|
||||
if(name==='RDATE')current.rdates=(current.rdates||[]).concat(value.split(',').map(item=>allDay?dateInstant(item):zonedInstant(item,parameters.TZID)).filter(value=>value!=null));
|
||||
if(name==='EXDATE')current.exdates=(current.exdates||[]).concat(value.split(',').map(item=>allDay?dateInstant(item):zonedInstant(item,parameters.TZID)).filter(value=>value!=null));
|
||||
if(name==='STATUS')current.status=value.toUpperCase();
|
||||
if(name==='TRANSP')current.transparency=value.toUpperCase();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
function recurrenceRule(value) {
|
||||
return Object.fromEntries(String(value||'').split(';').filter(Boolean).map(part=>part.split('=')));
|
||||
}
|
||||
function supportedRecurrence(value) {
|
||||
const rule=recurrenceRule(value),keys=Object.keys(rule);
|
||||
if(!['DAILY','WEEKLY'].includes(rule.FREQ))return false;
|
||||
const allowed=new Set(['FREQ','COUNT','INTERVAL','UNTIL',...(rule.FREQ==='WEEKLY'?['BYDAY']:[])]);
|
||||
if(keys.some(key=>!allowed.has(key)))return false;
|
||||
if(rule.COUNT&&(!/^\d+$/.test(rule.COUNT)||Number(rule.COUNT)<1))return false;
|
||||
if(rule.INTERVAL&&(!/^\d+$/.test(rule.INTERVAL)||Number(rule.INTERVAL)<1))return false;
|
||||
if(rule.COUNT&&rule.UNTIL)return false;
|
||||
if(rule.UNTIL&&instant(rule.UNTIL)==null&&dateInstant(rule.UNTIL)==null)return false;
|
||||
return !rule.BYDAY||rule.BYDAY.split(',').every(day=>/^(MO|TU|WE|TH|FR|SA|SU)$/.test(day));
|
||||
}
|
||||
function occurrences(event,rangeStart,rangeEnd) {
|
||||
const duration=event.end-event.start,excluded=new Set(event.exdates||[]);
|
||||
if(!event.rule) {
|
||||
return [event.start,...(event.rdates||[])].filter((start,index,all)=>start<rangeEnd&&!excluded.has(start)&&all.indexOf(start)===index)
|
||||
.map(start=>({start,end:start+duration}));
|
||||
}
|
||||
const rule=recurrenceRule(event.rule);
|
||||
if(!['DAILY','WEEKLY'].includes(rule.FREQ))return [event];
|
||||
const count=Math.max(1,Math.min(Number(rule.COUNT)||10000,10000));
|
||||
const result=[];
|
||||
const weekdays={SU:0,MO:1,TU:2,WE:3,TH:4,FR:5,SA:6};
|
||||
const selected=new Set((rule.BYDAY||'').split(',').map(day=>weekdays[day]).filter(day=>day!=null));
|
||||
if(rule.FREQ==='WEEKLY'&&!selected.size)selected.add(zoneParts(event.start,event.timeZone).weekday);
|
||||
const interval=Math.max(1,Number(rule.INTERVAL)||1),origin=zoneParts(event.start,event.timeZone);
|
||||
const until=rule.UNTIL?(rule.UNTIL.endsWith('Z')?instant(rule.UNTIL):
|
||||
(zonedInstant(rule.UNTIL,event.timeZone)??dateInstant(rule.UNTIL))):null;
|
||||
const originDay=Date.UTC(origin.year,origin.month-1,origin.day);
|
||||
let start=event.start,matched=0;
|
||||
if(rule.FREQ==='DAILY'&&start+duration<=rangeStart) {
|
||||
const target=zoneParts(rangeStart-duration,event.timeZone);
|
||||
const targetDay=Date.UTC(target.year,target.month-1,target.day);
|
||||
const elapsed=Math.max(0,Math.floor((targetDay-originDay)/(24*60*60*1000)));
|
||||
const jumps=Math.floor(elapsed/interval);
|
||||
if(jumps) {start=addCalendarDays(start,jumps*interval,event.timeZone);matched=jumps;}
|
||||
while(start+duration<=rangeStart&&matched<count) {start=addCalendarDays(start,interval,event.timeZone);matched+=1;}
|
||||
}
|
||||
if(rule.FREQ==='WEEKLY'&&start+duration<=rangeStart) {
|
||||
const target=zoneParts(rangeStart-duration,event.timeZone);
|
||||
const targetDay=Date.UTC(target.year,target.month-1,target.day);
|
||||
const elapsed=Math.max(0,Math.floor((targetDay-originDay)/(24*60*60*1000)));
|
||||
const cycleDays=7*interval,cycles=Math.floor(elapsed/cycleDays),offset=elapsed-cycles*cycleDays;
|
||||
matched=cycles*selected.size;
|
||||
start=addCalendarDays(event.start,cycles*cycleDays+(offset>=7?offset:0),event.timeZone);
|
||||
while(start+duration<=rangeStart&&matched<count) {
|
||||
const cursor=zoneParts(start,event.timeZone),cursorDay=Date.UTC(cursor.year,cursor.month-1,cursor.day);
|
||||
const weeks=Math.floor((cursorDay-originDay)/(7*24*60*60*1000));
|
||||
if(weeks%interval===0&&selected.has(cursor.weekday))matched+=1;
|
||||
start=addCalendarDays(start,1,event.timeZone);
|
||||
}
|
||||
}
|
||||
while(start<rangeEnd&&matched<count&&(until==null||start<=until)) {
|
||||
const cursor=zoneParts(start,event.timeZone),cursorDay=Date.UTC(cursor.year,cursor.month-1,cursor.day);
|
||||
const weeks=Math.floor((cursorDay-originDay)/(7*24*60*60*1000));
|
||||
const matches=rule.FREQ==='DAILY'||(weeks%interval===0&&selected.has(cursor.weekday));
|
||||
if(matches) {
|
||||
matched+=1;
|
||||
if(!excluded.has(start))result.push({start,end:start+duration});
|
||||
}
|
||||
start=addCalendarDays(start,rule.FREQ==='DAILY'?interval:1,event.timeZone);
|
||||
}
|
||||
(event.rdates||[]).forEach(extra=>{
|
||||
if(extra<rangeEnd&&!excluded.has(extra)&&!result.some(item=>item.start===extra))result.push({start:extra,end:extra+duration});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
function dayBoundary(date,minutes) {
|
||||
const [year,month,day]=date.split('-').map(Number);
|
||||
return new Date(year,month-1,day,Math.floor(minutes/60),minutes%60).getTime();
|
||||
}
|
||||
function localClock(value) {
|
||||
const date=new Date(value);
|
||||
return String(date.getHours()).padStart(2,'0')+':'+String(date.getMinutes()).padStart(2,'0');
|
||||
}
|
||||
function review(source,{dates,workdayStart='09:00',workdayEnd='17:00'}={}) {
|
||||
if(!Array.isArray(dates)||dates.length!==7)throw new Error('Week Ahead must contain seven dates.');
|
||||
const startMinute=clock(workdayStart),endMinute=clock(workdayEnd);
|
||||
if(startMinute==null||endMinute==null||endMinute<=startMinute)throw new Error('Working hours must end after they start.');
|
||||
const parsedEvents=events(source),rangeStart=dayBoundary(dates[0],0),rangeEnd=dayBoundary(dates[6],24*60);
|
||||
const unsupportedRecurrence=parsedEvents.filter(event=>event.rule&&!supportedRecurrence(event.rule)&&
|
||||
event.start<rangeEnd&&(event.end>rangeStart||event.rule)).length;
|
||||
const unsupportedTimezone=parsedEvents.filter(event=>event.unsupportedTimezone).length;
|
||||
const unsupported_count=unsupportedRecurrence+unsupportedTimezone;
|
||||
const calendarEvents=parsedEvents.filter(event=>!event.unsupportedTimezone).flatMap(event=>occurrences(event,rangeStart,rangeEnd));
|
||||
const days=dates.map(plan_date=>{
|
||||
const start=dayBoundary(plan_date,startMinute),end=dayBoundary(plan_date,endMinute);
|
||||
const ranges=calendarEvents.map(event=>[Math.max(start,event.start),Math.min(end,event.end)])
|
||||
.filter(range=>range[1]>range[0]).sort((left,right)=>left[0]-right[0]);
|
||||
const merged=[];
|
||||
ranges.forEach(range=>{
|
||||
const previous=merged[merged.length-1];
|
||||
if(previous&&range[0]<=previous[1])previous[1]=Math.max(previous[1],range[1]);
|
||||
else merged.push([...range]);
|
||||
});
|
||||
const busy_minutes=Math.round(merged.reduce((total,range)=>total+range[1]-range[0],0)/60000);
|
||||
const free_windows=[];let cursor=start;
|
||||
merged.forEach(range=>{
|
||||
if(range[0]>cursor)free_windows.push({start_time:localClock(cursor),end_time:localClock(range[0])});
|
||||
cursor=Math.max(cursor,range[1]);
|
||||
});
|
||||
if(cursor<end)free_windows.push({start_time:localClock(cursor),end_time:localClock(end)});
|
||||
return {plan_date,busy_minutes,capacity_minutes:endMinute-startMinute-busy_minutes,free_windows};
|
||||
});
|
||||
days.unsupported_count=unsupported_count;
|
||||
days.unsupported_timezone_count=unsupportedTimezone;
|
||||
return days;
|
||||
}
|
||||
function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
|
||||
let reviewed=null,activeAvailability=null,needsReflow=false,reflowPreview=null;
|
||||
const root=()=>qs('#week-capacity-import');
|
||||
function clear() {
|
||||
reviewed=null;needsReflow=false;reflowPreview=null;
|
||||
qs('#week-capacity-file').value='';
|
||||
qs('#week-capacity-review').hidden=true;
|
||||
qs('#week-capacity-days').innerHTML='';
|
||||
qs('#apply-week-capacities').disabled=false;
|
||||
qs('#apply-week-capacities').textContent='Apply seven capacities';
|
||||
}
|
||||
function open() {
|
||||
clear();root().hidden=false;qs('#week-capacity-status').textContent='';
|
||||
qs('#week-capacity-file').focus?.();return true;
|
||||
}
|
||||
function cancel() {clear();root().hidden=true;return true;}
|
||||
function reviewSource(source) {
|
||||
const display=controller.dates();
|
||||
reviewed=review(source,{dates:display.map(item=>item.date),workdayStart:qs('#week-capacity-start').value,
|
||||
workdayEnd:qs('#week-capacity-end').value});
|
||||
const current=controller.review?.().days||[];
|
||||
needsReflow=reviewed.some((day,index)=>Number(current[index]?.planned_minutes)>day.capacity_minutes);
|
||||
const previewValues=reviewed.map(day=>({plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
|
||||
free_windows:day.free_windows.map(window=>({...window}))}));
|
||||
reflowPreview=needsReflow?controller.previewCapacityReflow?.(previewValues):null;
|
||||
const destinations=new Map();
|
||||
(reflowPreview?.days||[]).forEach(day=>(day.ids||[]).forEach(id=>destinations.set(id,day.plan_date)));
|
||||
qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>{
|
||||
const previous=current[index]||{},planned=Number(previous.planned_minutes)||0,over=Math.max(0,planned-day.capacity_minutes);
|
||||
const moved=[...new Set((previous.ids||[]).map(id=>destinations.get(id)).filter(date=>date&&date!==day.plan_date))]
|
||||
.map(date=>display.find(item=>item.date===date)?.label||date);
|
||||
return '<article class="week-capacity-day'+(over?' is-overloaded':'')+'"><strong>'+display[index].label+'</strong><span>'+
|
||||
(Number.isInteger(previous.capacity_minutes)?previous.capacity_minutes+' → ':'')+day.capacity_minutes+' min available</span><small>'+
|
||||
planned+' min planned · '+day.busy_minutes+' min busy</small>'+(over?'<small class="week-capacity-over">'+over+
|
||||
' min over refreshed capacity</small>':'')+(moved.length?'<small>Moves to '+moved.join(', ')+'</small>':'')+'</article>';
|
||||
}).join('');
|
||||
qs('#week-capacity-review').hidden=false;
|
||||
const unsupported=reviewed.unsupported_count||0;
|
||||
const reflowBlocked=Boolean(reflowPreview?.blockers?.length);
|
||||
qs('#apply-week-capacities').disabled=unsupported>0||reflowBlocked;
|
||||
qs('#apply-week-capacities').textContent=needsReflow?'Apply capacities & reflow':'Apply seven capacities';
|
||||
const kind=reviewed.unsupported_timezone_count?'calendar event':'recurring event';
|
||||
qs('#week-capacity-status').textContent=unsupported?
|
||||
unsupported+' '+kind+(unsupported===1?'':'s')+' could not be counted. Apply is unavailable; export a simpler seven-day calendar and try again.':
|
||||
(reflowBlocked?'Reflow needs an estimate for every planned item before refreshed capacity can be applied.':
|
||||
(reflowPreview?.unscheduled?.length?reflowPreview.unscheduled.length+' planned item'+
|
||||
(reflowPreview.unscheduled.length===1?'':'s')+' will return to My Work. Review before applying.':
|
||||
'Review seven capacity totals. Calendar details stay on this device.'));
|
||||
return reviewed.map(day=>({...day}));
|
||||
}
|
||||
async function apply() {
|
||||
if(!reviewed||reviewed.unsupported_count)return false;
|
||||
const keep=Boolean(qs('#keep-week-free-times')?.checked);
|
||||
const values=reviewed.map(day=>({
|
||||
plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
|
||||
...(keep?{free_windows:day.free_windows.map(window=>({...window}))}:{}),
|
||||
}));
|
||||
const staged=needsReflow?controller.applyCapacityReflow?.(values):controller.stageCapacities(values);
|
||||
if(!staged)return false;
|
||||
qs('#apply-week-capacities').disabled=true;
|
||||
try {await controller.flush();activeAvailability=reviewed.map(day=>({plan_date:day.plan_date,
|
||||
free_windows:day.free_windows.map(window=>({...window}))}));cancel();onApplied();return true;}
|
||||
finally {qs('#apply-week-capacities').disabled=false;}
|
||||
}
|
||||
qs('#week-capacity-file')?.addEventListener('change',async event=>{
|
||||
const file=event.currentTarget.files?.[0];if(!file)return;
|
||||
try {
|
||||
if(file.size>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
|
||||
reviewSource(await file.text());
|
||||
} catch(error) {clear();qs('#week-capacity-status').textContent=error.message||'Calendar could not be read.';}
|
||||
});
|
||||
qs('#cancel-week-capacity-import')?.addEventListener('click',cancel);
|
||||
qs('#apply-week-capacities')?.addEventListener('click',()=>apply().catch(error=>{
|
||||
qs('#week-capacity-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Capacities remain saved on this phone.';
|
||||
}));
|
||||
function availability() {
|
||||
const saved=(controller.state?.().days||[]).filter(day=>Array.isArray(day.free_windows));
|
||||
const source=activeAvailability||saved;
|
||||
return source.length?source.map(day=>({plan_date:day.plan_date,
|
||||
free_windows:day.free_windows.map(window=>({...window}))})):null;
|
||||
}
|
||||
return {open,cancel,review:reviewSource,apply,state:()=>reviewed?reviewed.map(day=>({...day})):null,
|
||||
availability};
|
||||
}
|
||||
function mount(controller,weekWorkflow,qs) {
|
||||
const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview});
|
||||
StackchainWeekCalendar.setAvailabilityProvider(workflow.availability);
|
||||
qs('#open-week-capacity-import').addEventListener('click',workflow.open);
|
||||
return workflow;
|
||||
}
|
||||
return {review,createWorkflow,mount};
|
||||
}
|
||||
const weekCalendarImport=createWeekCalendarImport();
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=weekCalendarImport;
|
||||
@@ -0,0 +1,161 @@
|
||||
(function(root){
|
||||
'use strict';
|
||||
let availabilityProvider=()=>null;
|
||||
function escapeText(value){
|
||||
return String(value||'').replace(/\\/g,'\\\\').replace(/\r?\n/g,'\\n').replace(/,/g,'\\,').replace(/;/g,'\\;');
|
||||
}
|
||||
function compact(value){return String(value||'').replace(/[-:]/g,'');}
|
||||
function addMinutes(value,minutes){
|
||||
const [hours,mins]=String(value).split(':').map(Number),total=hours*60+mins+Number(minutes);
|
||||
return String(Math.floor(total/60)%24).padStart(2,'0')+':'+String(total%60).padStart(2,'0');
|
||||
}
|
||||
function clockMinutes(value){const [hours,minutes]=String(value).split(':').map(Number);return hours*60+minutes;}
|
||||
function stableId(value){return String(value||'work').replace(/[^a-z0-9]+/gi,'-').replace(/^-|-$/g,'').toLowerCase();}
|
||||
function foldLine(line){
|
||||
const encoder=new TextEncoder(),chunks=[];let chunk='',bytes=0,limit=75;
|
||||
for(const character of String(line)){
|
||||
const width=encoder.encode(character).length;
|
||||
if(chunk&&bytes+width>limit){chunks.push(chunk);chunk=character;bytes=width;limit=74;}
|
||||
else {chunk+=character;bytes+=width;}
|
||||
}
|
||||
chunks.push(chunk);return chunks.join('\r\n ');
|
||||
}
|
||||
function buildWeekSchedule(plan,startTimes,getItem,selected,availability){
|
||||
const blocks=[],blockers=[],availableByDate=new Map((availability||[]).map(day=>[day.plan_date,day.free_windows||[]]));
|
||||
(plan?.days||[]).slice().sort((a,b)=>a.plan_date.localeCompare(b.plan_date)).forEach(day=>{
|
||||
let cursor=startTimes?.[day.plan_date]||'09:00';
|
||||
const windows=availableByDate.has(day.plan_date)?availableByDate.get(day.plan_date):(day.free_windows||null);
|
||||
const candidates=[];
|
||||
(day.ids||[]).forEach(id=>{
|
||||
if(selected&&!selected.has(id))return;
|
||||
const minutes=Number(day.estimates?.[id]),item=getItem?.(id);
|
||||
if(!item||!Number.isFinite(minutes)||minutes<=0)return;
|
||||
const exact=startTimes?.[id]||day.start_times?.[id];
|
||||
let start=exact||cursor;
|
||||
if(!exact&&windows){
|
||||
const fit=windows.find(window=>{
|
||||
const candidate=Math.max(clockMinutes(cursor),clockMinutes(window.start_time));
|
||||
if(candidate+minutes>clockMinutes(window.end_time))return false;
|
||||
start=addMinutes('00:00',candidate);return true;
|
||||
});
|
||||
if(!fit){blockers.push({id,title:item.title||'Untitled work',plan_date:day.plan_date,minutes});return;}
|
||||
}
|
||||
const startMinute=clockMinutes(start),endMinute=startMinute+minutes;
|
||||
candidates.push({item,id,minutes,start,startMinute,endMinute,exact:Boolean(exact)});
|
||||
if(!exact)cursor=addMinutes('00:00',endMinute);
|
||||
});
|
||||
const invalid=new Set();
|
||||
candidates.forEach(candidate=>{
|
||||
if(candidate.endMinute>1440||windows&&!windows.some(window=>clockMinutes(window.start_time)<=candidate.startMinute&&candidate.endMinute<=clockMinutes(window.end_time)))invalid.add(candidate.id);
|
||||
});
|
||||
candidates.forEach((left,index)=>candidates.slice(index+1).forEach(right=>{
|
||||
if(left.startMinute<right.endMinute&&left.endMinute>right.startMinute){invalid.add(left.id);invalid.add(right.id);}
|
||||
}));
|
||||
candidates.forEach(candidate=>{
|
||||
if(invalid.has(candidate.id)){
|
||||
blockers.push({id:candidate.id,title:candidate.item.title||'Untitled work',plan_date:day.plan_date,minutes:candidate.minutes,reason:'invalid-exact-time'});return;
|
||||
}
|
||||
blocks.push({...candidate.item,id:candidate.id,plan_date:day.plan_date,start_time:candidate.start,
|
||||
end_time:addMinutes('00:00',candidate.endMinute),minutes:candidate.minutes});
|
||||
});
|
||||
});
|
||||
return {blocks,blockers};
|
||||
}
|
||||
function buildWeekBlocks(plan,startTimes,getItem,selected,availability){
|
||||
return buildWeekSchedule(plan,startTimes,getItem,selected,availability).blocks;
|
||||
}
|
||||
function serializeWeekCalendar(blocks,{timezone='UTC',revision=0,generatedAt}={}){
|
||||
const stamp=generatedAt||new Date().toISOString().replace(/[-:]/g,'').replace(/\.\d{3}/,'');
|
||||
const lines=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Stackchain//Week Ahead//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH','X-WR-CALNAME:Stackchain Week Ahead',`X-WR-TIMEZONE:${timezone}`,'BEGIN:VTIMEZONE',`TZID:${timezone}`,'END:VTIMEZONE'];
|
||||
(blocks||[]).forEach(block=>{
|
||||
const day=compact(block.plan_date),reference=block.repository+(block.number!=null?' #'+block.number:'');
|
||||
lines.push('BEGIN:VEVENT',`UID:week-${stableId(block.id)}@stackchain`,`SEQUENCE:${Math.max(0,Math.floor(Number(revision)||0))}`,`DTSTAMP:${stamp}`,
|
||||
`DTSTART;TZID=${timezone}:${day}T${compact(block.start_time)}00`,`DTEND;TZID=${timezone}:${day}T${compact(block.end_time)}00`,
|
||||
`SUMMARY:${escapeText(block.title||'Untitled work')}`,`DESCRIPTION:${escapeText(reference+' · Stackchain Week Ahead')}`,
|
||||
`URL:${String(block.url||'')}`,'TRANSP:OPAQUE','END:VEVENT');
|
||||
});
|
||||
lines.push('END:VCALENDAR');return lines.map(foldLine).join('\r\n')+'\r\n';
|
||||
}
|
||||
async function deliverWeekCalendar({text,filename,navigator,document,urlApi,FileCtor}){
|
||||
const file=new FileCtor([text],filename,{type:'text/calendar;charset=utf-8'});
|
||||
const payload={files:[file],title:'Stackchain Week Ahead',text:'Timed Week Ahead calendar blocks'};
|
||||
if(typeof navigator?.share==='function'&&typeof navigator?.canShare==='function'&&navigator.canShare(payload)){
|
||||
await navigator.share(payload);return 'shared';
|
||||
}
|
||||
const href=urlApi.createObjectURL(file);
|
||||
try{const anchor=document.createElement('a');anchor.href=href;anchor.download=filename;anchor.click();}
|
||||
finally{urlApi.revokeObjectURL(href);}
|
||||
return 'downloaded';
|
||||
}
|
||||
function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onSave,onDone,windowObject=root,navigatorObject=root.navigator,
|
||||
documentObject=root.document,urlApi=root.URL,FileCtor=root.File}={}){
|
||||
const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days');
|
||||
let plan=null,focusReturn=null;
|
||||
const selected=()=>new Set(Array.from(daysRoot.querySelectorAll('[data-week-calendar-item]')).filter(input=>input.checked).map(input=>input.value));
|
||||
const starts=()=>Object.fromEntries([
|
||||
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-start]')).map(input=>[input.dataset.weekCalendarStart,input.value]),
|
||||
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-exact]')).filter(input=>input.value).map(input=>[input.dataset.weekCalendarExact,input.value]),
|
||||
]);
|
||||
const schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.());
|
||||
const blocks=()=>schedule().blocks;
|
||||
function update(){
|
||||
const current=schedule(),byId=new Map(current.blocks.map(block=>[block.id,block])),blockedById=new Map(current.blockers.map(blocker=>[blocker.id,blocker]));
|
||||
daysRoot.querySelectorAll('[data-week-calendar-preview]').forEach(node=>{
|
||||
const id=node.dataset.weekCalendarPreview,block=byId.get(id),blocker=blockedById.get(id);
|
||||
node.textContent=block?block.start_time+'–'+block.end_time+' · '+block.minutes+' min':
|
||||
(blocker?'Does not fit imported free time':'Excluded from calendar');
|
||||
});
|
||||
qs('#share-week-calendar').disabled=!current.blocks.length||current.blockers.length>0;
|
||||
qs('#save-week-calendar-times').disabled=!current.blocks.length||current.blockers.length>0;
|
||||
if(current.blockers.length){
|
||||
const blocker=current.blockers[0];
|
||||
qs('#week-calendar-status').textContent=blocker.title+' does not fit free time on '+blocker.plan_date+'. Adjust the start, estimate, or calendar import.';
|
||||
}else qs('#week-calendar-status').textContent=(getAvailability?.()?'Planning around imported busy time · ':'Manual timing · ')+
|
||||
current.blocks.length+' calendar block'+(current.blocks.length===1?'':'s')+' selected.';
|
||||
}
|
||||
function close({back=false}={}){
|
||||
handoff.hidden=true;
|
||||
if(back){review.hidden=false;const target=focusReturn||qs('#confirm-week-plan');focusReturn=null;target.focus?.();}
|
||||
}
|
||||
function open(value,{returnFocus=null}={}){
|
||||
plan=value;if(returnFocus)focusReturn=returnFocus;review.hidden=true;handoff.hidden=false;
|
||||
const labels=new Map((value.days||[]).map(day=>[day.plan_date,new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(new Date(day.plan_date+'T12:00:00Z'))]));
|
||||
daysRoot.innerHTML=(value.days||[]).filter(day=>day.ids?.length).map(day=>'<article class="week-calendar-day"><header><h3>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
|
||||
'</h3><label>Start <input type="time" value="09:00" data-week-calendar-start="'+escapeAttribute(day.plan_date)+'"></label></header>'+
|
||||
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96),saved=day.start_times?.[id]||'';
|
||||
return '<div class="week-calendar-item"><label class="week-calendar-choice"><input type="checkbox" data-week-calendar-item value="'+escapeAttribute(id)+'" checked><span><strong>'+escapeHtml(item?.title||'Work details unavailable')+
|
||||
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label><label class="week-calendar-time">Start<input type="time" value="'+escapeAttribute(saved)+'" data-week-calendar-exact="'+escapeAttribute(id)+'" aria-label="Exact start for '+escapeAttribute(item?.title||'work')+'"></label></div>';}).join('')+'</article>').join('');
|
||||
daysRoot.querySelectorAll('input').forEach(input=>input.addEventListener('change',update));
|
||||
update();qs('#back-to-week-review-from-calendar').focus?.();return true;
|
||||
}
|
||||
qs('#back-to-week-review-from-calendar').addEventListener('click',()=>close({back:true}));
|
||||
qs('#save-week-calendar-times').addEventListener('click',async()=>{
|
||||
const button=qs('#save-week-calendar-times'),current=schedule();if(!current.blocks.length||current.blockers.length)return;
|
||||
button.disabled=true;qs('#week-calendar-status').textContent='Saving exact task times…';
|
||||
const byDate={};current.blocks.forEach(block=>(byDate[block.plan_date]||(byDate[block.plan_date]={}))[block.id]=block.start_time);
|
||||
try{await onSave?.(byDate);plan={...plan,days:(plan.days||[]).map(day=>({...day,start_times:{...(byDate[day.plan_date]||{})}}))};
|
||||
qs('#week-calendar-status').textContent='Exact task times saved to Week Ahead.';}
|
||||
catch(error){qs('#week-calendar-status').textContent=(error?.message||'Exact times could not be saved.')+' Retry when connected.';}
|
||||
finally{button.disabled=false;}
|
||||
});
|
||||
qs('#export-saved-week-calendar')?.addEventListener('click',event=>{focusReturn=event.currentTarget;qs('#confirm-week-plan').click();});
|
||||
qs('#share-week-calendar').addEventListener('click',async()=>{
|
||||
const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return;
|
||||
button.disabled=true;qs('#week-calendar-status').textContent='Preparing calendar blocks…';
|
||||
const day=new Date().toISOString().slice(0,10);
|
||||
try{
|
||||
const text=serializeWeekCalendar(current,{timezone:plan.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone,revision:plan.revision});
|
||||
const result=await deliverWeekCalendar({text,filename:'stackchain-week-ahead-'+day+'.ics',navigator:navigatorObject,
|
||||
document:documentObject,urlApi,FileCtor});
|
||||
close();onDone?.(result);
|
||||
}catch(error){
|
||||
qs('#week-calendar-status').textContent=error?.name==='AbortError'?'Share cancelled. Your Week Ahead is still ready.':'Calendar export failed. Retry without leaving Week Ahead.';
|
||||
button.disabled=false;
|
||||
}
|
||||
});
|
||||
return {open,close,blocks};
|
||||
}
|
||||
function setAvailabilityProvider(value){availabilityProvider=typeof value==='function'?value:()=>null;}
|
||||
const api={buildWeekBlocks,buildWeekSchedule,deliverWeekCalendar,escapeText,foldLine,mountWeekCalendarHandoff,serializeWeekCalendar,setAvailabilityProvider};
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.StackchainWeekCalendar=api;
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@
|
||||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'authored', 'update', 'later', 'draft', 'following'];
|
||||
const sections = {
|
||||
issue: ['overview', 'conversation', 'reply', 'actions'],
|
||||
filed: ['overview', 'conversation', 'reply', 'actions'],
|
||||
|
||||
@@ -11,16 +11,18 @@ async function loadWorkspace({
|
||||
window?.addEventListener('online', captureOnline);
|
||||
const status = document.querySelector('#my-work-action-status');
|
||||
const retryButton = document.querySelector('#retry-workspace');
|
||||
const url = document.querySelector(
|
||||
'meta[name="stackchain-feature-today-timer"]'
|
||||
)?.content || '';
|
||||
const urls = {'today-timer':url};
|
||||
const urls = Object.fromEntries(['today-timer', 'planning'].map(name => [name,
|
||||
document.querySelector(`meta[name="stackchain-feature-${name}"]`)?.content || ''
|
||||
]));
|
||||
const originalUrls = {...urls};
|
||||
const loader = createLoader({document, urls});
|
||||
|
||||
let attempts = 0;
|
||||
const load = () => {
|
||||
if (attempts++) urls['today-timer'] = url + '?retry=' + attempts;
|
||||
return loader.load('today-timer');
|
||||
if (attempts++) Object.keys(urls).forEach(name => {
|
||||
urls[name] = originalUrls[name] + '?retry=' + attempts;
|
||||
});
|
||||
return Promise.all(Object.keys(urls).map(name => loader.load(name)));
|
||||
};
|
||||
const waitForRecovery = () => new Promise(resolve => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
|
||||
@@ -19,6 +19,7 @@ RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
|
||||
RUNTIME_FILES = (
|
||||
"README.md",
|
||||
"requirements.txt",
|
||||
"scripts/rotate_private_state.py",
|
||||
"scripts/rotate_unfiled_drafts.py",
|
||||
)
|
||||
EXCLUDED_PARTS = {"__pycache__", ".pytest_cache"}
|
||||
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rewrap shared private-state envelopes under the configured active key."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.private_state import connect_private_sqlite # noqa: E402
|
||||
from src.state_encryption import ( # noqa: E402
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
private_state_encryption_config,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Field:
|
||||
column: str
|
||||
binding: str
|
||||
alias: str | None = None
|
||||
plaintext_string: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Table:
|
||||
name: str
|
||||
context_columns: tuple[str, ...]
|
||||
fields: tuple[Field, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Store:
|
||||
name: str
|
||||
identity: str
|
||||
env: str
|
||||
filename: str
|
||||
tables: tuple[Table, ...]
|
||||
|
||||
|
||||
STORES = (
|
||||
Store("today", "today", "STACKCHAIN_TODAY_DB", "today.sqlite3", (
|
||||
Table("today_plans", ("login",), (Field("ids", "plan:{login}"),)),
|
||||
Table("tomorrow_plans", ("login",), (Field("payload", "tomorrow:{login}"),)),
|
||||
Table("tomorrow_promotions", ("login", "promotion_id"), (Field("result", "tomorrow-promotion:{login}:{promotion_id}"),)),
|
||||
Table("week_plans", ("login",), (Field("payload", "week:{login}"),)),
|
||||
Table("week_promotions", ("login", "promotion_id"), (Field("result", "week-promotion:{login}:{promotion_id}"),)),
|
||||
Table("week_reschedules", ("login", "operation_id"), (Field("result", "week-reschedule:{login}:{operation_id}"),)),
|
||||
Table("today_sessions", ("login",), (Field("device_id", "session:{login}"),)),
|
||||
Table("today_recaps", ("login",), (
|
||||
Field("session_id", "recap-id:{login}", "session_id", True),
|
||||
Field("items", "recap-items:{login}:{session_id}"),
|
||||
)),
|
||||
Table("today_time_logs", ("login",), (
|
||||
Field("session_id", "time-log-session:{login}", "session_id", True),
|
||||
Field("identity", "time-log-identity:{login}:{session_id}", "identity", True),
|
||||
Field("actual_minutes", "time-log-payload:{login}:{session_id}:{identity}"),
|
||||
)),
|
||||
)),
|
||||
Store("later", "later", "STACKCHAIN_LATER_DB", "later.sqlite3", (
|
||||
Table("later_plans", ("login",), (Field("records", "plan:{login}"),)),
|
||||
Table("later_item_revisions", ("login",), (Field("item_id", "item-revision:{login}", plaintext_string=True),)),
|
||||
)),
|
||||
Store("live-snapshot", "live-snapshot", "STACKCHAIN_LIVE_SNAPSHOT_DB", "live-snapshot.sqlite3", (
|
||||
Table("live_snapshot", ("generation",), (Field("value_json", "{generation}"),)),
|
||||
)),
|
||||
Store("available-issue-snapshot", "available-issue-snapshot", "STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB", "available-issue-snapshot.sqlite3", (
|
||||
Table("available_issue_snapshot", (), (Field("items_json", "singleton"),)),
|
||||
)),
|
||||
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
|
||||
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
|
||||
)),
|
||||
Store(
|
||||
"completed-filed-reviews",
|
||||
"completed-filed-reviews",
|
||||
"STACKCHAIN_COMPLETED_FILED_REVIEW_DB",
|
||||
"completed-filed-reviews.sqlite3",
|
||||
(
|
||||
Table(
|
||||
"completed_filed_review_collections",
|
||||
("login",),
|
||||
(Field("receipts", "receipts:{login}"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
Store("security-events", "security-events", "STACKCHAIN_SECURITY_EVENT_DB", "security-events.sqlite3", (
|
||||
Table("security_events", ("id",), (Field("payload", "event:{id}"),)),
|
||||
)),
|
||||
Store("idempotency-ledger", "idempotency-ledger", "STACKCHAIN_IDEMPOTENCY_DB", "idempotency.sqlite3", (
|
||||
Table("idempotency_operations", ("key",), (
|
||||
Field("fingerprint", "{key}:fingerprint"),
|
||||
Field("response_json", "{key}:response"),
|
||||
)),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
def _tables(connection: sqlite3.Connection) -> set[str]:
|
||||
return {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def rotate_store(path: Path, spec: Store, config) -> dict[str, int]:
|
||||
counts = {"current": 0, "failed": 0, "migrated": 0, "total": 0}
|
||||
if not path.exists():
|
||||
return counts
|
||||
cipher = PrivateStateCipher(config, store=spec.identity)
|
||||
with connect_private_sqlite(path, timeout=1.0) as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
available = _tables(connection)
|
||||
for table in spec.tables:
|
||||
if table.name not in available:
|
||||
continue
|
||||
columns = (*table.context_columns, *(field.column for field in table.fields))
|
||||
query = f"SELECT rowid AS _rotation_rowid, {', '.join(columns)} FROM {table.name}"
|
||||
for row in connection.execute(query).fetchall():
|
||||
context = {name: row[name] for name in table.context_columns}
|
||||
updates: dict[str, str] = {}
|
||||
row_failed = False
|
||||
row_counts = {"current": 0, "migrated": 0, "total": 0}
|
||||
for field in table.fields:
|
||||
payload = row[field.column]
|
||||
if payload is None:
|
||||
continue
|
||||
row_counts["total"] += 1
|
||||
try:
|
||||
binding = field.binding.format(**context)
|
||||
if isinstance(payload, str) and not payload.startswith(("v1:", "v2:")) and field.plaintext_string:
|
||||
value, stale = payload, True
|
||||
else:
|
||||
value, stale = cipher.open(str(payload), binding=binding)
|
||||
if field.alias:
|
||||
context[field.alias] = value
|
||||
if stale:
|
||||
updates[field.column] = cipher.seal(value, binding=binding)
|
||||
row_counts["migrated"] += 1
|
||||
else:
|
||||
row_counts["current"] += 1
|
||||
except (KeyError, PrivateStateEncryptionError, ValueError):
|
||||
row_failed = True
|
||||
break
|
||||
counts["total"] += row_counts["total"]
|
||||
if row_failed:
|
||||
counts["failed"] += 1
|
||||
continue
|
||||
if updates:
|
||||
assignments = ", ".join(f"{name} = ?" for name in updates)
|
||||
connection.execute(
|
||||
f"UPDATE {table.name} SET {assignments} WHERE rowid = ?",
|
||||
(*updates.values(), row["_rotation_rowid"]),
|
||||
)
|
||||
counts["migrated"] += row_counts["migrated"]
|
||||
counts["current"] += row_counts["current"]
|
||||
return counts
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
config = private_state_encryption_config()
|
||||
if isinstance(config, bytes):
|
||||
raise PrivateStateEncryptionError("rotation requires a keyring")
|
||||
state = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"))
|
||||
report = {
|
||||
spec.name: rotate_store(
|
||||
Path(os.getenv(spec.env, str(state / spec.filename))), spec, config
|
||||
)
|
||||
for spec in STORES
|
||||
}
|
||||
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
|
||||
print(json.dumps({"error": "Private-state rotation configuration is unavailable"}, sort_keys=True))
|
||||
return 2
|
||||
print(json.dumps(report, sort_keys=True))
|
||||
return 1 if any(item["failed"] for item in report.values()) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -12,7 +12,7 @@ from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import (
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
private_state_encryption_key,
|
||||
private_state_encryption_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class AvailableIssueSnapshotStore:
|
||||
self.path = Path(path)
|
||||
self.clock = clock or time.time
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="available-issue-snapshot",
|
||||
)
|
||||
with self._connect() as connection:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Durable, account-scoped completed Filed review receipts."""
|
||||
"""Durable, encrypted, account-scoped completed Filed review receipts."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -6,33 +6,68 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
|
||||
|
||||
class CompletedFiledReviewStore:
|
||||
def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0):
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 200,
|
||||
timeout: float = 1.0,
|
||||
encryption_key: bytes | None = None,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="completed-filed-reviews",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA secure_delete=ON")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS completed_filed_reviews (
|
||||
login TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
touched_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (login, repository, issue_number)
|
||||
CREATE TABLE IF NOT EXISTS completed_filed_review_collections (
|
||||
login TEXT PRIMARY KEY,
|
||||
receipts TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
legacy = connection.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'completed_filed_reviews'"
|
||||
).fetchone()
|
||||
if legacy:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"SELECT login, repository, issue_number, updated_at "
|
||||
"FROM completed_filed_reviews ORDER BY login, touched_at"
|
||||
).fetchall()
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
for login, repository, number, updated_at in rows:
|
||||
grouped.setdefault(login, []).append({
|
||||
"repository": repository,
|
||||
"number": int(number),
|
||||
"updated_at": updated_at,
|
||||
})
|
||||
for login, receipts in grouped.items():
|
||||
normalized = [self._receipt(item) for item in receipts][-self.limit:]
|
||||
connection.execute(
|
||||
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
|
||||
(login, self._seal(login, normalized)),
|
||||
)
|
||||
connection.execute("DROP TABLE completed_filed_reviews")
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
@@ -45,7 +80,7 @@ class CompletedFiledReviewStore:
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _receipt(raw: dict) -> tuple[str, int, str]:
|
||||
def _receipt(raw: dict) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("receipt must be an object")
|
||||
repository = raw.get("repository")
|
||||
@@ -63,23 +98,41 @@ class CompletedFiledReviewStore:
|
||||
raise ValueError("updated_at is invalid") from error
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("updated_at is invalid")
|
||||
return repository, number, updated_at
|
||||
return {"repository": repository, "number": number, "updated_at": updated_at}
|
||||
|
||||
def _open(self, login: str, payload: str | None) -> tuple[list[dict], bool]:
|
||||
if payload is None:
|
||||
return [], False
|
||||
value, stale = self._cipher.open(payload, binding=f"receipts:{login}")
|
||||
if not isinstance(value, list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
try:
|
||||
return [self._receipt(item) for item in value], stale
|
||||
except ValueError as error:
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
||||
|
||||
def _seal(self, login: str, receipts: list[dict]) -> str:
|
||||
return self._cipher.seal(receipts, binding=f"receipts:{login}")
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(rows) -> dict:
|
||||
return {"receipts": [
|
||||
{"repository": row[0], "number": int(row[1]), "updated_at": row[2]}
|
||||
for row in rows
|
||||
]}
|
||||
def _snapshot(receipts: list[dict]) -> dict:
|
||||
return {"receipts": receipts}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? ORDER BY touched_at",
|
||||
(self._login(login),),
|
||||
).fetchall()
|
||||
return self._snapshot(rows)
|
||||
row = connection.execute(
|
||||
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
|
||||
(login,),
|
||||
).fetchone()
|
||||
receipts, stale = self._open(login, row[0] if row else None)
|
||||
if row is not None and stale:
|
||||
connection.execute(
|
||||
"UPDATE completed_filed_review_collections SET receipts = ? "
|
||||
"WHERE login = ? AND receipts = ?",
|
||||
(self._seal(login, receipts), login, row[0]),
|
||||
)
|
||||
return self._snapshot(receipts)
|
||||
|
||||
def merge(self, login: str, receipts: list[dict]) -> dict:
|
||||
login = self._login(login)
|
||||
@@ -88,35 +141,25 @@ class CompletedFiledReviewStore:
|
||||
normalized = [self._receipt(receipt) for receipt in receipts]
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
touched = int(connection.execute(
|
||||
"SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?",
|
||||
row = connection.execute(
|
||||
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
|
||||
(login,),
|
||||
).fetchone()[0])
|
||||
for repository, number, updated_at in normalized:
|
||||
current = connection.execute(
|
||||
"SELECT updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? AND repository = ? AND issue_number = ?",
|
||||
(login, repository, number),
|
||||
).fetchone()
|
||||
if current is not None and current[0] >= updated_at:
|
||||
).fetchone()
|
||||
current, _stale = self._open(login, row[0] if row else None)
|
||||
for incoming in normalized:
|
||||
match = next((
|
||||
item for item in current
|
||||
if item["repository"] == incoming["repository"] and item["number"] == incoming["number"]
|
||||
), None)
|
||||
if match is not None and match["updated_at"] >= incoming["updated_at"]:
|
||||
continue
|
||||
touched += 1
|
||||
connection.execute(
|
||||
"INSERT INTO completed_filed_reviews "
|
||||
"(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(login, repository, issue_number) DO UPDATE SET "
|
||||
"updated_at=excluded.updated_at, touched_at=excluded.touched_at",
|
||||
(login, repository, number, updated_at, touched),
|
||||
)
|
||||
if match is not None:
|
||||
current.remove(match)
|
||||
current.append(incoming)
|
||||
current = current[-self.limit:]
|
||||
connection.execute(
|
||||
"DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN ("
|
||||
"SELECT rowid FROM completed_filed_reviews WHERE login = ? "
|
||||
"ORDER BY touched_at DESC LIMIT ?)",
|
||||
(login, login, self.limit),
|
||||
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
|
||||
(login, self._seal(login, current)),
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? ORDER BY touched_at",
|
||||
(login,),
|
||||
).fetchall()
|
||||
return self._snapshot(rows)
|
||||
return self._snapshot(current)
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Encrypted, account-scoped registry of explicitly followed Gitea issues."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_STATES = {"open", "closed"}
|
||||
_KINDS = {"issue", "pull"}
|
||||
|
||||
|
||||
class FollowingStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 50,
|
||||
timeout: float = 1.0,
|
||||
encryption_key: bytes | None = None,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="following",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS following_issues (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
items TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
normalized = str(login).strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "items": []}, False
|
||||
items, legacy = self._cipher.open(row[1], binding=f"items:{login}")
|
||||
if not isinstance(items, list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return {"revision": int(row[0]), "items": items}, legacy
|
||||
|
||||
def _seal(self, login: str, items: list[dict]) -> str:
|
||||
return self._cipher.seal(items, binding=f"items:{login}")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_item(raw: dict) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("following item must be an object")
|
||||
repository = raw.get("repository")
|
||||
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
||||
raise ValueError("repository is invalid")
|
||||
number = raw.get("number")
|
||||
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||
raise ValueError("number is invalid")
|
||||
kind = raw.get("kind", "issue")
|
||||
if kind not in _KINDS:
|
||||
raise ValueError("kind is invalid")
|
||||
title = raw.get("title")
|
||||
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300:
|
||||
raise ValueError("title is invalid")
|
||||
state = raw.get("state")
|
||||
if state not in _STATES:
|
||||
raise ValueError("state is invalid")
|
||||
updated_at = raw.get("updated_at")
|
||||
if not isinstance(updated_at, str) or not updated_at or len(updated_at) > 64:
|
||||
raise ValueError("updated_at is invalid")
|
||||
url = raw.get("url")
|
||||
if not isinstance(url, str) or not url.startswith(("http://", "https://")) or len(url) > 2048:
|
||||
raise ValueError("url is invalid")
|
||||
last_seen_updated_at = raw.get("last_seen_updated_at", updated_at)
|
||||
if (
|
||||
not isinstance(last_seen_updated_at, str)
|
||||
or not last_seen_updated_at
|
||||
or len(last_seen_updated_at) > 64
|
||||
):
|
||||
raise ValueError("last seen update is invalid")
|
||||
item = {
|
||||
"repository": repository,
|
||||
"kind": kind,
|
||||
"number": number,
|
||||
"title": title.strip(),
|
||||
"state": state,
|
||||
"updated_at": updated_at,
|
||||
"url": url,
|
||||
"last_seen_updated_at": last_seen_updated_at,
|
||||
}
|
||||
kept_updated_at = raw.get("kept_updated_at")
|
||||
if kept_updated_at is not None:
|
||||
if not isinstance(kept_updated_at, str) or not kept_updated_at or len(kept_updated_at) > 64:
|
||||
raise ValueError("kept update is invalid")
|
||||
item["kept_updated_at"] = kept_updated_at
|
||||
reviewed_title = raw.get("reviewed_title")
|
||||
if reviewed_title is not None:
|
||||
if not isinstance(reviewed_title, str) or not reviewed_title.strip() or len(reviewed_title.strip()) > 300:
|
||||
raise ValueError("reviewed title is invalid")
|
||||
item["reviewed_title"] = reviewed_title.strip()
|
||||
reviewed_state = raw.get("reviewed_state")
|
||||
if reviewed_state is not None:
|
||||
if reviewed_state not in _STATES:
|
||||
raise ValueError("reviewed state is invalid")
|
||||
item["reviewed_state"] = reviewed_state
|
||||
return item
|
||||
|
||||
@classmethod
|
||||
def _present(cls, snapshot: dict) -> dict:
|
||||
changed = []
|
||||
unchanged = []
|
||||
for raw in snapshot["items"]:
|
||||
stored = cls._normalize_item(raw)
|
||||
unseen = (stored["updated_at"] != stored["last_seen_updated_at"] or
|
||||
stored.get("kept_updated_at") == stored["updated_at"])
|
||||
item = {key: value for key, value in stored.items()
|
||||
if key not in {"last_seen_updated_at", "kept_updated_at",
|
||||
"reviewed_title", "reviewed_state"}}
|
||||
item["has_unseen_change"] = unseen
|
||||
if unseen:
|
||||
item["reviewed_at"] = stored["last_seen_updated_at"]
|
||||
if stored.get("reviewed_state") == "open" and stored["state"] == "closed":
|
||||
item["change_summary"] = "Closed since last review"
|
||||
elif stored.get("reviewed_state") == "closed" and stored["state"] == "open":
|
||||
item["change_summary"] = "Reopened since last review"
|
||||
elif stored.get("reviewed_title") not in {None, stored["title"]}:
|
||||
item["change_summary"] = "Title changed since last review"
|
||||
else:
|
||||
item["change_summary"] = "New activity"
|
||||
(changed if unseen else unchanged).append(item)
|
||||
changed.sort(key=lambda item: item["updated_at"], reverse=True)
|
||||
return {"revision": snapshot["revision"], "items": changed + unchanged}
|
||||
|
||||
@staticmethod
|
||||
def _identity(item: dict) -> tuple[str, str, int]:
|
||||
return item.get("kind", "issue"), item["repository"].lower(), item["number"]
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot, legacy = self._snapshot(row, login)
|
||||
snapshot["items"] = [self._normalize_item(item) for item in snapshot["items"]]
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE following_issues SET items = ? WHERE login = ? AND items = ?",
|
||||
(self._seal(login, snapshot["items"]), login, row[1]),
|
||||
)
|
||||
return self._present(snapshot)
|
||||
|
||||
def preflight(self, login: str, raw_item: dict, watching: bool) -> dict:
|
||||
"""Validate a requested change and capacity without mutating the registry."""
|
||||
login = self._login(login)
|
||||
item = self._normalize_item(raw_item)
|
||||
if not isinstance(watching, bool):
|
||||
raise ValueError("watching is invalid")
|
||||
if not watching:
|
||||
return item
|
||||
identity = self._identity(item)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
if (
|
||||
len(current["items"]) >= self.limit
|
||||
and not any(self._identity(candidate) == identity for candidate in current["items"])
|
||||
):
|
||||
raise ValueError(f"following is limited to {self.limit} issues")
|
||||
return item
|
||||
|
||||
def set_watching(self, login: str, raw_item: dict, watching: bool) -> dict:
|
||||
login = self._login(login)
|
||||
item = self._normalize_item(raw_item)
|
||||
if not isinstance(watching, bool):
|
||||
raise ValueError("watching is invalid")
|
||||
identity = self._identity(item)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
items = [self._normalize_item(candidate) for candidate in current["items"]]
|
||||
index = next(
|
||||
(position for position, candidate in enumerate(items)
|
||||
if self._identity(candidate) == identity),
|
||||
None,
|
||||
)
|
||||
if watching:
|
||||
if index is None:
|
||||
if len(items) >= self.limit:
|
||||
raise ValueError(f"following is limited to {self.limit} issues")
|
||||
items.insert(0, item)
|
||||
else:
|
||||
item["last_seen_updated_at"] = items[index]["last_seen_updated_at"]
|
||||
for key in ("kept_updated_at", "reviewed_title", "reviewed_state"):
|
||||
if key in items[index]:
|
||||
item[key] = items[index][key]
|
||||
if items[index] == item:
|
||||
return self._present({"revision": current["revision"], "items": items})
|
||||
items.pop(index)
|
||||
items.insert(0, item)
|
||||
elif index is None:
|
||||
return self._present({"revision": current["revision"], "items": items})
|
||||
else:
|
||||
items.pop(index)
|
||||
revision = current["revision"] + 1
|
||||
connection.execute(
|
||||
"INSERT INTO following_issues(login, revision, items) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, items=excluded.items",
|
||||
(login, revision, self._seal(login, items)),
|
||||
)
|
||||
return self._present({"revision": revision, "items": items})
|
||||
|
||||
def refresh(self, login: str, raw_items: list[dict]) -> dict:
|
||||
"""Merge successful upstream snapshots while preserving seen revisions."""
|
||||
login = self._login(login)
|
||||
fresh = {self._identity(item): self._normalize_item(item) for item in raw_items}
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
items = [self._normalize_item(candidate) for candidate in current["items"]]
|
||||
changed = False
|
||||
for index, item in enumerate(items):
|
||||
update = fresh.get(self._identity(item))
|
||||
if update is None:
|
||||
continue
|
||||
update["last_seen_updated_at"] = item["last_seen_updated_at"]
|
||||
for key in ("kept_updated_at", "reviewed_title", "reviewed_state"):
|
||||
if key in item:
|
||||
update[key] = item[key]
|
||||
if update["updated_at"] != item["updated_at"]:
|
||||
update.setdefault("reviewed_title", item["title"])
|
||||
update.setdefault("reviewed_state", item["state"])
|
||||
if update != item:
|
||||
items[index] = update
|
||||
changed = True
|
||||
revision = current["revision"]
|
||||
if changed:
|
||||
revision += 1
|
||||
connection.execute(
|
||||
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
|
||||
(revision, self._seal(login, items), login),
|
||||
)
|
||||
return self._present({"revision": revision, "items": items})
|
||||
|
||||
def acknowledge(
|
||||
self,
|
||||
login: str,
|
||||
repository: str,
|
||||
number: int,
|
||||
updated_at: str,
|
||||
*,
|
||||
kind: str = "issue",
|
||||
) -> dict:
|
||||
"""Mark only the exact upstream revision successfully opened by the operator."""
|
||||
login = self._login(login)
|
||||
if kind not in _KINDS:
|
||||
raise ValueError("kind is invalid")
|
||||
identity = (kind, str(repository).lower(), number)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
items = [self._normalize_item(candidate) for candidate in current["items"]]
|
||||
revision = current["revision"]
|
||||
for item in items:
|
||||
if self._identity(item) != identity or item["updated_at"] != updated_at:
|
||||
continue
|
||||
if (item["last_seen_updated_at"] != updated_at or
|
||||
item.get("kept_updated_at") == updated_at):
|
||||
item["last_seen_updated_at"] = updated_at
|
||||
item.pop("kept_updated_at", None)
|
||||
item.pop("reviewed_title", None)
|
||||
item.pop("reviewed_state", None)
|
||||
revision += 1
|
||||
connection.execute(
|
||||
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
|
||||
(revision, self._seal(login, items), login),
|
||||
)
|
||||
break
|
||||
return self._present({"revision": revision, "items": items})
|
||||
|
||||
def keep_unseen(
|
||||
self,
|
||||
login: str,
|
||||
repository: str,
|
||||
number: int,
|
||||
updated_at: str,
|
||||
*,
|
||||
kind: str = "issue",
|
||||
) -> dict:
|
||||
"""Restore only the exact loaded revision to the unseen review queue."""
|
||||
login = self._login(login)
|
||||
if kind not in _KINDS:
|
||||
raise ValueError("kind is invalid")
|
||||
identity = (kind, str(repository).lower(), number)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
items = [self._normalize_item(candidate) for candidate in current["items"]]
|
||||
revision = current["revision"]
|
||||
for item in items:
|
||||
if self._identity(item) != identity or item["updated_at"] != updated_at:
|
||||
continue
|
||||
if item.get("kept_updated_at") != updated_at:
|
||||
item["kept_updated_at"] = updated_at
|
||||
revision += 1
|
||||
connection.execute(
|
||||
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
|
||||
(revision, self._seal(login, items), login),
|
||||
)
|
||||
break
|
||||
return self._present({"revision": revision, "items": items})
|
||||
@@ -33,14 +33,19 @@ FEATURE_SOURCES = {
|
||||
"static/device-storage.js", "static/mobile-device-setup.js",
|
||||
),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"planning": (
|
||||
"static/plan-today.js", "static/plan-today-readiness.js",
|
||||
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js", "static/following.js",
|
||||
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
|
||||
),
|
||||
"today-timer": (
|
||||
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-composer-viewport.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
"static/search-batch-plan.js", "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
),
|
||||
}
|
||||
@@ -90,7 +95,7 @@ def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> Runti
|
||||
digest = hashlib.sha256(content).hexdigest()[:16]
|
||||
return RuntimeBundle(
|
||||
runtime_bytes=content,
|
||||
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
|
||||
runtime_gzip_bytes=gzip.compress(content, compresslevel=9, mtime=0),
|
||||
runtime_digest=digest,
|
||||
runtime_name=f"{prefix}-{digest}.js",
|
||||
)
|
||||
|
||||
+427
-17
@@ -370,6 +370,7 @@ WORK_SEARCHES = {
|
||||
"filed": ("created=true", "issues", "created_by_me"),
|
||||
"pull": ("assigned=true", "pulls", "assigned_to_me"),
|
||||
"review": ("review_requested=true", "pulls", "review_requested"),
|
||||
"authored": ("created=true", "pulls", "authored_by_me"),
|
||||
}
|
||||
|
||||
|
||||
@@ -608,6 +609,8 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
|
||||
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
|
||||
"state": state,
|
||||
"updated_at": issue.get("updated_at", "")
|
||||
if isinstance(issue.get("updated_at"), str) else "",
|
||||
"author": author.get("login", "") if isinstance(author.get("login"), str) else "",
|
||||
"labels": [
|
||||
label["name"] for label in labels
|
||||
@@ -1007,6 +1010,39 @@ async def _notification_subscription(repository: str, number: str) -> dict:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
async def issue_subscription(repository: str, number: int) -> dict:
|
||||
"""Return the current operator's authoritative issue subscription state."""
|
||||
value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("Gitea subscription response was not an object")
|
||||
return {
|
||||
"watching": value.get("subscribed") is True and value.get("ignored") is not True
|
||||
}
|
||||
|
||||
|
||||
async def set_issue_subscription(repository: str, number: int, watching: bool) -> dict:
|
||||
"""Set and then confirm the current operator's issue subscription state."""
|
||||
user = await fetch("user")
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("Gitea did not identify the current operator")
|
||||
path = (
|
||||
f"/api/v1/repos/{repository}/issues/{number}/subscriptions/"
|
||||
f"{quote(login, safe='')}"
|
||||
)
|
||||
client = _get_client()
|
||||
response = (
|
||||
await client.put(path, headers=_auth())
|
||||
if watching
|
||||
else await client.delete(path, headers=_auth())
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = await issue_subscription(repository, number)
|
||||
if confirmed["watching"] is not watching:
|
||||
raise ValueError("Gitea did not confirm the requested subscription state")
|
||||
return confirmed
|
||||
|
||||
|
||||
async def notification_detail(thread_id: int) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -1927,6 +1963,182 @@ async def pull_handoff_candidates(repository: str) -> list[dict]:
|
||||
][:25]
|
||||
|
||||
|
||||
async def pull_review_candidates(repository: str, number: int) -> list[dict]:
|
||||
pull, candidates = await asyncio.gather(
|
||||
fetch(f"repos/{repository}/pulls/{number}"),
|
||||
pull_handoff_candidates(repository),
|
||||
)
|
||||
if not isinstance(pull, dict):
|
||||
raise ValueError("Gitea pull request response was not an object")
|
||||
excluded = {
|
||||
item["login"].casefold()
|
||||
for item in (pull.get("requested_reviewers") or [])
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
}
|
||||
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
if isinstance(author.get("login"), str):
|
||||
excluded.add(author["login"].casefold())
|
||||
return [
|
||||
item for item in candidates
|
||||
if item["login"].casefold() not in excluded
|
||||
][:25]
|
||||
|
||||
|
||||
async def request_assigned_pull_review(
|
||||
repository: str, number: int, reviewer: str, expected_head_sha: str
|
||||
) -> dict:
|
||||
login, pull = await _current_login_and_target(
|
||||
f"repos/{repository}/pulls/{number}"
|
||||
)
|
||||
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull.get("merged") is True
|
||||
or not _login_in_users(login, pull.get("assignees"))
|
||||
or head.get("sha") != expected_head_sha
|
||||
):
|
||||
raise IssueNotAvailableError("Pull request is no longer eligible for review request")
|
||||
eligible = {
|
||||
item["login"] for item in await pull_review_candidates(repository, number)
|
||||
}
|
||||
if reviewer not in eligible:
|
||||
raise IssueNotAvailableError("Reviewer is not eligible")
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/pulls/{number}/requested_reviewers",
|
||||
headers=_auth(),
|
||||
json={"reviewers": [reviewer]},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed_response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth()
|
||||
)
|
||||
confirmed_response.raise_for_status()
|
||||
confirmed = confirmed_response.json()
|
||||
requested = [
|
||||
item["login"] for item in (confirmed.get("requested_reviewers") or [])
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
] if isinstance(confirmed, dict) else []
|
||||
if reviewer not in requested:
|
||||
raise ValueError("Gitea did not confirm the requested reviewer")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"head_sha": expected_head_sha,
|
||||
"requested_reviewers": requested,
|
||||
"reviewer": reviewer,
|
||||
}
|
||||
|
||||
|
||||
async def publish_authored_assigned_pull(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> dict:
|
||||
login, pull = await _current_login_and_target(
|
||||
f"repos/{repository}/pulls/{number}"
|
||||
)
|
||||
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
|
||||
title = pull.get("title") if isinstance(pull.get("title"), str) else ""
|
||||
ready_title = re.sub(
|
||||
r"^(?:WIP\s*:|\[WIP\]|Draft\s*:|\[Draft\])\s*",
|
||||
"",
|
||||
title,
|
||||
count=1,
|
||||
flags=re.IGNORECASE,
|
||||
).strip()
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull.get("merged") is True
|
||||
or pull.get("draft") is not True
|
||||
or author.get("login", "").casefold() != login.casefold()
|
||||
or not _login_in_users(login, pull.get("assignees"))
|
||||
or head.get("sha") != expected_head_sha
|
||||
or not ready_title
|
||||
or ready_title == title
|
||||
):
|
||||
raise IssueNotAvailableError("Draft pull request is no longer publishable")
|
||||
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/pulls/{number}",
|
||||
headers=_auth(),
|
||||
json={"title": ready_title},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed_response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth()
|
||||
)
|
||||
confirmed_response.raise_for_status()
|
||||
confirmed = confirmed_response.json()
|
||||
confirmed_head = (
|
||||
confirmed.get("head")
|
||||
if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict)
|
||||
else {}
|
||||
)
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or confirmed.get("state") != "open"
|
||||
or confirmed.get("draft") is not False
|
||||
or confirmed.get("title") != ready_title
|
||||
or confirmed_head.get("sha") != expected_head_sha
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the pull request is ready")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": ready_title,
|
||||
"head_sha": expected_head_sha,
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
}
|
||||
|
||||
|
||||
async def cancel_assigned_pull_review(
|
||||
repository: str, number: int, reviewer: str, expected_head_sha: str
|
||||
) -> dict:
|
||||
login, pull = await _current_login_and_target(
|
||||
f"repos/{repository}/pulls/{number}"
|
||||
)
|
||||
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
|
||||
requested = [
|
||||
item["login"] for item in (pull.get("requested_reviewers") or [])
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
]
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull.get("merged") is True
|
||||
or not _login_in_users(login, pull.get("assignees"))
|
||||
or head.get("sha") != expected_head_sha
|
||||
or reviewer not in requested
|
||||
):
|
||||
raise IssueNotAvailableError("Pending review request changed")
|
||||
response = await _get_client().request(
|
||||
"DELETE",
|
||||
f"/api/v1/repos/{repository}/pulls/{number}/requested_reviewers",
|
||||
headers=_auth(),
|
||||
json={"reviewers": [reviewer]},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed_response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth()
|
||||
)
|
||||
confirmed_response.raise_for_status()
|
||||
confirmed = confirmed_response.json()
|
||||
remaining = [
|
||||
item["login"] for item in (confirmed.get("requested_reviewers") or [])
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
] if isinstance(confirmed, dict) else []
|
||||
confirmed_head = confirmed.get("head") if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict) else {}
|
||||
if reviewer in remaining or confirmed_head.get("sha") != expected_head_sha:
|
||||
raise ValueError("Gitea did not confirm reviewer cancellation")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"head_sha": expected_head_sha,
|
||||
"requested_reviewers": remaining,
|
||||
"reviewer": reviewer,
|
||||
}
|
||||
|
||||
|
||||
async def _change_assigned_pull_owners(
|
||||
repository: str, number: int, recipient: str | None
|
||||
) -> dict:
|
||||
@@ -2557,29 +2769,34 @@ async def is_open_authored_issue(repository: str, number: int) -> bool:
|
||||
|
||||
|
||||
async def pull_requests() -> WorkItems:
|
||||
assigned, review_requested = await asyncio.gather(
|
||||
work_page("pull"),
|
||||
work_page("review"),
|
||||
streams = ("pull", "review", "authored")
|
||||
outcomes = await asyncio.gather(
|
||||
*(work_page(stream) for stream in streams),
|
||||
return_exceptions=True,
|
||||
)
|
||||
for outcome in outcomes:
|
||||
if isinstance(outcome, asyncio.CancelledError):
|
||||
raise outcome
|
||||
if all(isinstance(outcome, BaseException) for outcome in outcomes):
|
||||
raise outcomes[0]
|
||||
|
||||
merged: dict[int, dict] = {}
|
||||
for result in (
|
||||
assigned,
|
||||
review_requested,
|
||||
):
|
||||
for pull in result["items"]:
|
||||
pagination = {}
|
||||
for stream, outcome in zip(streams, outcomes, strict=True):
|
||||
if isinstance(outcome, BaseException):
|
||||
pagination[stream] = {
|
||||
"page": 1, "total": 0, "has_more": False, "unavailable": True,
|
||||
}
|
||||
continue
|
||||
pagination[stream] = _page_metadata(outcome)
|
||||
for pull in outcome["items"]:
|
||||
identity = pull.get("id")
|
||||
if identity not in merged:
|
||||
merged[identity] = {**pull, "work_reasons": []}
|
||||
for reason in pull.get("work_reasons", []):
|
||||
if reason not in merged[identity]["work_reasons"]:
|
||||
merged[identity]["work_reasons"].append(reason)
|
||||
return WorkItems(
|
||||
list(merged.values()),
|
||||
{
|
||||
"pull": _page_metadata(assigned),
|
||||
"review": _page_metadata(review_requested),
|
||||
},
|
||||
)
|
||||
return WorkItems(list(merged.values()), pagination)
|
||||
|
||||
|
||||
async def is_requested_review(repository: str, number: int) -> bool:
|
||||
@@ -2602,6 +2819,52 @@ async def is_assigned_pull(repository: str, number: int) -> bool:
|
||||
)
|
||||
|
||||
|
||||
async def update_authored_assigned_pull(
|
||||
repository: str,
|
||||
number: int,
|
||||
title: str,
|
||||
body: str,
|
||||
expected_head_sha: str,
|
||||
) -> dict:
|
||||
path = f"repos/{repository}/pulls/{number}"
|
||||
login, pull = await _current_login_and_target(path)
|
||||
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull.get("merged") is True
|
||||
or author.get("login") != login
|
||||
or not _login_in_users(login, pull.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("pull request not found")
|
||||
if head.get("sha") != expected_head_sha:
|
||||
raise IssueEditConflictError("pull request head changed upstream")
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/{path}",
|
||||
headers=_auth(),
|
||||
json={"title": title, "body": body},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
confirmed_head = confirmed.get("head") if isinstance(confirmed.get("head"), dict) else {}
|
||||
if (
|
||||
confirmed.get("number") != number
|
||||
or confirmed.get("title") != title
|
||||
or confirmed.get("body", "") != body
|
||||
or confirmed_head.get("sha") != expected_head_sha
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the pull request update")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"head_sha": expected_head_sha,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"url": _safe_gitea_web_url(confirmed.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
async def pull_completion_detail(repository: str, number: int) -> dict:
|
||||
base = f"repos/{repository}/pulls/{number}"
|
||||
pull = await fetch(base)
|
||||
@@ -2624,6 +2887,106 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _normalize_review_comments(comments: object) -> list[dict]:
|
||||
normalized = []
|
||||
for comment in (comments if isinstance(comments, list) else [])[:100]:
|
||||
if not isinstance(comment, dict):
|
||||
continue
|
||||
path = comment.get("path")
|
||||
body = comment.get("body")
|
||||
if not isinstance(path, str) or not path.strip() or not isinstance(body, str) or not body.strip():
|
||||
continue
|
||||
item = {"path": path.strip()[:300], "body": body.strip()[:500]}
|
||||
comment_id = comment.get("id")
|
||||
if isinstance(comment_id, int) and comment_id > 0:
|
||||
item["id"] = comment_id
|
||||
position = comment.get("new_position") or comment.get("old_position")
|
||||
if isinstance(position, int) and position > 0:
|
||||
item["line"] = position
|
||||
normalized.append(item)
|
||||
if len(normalized) == 20:
|
||||
break
|
||||
return normalized
|
||||
|
||||
|
||||
def _latest_feedback_review_ids(reviews: object) -> dict[str, int]:
|
||||
latest: dict[str, tuple[int, str]] = {}
|
||||
for review in (reviews if isinstance(reviews, list) else [])[:100]:
|
||||
if not isinstance(review, dict):
|
||||
continue
|
||||
user = review.get("user") if isinstance(review.get("user"), dict) else {}
|
||||
login = user.get("login")
|
||||
review_id = review.get("id")
|
||||
state = review.get("state")
|
||||
if (
|
||||
not isinstance(login, str) or not login or not isinstance(review_id, int)
|
||||
or review_id <= 0 or not isinstance(state, str)
|
||||
):
|
||||
continue
|
||||
key = login.casefold()
|
||||
if key not in latest or review_id >= latest[key][0]:
|
||||
latest[key] = (review_id, state)
|
||||
feedback = [
|
||||
(key, review_id) for key, (review_id, state) in latest.items()
|
||||
if state in {"REQUEST_CHANGES", "COMMENT"}
|
||||
]
|
||||
return dict(feedback[:10])
|
||||
|
||||
|
||||
def _normalize_reviewer_statuses(pull: dict, reviews: object, head_sha: str) -> list[dict]:
|
||||
"""Return one bounded, current decision per reviewer without exposing raw review data."""
|
||||
latest: dict[str, dict] = {}
|
||||
for review in (reviews if isinstance(reviews, list) else [])[:100]:
|
||||
if not isinstance(review, dict):
|
||||
continue
|
||||
user = review.get("user") if isinstance(review.get("user"), dict) else {}
|
||||
login = user.get("login")
|
||||
state = review.get("state")
|
||||
commit_id = review.get("commit_id")
|
||||
if (
|
||||
not isinstance(login, str)
|
||||
or not login
|
||||
or state not in {"APPROVED", "REQUEST_CHANGES", "COMMENT"}
|
||||
or not isinstance(commit_id, str)
|
||||
or not commit_id
|
||||
):
|
||||
continue
|
||||
previous = latest.get(login.casefold())
|
||||
review_id = review.get("id") if isinstance(review.get("id"), int) else 0
|
||||
if previous is None or review_id >= previous["id"]:
|
||||
body = review.get("body")
|
||||
latest[login.casefold()] = {
|
||||
"id": review_id, "login": login, "state": state, "commit_id": commit_id,
|
||||
"summary": body.strip()[:500] if isinstance(body, str) and body.strip() else "",
|
||||
}
|
||||
|
||||
statuses: dict[str, dict] = {}
|
||||
state_names = {
|
||||
"APPROVED": "approved", "REQUEST_CHANGES": "changes_requested", "COMMENT": "commented",
|
||||
}
|
||||
for key, review in latest.items():
|
||||
current = review["commit_id"] == head_sha
|
||||
status = state_names[review["state"]] if current else "outdated"
|
||||
statuses[key] = {
|
||||
"review_id": review["id"],
|
||||
"login": review["login"],
|
||||
"status": status,
|
||||
"head_sha": review["commit_id"],
|
||||
"blocking": status in {"changes_requested", "outdated"},
|
||||
}
|
||||
if review["summary"]:
|
||||
statuses[key]["summary"] = review["summary"]
|
||||
|
||||
requested = pull.get("requested_reviewers") if isinstance(pull, dict) else []
|
||||
for reviewer in (requested if isinstance(requested, list) else [])[:25]:
|
||||
login = reviewer.get("login") if isinstance(reviewer, dict) else None
|
||||
if isinstance(login, str) and login:
|
||||
statuses[login.casefold()] = {
|
||||
"login": login, "status": "waiting", "head_sha": head_sha, "blocking": True,
|
||||
}
|
||||
return sorted(statuses.values(), key=lambda item: item["login"].casefold())
|
||||
|
||||
|
||||
async def pull_completion_review(repository: str, number: int) -> dict:
|
||||
base = f"repos/{repository}/pulls/{number}"
|
||||
pull = await fetch(base)
|
||||
@@ -2633,15 +2996,17 @@ async def pull_completion_review(repository: str, number: int) -> dict:
|
||||
head: dict = head_value if isinstance(head_value, dict) else {}
|
||||
sha_value = head.get("sha")
|
||||
sha = sha_value if isinstance(sha_value, str) else ""
|
||||
files, status, diff_result = await asyncio.gather(
|
||||
files, status, diff_result, reviews = await asyncio.gather(
|
||||
fetch(f"{base}/files"),
|
||||
fetch(f"repos/{repository}/commits/{sha}/status"),
|
||||
fetch_text(
|
||||
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
|
||||
),
|
||||
fetch(f"{base}/reviews?limit=100"),
|
||||
)
|
||||
diff, diff_truncated = diff_result
|
||||
previews = _diff_previews(diff, diff_truncated)
|
||||
reviewers = _normalize_reviewer_statuses(pull, reviews, sha)
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
@@ -2652,6 +3017,7 @@ async def pull_completion_review(repository: str, number: int) -> dict:
|
||||
"merged": pull.get("merged") is True,
|
||||
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
|
||||
"checks": _normalize_commit_checks(status),
|
||||
"reviewers": reviewers,
|
||||
"files": [
|
||||
{
|
||||
"filename": item.get("filename", ""),
|
||||
@@ -2674,6 +3040,40 @@ async def pull_completion_review(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def pull_review_feedback(
|
||||
repository: str, number: int, review_id: int, expected_head_sha: str
|
||||
) -> dict:
|
||||
"""Load optional inline feedback only for a review on the confirmed pull head."""
|
||||
base = f"repos/{repository}/pulls/{number}"
|
||||
pull, reviews = await asyncio.gather(
|
||||
fetch(base),
|
||||
fetch(f"{base}/reviews?limit=100"),
|
||||
)
|
||||
head = pull.get("head") if isinstance(pull, dict) else None
|
||||
head_sha = head.get("sha") if isinstance(head, dict) else None
|
||||
if head_sha != expected_head_sha:
|
||||
raise StaleReviewError("Pull request head changed")
|
||||
review_items = reviews if isinstance(reviews, list) else []
|
||||
latest_ids = set(_latest_feedback_review_ids(review_items).values())
|
||||
review = next(
|
||||
(
|
||||
item for item in review_items
|
||||
if isinstance(item, dict) and item.get("id") == review_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if review is None or review_id not in latest_ids:
|
||||
raise StaleReviewError("Review feedback is no longer current")
|
||||
comments = await fetch(f"{base}/reviews/{review_id}/comments")
|
||||
reviewed_head = review.get("commit_id")
|
||||
return {
|
||||
"review_id": review_id,
|
||||
"head_sha": head_sha,
|
||||
"reviewed_head_sha": reviewed_head if isinstance(reviewed_head, str) else "",
|
||||
"comments": _normalize_review_comments(comments),
|
||||
}
|
||||
|
||||
|
||||
async def release_receipt_status(repository: str, commit_sha: str) -> dict:
|
||||
"""Return CI and release evidence for one exact merge commit."""
|
||||
status, releases = await asyncio.gather(
|
||||
@@ -2726,7 +3126,10 @@ async def pull_check_status(repository: str, number: int) -> dict:
|
||||
head: dict = head_value if isinstance(head_value, dict) else {}
|
||||
sha_value = head.get("sha")
|
||||
sha = sha_value if isinstance(sha_value, str) else ""
|
||||
status = await fetch(f"repos/{repository}/commits/{sha}/status")
|
||||
status, reviews = await asyncio.gather(
|
||||
fetch(f"repos/{repository}/commits/{sha}/status"),
|
||||
fetch(f"{base}/reviews?limit=100"),
|
||||
)
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
@@ -2737,6 +3140,7 @@ async def pull_check_status(repository: str, number: int) -> dict:
|
||||
"merged": pull.get("merged") is True,
|
||||
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
|
||||
"checks": _normalize_commit_checks(status),
|
||||
"reviewers": _normalize_reviewer_statuses(pull, reviews, sha),
|
||||
}
|
||||
|
||||
|
||||
@@ -2777,6 +3181,12 @@ async def merge_assigned_pull(
|
||||
or ci_state != "success"
|
||||
):
|
||||
raise PullNotMergeableError("Pull request is not currently safe to merge")
|
||||
reviews = await fetch(f"{base}/reviews?limit=100")
|
||||
if any(
|
||||
reviewer["blocking"]
|
||||
for reviewer in _normalize_reviewer_statuses(pull, reviews, current_sha)
|
||||
):
|
||||
raise PullNotMergeableError("Pull request review is not currently resolved")
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/{base}/merge",
|
||||
headers=_auth(),
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, private_state_encryption_key
|
||||
from src.state_encryption import PrivateStateCipher, private_state_encryption_config
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -38,7 +38,7 @@ class IdempotencyLedger:
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
self.clock = clock
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="idempotency-ledger",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
+4
-4
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
class LaterStore:
|
||||
@@ -26,7 +26,7 @@ class LaterStore:
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="later",
|
||||
)
|
||||
self._initialize()
|
||||
@@ -124,7 +124,7 @@ class LaterStore:
|
||||
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
|
||||
(login,),
|
||||
):
|
||||
if stored_item_id.startswith("v1:"):
|
||||
if stored_item_id.startswith(("v1:", "v2:")):
|
||||
item_id, _legacy = self._cipher.open(
|
||||
stored_item_id, binding=f"item-revision:{login}"
|
||||
)
|
||||
@@ -141,7 +141,7 @@ class LaterStore:
|
||||
) -> dict[str, str]:
|
||||
migrated = dict(stored_ids)
|
||||
for item_id, stored_item_id in stored_ids.items():
|
||||
if stored_item_id.startswith("v1:"):
|
||||
if stored_item_id.startswith(("v1:", "v2:")):
|
||||
continue
|
||||
sealed_item_id = self._cipher.seal(
|
||||
item_id, binding=f"item-revision:{login}"
|
||||
|
||||
@@ -15,7 +15,7 @@ from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import (
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
private_state_encryption_key,
|
||||
private_state_encryption_config,
|
||||
)
|
||||
|
||||
SECTIONS = ("context", "events", "notifications")
|
||||
@@ -63,7 +63,7 @@ class LiveSnapshotStore:
|
||||
self.path = Path(path)
|
||||
self.clock = clock or time.time
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="live-snapshot",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
+956
-7
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
@@ -99,6 +100,135 @@ async def send_web_push(
|
||||
)
|
||||
|
||||
|
||||
async def dispatch_following_changes(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
following: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
lease_seconds: float = 60.0,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
) -> int:
|
||||
"""Notify opted-in devices once for each privacy-safe Following change set."""
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="following",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
devices = await asyncio.to_thread(store.following_notification_devices)
|
||||
if not devices:
|
||||
return 0
|
||||
snapshot = await following()
|
||||
if not isinstance(snapshot, dict) or snapshot.get("complete") is False:
|
||||
return 0
|
||||
changed = []
|
||||
for item in snapshot.get("items", []):
|
||||
if not isinstance(item, dict) or item.get("has_unseen_change") is not True:
|
||||
continue
|
||||
repository = item.get("repository")
|
||||
kind = item.get("kind")
|
||||
number = item.get("number")
|
||||
updated_at = item.get("updated_at")
|
||||
if (
|
||||
isinstance(repository, str)
|
||||
and kind in {"issue", "pull"}
|
||||
and isinstance(number, int)
|
||||
and not isinstance(number, bool)
|
||||
and number > 0
|
||||
and isinstance(updated_at, str)
|
||||
and updated_at
|
||||
):
|
||||
changed.append((repository.lower(), kind, number, updated_at))
|
||||
changed.sort()
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps(changed, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
if not changed:
|
||||
await asyncio.gather(*(
|
||||
asyncio.to_thread(store.mark_following_delivered, device.session_id, fingerprint)
|
||||
for device in devices
|
||||
))
|
||||
return 0
|
||||
pending = [device for device in devices if device.delivered_fingerprint != fingerprint]
|
||||
if not pending:
|
||||
return 0
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses([device.session_id for device in pending])
|
||||
except Exception:
|
||||
return 0
|
||||
for device in pending:
|
||||
if statuses.get(device.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
pending = [
|
||||
device for device in pending if statuses.get(device.session_id) == "active"
|
||||
]
|
||||
count = min(len(changed), 50)
|
||||
payload = json.dumps({
|
||||
"title": f"{count} watched item{'s' if count != 1 else ''} changed",
|
||||
"body": "Open Following to review the latest activity.",
|
||||
"route": "#/my-work/following",
|
||||
"tag": f"stackchain-following-{fingerprint[:16]}",
|
||||
"following_count": count,
|
||||
}, separators=(",", ":"))
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def dispatch_device(device) -> int:
|
||||
async with semaphore:
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="following",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not still_owner:
|
||||
return 0
|
||||
try:
|
||||
operation = (
|
||||
send(device.subscription, payload)
|
||||
if send is not None
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
device.session_id,
|
||||
"following",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, device.session_id, "following"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_following_delivered, device.session_id, fingerprint
|
||||
)
|
||||
return 1
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(dispatch_device(device) for device in pending), return_exceptions=True
|
||||
)
|
||||
return sum(result for result in results if isinstance(result, int))
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="following")
|
||||
|
||||
|
||||
async def dispatch_unread_updates(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
@@ -133,6 +263,7 @@ async def dispatch_unread_updates(
|
||||
for item in page.get("items", [])
|
||||
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
|
||||
}
|
||||
unread_count = min(len(thread_revisions), 9999)
|
||||
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
|
||||
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
|
||||
if session_statuses is not None:
|
||||
@@ -201,6 +332,7 @@ async def dispatch_unread_updates(
|
||||
"route": f"#/my-work/update/{thread_id}",
|
||||
"tag": f"stackchain-update-{thread_id}",
|
||||
"notification_id": thread_id,
|
||||
"unread_count": unread_count,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
@@ -258,6 +390,7 @@ async def dispatch_unread_updates(
|
||||
"route": "#/my-work/updates",
|
||||
"tag": "stackchain-update-digest",
|
||||
"update_count": len(overflow_revisions),
|
||||
"unread_count": unread_count,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
@@ -356,6 +489,125 @@ async def dispatch_deadline_reminders(
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="deadline")
|
||||
|
||||
|
||||
async def dispatch_start_day_reminders(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
tomorrow: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
lease_seconds: float = 60.0,
|
||||
max_concurrency: int = 8,
|
||||
) -> int:
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="start-day",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
devices = await asyncio.to_thread(store.start_day_reminder_devices)
|
||||
if not devices:
|
||||
return 0
|
||||
current = now or datetime.now(timezone.utc)
|
||||
due_devices = []
|
||||
for device in devices:
|
||||
try:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
except ZoneInfoNotFoundError:
|
||||
continue
|
||||
if local_now.hour >= device.reminder_hour:
|
||||
due_devices.append((device, local_now.date().isoformat()))
|
||||
if not due_devices:
|
||||
return 0
|
||||
plan = await tomorrow()
|
||||
ids = plan.get("ids") if isinstance(plan, dict) else None
|
||||
plan_date = plan.get("plan_date") if isinstance(plan, dict) else None
|
||||
if not isinstance(ids, list) or not ids or not isinstance(plan_date, str):
|
||||
return 0
|
||||
due_devices = [
|
||||
(device, local_day) for device, local_day in due_devices
|
||||
if local_day >= plan_date and device.delivered_plan_date != plan_date
|
||||
]
|
||||
if not due_devices:
|
||||
return 0
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses(
|
||||
[device.session_id for device, _local_day in due_devices]
|
||||
)
|
||||
except Exception:
|
||||
return 0
|
||||
for device, _local_day in due_devices:
|
||||
if statuses.get(device.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
due_devices = [
|
||||
pair for pair in due_devices
|
||||
if statuses.get(pair[0].session_id) == "active"
|
||||
]
|
||||
payload = json.dumps({
|
||||
"title": "Your planned day is ready",
|
||||
"body": "Open Stackchain to prepare Today.",
|
||||
"route": "#/my-work/start-day",
|
||||
"tag": f"stackchain-start-day-{plan_date}",
|
||||
"plan_date": plan_date,
|
||||
}, separators=(",", ":"))
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def dispatch_device(device) -> int:
|
||||
async with semaphore:
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="start-day",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not still_owner:
|
||||
return 0
|
||||
try:
|
||||
operation = (
|
||||
send(device.subscription, payload) if send is not None
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
device.session_id,
|
||||
"start-day",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, device.session_id, "start-day"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_start_day_reminder_delivered, device.session_id, plan_date
|
||||
)
|
||||
return 1
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(dispatch_device(device) for device, _local_day in due_devices),
|
||||
return_exceptions=True,
|
||||
)
|
||||
return sum(result for result in results if isinstance(result, int))
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="start-day")
|
||||
|
||||
|
||||
async def _dispatch_deadline_reminders_unlocked(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
|
||||
@@ -43,6 +43,22 @@ class DeadlineReminderDevice:
|
||||
snoozed_until: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StartDayReminderDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
timezone: str
|
||||
reminder_hour: int
|
||||
delivered_plan_date: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FollowingNotificationDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
delivered_fingerprint: str | None
|
||||
|
||||
|
||||
class DisabledPushSubscriptionStore:
|
||||
"""No-persistence store used when Web Push is not configured."""
|
||||
|
||||
@@ -66,6 +82,18 @@ class DisabledPushSubscriptionStore:
|
||||
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
||||
return []
|
||||
|
||||
def start_day_preferences(self, session_id: str) -> dict:
|
||||
return {"enabled": False, "timezone": "UTC", "reminder_hour": 9}
|
||||
|
||||
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
|
||||
return []
|
||||
|
||||
def following_preferences(self, session_id: str) -> dict:
|
||||
return {"enabled": False}
|
||||
|
||||
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
|
||||
return []
|
||||
|
||||
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
|
||||
return []
|
||||
|
||||
@@ -96,6 +124,18 @@ class DisabledPushSubscriptionStore:
|
||||
def mark_deadline_reminder_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_start_day_preferences(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_start_day_reminder_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_following_preferences(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_following_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def reconcile_unread(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
@@ -206,6 +246,22 @@ class PushSubscriptionStore:
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_start_day_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
delivered_plan_date TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_following_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
delivered_fingerprint TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
delivery_columns = {
|
||||
@@ -485,6 +541,100 @@ class PushSubscriptionStore:
|
||||
(local_day, session_id),
|
||||
)
|
||||
|
||||
def set_start_day_preferences(
|
||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_start_day_preferences(
|
||||
session_id, enabled, timezone, reminder_hour
|
||||
) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
timezone = excluded.timezone,
|
||||
reminder_hour = excluded.reminder_hour""",
|
||||
(session_id, int(enabled), timezone, reminder_hour),
|
||||
)
|
||||
|
||||
def start_day_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT enabled, timezone, reminder_hour
|
||||
FROM push_start_day_preferences WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"enabled": bool(row[0]) if row else False,
|
||||
"timezone": row[1] if row else "UTC",
|
||||
"reminder_hour": row[2] if row else 9,
|
||||
}
|
||||
|
||||
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
||||
p.reminder_hour, p.delivered_plan_date
|
||||
FROM push_subscriptions s
|
||||
JOIN push_start_day_preferences p ON p.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
return [
|
||||
StartDayReminderDevice(
|
||||
row[0], self._open_subscription(row[0], row[1]), row[2], row[3], row[4]
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def mark_start_day_reminder_delivered(
|
||||
self, session_id: str, plan_date: str
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_start_day_preferences SET delivered_plan_date = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(plan_date, session_id),
|
||||
)
|
||||
|
||||
def set_following_preferences(self, session_id: str, *, enabled: bool) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_following_preferences(session_id, enabled)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""",
|
||||
(session_id, int(enabled)),
|
||||
)
|
||||
|
||||
def following_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT enabled FROM push_following_preferences WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {"enabled": bool(row[0]) if row else False}
|
||||
|
||||
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.delivered_fingerprint
|
||||
FROM push_subscriptions s
|
||||
JOIN push_following_preferences p ON p.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
return [
|
||||
FollowingNotificationDevice(
|
||||
row[0], self._open_subscription(row[0], row[1]), row[2]
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def mark_following_delivered(self, session_id: str, fingerprint: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_following_preferences SET delivered_fingerprint = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(fingerprint, session_id),
|
||||
)
|
||||
|
||||
def claim_unseen(
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||
) -> list[PushDelivery]:
|
||||
|
||||
@@ -5,7 +5,7 @@ import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
@@ -33,7 +33,7 @@ class SavedSearchStore:
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="saved-searches",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
@@ -10,7 +10,7 @@ from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import (
|
||||
PrivateStateCipher,
|
||||
PrivateStateEncryptionError,
|
||||
private_state_encryption_key,
|
||||
private_state_encryption_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class SecurityEventStore:
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
try:
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="security-events",
|
||||
)
|
||||
except PrivateStateEncryptionError as exc:
|
||||
|
||||
+136
-18
@@ -6,6 +6,8 @@ import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
@@ -16,7 +18,7 @@ class PrivateStateEncryptionError(RuntimeError):
|
||||
|
||||
|
||||
def decode_private_state_encryption_key(encoded: str) -> bytes:
|
||||
"""Decode the independently injected 256-bit private-state key."""
|
||||
"""Decode an independently injected 256-bit private-state key."""
|
||||
try:
|
||||
key = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError) as error:
|
||||
@@ -31,38 +33,130 @@ def decode_private_state_encryption_key(encoded: str) -> bytes:
|
||||
|
||||
|
||||
def private_state_encryption_key() -> bytes:
|
||||
"""Load the legacy single key setting."""
|
||||
return decode_private_state_encryption_key(
|
||||
os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", "")
|
||||
)
|
||||
|
||||
|
||||
_KEY_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
|
||||
_MAX_KEYS = 4
|
||||
|
||||
|
||||
def decode_private_state_encryption_keyring(
|
||||
encoded: str, active_key_id: str
|
||||
) -> tuple[dict[str, bytes], str]:
|
||||
"""Decode a bounded key-ID-to-key JSON object and validate its active key."""
|
||||
try:
|
||||
raw = json.loads(encoded)
|
||||
except (TypeError, json.JSONDecodeError) as error:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption keyring is invalid"
|
||||
) from error
|
||||
if not isinstance(raw, dict) or not raw:
|
||||
raise PrivateStateEncryptionError("private state encryption keyring is invalid")
|
||||
if len(raw) > _MAX_KEYS:
|
||||
raise PrivateStateEncryptionError(
|
||||
f"private state encryption keyring supports at most {_MAX_KEYS} keys"
|
||||
)
|
||||
keys: dict[str, bytes] = {}
|
||||
for key_id, value in raw.items():
|
||||
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
|
||||
raise PrivateStateEncryptionError("private state encryption key ID is invalid")
|
||||
if not isinstance(value, str):
|
||||
raise PrivateStateEncryptionError("private state encryption keyring is invalid")
|
||||
keys[key_id] = decode_private_state_encryption_key(value)
|
||||
if active_key_id not in keys:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state active key is missing from the keyring"
|
||||
)
|
||||
return keys, active_key_id
|
||||
|
||||
|
||||
def private_state_encryption_config() -> bytes | tuple[dict[str, bytes], str]:
|
||||
"""Load a rotation keyring when configured, otherwise the legacy single key."""
|
||||
encoded = os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS", "")
|
||||
active = os.getenv("STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID", "")
|
||||
if encoded or active:
|
||||
return decode_private_state_encryption_keyring(encoded, active)
|
||||
return private_state_encryption_key()
|
||||
|
||||
|
||||
class PrivateStateCipher:
|
||||
"""Seal JSON values with store-specific authenticated context."""
|
||||
|
||||
def __init__(self, key: bytes, *, store: str):
|
||||
if not isinstance(key, bytes) or len(key) != 32:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption requires exactly 32 key bytes"
|
||||
)
|
||||
def __init__(
|
||||
self,
|
||||
key: bytes | Mapping[str, bytes] | tuple[dict[str, bytes], str],
|
||||
*,
|
||||
store: str,
|
||||
active_key_id: str | None = None,
|
||||
):
|
||||
if isinstance(key, tuple):
|
||||
key, active_key_id = key
|
||||
if isinstance(key, Mapping):
|
||||
if not key or len(key) > _MAX_KEYS or active_key_id not in key:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption keyring configuration is invalid"
|
||||
)
|
||||
ciphers: dict[str, AESGCM] = {}
|
||||
for key_id, raw_key in key.items():
|
||||
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption key ID is invalid"
|
||||
)
|
||||
if not isinstance(raw_key, bytes) or len(raw_key) != 32:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption requires exactly 32 key bytes"
|
||||
)
|
||||
ciphers[key_id] = AESGCM(raw_key)
|
||||
self._ciphers = ciphers
|
||||
self._active_key_id = active_key_id
|
||||
self._legacy_cipher = None
|
||||
else:
|
||||
if not isinstance(key, bytes) or len(key) != 32:
|
||||
raise PrivateStateEncryptionError(
|
||||
"private state encryption requires exactly 32 key bytes"
|
||||
)
|
||||
self._ciphers = {}
|
||||
self._active_key_id = None
|
||||
self._legacy_cipher = AESGCM(key)
|
||||
if not store or "\0" in store:
|
||||
raise ValueError("private state store identity is invalid")
|
||||
self._cipher = AESGCM(key)
|
||||
self._store = store
|
||||
|
||||
def _aad(self, binding: str) -> bytes:
|
||||
return f"stackchain:private-state:v1\0{self._store}\0{binding}".encode()
|
||||
def _aad(self, binding: str, *, version: str = "v1", key_id: str = "") -> bytes:
|
||||
prefix = f"stackchain:private-state:{version}\0{self._store}\0{binding}"
|
||||
if version == "v2":
|
||||
prefix += f"\0{key_id}"
|
||||
return prefix.encode()
|
||||
|
||||
def seal(self, value: object, *, binding: str = "singleton") -> str:
|
||||
plaintext = json.dumps(value, separators=(",", ":")).encode()
|
||||
nonce = os.urandom(12)
|
||||
sealed = nonce + self._cipher.encrypt(nonce, plaintext, self._aad(binding))
|
||||
if self._active_key_id is not None:
|
||||
key_id = self._active_key_id
|
||||
sealed = nonce + self._ciphers[key_id].encrypt(
|
||||
nonce, plaintext, self._aad(binding, version="v2", key_id=key_id)
|
||||
)
|
||||
return f"v2:{key_id}:" + base64.urlsafe_b64encode(sealed).decode()
|
||||
sealed = nonce + self._legacy_cipher.encrypt(
|
||||
nonce, plaintext, self._aad(binding)
|
||||
)
|
||||
return "v1:" + base64.urlsafe_b64encode(sealed).decode()
|
||||
|
||||
@staticmethod
|
||||
def _decode_sealed(payload: str) -> bytes:
|
||||
sealed = base64.b64decode(payload, altchars=b"-_", validate=True)
|
||||
if len(sealed) < 28:
|
||||
raise ValueError("encrypted payload is too short")
|
||||
return sealed
|
||||
|
||||
def open(self, payload: str, *, binding: str = "singleton") -> tuple[object, bool]:
|
||||
"""Return the decoded value and whether plaintext migration is required."""
|
||||
"""Return the decoded value and whether active-key rewrapping is required."""
|
||||
if not isinstance(payload, str):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
if not payload.startswith("v1:"):
|
||||
if not payload.startswith(("v1:", "v2:")):
|
||||
try:
|
||||
return json.loads(payload), True
|
||||
except (TypeError, json.JSONDecodeError) as error:
|
||||
@@ -70,13 +164,37 @@ class PrivateStateCipher:
|
||||
"private state could not be decrypted"
|
||||
) from error
|
||||
try:
|
||||
sealed = base64.b64decode(payload[3:], altchars=b"-_", validate=True)
|
||||
if len(sealed) < 28:
|
||||
raise ValueError("encrypted payload is too short")
|
||||
plaintext = self._cipher.decrypt(
|
||||
sealed[:12], sealed[12:], self._aad(binding)
|
||||
if payload.startswith("v2:"):
|
||||
_, key_id, encoded = payload.split(":", 2)
|
||||
cipher = self._ciphers.get(key_id)
|
||||
if cipher is None:
|
||||
raise ValueError("encrypted payload uses an unavailable key")
|
||||
sealed = self._decode_sealed(encoded)
|
||||
plaintext = cipher.decrypt(
|
||||
sealed[:12],
|
||||
sealed[12:],
|
||||
self._aad(binding, version="v2", key_id=key_id),
|
||||
)
|
||||
return json.loads(plaintext), key_id != self._active_key_id
|
||||
|
||||
sealed = self._decode_sealed(payload[3:])
|
||||
candidates = (
|
||||
[self._legacy_cipher]
|
||||
if self._legacy_cipher is not None
|
||||
else list(self._ciphers.values())
|
||||
)
|
||||
return json.loads(plaintext), False
|
||||
plaintext = None
|
||||
for cipher in candidates:
|
||||
try:
|
||||
plaintext = cipher.decrypt(
|
||||
sealed[:12], sealed[12:], self._aad(binding)
|
||||
)
|
||||
break
|
||||
except InvalidTag:
|
||||
continue
|
||||
if plaintext is None:
|
||||
raise InvalidTag
|
||||
return json.loads(plaintext), self._active_key_id is not None
|
||||
except (
|
||||
binascii.Error,
|
||||
InvalidTag,
|
||||
|
||||
+842
-15
File diff suppressed because it is too large.
Load diff
@@ -125,6 +125,8 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
number = 0
|
||||
comments = self.server.activity_comments.get(number, [])
|
||||
self._json(200, comments, **{"X-Total-Count": str(len(comments))})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/subscriptions/check"):
|
||||
self._json(200, {"subscribed": False, "ignored": False})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"):
|
||||
self._json(200, [])
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/"):
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_addresses_every_review_comment_and_requests_updated_review(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"merged": False,
|
||||
"ci_state": "success",
|
||||
"head_sha": "current-head",
|
||||
"reviewers": [{
|
||||
"review_id": 42,
|
||||
"login": "sam",
|
||||
"status": "changes_requested",
|
||||
"head_sha": "current-head",
|
||||
"blocking": True,
|
||||
"comments": [
|
||||
{"id": 99, "path": "src/api.py", "body": "Handle the empty state.", "line": 12},
|
||||
{"id": 100, "path": "frontend/app.js", "body": "Explain the retry.", "line": 8},
|
||||
],
|
||||
}],
|
||||
"files": [
|
||||
{"filename": "src/api.py", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -11 +11 @@", "+return empty"]},
|
||||
{"filename": "frontend/app.js", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -7 +7 @@", "+showRetry()"]},
|
||||
],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.feedbackCalls = [];
|
||||
globalThis.feedbackStored = new Map();
|
||||
globalThis.feedbackStorage = {
|
||||
getItem:key => feedbackStored.has(key) ? feedbackStored.get(key) : null,
|
||||
setItem:(key, value) => feedbackStored.set(key, String(value)),
|
||||
removeItem:key => feedbackStored.delete(key),
|
||||
};
|
||||
globalThis.feedbackItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
globalThis.feedbackDetail = detail;
|
||||
globalThis.feedbackController = createPullSheet({
|
||||
storage: feedbackStorage,
|
||||
createOperationId: () => 'feedback-operation',
|
||||
fetchJson: async (path, options = {}) => {
|
||||
feedbackCalls.push({path, options});
|
||||
if (path.endsWith('/comments')) return {id: 501, body:JSON.parse(options.body).body};
|
||||
if (path.endsWith('/request-review')) return {
|
||||
number:7, reviewer:'sam', head_sha:'current-head', requested_reviewers:['sam']
|
||||
};
|
||||
throw new Error('unexpected request');
|
||||
},
|
||||
});
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
|
||||
createPullSheet.renderFile(file, index, false, value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'))
|
||||
).join('');
|
||||
createPullSheet.bindFeedbackControls(document, feedbackController,
|
||||
() => feedbackItem, () => feedbackDetail, () => 'timmy');
|
||||
createPullSheet.review(detail, null, document);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
page.locator('[data-address-review-feedback="42"]').click()
|
||||
assert page.locator("#pull-feedback-progress").text_content() == "Comment 1 of 2"
|
||||
assert page.locator("#finish-pull-feedback").is_disabled()
|
||||
page.locator('[data-feedback-disposition="addressed"]').click()
|
||||
page.locator("#next-pull-feedback").click()
|
||||
page.locator("#pull-feedback-note").fill("Added a visible retry message.")
|
||||
page.locator('[data-feedback-disposition="discussion"]').click()
|
||||
assert not page.locator("#finish-pull-feedback").is_disabled()
|
||||
page.locator("#finish-pull-feedback").click()
|
||||
page.locator("#request-feedback-review").click()
|
||||
|
||||
page.wait_for_function("feedbackCalls.length === 2")
|
||||
result = page.evaluate(
|
||||
"""() => ({
|
||||
calls:feedbackCalls,
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
controlHeights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
|
||||
.filter(button => !button.hidden).map(button => button.getBoundingClientRect().height),
|
||||
status:document.querySelector('#pull-feedback-status').textContent,
|
||||
secondExpanded:document.querySelector('[data-pull-filename="frontend/app.js"] .pull-file-toggle')
|
||||
.getAttribute('aria-expanded'),
|
||||
persisted:Array.from(feedbackStored.keys()).some(key => key.includes('feedback-pass')),
|
||||
})"""
|
||||
)
|
||||
result["resetHidden"] = page.evaluate(
|
||||
"""() => {
|
||||
createPullSheet.resetReviewRequestControls(document, feedbackDetail);
|
||||
return document.querySelector('#pull-feedback-pass').hidden;
|
||||
}"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert result["scrollWidth"] <= result["clientWidth"]
|
||||
assert min(result["controlHeights"]) >= 44
|
||||
assert result["secondExpanded"] == "true"
|
||||
assert result["persisted"] is True
|
||||
assert result["resetHidden"] is True
|
||||
assert result["calls"][0]["path"].endswith("/comments")
|
||||
assert result["calls"][0]["options"]["headers"]["Idempotency-Key"] == "feedback-operation"
|
||||
summary = result["calls"][0]["options"]["body"]
|
||||
assert "Handle the empty state" in summary
|
||||
assert "Addressed" in summary
|
||||
assert "Needs discussion" in summary
|
||||
assert "Added a visible retry message" in summary
|
||||
assert result["calls"][1]["path"].endswith("/request-review")
|
||||
assert '"reviewer":"sam"' in result["calls"][1]["options"]["body"]
|
||||
assert result["status"] == "Response posted; updated review requested from @sam."
|
||||
@@ -0,0 +1,89 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile My PRs journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "my-work.js")
|
||||
page.add_script_tag(path=FRONTEND / "work-route.js")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
page.evaluate("document.querySelector('#mobile-queue-sheet').showModal()")
|
||||
|
||||
row = page.locator('[data-mobile-queue="authored"]')
|
||||
expect(row).to_have_count(1)
|
||||
expect(row).to_contain_text("My PRs")
|
||||
expect(row).to_contain_text("Pull requests you authored")
|
||||
assert row.bounding_box()["height"] >= 44
|
||||
|
||||
result = page.evaluate("""() => {
|
||||
const work = buildMyWork({
|
||||
user:{login:'timmy'},
|
||||
pull_requests:[{
|
||||
number:41,title:'Ship the authored mobile flow',repository:'stackchain/dashboard',
|
||||
state:'open',user:'timmy',assignees:[],work_reasons:['authored_by_me'],
|
||||
url:'https://forge.example/git/stackchain/dashboard/pulls/41'
|
||||
}]
|
||||
});
|
||||
const authored = filterMyWork(work, 'authored');
|
||||
let selected = '';
|
||||
let route = '';
|
||||
const action = document.createElement('button');
|
||||
action.className = 'my-work-card-main';
|
||||
action.addEventListener('click', () => { route = createWorkRoute.serialize(authored[0]); });
|
||||
const launcher = createMobileQueueLauncher({
|
||||
selectFilter:name => { selected = name; },
|
||||
firstAction:() => action,
|
||||
announce:() => {},
|
||||
});
|
||||
const opened = launcher.open('authored');
|
||||
return {selected, route, opened, count:authored.length};
|
||||
}""")
|
||||
assert result == {
|
||||
"selected": "authored",
|
||||
"route": "#/my-work/pull/stackchain/dashboard/41",
|
||||
"opened": "opened",
|
||||
"count": 1,
|
||||
}
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_empty_authored_queue_announces_the_standard_empty_message():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 320, "height": 568})
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
|
||||
message = page.evaluate("""() => {
|
||||
let announcement = '';
|
||||
const launcher = createMobileQueueLauncher({
|
||||
selectFilter:() => {}, firstAction:() => null,
|
||||
announce:value => { announcement = value; },
|
||||
});
|
||||
launcher.open('authored');
|
||||
return announcement;
|
||||
}""")
|
||||
assert message == "No work is ready to open."
|
||||
browser.close()
|
||||
@@ -0,0 +1,88 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
if os.environ.get("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("release browser journey is opt-in", allow_module_level=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_cancels_pending_review_and_opens_replacement_picker(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.cancelCalls = [];
|
||||
globalThis.cancelItem = {
|
||||
repository:'stackchain/api', number:7, key:'stackchain/api#7'
|
||||
};
|
||||
globalThis.cancelDetail = {
|
||||
state:'open', draft:false, mergeable:true, merged:false,
|
||||
ci_state:'success', head_sha:'abc1234', files:[],
|
||||
reviewers:[{
|
||||
login:'sam', status:'waiting', head_sha:'abc1234', blocking:true
|
||||
}],
|
||||
};
|
||||
globalThis.cancelController = createPullSheet({
|
||||
storage:null,
|
||||
fetchJson:async (path, options={}) => {
|
||||
cancelCalls.push({path, options});
|
||||
return {
|
||||
repository:'stackchain/api', number:7, reviewer:'sam',
|
||||
head_sha:'abc1234', requested_reviewers:[],
|
||||
};
|
||||
},
|
||||
});
|
||||
createPullSheet.bindReviewRequestControls(
|
||||
document, cancelController, () => cancelItem, () => cancelDetail
|
||||
);
|
||||
createPullSheet.review(cancelDetail, null, document);
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
}"""
|
||||
)
|
||||
|
||||
cancel = page.locator('[data-cancel-review-request="sam"]')
|
||||
bounds = cancel.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
cancel.click()
|
||||
page.wait_for_function("cancelCalls.length === 1")
|
||||
page.wait_for_function(
|
||||
"document.querySelector('#pull-review-request-status').textContent.includes('Choose a replacement reviewer')"
|
||||
)
|
||||
result = page.evaluate(
|
||||
"""() => ({
|
||||
calls:cancelCalls,
|
||||
waitingRows:document.querySelectorAll('[data-reviewer-status="waiting"]').length,
|
||||
pickerOpen:document.querySelector('#pull-review-request').open,
|
||||
focused:document.activeElement?.id,
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
})"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert result["scrollWidth"] <= result["clientWidth"]
|
||||
assert result["waitingRows"] == 0
|
||||
assert result["pickerOpen"] is True
|
||||
assert result["focused"] == "load-pull-reviewers"
|
||||
assert len(result["calls"]) == 1
|
||||
assert result["calls"][0]["options"]["method"] == "DELETE"
|
||||
assert result["calls"][0]["options"]["body"] == (
|
||||
'{"reviewer":"sam","expected_head_sha":"abc1234"}'
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile detail-watch journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_issue_and_pull_detail_watch_controls_are_phone_usable(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||
|
||||
for sheet, button, expected in [
|
||||
("#issue-sheet", "#watch-issue-detail", "Watch issue"),
|
||||
("#pull-sheet", "#watch-pull-detail", "Watch pull request"),
|
||||
]:
|
||||
page.locator(sheet).evaluate("node => node.classList.add('open')")
|
||||
control = page.locator(button)
|
||||
expect(control).to_be_visible()
|
||||
expect(control).to_have_text(expected)
|
||||
assert control.bounding_box()["height"] >= 44
|
||||
control.focus()
|
||||
expect(control).to_be_focused()
|
||||
page.locator(sheet).evaluate("node => node.classList.remove('open')")
|
||||
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
browser.close()
|
||||
@@ -0,0 +1,146 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile Following journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_following_queue_is_phone_usable_at_narrow_viewport(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||
page.add_script_tag(path=FRONTEND / "following.js")
|
||||
|
||||
row = page.locator('[data-mobile-queue="following"]')
|
||||
expect(row).to_have_count(1)
|
||||
expect(row).to_contain_text("Issues and pull requests you watch")
|
||||
following_alert = page.locator('label[for="push-following"]')
|
||||
expect(following_alert).to_have_count(1)
|
||||
expect(following_alert).to_contain_text("Notify me when Following changes")
|
||||
page.locator("#work-settings-toggle").click()
|
||||
expect(following_alert).to_be_visible()
|
||||
assert following_alert.bounding_box()["height"] >= 44
|
||||
page.evaluate("""() => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok:true,
|
||||
json:async () => ({revision:1,items:[{
|
||||
repository:'stackchain/api', kind:'pull', number:42,
|
||||
title:'Changed pull request with a long mobile title', state:'open',
|
||||
updated_at:'2026-08-23T05:00:00Z', has_unseen_change:true,
|
||||
change_summary:'Closed since last review'
|
||||
}]})
|
||||
});
|
||||
globalThis.followingReleaseQueue = attachFollowing(() => {});
|
||||
globalThis.followingReleaseQueue.open();
|
||||
}""")
|
||||
|
||||
expect(page.locator("#following-sheet")).to_be_visible()
|
||||
expect(page.locator(".following-card")).to_be_visible()
|
||||
expect(page.locator(".following-card")).to_contain_text("Closed since last review")
|
||||
expect(page.locator(".following-card")).to_contain_text("Pull request")
|
||||
expect(page.locator("#review-following")).to_have_text("Review new activity")
|
||||
assert page.locator("#review-following").bounding_box()["height"] >= 44
|
||||
assert page.locator(".following-card").bounding_box()["height"] >= 44
|
||||
assert page.locator("#close-following").bounding_box()["height"] >= 44
|
||||
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#following-sheet').close();
|
||||
document.querySelector('#search-preview').classList.add('open');
|
||||
renderSearchPreviewReview({status:'ready',data:{ci_state:'success',files:[{
|
||||
filename:'src/a/very/long/mobile/path/that-must-wrap/review.py',status:'modified',
|
||||
additions:1,deletions:1,diff_available:true,
|
||||
diff_lines:['@@ -1 +1 @@','-unsafe long line that scrolls inside the diff only',
|
||||
'+safe long line that scrolls inside the diff only']
|
||||
}]}},document,value=>String(value));
|
||||
document.querySelector('[data-search-preview-section="changes"]').hidden=false;
|
||||
}""")
|
||||
changes = page.locator("#search-preview-review")
|
||||
expect(changes).to_be_visible()
|
||||
expect(changes).to_contain_text("CI passed · 1 changed file.")
|
||||
expect(changes).to_contain_text("review.py")
|
||||
assert page.locator('[data-search-preview-section="changes"]').bounding_box()["height"] >= 44
|
||||
assert page.locator("#search-preview-review .pull-diff").evaluate(
|
||||
"node => node.scrollWidth >= node.clientWidth"
|
||||
) is True
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
|
||||
retire = page.locator("#watch-search-result")
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#following-sheet').close();
|
||||
document.querySelector('#search-preview').classList.add('open');
|
||||
const button = document.querySelector('#watch-search-result');
|
||||
renderSearchPreviewWatch(
|
||||
{repository:'stackchain/api', number:42, kind:'issue', state:'closed',
|
||||
following:true, watching:true},
|
||||
{status:'ready'},
|
||||
button
|
||||
);
|
||||
}""")
|
||||
expect(retire).to_be_visible()
|
||||
expect(retire).to_have_text("Stop watching & next")
|
||||
assert retire.bounding_box()["height"] >= 44
|
||||
page.evaluate("""() => renderSearchPreviewWatch(
|
||||
{repository:'stackchain/api', number:42, kind:'pull', state:'open', watching:false},
|
||||
{status:'ready'}, document.querySelector('#watch-search-result'))""")
|
||||
expect(retire).to_have_text("Watch pull request")
|
||||
assert retire.bounding_box()["height"] >= 44
|
||||
keep = page.locator("#keep-following-for-later")
|
||||
page.evaluate("document.querySelector('#keep-following-for-later').hidden = false")
|
||||
expect(keep).to_be_visible()
|
||||
expect(keep).to_have_text("Keep for later & next")
|
||||
assert keep.bounding_box()["height"] >= 44
|
||||
assert page.locator("#keep-following-status").evaluate(
|
||||
"node => getComputedStyle(node).overflowWrap"
|
||||
) == "anywhere"
|
||||
assert page.locator("#keep-following-status").bounding_box()["height"] >= 20
|
||||
overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
|
||||
assert overflow is False
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_following_alert_route_opens_truthful_empty_state_with_one_refresh():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "following.js")
|
||||
|
||||
result = page.evaluate("""async () => {
|
||||
let requests=0;
|
||||
globalThis.fetch=async () => {
|
||||
requests += 1;
|
||||
return {ok:true,json:async () => ({revision:2,items:[]})};
|
||||
};
|
||||
const queue=attachFollowing(() => {});
|
||||
await queue.route();
|
||||
return {requests,open:document.querySelector('#following-sheet').open,
|
||||
status:document.querySelector('#following-status').textContent};
|
||||
}""")
|
||||
|
||||
assert result == {
|
||||
"requests": 1,
|
||||
|
||||
"open": True,
|
||||
"status": "No watched items yet. Watch an issue or pull request from Search to keep it here.",
|
||||
}
|
||||
expect(page.locator("#following-sheet")).to_be_visible()
|
||||
assert page.locator("#close-following").bounding_box()["height"] >= 44
|
||||
browser.close()
|
||||
@@ -64,6 +64,49 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
||||
expect(page.locator("#mobile-first-task-status")).to_contain_text("Create a task now")
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_enabled()
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_focused()
|
||||
|
||||
page.locator("#close-mobile-first-task").click()
|
||||
context.set_offline(False)
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
document.querySelector('[data-mobile-today-hud]').hidden = false;
|
||||
localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching');
|
||||
const productionCoach = document.querySelector('[data-mobile-first-task-coach]');
|
||||
const isolatedCoach = productionCoach.cloneNode(true);
|
||||
productionCoach.replaceWith(isolatedCoach);
|
||||
window.firstTaskOutcomeProbe = createMobileFirstTask({
|
||||
getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true,
|
||||
coach: isolatedCoach,
|
||||
});
|
||||
return window.firstTaskOutcomeProbe.refresh();
|
||||
}"""
|
||||
)
|
||||
coach = page.locator("[data-mobile-first-task-coach]")
|
||||
expect(coach).to_be_visible()
|
||||
expect(coach).to_contain_text("Complete your first task")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True
|
||||
expect(page.locator("#mobile-first-task-receipt")).to_be_visible()
|
||||
expect(coach).to_be_hidden()
|
||||
expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
fresh_context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
fresh_page = fresh_context.new_page()
|
||||
fresh_page.goto(origin + "/", wait_until="networkidle")
|
||||
fresh_page.locator('input[name="device_label"]').fill("Replacement release phone")
|
||||
fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
fresh_page.locator("#submit-sign-in").click()
|
||||
fresh_page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||
assert fresh_page.evaluate(
|
||||
"localStorage.getItem('stackchain.first-task.v1:timmy')"
|
||||
) == "complete"
|
||||
fresh_page.locator('[data-mobile-task="work"]').click()
|
||||
expect(fresh_page.locator("#mobile-first-task")).to_be_hidden()
|
||||
fresh_context.close()
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
@@ -180,6 +223,18 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator("#work-settings-toggle").click()
|
||||
tomorrow = page.locator("#plan-tomorrow")
|
||||
expect(tomorrow).to_be_visible()
|
||||
tomorrow_bounds = tomorrow.bounding_box()
|
||||
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
|
||||
tomorrow.click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
|
||||
expect(page.locator("#save-and-start-today")).to_be_hidden()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
|
||||
expect(delivery_queue).to_be_visible()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_publishes_draft_and_continues_to_reviewer_picker(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"number": 7,
|
||||
"title": "WIP: Ship mobile flow",
|
||||
"author": "timmy",
|
||||
"state": "open",
|
||||
"draft": True,
|
||||
"merged": False,
|
||||
"head_sha": "abc1234",
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
window.publishDetail = detail;
|
||||
window.publishItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
const controller = createPullSheet({
|
||||
storage:null,
|
||||
fetchJson:(path, options) => {
|
||||
if (!path.endsWith('/ready') || options.method !== 'PATCH') {
|
||||
return Promise.reject(new Error('unexpected request'));
|
||||
}
|
||||
const payload = JSON.parse(options.body);
|
||||
if (payload.expected_head_sha !== detail.head_sha) {
|
||||
return Promise.reject(new Error('wrong head'));
|
||||
}
|
||||
return Promise.resolve({
|
||||
repository:'stackchain/api', number:7, title:'Ship mobile flow',
|
||||
head_sha:detail.head_sha, state:'open', draft:false,
|
||||
});
|
||||
},
|
||||
});
|
||||
createPullSheet.bindOwnershipControls(
|
||||
document, controller, () => window.publishItem, async () => false,
|
||||
() => window.publishDetail, () => 'timmy'
|
||||
);
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
controller.setReviewDetail(detail);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
ready = page.get_by_role("button", name="Ready for review")
|
||||
before = ready.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.hidden})")
|
||||
ready.click()
|
||||
page.get_by_text("Published and ready for review.").wait_for()
|
||||
metrics = page.evaluate(
|
||||
"""() => ({
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
readyHidden:document.querySelector('#publish-draft-pull').hidden,
|
||||
requestOpen:document.querySelector('#pull-review-request').open,
|
||||
reviewerFocused:document.activeElement === document.querySelector('#load-pull-reviewers'),
|
||||
title:window.publishDetail.title,
|
||||
draft:window.publishDetail.draft,
|
||||
})"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert before["hidden"] is False
|
||||
assert before["height"] >= 44
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
assert metrics["readyHidden"] is True
|
||||
assert metrics["requestOpen"] is True
|
||||
assert metrics["reviewerFocused"] is True
|
||||
assert metrics["title"] == "Ship mobile flow"
|
||||
assert metrics["draft"] is False
|
||||
@@ -0,0 +1,172 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_received_review_feedback_opens_matching_change(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"merged": False,
|
||||
"ci_state": "success",
|
||||
"head_sha": "current-head",
|
||||
"reviewers": [{
|
||||
"login": "sam",
|
||||
"status": "outdated",
|
||||
"head_sha": "old-head-123456",
|
||||
"blocking": True,
|
||||
"summary": "Please handle the narrow empty state before merging.",
|
||||
"comments": [{
|
||||
"path": "src/a/very/long/mobile/path/review_target.py",
|
||||
"body": "Return before parsing when the payload is empty.",
|
||||
"line": 12,
|
||||
}],
|
||||
}],
|
||||
"files": [{
|
||||
"filename": "src/a/very/long/mobile/path/review_target.py",
|
||||
"status": "modified",
|
||||
"additions": 2,
|
||||
"deletions": 1,
|
||||
"diff_available": True,
|
||||
"diff_lines": ["@@ -10,2 +10,3 @@", "-parse(payload)", "+if not payload: return", "+parse(payload)"],
|
||||
}],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
createPullSheet.review(detail, null, document);
|
||||
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
|
||||
createPullSheet.renderFile(file, index, false, value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'))
|
||||
).join('');
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
jump = page.locator('[data-review-feedback-file]')
|
||||
jump.click()
|
||||
metrics = page.evaluate(
|
||||
"""() => {
|
||||
const jump = document.querySelector('[data-review-feedback-file]');
|
||||
const toggle = document.querySelector('.pull-file-toggle');
|
||||
const panel = document.getElementById(toggle.getAttribute('aria-controls'));
|
||||
return {
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
jumpHeight: jump.getBoundingClientRect().height,
|
||||
expanded: toggle.getAttribute('aria-expanded'),
|
||||
panelHidden: panel.hidden,
|
||||
focused: document.activeElement === toggle,
|
||||
reviewedHead: document.querySelector('.pull-review-feedback').textContent,
|
||||
updatedReviewVisible: !document.querySelector('#request-updated-pull-review').hidden,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
assert metrics["jumpHeight"] >= 44
|
||||
assert metrics["expanded"] == "true"
|
||||
assert metrics["panelHidden"] is False
|
||||
assert metrics["focused"] is True
|
||||
assert "Reviewed head old-head" in metrics["reviewedHead"]
|
||||
assert metrics["updatedReviewVisible"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_pull_review_retries_inline_feedback_without_losing_core_review(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open", "draft": False, "mergeable": True, "merged": False,
|
||||
"ci_state": "success", "head_sha": "abc1234",
|
||||
"reviewers": [{
|
||||
"login": "sam", "review_id": 8, "status": "changes_requested",
|
||||
"head_sha": "abc1234", "blocking": True,
|
||||
"summary": "Please handle the empty state.",
|
||||
}],
|
||||
"files": [],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
window.lazyFeedbackDetail = detail;
|
||||
window.lazyFeedbackItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
let attempts = 0;
|
||||
const controller = createPullSheet({
|
||||
storage:null,
|
||||
fetchJson:() => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return Promise.reject(new Error('feedback unavailable'));
|
||||
return Promise.resolve({
|
||||
review_id:8, head_sha:'abc1234', reviewed_head_sha:'abc1234',
|
||||
comments:[{id:99, path:'src/api.py', body:'Return before parsing.', line:12}],
|
||||
});
|
||||
},
|
||||
});
|
||||
createPullSheet.bindFeedbackControls(
|
||||
document, controller, () => window.lazyFeedbackItem,
|
||||
() => window.lazyFeedbackDetail, () => 'timmy'
|
||||
);
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
const eligibility = createPullSheet.review(detail, null, document);
|
||||
document.querySelector('#pull-merge-state').textContent = eligibility.reason;
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
load = page.get_by_role("button", name="Load inline feedback")
|
||||
load.click()
|
||||
retry = page.get_by_role("button", name="Retry feedback")
|
||||
retry.wait_for()
|
||||
retry.click()
|
||||
page.get_by_text("Return before parsing.").wait_for()
|
||||
metrics = page.evaluate(
|
||||
"""() => {
|
||||
const details = document.querySelector('.pull-review-feedback');
|
||||
const address = document.querySelector('[data-address-review-feedback]');
|
||||
return {
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
blocker: document.querySelector('#pull-merge-state').textContent,
|
||||
feedback: details.textContent,
|
||||
addressHeight: address.getBoundingClientRect().height,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
assert "requested changes" in metrics["blocker"]
|
||||
assert "Return before parsing." in metrics["feedback"]
|
||||
assert metrics["addressHeight"] >= 44
|
||||
@@ -0,0 +1,87 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_pull_reviewer_status_wraps_and_keeps_updated_review_reachable(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"merged": False,
|
||||
"ci_state": "success",
|
||||
"head_sha": "current-head",
|
||||
"reviewers": [
|
||||
{
|
||||
"login": "casey-with-a-very-long-mobile-login",
|
||||
"status": "waiting",
|
||||
"head_sha": "current-head",
|
||||
"blocking": True,
|
||||
},
|
||||
{
|
||||
"login": "sam",
|
||||
"status": "changes_requested",
|
||||
"head_sha": "current-head",
|
||||
"blocking": True,
|
||||
},
|
||||
{
|
||||
"login": "lee",
|
||||
"status": "outdated",
|
||||
"head_sha": "old-head",
|
||||
"blocking": True,
|
||||
},
|
||||
{
|
||||
"login": "pat",
|
||||
"status": "approved",
|
||||
"head_sha": "current-head",
|
||||
"blocking": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
createPullSheet.review(detail, null, document);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
metrics = page.evaluate(
|
||||
"""() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
updatedReviewHeight: document.querySelector('#request-updated-pull-review')
|
||||
.getBoundingClientRect().height,
|
||||
updatedReviewVisible: !document.querySelector('#request-updated-pull-review').hidden,
|
||||
statuses: Array.from(document.querySelectorAll('.pull-reviewer-status span'))
|
||||
.map(node => node.textContent),
|
||||
})"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
assert metrics["updatedReviewHeight"] >= 44
|
||||
assert metrics["updatedReviewVisible"] is True
|
||||
assert metrics["statuses"] == [
|
||||
"Waiting for review",
|
||||
"Changes requested",
|
||||
"Outdated after new commits",
|
||||
"Approved current head",
|
||||
]
|
||||
@@ -32,12 +32,29 @@ def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_d
|
||||
page.locator("#search-preview-conversation").evaluate("node => node.style.minHeight = '500px'")
|
||||
page.locator("#search-preview-reply-workspace").evaluate("node => node.style.minHeight = '500px'")
|
||||
page.locator("#search-preview-actions").evaluate("node => node.style.minHeight = '300px'")
|
||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||
page.evaluate("""() => {
|
||||
const assignedIssue = {
|
||||
kind:'issue', state:'open', claimable:false, assigned_to_me:false,
|
||||
assignees:['alexander'], watching:false,
|
||||
};
|
||||
globalThis.renderSearchPreviewWatch(
|
||||
assignedIssue, {status:'ready'}, document.querySelector('#watch-search-result')
|
||||
);
|
||||
}""")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-search-preview-nav.js")
|
||||
|
||||
watch = page.locator("#watch-search-result")
|
||||
expect(watch).to_be_visible()
|
||||
expect(watch).to_have_text("Watch issue")
|
||||
watch_bounds = watch.bounding_box()
|
||||
assert watch_bounds and watch_bounds["height"] >= 44
|
||||
navigation = page.locator(".mobile-search-preview-nav")
|
||||
expect(navigation).to_be_visible()
|
||||
buttons = navigation.locator("button")
|
||||
buttons = navigation.locator("button:visible")
|
||||
assert navigation.locator("button").count() == 5
|
||||
assert buttons.count() == 4
|
||||
expect(navigation.locator('[data-search-preview-section="changes"]')).to_be_hidden()
|
||||
for button in buttons.all():
|
||||
bounds = button.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
sync_api = pytest.importorskip("playwright.sync_api")
|
||||
|
||||
FRONTEND = Path(__file__).parents[2] / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width,height", [(320, 568), (390, 844)])
|
||||
def test_search_week_plan_sheet_fits_small_phones_with_touch_safe_controls(width, height):
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
with sync_api.sync_playwright() as playwright:
|
||||
try:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium unavailable: {error}")
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page.set_content(html)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.locator("#search-week-plan-days").evaluate(
|
||||
"""node => node.innerHTML = Array.from({length:7}, (_, index) =>
|
||||
`<button type="button" data-search-week-date="2026-08-${21+index}" aria-pressed="${index===0}">
|
||||
<strong>Day ${index+1}</strong><small>${index*30} of 120 min · ${index} planned</small>
|
||||
</button>`).join('')"""
|
||||
)
|
||||
page.locator("#search-week-plan-sheet").evaluate("node => node.hidden = false")
|
||||
page.locator("#search-week-plan-estimate").fill("45")
|
||||
|
||||
expect = sync_api.expect
|
||||
expect(page.locator("#search-week-plan-sheet")).to_be_visible()
|
||||
expect(page.locator("#confirm-search-week-plan")).to_be_visible()
|
||||
for button in page.locator("#search-week-plan-days button").all():
|
||||
bounds = button.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.locator("#confirm-search-week-plan").bounding_box()["height"] >= 44
|
||||
assert page.locator("#cancel-search-week-plan").bounding_box()["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.locator(".search-week-plan-panel").evaluate(
|
||||
"node => node.scrollWidth <= node.clientWidth"
|
||||
)
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width,height", [(320, 568), (390, 844)])
|
||||
def test_search_week_batch_review_fits_small_phones_without_horizontal_overflow(width, height):
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
with sync_api.sync_playwright() as playwright:
|
||||
try:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium unavailable: {error}")
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page.set_content(html)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.locator("#search-week-batch-list").evaluate(
|
||||
"""node => node.innerHTML = Array.from({length:4}, (_, index) =>
|
||||
`<div class="search-week-batch-row"><div><span class="small">stackchain/dashboard#${1196+index}</span>
|
||||
<strong>Substantial mobile planning issue ${index+1}</strong></div>
|
||||
<div class="search-week-batch-row-controls"><label>Day<select><option>Fri, Aug 21 · 2 planned</option></select></label>
|
||||
<label>Minutes<input type="number" value="45"></label></div></div>`).join('')"""
|
||||
)
|
||||
page.locator("#search-week-batch-review").evaluate("node => node.hidden = false")
|
||||
page.locator("#cmd-palette").evaluate("node => node.classList.add('open')")
|
||||
|
||||
expect = sync_api.expect
|
||||
expect(page.locator("#search-week-batch-review")).to_be_visible()
|
||||
expect(page.locator("#confirm-search-week-batch")).to_be_visible()
|
||||
for control in page.locator("#search-week-batch-review select, #search-week-batch-review input, #search-week-batch-review button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.locator("#search-week-batch-review").evaluate(
|
||||
"node => node.scrollWidth <= node.clientWidth"
|
||||
)
|
||||
browser.close()
|
||||
@@ -88,7 +88,16 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
expect(page.locator("#send-issue-comment-next")).to_be_visible()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
try:
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
except AssertionError as error:
|
||||
raise AssertionError(page.evaluate("""() => ({
|
||||
hidden:document.querySelector('#plan-today-sheet').hidden,
|
||||
state:history.state,
|
||||
bodyClass:document.body.className,
|
||||
status:document.querySelector('#my-work-action-status').textContent,
|
||||
error:document.querySelector('#plan-today-error').textContent,
|
||||
})""")) from error
|
||||
|
||||
page.locator("#close-issue-sheet").click()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Today to Week Ahead journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reschedules_active_today_into_week_ahead(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with release_server(
|
||||
archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
|
||||
) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(
|
||||
viewport={"width": width, "height": height}, timezone_id="UTC"
|
||||
)
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Reschedule release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
plan_date = (date.today() + timedelta(days=2)).isoformat()
|
||||
saved_week = page.evaluate(
|
||||
"""async ({planDate}) => {
|
||||
const response=await fetch('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({base_revision:0,timezone:'UTC',days:[{
|
||||
plan_date:planDate,ids:[],capacity_minutes:90,estimates:{}
|
||||
}]})});
|
||||
return {status:response.status,body:await response.json()};
|
||||
}""",
|
||||
{"planDate": plan_date},
|
||||
)
|
||||
assert saved_week["status"] == 200
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
page.locator("#plan-today-available").fill("90")
|
||||
page.locator("#plan-today-available").press("Tab")
|
||||
page.locator("#build-today-plan").click()
|
||||
missing = page.locator("[data-plan-missing-estimate]")
|
||||
for _ in range(2):
|
||||
missing.nth(0).fill("30")
|
||||
missing.nth(0).press("Tab")
|
||||
save_and_start = page.locator("#save-and-start-today")
|
||||
expect(save_and_start).to_be_enabled()
|
||||
save_and_start.click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden(timeout=10_000)
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
issue_sheet = page.locator("#issue-sheet")
|
||||
if issue_sheet.is_visible():
|
||||
page.locator("#close-issue-sheet").click()
|
||||
expect(issue_sheet).to_be_hidden()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
launcher = page.locator("[data-work-session-reschedule-week]")
|
||||
page.locator("[data-mobile-today-more]").click()
|
||||
expect(launcher).to_be_visible()
|
||||
launcher_bounds = launcher.bounding_box()
|
||||
assert launcher_bounds and launcher_bounds["height"] >= 44
|
||||
launcher.click()
|
||||
dialog = page.locator("#today-week-reschedule")
|
||||
expect(dialog).to_be_visible()
|
||||
days = page.locator("#today-week-reschedule-days button")
|
||||
try:
|
||||
expect(days).to_have_count(7)
|
||||
except AssertionError as error:
|
||||
raise AssertionError({
|
||||
"status": page.locator("#today-week-reschedule-status").text_content(),
|
||||
"page_errors": page_errors,
|
||||
"today": page.evaluate("async()=>await (await fetch('api/v1/today')).json()"),
|
||||
"week": page.evaluate("async()=>await (await fetch('api/v1/week')).json()"),
|
||||
}) from error
|
||||
for control in [*days.all(), page.locator("#cancel-today-week-reschedule"), page.locator("#confirm-today-week-reschedule")]:
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator("#cancel-today-week-reschedule").click()
|
||||
expect(dialog).to_be_hidden()
|
||||
expect(launcher).to_be_focused()
|
||||
|
||||
launcher.click()
|
||||
destination = page.locator(
|
||||
f'#today-week-reschedule-days button[data-plan-date="{plan_date}"]'
|
||||
)
|
||||
destination.click()
|
||||
page.locator("#confirm-today-week-reschedule").click()
|
||||
expect(dialog).to_be_hidden()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
|
||||
week = page.evaluate("async()=>await (await fetch('api/v1/week')).json()")
|
||||
today = page.evaluate("async()=>await (await fetch('api/v1/today')).json()")
|
||||
assert week["days"][0]["plan_date"] == plan_date
|
||||
assert len(week["days"][0]["ids"]) == 1
|
||||
assert len(today["ids"]) == 1
|
||||
assert not page_errors
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=2)
|
||||
@@ -56,17 +56,19 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
|
||||
{kind:'issue',repository:'acme/mobile',number:42,title:'Polish desktop filters',key:'acme/mobile#42'},
|
||||
];
|
||||
const identity=item => `${item.kind}:${item.repository}:${item.number}:`;
|
||||
const today=items.map(identity); const later={}; const operations=[];
|
||||
const today=items.map(identity); const operations=[];
|
||||
let tomorrow={revision:4,ids:['issue:acme/mobile:42:'],capacity_minutes:120,
|
||||
estimates:{'issue:acme/mobile:42:':45},plan_date:'2026-08-18'};
|
||||
const todayWork={identity,read:()=>[...today],contains:item=>today.includes(identity(item)),remove:item=>{
|
||||
const index=today.indexOf(identity(item)); if(index<0)return false; today.splice(index,1); return true;
|
||||
}};
|
||||
const laterWork={presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:(item,wake)=>{
|
||||
later[identity(item)]=wake.toISOString(); return 'deferred';
|
||||
const tomorrowPlan={load:async()=>tomorrow,stage:value=>{
|
||||
tomorrow={...tomorrow,...value,sync_pending:true}; return tomorrow;
|
||||
}};
|
||||
const todaySync={enqueue:(action,id)=>{operations.push([action,id]);return true;},flush:()=>Promise.resolve(true)};
|
||||
const controller=createTodayWrapUp({todayWork,laterWork,todaySync});
|
||||
const controller=createTodayWrapUp({todayWork,tomorrowPlan,todaySync});
|
||||
const view=createTodayWrapUpView({controller,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
|
||||
window.__wrapTest={view,today,later,operations};
|
||||
window.__wrapTest={view,today,getTomorrow:()=>tomorrow,operations};
|
||||
view.open(items,{});
|
||||
}
|
||||
"""
|
||||
@@ -84,11 +86,18 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
|
||||
page.evaluate("window.__wrapTest.view.finish(document.querySelector('#finish-today-wrap-up'))")
|
||||
expect(sheet).to_be_hidden()
|
||||
result = page.evaluate(
|
||||
"({today:window.__wrapTest.today,later:window.__wrapTest.later,operations:window.__wrapTest.operations})"
|
||||
"({today:window.__wrapTest.today,tomorrow:window.__wrapTest.getTomorrow(),operations:window.__wrapTest.operations})"
|
||||
)
|
||||
assert result == {
|
||||
"today": ["issue:acme/mobile:42:"],
|
||||
"later": {"issue:acme/mobile:41:": "2026-08-17T09:00:00.000Z"},
|
||||
"tomorrow": {
|
||||
"revision": 4,
|
||||
"ids": ["issue:acme/mobile:42:", "issue:acme/mobile:41:"],
|
||||
"capacity_minutes": 120,
|
||||
"estimates": {"issue:acme/mobile:42:": 45},
|
||||
"plan_date": "2026-08-18",
|
||||
"sync_pending": True,
|
||||
},
|
||||
"operations": [["remove", "issue:acme/mobile:41:"]],
|
||||
}
|
||||
assert browser_errors == []
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Tomorrow conflict journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_resolves_cross_device_tomorrow_conflicts_on_mobile(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
put_bodies: list[dict] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
|
||||
def tomorrow_route(route):
|
||||
if route.request.method == "PUT":
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
put_bodies.append(body)
|
||||
if body.get("base_revision") != 12:
|
||||
route.fulfill(status=409, content_type="application/json", body='{"detail":"changed elsewhere"}')
|
||||
return
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"revision": 13, **body}),
|
||||
)
|
||||
return
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps(
|
||||
{
|
||||
"revision": 12,
|
||||
"ids": ["issue:acme/mobile:server:"],
|
||||
"capacity_minutes": 60,
|
||||
"estimates": {"issue:acme/mobile:server:": 30},
|
||||
"plan_date": "2026-08-21",
|
||||
"timezone": "UTC",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
page.route("**/api/v1/tomorrow", tomorrow_route)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Tomorrow conflict release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
def stage_phone_plan():
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
localStorage.setItem('stackchain.tomorrow-sync.v1.timmy', JSON.stringify({
|
||||
base_revision:11, revision:11,
|
||||
ids:['issue:acme/mobile:phone:'], capacity_minutes:90,
|
||||
estimates:{'issue:acme/mobile:phone:':45},
|
||||
plan_date:'2026-08-21', timezone:'UTC', sync_pending:true,
|
||||
}));
|
||||
window.dispatchEvent(new Event('online'));
|
||||
}"""
|
||||
)
|
||||
expect(page.locator("#mobile-tomorrow-summary")).to_have_text(
|
||||
"Conflict · review required"
|
||||
)
|
||||
|
||||
def open_conflict():
|
||||
queue_sheet = page.locator("#mobile-queue-sheet")
|
||||
if not queue_sheet.evaluate("element => element.open"):
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="tomorrow"]').click()
|
||||
review = page.locator("#tomorrow-conflict-review")
|
||||
expect(review).to_be_visible()
|
||||
expect(page.locator("#plan-today-title")).to_have_text(
|
||||
"Resolve Tomorrow conflict"
|
||||
)
|
||||
expect(page.locator("#tomorrow-conflict-phone-plan")).to_contain_text(
|
||||
"issue:acme/mobile:phone:"
|
||||
)
|
||||
expect(page.locator("#tomorrow-conflict-phone-plan")).to_contain_text(
|
||||
"45 min"
|
||||
)
|
||||
expect(page.locator("#tomorrow-conflict-server-plan")).to_contain_text(
|
||||
"issue:acme/mobile:server:"
|
||||
)
|
||||
expect(page.locator("#tomorrow-conflict-server-plan")).to_contain_text(
|
||||
"30 min"
|
||||
)
|
||||
expect(page.locator("#keep-phone-tomorrow")).to_be_focused()
|
||||
for selector in ("#keep-phone-tomorrow", "#use-server-tomorrow"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
stage_phone_plan()
|
||||
open_conflict()
|
||||
initial_puts = len(put_bodies)
|
||||
page.locator("#use-server-tomorrow").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
expect(page.locator("#mobile-tomorrow-summary")).to_contain_text("1 planned")
|
||||
assert len(put_bodies) == initial_puts
|
||||
|
||||
stage_phone_plan()
|
||||
open_conflict()
|
||||
page.locator("#keep-phone-tomorrow").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
expect(page.locator("#mobile-tomorrow-summary")).to_contain_text("1 planned · 45 of 90 min")
|
||||
assert put_bodies[-1]["base_revision"] == 12
|
||||
assert put_bodies[-1]["ids"] == ["issue:acme/mobile:phone:"]
|
||||
assert page.evaluate(
|
||||
"localStorage.getItem('stackchain.tomorrow-sync.v1.timmy')"
|
||||
) is None
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -0,0 +1,546 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Week Ahead journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
saved: list[dict] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": width, "height": height}, timezone_id="UTC")
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
pulled: list[dict] = []
|
||||
|
||||
page.route("**/api/v1/today", lambda route: route.fulfill(
|
||||
status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 3, "ids": ["issue:acme/mobile:99:"],
|
||||
"capacity_minutes": 60, "estimates": {"issue:acme/mobile:99:": 30},
|
||||
}),
|
||||
))
|
||||
|
||||
def pull_item_route(route):
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
pulled.append(body)
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"today": {"revision": 4, "ids": ["issue:acme/mobile:99:", body["identity"]],
|
||||
"capacity_minutes": 60,
|
||||
"estimates": {"issue:acme/mobile:99:": 30, body["identity"]: 45}},
|
||||
"week": {"revision": 99, "timezone": "UTC", "days": [{
|
||||
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
|
||||
"ids": [], "capacity_minutes": 60, "estimates": {},
|
||||
}]},
|
||||
}))
|
||||
|
||||
page.route("**/api/v1/week/pull-item", pull_item_route)
|
||||
|
||||
def week_route(route):
|
||||
if route.request.method == "PUT":
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
saved.append(body)
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": body["base_revision"] + 1,
|
||||
"timezone": body["timezone"], "days": body["days"],
|
||||
**({"availability_defaults": body["availability_defaults"]}
|
||||
if "availability_defaults" in body else {}),
|
||||
}))
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 0, "timezone": None, "days": [{
|
||||
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
|
||||
"ids": ["issue:acme/mobile:41:", "issue:acme/mobile:42:"], "capacity_minutes": 60,
|
||||
"estimates": {"issue:acme/mobile:41:": 45, "issue:acme/mobile:42:": 45},
|
||||
}],
|
||||
}))
|
||||
|
||||
page.route("**/api/v1/week", week_route)
|
||||
page.route(
|
||||
"**/api/v1/repos/acme/mobile/issues/41/close",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"number":41,"state":"closed","updated_at":"2026-08-21T12:00:00Z"}',
|
||||
),
|
||||
)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Week Ahead release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
page.wait_for_timeout(100)
|
||||
|
||||
assert not page_errors, f"Week Ahead launch raised: {page_errors}"
|
||||
expect(page.locator("#plan-today-title")).to_have_text("Week Ahead")
|
||||
overview = page.locator("#week-review")
|
||||
expect(overview).to_be_visible()
|
||||
cards = page.locator("#week-review-days .week-review-day")
|
||||
expect(cards).to_have_count(7)
|
||||
expect(cards.first).to_contain_text("Next up")
|
||||
expect(cards.first).to_contain_text("Ship mobile capture")
|
||||
expect(page.locator("#week-review-status")).to_contain_text("30 min over capacity")
|
||||
expect(page.locator("#confirm-week-plan")).to_be_hidden()
|
||||
blocked_export = page.locator("#export-saved-week-calendar")
|
||||
expect(blocked_export).to_be_visible()
|
||||
expect(blocked_export).to_be_disabled()
|
||||
availability = page.locator("#open-week-availability")
|
||||
expect(availability).to_be_visible()
|
||||
availability_bounds = availability.bounding_box()
|
||||
assert availability_bounds and availability_bounds["height"] >= 44
|
||||
availability.click()
|
||||
editor = page.locator("#week-availability-editor")
|
||||
expect(editor).to_be_visible()
|
||||
capacities = editor.locator("[data-weekday-capacity]")
|
||||
expect(capacities).to_have_count(7)
|
||||
for index, minutes in enumerate((480, 480, 480, 480, 360, 0, 0)):
|
||||
capacities.nth(index).fill(str(minutes))
|
||||
bounds = capacities.nth(index).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#save-week-availability").click()
|
||||
expect(editor).to_be_hidden()
|
||||
expect(page.locator("#week-review-status")).to_have_text("Weekly availability saved.")
|
||||
assert saved[-1]["availability_defaults"] == [480, 480, 480, 480, 360, 0, 0]
|
||||
availability.click()
|
||||
page.locator("#disable-week-availability").click()
|
||||
expect(editor).to_be_hidden()
|
||||
expect(page.locator("#week-review-status")).to_have_text("Weekly availability disabled.")
|
||||
assert "availability_defaults" not in saved[-1]
|
||||
edit_week = page.locator("#edit-week-plan")
|
||||
expect(edit_week).to_be_visible()
|
||||
remove_actions = cards.first.locator("[data-week-unplan]")
|
||||
expect(remove_actions).to_have_count(2)
|
||||
remove_bounds = remove_actions.nth(1).bounding_box()
|
||||
assert remove_bounds and remove_bounds["height"] >= 44
|
||||
remove_actions.nth(1).click()
|
||||
expect(cards.first).not_to_contain_text("Polish desktop filters")
|
||||
expect(page.locator("#week-unplan-receipt")).to_have_text(
|
||||
"Polish desktop filters removed from Week Ahead."
|
||||
)
|
||||
undo = page.locator("#undo-week-unplan")
|
||||
expect(undo).to_be_visible()
|
||||
undo_bounds = undo.bounding_box()
|
||||
assert undo_bounds and undo_bounds["height"] >= 44
|
||||
undo.click()
|
||||
expect(cards.first).to_contain_text("Polish desktop filters")
|
||||
expect(page.locator("#week-unplan-receipt")).to_be_hidden()
|
||||
assert saved[-2]["days"][0]["ids"] == ["issue:acme/mobile:41:"]
|
||||
assert saved[-1]["days"][0]["ids"] == [
|
||||
"issue:acme/mobile:41:", "issue:acme/mobile:42:"
|
||||
]
|
||||
writes_before_reflow = len(saved)
|
||||
reflow = page.locator("#open-week-reflow")
|
||||
expect(reflow).to_be_visible()
|
||||
bounds = reflow.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
reflow.click()
|
||||
preview = page.locator("#week-reflow-review")
|
||||
expect(preview).to_be_visible()
|
||||
expect(page.locator("#week-reflow-days")).to_contain_text("1 item · 45 of 60 min")
|
||||
expect(page.locator("#week-reflow-unscheduled")).to_have_text(
|
||||
"1 item cannot fit: Polish desktop filters. It will remain in My Work."
|
||||
)
|
||||
for control in (page.locator("#cancel-week-reflow"), page.locator("#apply-week-reflow")):
|
||||
control_bounds = control.bounding_box()
|
||||
assert control_bounds and control_bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#cancel-week-reflow").click()
|
||||
expect(preview).to_be_hidden()
|
||||
assert len(saved) == writes_before_reflow, "cancelling reflow must not write"
|
||||
reflow.click()
|
||||
page.locator("#apply-week-reflow").click()
|
||||
expect(page.locator("#week-review-status")).to_have_text("Week Ahead reflowed and saved.")
|
||||
expect(preview).to_be_hidden()
|
||||
assert len(saved) == writes_before_reflow + 1
|
||||
assert [day["ids"] for day in saved[-1]["days"]][:2] == [["issue:acme/mobile:41:"], []]
|
||||
planned_item = cards.first.locator("[data-week-open-item]")
|
||||
expect(planned_item).to_have_count(1)
|
||||
planned_bounds = planned_item.bounding_box()
|
||||
assert planned_bounds and planned_bounds["height"] >= 44
|
||||
planned_item.click()
|
||||
expect(page.locator("#issue-sheet")).to_have_class(re.compile(r"\bopen\b"))
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
expect(overview).to_be_visible()
|
||||
page.locator("#close-issue-sheet").click()
|
||||
expect(page.locator("#issue-sheet")).not_to_have_class(re.compile(r"\bopen\b"))
|
||||
expect(overview).to_be_visible()
|
||||
expect(planned_item).to_be_focused()
|
||||
assert len(saved) == writes_before_reflow + 1, "opening and inspecting Week Ahead must not add a write"
|
||||
export_saved = page.locator("#export-saved-week-calendar")
|
||||
expect(export_saved).to_be_visible()
|
||||
expect(export_saved).to_be_enabled()
|
||||
export_bounds = export_saved.bounding_box()
|
||||
assert export_bounds and export_bounds["height"] >= 44
|
||||
writes_before_export = len(saved)
|
||||
export_saved.click()
|
||||
expect(page.locator("#week-calendar-handoff")).to_be_visible()
|
||||
expect(overview).to_be_hidden()
|
||||
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text(
|
||||
"09:00–09:45 · 45 min"
|
||||
)
|
||||
assert len(saved) == writes_before_export, "exporting a saved week must not add a write"
|
||||
page.locator("#back-to-week-review-from-calendar").click()
|
||||
expect(overview).to_be_visible()
|
||||
expect(export_saved).to_be_focused()
|
||||
for control in (cards.first.locator("[data-week-edit-day]"), edit_week):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
edit_week.click()
|
||||
expect(overview).to_be_hidden()
|
||||
expect(page.locator("#plan-today-title")).to_have_text("Plan Week Ahead")
|
||||
dates = page.locator("#week-plan-dates button")
|
||||
expect(dates).to_have_count(7)
|
||||
expect(dates.first).to_have_attribute("aria-current", "date")
|
||||
for index in range(7):
|
||||
bounds = dates.nth(index).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
dates.nth(2).click()
|
||||
expect(dates.nth(2)).to_have_attribute("aria-current", "date")
|
||||
expect(page.locator("#week-plan-progress")).to_have_text("Day 3 of 7 · 1 planned")
|
||||
expect(page.locator("#save-today-plan")).to_have_text("Save & next")
|
||||
page.locator("#save-today-plan").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(dates.nth(3)).to_have_attribute("aria-current", "date")
|
||||
expect(page.locator("#week-plan-progress")).to_have_text("Day 4 of 7 · 1 planned")
|
||||
dates.nth(6).click()
|
||||
expect(page.locator("#save-today-plan")).to_have_text("Save week")
|
||||
page.locator("#save-today-plan").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
expect(page.locator("#week-review-days .week-review-day")).to_have_count(7)
|
||||
first_day = page.locator("#week-review-days .week-review-day").first
|
||||
expect(first_day).to_contain_text("Ship mobile capture")
|
||||
expect(first_day).to_contain_text("acme/mobile #41 · Issue · 45 min")
|
||||
expect(first_day.locator("code")).to_have_count(0)
|
||||
edit = first_day.locator("[data-week-edit-day]")
|
||||
bounds = edit.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
edit.click()
|
||||
expect(page.locator("#week-review")).to_be_hidden()
|
||||
expect(page.locator("#back-to-week-review")).to_be_visible()
|
||||
expect(page.locator("#save-today-plan")).to_have_text("Save & review")
|
||||
page.locator("#back-to-week-review").click()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
expect(first_day).to_contain_text("Ship mobile capture")
|
||||
|
||||
page.locator("#open-week-capacity-import").click()
|
||||
capacity_import = page.locator("#week-capacity-import")
|
||||
expect(capacity_import).to_be_visible()
|
||||
private_title = "Private customer planning"
|
||||
tomorrow = (date.today() + timedelta(days=1)).strftime("%Y%m%d")
|
||||
excluded = (date.today() + timedelta(days=2)).strftime("%Y%m%d")
|
||||
added = (date.today() + timedelta(days=4)).strftime("%Y%m%d")
|
||||
calendar = (
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
|
||||
f"SUMMARY:{private_title}\r\nDTSTART;TZID=America/New_York:{tomorrow}T050000\r\n"
|
||||
f"DTEND;TZID=America/New_York:{tomorrow}T060000\r\nRRULE:FREQ=DAILY;COUNT=3\r\n"
|
||||
f"EXDATE;TZID=America/New_York:{excluded}T050000\r\n"
|
||||
f"RDATE;TZID=America/New_York:{added}T050000\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
||||
)
|
||||
unsupported_title = "Private monthly board review"
|
||||
unsupported_calendar = (
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
|
||||
f"SUMMARY:{unsupported_title}\r\nDTSTART:{tomorrow}T100000\r\n"
|
||||
f"DTEND:{tomorrow}T110000\r\nRRULE:FREQ=MONTHLY;BYDAY=1FR\r\n"
|
||||
"END:VEVENT\r\nEND:VCALENDAR"
|
||||
)
|
||||
page.locator("#week-capacity-file").set_input_files({
|
||||
"name": "unsupported.ics", "mimeType": "text/calendar", "buffer": unsupported_calendar.encode()
|
||||
})
|
||||
expect(page.locator("#week-capacity-status")).to_contain_text(
|
||||
"1 recurring event could not be counted"
|
||||
)
|
||||
expect(page.locator("#apply-week-capacities")).to_be_disabled()
|
||||
expect(capacity_import).not_to_contain_text(unsupported_title)
|
||||
page.locator("#week-capacity-file").set_input_files({
|
||||
"name": "availability.ics", "mimeType": "text/calendar", "buffer": calendar.encode()
|
||||
})
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day")).to_have_count(7)
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day").first).to_contain_text(
|
||||
"420 min available"
|
||||
)
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day").nth(1)).to_contain_text(
|
||||
"480 min available"
|
||||
)
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day").nth(2)).to_contain_text(
|
||||
"420 min available"
|
||||
)
|
||||
expect(page.locator("#week-capacity-days .week-capacity-day").nth(3)).to_contain_text(
|
||||
"420 min available"
|
||||
)
|
||||
expect(capacity_import).not_to_contain_text(private_title)
|
||||
for control in (
|
||||
page.locator("#cancel-week-capacity-import"),
|
||||
page.locator("#week-capacity-file"),
|
||||
page.locator("#week-capacity-start"),
|
||||
page.locator("#week-capacity-end"),
|
||||
page.locator("#keep-week-free-times"),
|
||||
page.locator("#apply-week-capacities"),
|
||||
):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#keep-week-free-times").check()
|
||||
page.locator("#apply-week-capacities").click()
|
||||
expect(capacity_import).to_be_hidden()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
expect(first_day).to_contain_text("45 of 420 min")
|
||||
assert saved[-1]["days"][0]["free_windows"] == [
|
||||
{"start_time": "10:00", "end_time": "17:00"}
|
||||
]
|
||||
|
||||
page.wait_for_function("() => !document.querySelector('#confirm-week-plan').disabled")
|
||||
confirm = page.locator("#confirm-week-plan")
|
||||
bounds = confirm.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
confirm.click()
|
||||
expect(page.locator("#week-calendar-handoff")).to_be_visible()
|
||||
expect(page.locator("#week-review")).to_be_hidden()
|
||||
start = page.locator("[data-week-calendar-start]").first
|
||||
expect(start).to_have_value("09:00")
|
||||
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:00–10:45 · 45 min")
|
||||
expect(page.locator("#week-calendar-status")).to_contain_text("Planning around imported busy time")
|
||||
exact = page.locator('[data-week-calendar-exact="issue:acme/mobile:41:"]')
|
||||
expect(exact).to_have_value("")
|
||||
exact.fill("14:00")
|
||||
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("14:00–14:45 · 45 min")
|
||||
save_times = page.locator("#save-week-calendar-times")
|
||||
for control in (start, exact, page.locator("#back-to-week-review-from-calendar"), save_times, page.locator("#share-week-calendar")):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
save_times.click()
|
||||
expect(page.locator("#week-calendar-status")).to_have_text("Exact task times saved to Week Ahead.")
|
||||
assert saved[-1]["days"][0]["start_times"] == {"issue:acme/mobile:41:": "14:00"}
|
||||
page.locator("#back-to-week-review-from-calendar").click()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
confirm.click()
|
||||
with page.expect_download() as download_info:
|
||||
page.locator("#share-week-calendar").click()
|
||||
assert download_info.value.suggested_filename.startswith("stackchain-week-ahead-")
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
|
||||
assert saved and len(saved[-1]["days"]) == 7
|
||||
assert [day["plan_date"] for day in saved[-1]["days"]] == sorted(
|
||||
day["plan_date"] for day in saved[-1]["days"]
|
||||
)
|
||||
|
||||
before_retirement = len(saved)
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
expect(overview).to_be_visible()
|
||||
planned_item = cards.first.locator(
|
||||
'[data-week-open-item="issue:acme/mobile:41:"]'
|
||||
)
|
||||
planned_item.click()
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
page.once("dialog", lambda dialog: dialog.accept())
|
||||
page.locator("#close-issue").click()
|
||||
expect(page.locator("#issue-sheet")).not_to_have_class(re.compile(r"\bopen\b"))
|
||||
expect(cards.first).not_to_contain_text("Ship mobile capture")
|
||||
expect(cards.first).to_contain_text("Polish desktop filters")
|
||||
expect(cards.first).to_contain_text("45 of 60 min")
|
||||
expect(page.locator("#week-review-status")).to_have_text(
|
||||
"Ship mobile capture removed and Week Ahead saved."
|
||||
)
|
||||
expect(edit_week).to_be_focused()
|
||||
assert len(saved) == before_retirement + 1
|
||||
assert saved[-1]["days"][0]["ids"] == ["issue:acme/mobile:42:"]
|
||||
assert saved[-1]["days"][0]["capacity_minutes"] == 60
|
||||
pull = cards.first.locator('[data-week-pull-item="issue:acme/mobile:42:"]')
|
||||
expect(pull).to_have_text("Review Today · 75 of 60 min")
|
||||
pull_bounds = pull.bounding_box()
|
||||
assert pull_bounds and pull_bounds["height"] >= 44
|
||||
page.once("dialog", lambda dialog: dialog.dismiss())
|
||||
pull.click()
|
||||
expect(cards.first).to_contain_text("Polish desktop filters")
|
||||
assert pulled == [], "cancelling the overload review must not submit"
|
||||
expect(pull).to_have_text("Review Today · 75 of 60 min")
|
||||
page.once("dialog", lambda dialog: dialog.accept())
|
||||
pull.click()
|
||||
expect(cards.first).not_to_contain_text("Polish desktop filters")
|
||||
expect(page.locator("#week-review-status")).to_have_text(
|
||||
"Polish desktop filters added to Today."
|
||||
)
|
||||
assert pulled and pulled[-1]["identity"] == "issue:acme/mobile:42:"
|
||||
assert pulled[-1]["today_revision"] == 3
|
||||
assert pulled[-1]["allow_over_capacity"] is True
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reconciles_only_the_week_day_changed_on_both_devices(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
puts: list[dict] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
|
||||
def week_route(route):
|
||||
if route.request.method == "PUT":
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
puts.append(body)
|
||||
if len(puts) == 1:
|
||||
route.fulfill(status=409, content_type="application/json", body='{"message":"changed"}')
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 6, "timezone": body["timezone"], "days": body["days"],
|
||||
}))
|
||||
return
|
||||
if puts:
|
||||
changed_date = puts[0]["days"][0]["plan_date"]
|
||||
following_date = (date.fromisoformat(changed_date) + timedelta(days=1)).isoformat()
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 5, "timezone": puts[0]["timezone"], "days": [
|
||||
{"plan_date": changed_date, "ids": [], "capacity_minutes": 90, "estimates": {}},
|
||||
{"plan_date": following_date, "ids": [], "capacity_minutes": 30, "estimates": {}},
|
||||
],
|
||||
}))
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 4, "timezone": None, "days": [],
|
||||
}))
|
||||
|
||||
page.route("**/api/v1/week", week_route)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Week conflict release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
page.locator("#edit-week-plan").click()
|
||||
page.locator("#plan-today-available").fill("60")
|
||||
page.locator("#save-today-plan").click()
|
||||
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent.includes('Conflict')")
|
||||
|
||||
choices = page.locator("[data-week-conflict-choice]")
|
||||
expect(choices).to_have_count(2)
|
||||
for index in range(2):
|
||||
bounds = choices.nth(index).locator("xpath=..").bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator('[data-week-conflict-choice="phone"]').check()
|
||||
expect(page.locator("#save-merged-week")).to_be_enabled()
|
||||
page.locator("#save-merged-week").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
|
||||
assert len(puts) == 2
|
||||
assert [day["capacity_minutes"] for day in puts[-1]["days"]] == [60, 30]
|
||||
assert not page_errors, f"Week conflict recovery raised: {page_errors}"
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reads_the_confirmed_week_offline_then_retries_live(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
online = True
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": width, "height": height}, timezone_id="UTC")
|
||||
page_errors: list[str] = []
|
||||
page.on("pageerror", lambda error: page_errors.append(str(error)))
|
||||
|
||||
def week_route(route):
|
||||
if not online:
|
||||
route.fulfill(status=503, content_type="application/json", body='{"message":"offline"}')
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": 7, "timezone": "UTC", "days": [{
|
||||
"plan_date": (date.today() + timedelta(days=1)).isoformat(),
|
||||
"ids": ["issue:acme/mobile:41:"], "capacity_minutes": 60,
|
||||
"estimates": {"issue:acme/mobile:41:": 30},
|
||||
}],
|
||||
}))
|
||||
|
||||
page.route("**/api/v1/week", week_route)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Offline Week Ahead phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture")
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
online = False
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="week"]').click()
|
||||
notice = page.locator("#week-offline-snapshot")
|
||||
expect(notice).to_be_visible()
|
||||
expect(notice).to_contain_text("Offline snapshot")
|
||||
expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture")
|
||||
expect(page.locator("[data-week-edit-day]")).to_have_count(0)
|
||||
expect(page.locator("#edit-week-plan")).to_be_hidden()
|
||||
expect(page.locator("#open-week-capacity-import")).to_be_hidden()
|
||||
retry = page.locator("#retry-week-live")
|
||||
expect(retry).to_be_visible()
|
||||
bounds = retry.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
online = True
|
||||
retry.click()
|
||||
expect(notice).to_be_hidden()
|
||||
expect(page.locator("#edit-week-plan")).to_be_visible()
|
||||
expect(page.locator("[data-week-edit-day]")).to_have_count(7)
|
||||
assert not page_errors, f"offline Week Ahead recovery raised: {page_errors}"
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
@@ -419,11 +419,12 @@ async def test_slow_ledger_completion_does_not_block_health_requests(monkeypatch
|
||||
return {"id": 249, "body": body}
|
||||
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
original_complete = main._idempotency_ledger.complete
|
||||
|
||||
def slow_complete(key, response):
|
||||
started.set()
|
||||
time.sleep(0.2)
|
||||
assert release.wait(1)
|
||||
return original_complete(key, response)
|
||||
|
||||
monkeypatch.setattr(main._idempotency_ledger, "complete", slow_complete)
|
||||
@@ -432,15 +433,16 @@ async def test_slow_ledger_completion_does_not_block_health_requests(monkeypatch
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
authored = asyncio.create_task(client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/248/comments",
|
||||
json={"body": "Persist without freezing"},
|
||||
headers={"Idempotency-Key": "slow-completion-248"},
|
||||
))
|
||||
assert await asyncio.to_thread(started.wait, 1)
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
health = await client.get("/healthz")
|
||||
health_elapsed = asyncio.get_running_loop().time() - started_at
|
||||
release.set()
|
||||
authored_response = await authored
|
||||
|
||||
assert health.status_code == 200
|
||||
|
||||
@@ -48,7 +48,7 @@ def test_release_promotion_waits_for_tests_and_bundle():
|
||||
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
|
||||
|
||||
|
||||
def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
def test_release_promotion_waits_for_packaged_mobile_journeys():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
@@ -61,12 +61,42 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
assert (
|
||||
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
|
||||
"tests/e2e/test_mobile_search_preview_navigation.py "
|
||||
"tests/e2e/test_mobile_search_week_plan.py "
|
||||
"tests/e2e/test_mobile_find_work_release.py "
|
||||
"tests/e2e/test_mobile_home_bootstrap_release.py "
|
||||
"tests/e2e/test_mobile_sign_out_release.py "
|
||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
||||
"tests/e2e/test_mobile_today_summary_release.py "
|
||||
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
|
||||
"tests/e2e/test_mobile_tomorrow_conflict_release.py "
|
||||
"tests/e2e/test_mobile_week_ahead_release.py "
|
||||
"tests/e2e/test_mobile_today_week_reschedule_release.py "
|
||||
"tests/e2e/test_mobile_wrap_up_handoff_release.py "
|
||||
"tests/e2e/test_mobile_following_release.py "
|
||||
"tests/e2e/test_mobile_detail_watch_release.py "
|
||||
"tests/e2e/test_mobile_pull_reviewer_status_release.py "
|
||||
"tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q"
|
||||
) in browser
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
|
||||
|
||||
def test_browser_job_runs_packaged_today_week_reschedule_journey():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
|
||||
assert "tests/e2e/test_mobile_today_week_reschedule_release.py" in browser
|
||||
|
||||
|
||||
def test_browser_job_gates_latest_pull_review_mobile_journeys():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
|
||||
assert "tests/e2e/test_mobile_address_review_feedback_release.py" in browser
|
||||
assert "tests/e2e/test_mobile_cancel_pull_review_request_release.py" in browser
|
||||
|
||||
|
||||
def test_browser_job_gates_authored_pull_queue_journey():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
|
||||
assert "tests/e2e/test_mobile_authored_pull_queue_release.py" in browser
|
||||
@@ -389,6 +389,27 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_reports_only_successfully_loaded_revisions_to_its_source_queue():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const loaded=[];
|
||||
const preview=createSearchPreview({{
|
||||
fetchJson:item => item.number === 9 ? Promise.reject(new Error('preview failed')) :
|
||||
Promise.resolve({{...item,title:'Loaded'}}),
|
||||
mutate:async()=>{{}}, onOpened:item=>loaded.push([item.number,item.updated_at]), onState:()=>{{}},
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api',number:42,kind:'issue',updated_at:'rev-1'}});
|
||||
await preview.open({{repository:'stackchain/api',number:9,kind:'issue',updated_at:'rev-2'}}).catch(()=>{{}});
|
||||
process.stdout.write(JSON.stringify(loaded));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [[42, "rev-1"]]
|
||||
|
||||
|
||||
def test_search_preview_queue_and_next_advances_only_after_durable_admission():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
@@ -631,7 +652,7 @@ def test_mobile_search_preview_renders_session_navigation_and_advances_after_pla
|
||||
assert 'aria-label="Search result navigation"' in html
|
||||
assert ".search-preview-navigation" in css
|
||||
assert ".search-preview-header button, .search-preview-actions button" in css
|
||||
assert "session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})]" in html
|
||||
assert "getSession:() => followingQueue.session() || commandSearchState" in html
|
||||
preview_source = SEARCH_PREVIEW.read_text()
|
||||
assert "api.previous()" in preview_source
|
||||
assert "api.next()" in preview_source
|
||||
@@ -806,6 +827,219 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_watch_is_single_flight_and_keeps_visible_context_on_failure():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let calls = 0; let rejectWatch; const states = [];
|
||||
const detail = {{repository:'stackchain/api',number:42,kind:'issue',claimable:true,watching:false}};
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson:async()=>detail, mutate:async()=>{{}},
|
||||
watch:()=>{{calls+=1;return new Promise((_resolve,reject)=>rejectWatch=reject);}},
|
||||
onState:state=>states.push(state),
|
||||
}});
|
||||
await preview.open(detail);
|
||||
const first=preview.setWatching(true); const second=preview.setWatching(true);
|
||||
if (calls !== 1 || first !== second) throw new Error('watch mutation was not single-flight');
|
||||
rejectWatch(new Error('offline')); await Promise.allSettled([first,second]);
|
||||
const final=states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{calls,status:final.status,number:final.detail.number,
|
||||
watching:final.detail.watching,error:final.error.message}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": 1,
|
||||
"status": "watch-error",
|
||||
"number": 42,
|
||||
"watching": False,
|
||||
"error": "offline",
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_preserves_authoritative_watch_and_offers_following_repair():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const states = [];
|
||||
const detail = {{repository:'stackchain/api',number:42,kind:'pull',state:'open',claimable:true,watching:false}};
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson:async()=>detail,
|
||||
watch:async()=>({{watching:true,following_synced:false,error:'Watching in Gitea, but Following could not sync. Retry this action.'}}),
|
||||
onState:state=>states.push(state),
|
||||
}});
|
||||
await preview.open(detail);
|
||||
await preview.setWatching(true);
|
||||
const final=states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
status:final.status,
|
||||
watching:final.detail.watching,
|
||||
message:globalThis.searchPreviewWatchStatus(final),
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"status": "watch-partial",
|
||||
"watching": True,
|
||||
"message": "Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch pull request to repair.",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_search_preview_exposes_touch_safe_watch_action():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert '<button id="watch-search-result" type="button" hidden>Watch issue</button>' in html
|
||||
assert "wireSearchPreviewWatch" in dashboard
|
||||
assert "preview.setWatching" in SEARCH_PREVIEW.read_text()
|
||||
assert ".search-preview-actions button" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_search_preview_watch_is_available_for_open_issues_pulls_and_closed_following_retirement():
|
||||
script = f"""
|
||||
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const loaded = [];
|
||||
const options = globalThis.searchPreviewSubscriptionOptions(async path => {{
|
||||
loaded.push(path);
|
||||
return {{watching:true}};
|
||||
}});
|
||||
const assigned = {{repository:'stackchain/api',number:42,kind:'issue',state:'open',
|
||||
claimable:false,assignees:['alexander']}};
|
||||
const hydrated = await options.load(assigned);
|
||||
const button = {{hidden:true,textContent:'',disabled:false}};
|
||||
globalThis.renderSearchPreviewWatch(hydrated, {{status:'ready'}}, button);
|
||||
const excluded = [];
|
||||
for (const detail of [
|
||||
{{...assigned,state:'closed'}},
|
||||
{{...assigned,kind:'pull'}},
|
||||
]) {{
|
||||
await options.load(detail);
|
||||
const candidate = {{hidden:false,textContent:'',disabled:false}};
|
||||
globalThis.renderSearchPreviewWatch(detail, {{status:'ready'}}, candidate);
|
||||
excluded.push(candidate.hidden);
|
||||
}}
|
||||
const closedFollowing = await options.load({{...assigned,state:'closed',following:true}});
|
||||
const retire = {{hidden:true,textContent:'',disabled:false}};
|
||||
globalThis.renderSearchPreviewWatch(closedFollowing, {{status:'ready'}}, retire);
|
||||
process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,excluded,closedFollowing,retire}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["loaded"] == [
|
||||
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
|
||||
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=pull",
|
||||
]
|
||||
assert payload["watching"] is True
|
||||
assert payload["button"] == {
|
||||
"hidden": False,
|
||||
"textContent": "Stop watching",
|
||||
"disabled": False,
|
||||
}
|
||||
assert payload["excluded"] == [True, False]
|
||||
assert payload["closedFollowing"]["watching"] is True
|
||||
assert payload["retire"] == {
|
||||
"hidden": False,
|
||||
"textContent": "Stop watching & next",
|
||||
"disabled": False,
|
||||
}
|
||||
|
||||
|
||||
def test_closed_following_unwatch_is_single_flight_and_advances_only_after_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let finish;
|
||||
const states=[]; const retired=[]; const opened=[]; let calls=0;
|
||||
const next={{repository:'stackchain/web',number:9,kind:'issue',state:'open'}};
|
||||
const preview=createSearchPreview({{
|
||||
fetchJson:item => Promise.resolve(item), mutate:()=>Promise.resolve(),
|
||||
watch:() => {{ calls += 1; return new Promise(resolve => finish=resolve); }},
|
||||
afterUnwatch:item => {{ retired.push(item.number); return next; }},
|
||||
onNavigate:item => opened.push(item.number), onState:state => states.push(state),
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api',number:42,kind:'issue',state:'closed',following:true,watching:true}});
|
||||
const first=preview.setWatching(false); const second=preview.setWatching(false);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const before={{calls,retired:[...retired],opened:[...opened]}};
|
||||
finish({{watching:false,following_synced:true}});
|
||||
await Promise.all([first,second]);
|
||||
process.stdout.write(JSON.stringify({{before,calls,same:first===second,retired,opened,last:states.at(-1)}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["before"] == {"calls": 1, "retired": [], "opened": []}
|
||||
assert payload["same"] is True
|
||||
assert payload["retired"] == [42]
|
||||
assert payload["opened"] == [9]
|
||||
assert payload["last"]["detail"]["number"] == 9
|
||||
|
||||
|
||||
def test_search_subscription_preview_hydrates_detail_before_watch_state():
|
||||
script = f"""
|
||||
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const calls=[];
|
||||
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
|
||||
calls.push(path);
|
||||
if (path.includes('/subscription')) return {{watching:true}};
|
||||
return {{repository:'stackchain/api',number:42,kind:'issue',state:'open',title:'Hydrated'}};
|
||||
}});
|
||||
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue'}});
|
||||
process.stdout.write(JSON.stringify({{calls,detail}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": [
|
||||
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue",
|
||||
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
|
||||
],
|
||||
"detail": {
|
||||
"repository": "stackchain/api", "number": 42, "kind": "issue",
|
||||
"state": "open", "title": "Hydrated", "watching": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_closed_following_preview_preserves_origin_and_skips_status_lookup():
|
||||
script = f"""
|
||||
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const calls=[];
|
||||
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
|
||||
calls.push(path);
|
||||
return {{repository:'stackchain/api',number:42,kind:'issue',state:'closed',title:'Finished'}};
|
||||
}});
|
||||
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue',following:true}});
|
||||
process.stdout.write(JSON.stringify({{calls,detail}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["calls"] == [
|
||||
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue"
|
||||
]
|
||||
assert payload["detail"]["following"] is True
|
||||
assert payload["detail"]["watching"] is True
|
||||
|
||||
|
||||
def test_search_preview_shares_canonical_url_without_closing_the_preview():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
@@ -1100,7 +1334,7 @@ def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "detail.reopenable ? 'Reopen & resume'" in html
|
||||
assert "mutate: (detail, action) => fetchReviewJson(" in html
|
||||
assert "mutate:(detail,action)=>fetchReviewJson(" in html
|
||||
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-whiteboard')", 1
|
||||
)[0]
|
||||
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert "stackchain-dashboard-shell-v138" in worker
|
||||
@@ -1,3 +1,5 @@
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -21,6 +23,30 @@ def test_receipts_merge_without_lost_updates_and_remain_account_scoped(tmp_path)
|
||||
assert store.get("alexander") == {"receipts": []}
|
||||
|
||||
|
||||
def test_receipt_history_is_encrypted_and_bound_to_its_account(tmp_path):
|
||||
database = tmp_path / "completed-filed.sqlite3"
|
||||
store = CompletedFiledReviewStore(database, encryption_key=b"f" * 32)
|
||||
timmy = receipt("private-timmy/repository", 912)
|
||||
alexander = receipt("private-alexander/repository", 427)
|
||||
|
||||
store.merge("timmy-private-login", [timmy])
|
||||
store.merge("alexander-private-login", [alexander])
|
||||
|
||||
raw = database.read_bytes()
|
||||
assert b"private-timmy/repository" not in raw
|
||||
assert b"private-alexander/repository" not in raw
|
||||
with sqlite3.connect(database) as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT login, receipts FROM completed_filed_review_collections ORDER BY login"
|
||||
).fetchall()
|
||||
connection.execute(
|
||||
"UPDATE completed_filed_review_collections SET receipts = ? WHERE login = ?",
|
||||
(rows[1][1], rows[0][0]),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="private state could not be decrypted"):
|
||||
store.get("alexander-private-login")
|
||||
|
||||
|
||||
def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
|
||||
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2)
|
||||
newer = receipt(updated_at="2026-08-16T12:00:00Z")
|
||||
@@ -32,6 +58,42 @@ def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
|
||||
assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]}
|
||||
|
||||
|
||||
def test_legacy_plaintext_receipts_migrate_for_every_account_without_loss(tmp_path):
|
||||
database = tmp_path / "completed-filed.sqlite3"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE completed_filed_reviews ("
|
||||
"login TEXT NOT NULL, repository TEXT NOT NULL, issue_number INTEGER NOT NULL, "
|
||||
"updated_at TEXT NOT NULL, touched_at INTEGER NOT NULL, "
|
||||
"PRIMARY KEY (login, repository, issue_number))"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO completed_filed_reviews VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
("timmy", "legacy-private/first", 1, "2026-08-14T12:00:00Z", 1),
|
||||
("timmy", "legacy-private/second", 2, "2026-08-15T12:00:00Z", 2),
|
||||
("alexander", "legacy-private/other", 3, "2026-08-16T12:00:00Z", 1),
|
||||
],
|
||||
)
|
||||
|
||||
store = CompletedFiledReviewStore(database, encryption_key=b"m" * 32)
|
||||
|
||||
assert store.get("timmy") == {"receipts": [
|
||||
receipt("legacy-private/first", 1, "2026-08-14T12:00:00Z"),
|
||||
receipt("legacy-private/second", 2, "2026-08-15T12:00:00Z"),
|
||||
]}
|
||||
assert store.get("alexander") == {"receipts": [
|
||||
receipt("legacy-private/other", 3, "2026-08-16T12:00:00Z")
|
||||
]}
|
||||
raw = database.read_bytes()
|
||||
assert b"legacy-private/first" not in raw
|
||||
assert b"legacy-private/other" not in raw
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT count(*) FROM sqlite_master WHERE name = 'completed_filed_reviews'"
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("candidate", [
|
||||
{"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"},
|
||||
{"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"},
|
||||
|
||||
@@ -364,6 +364,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
@@ -391,6 +392,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
@@ -523,6 +525,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -547,6 +550,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -644,6 +648,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
MODULE = ROOT / "frontend" / "search-preview.js"
|
||||
|
||||
|
||||
def run(script: str) -> dict:
|
||||
harness = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const createDetailWatch = globalThis.createDetailWatch;
|
||||
const state = {{ requests:[], renders:[], refreshes:0 }};
|
||||
const feature = createDetailWatch({{
|
||||
fetchJson: async (path, options={{}}) => {{
|
||||
state.requests.push({{path,method:options.method || 'GET'}});
|
||||
return {{watching:false,following_synced:true}};
|
||||
}},
|
||||
refreshFollowing: async () => {{ state.refreshes += 1; }},
|
||||
onState: (status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_detail_watch_loads_authoritative_typed_subscription_state():
|
||||
result = run("""
|
||||
await feature.open({repository:'stackchain/api',kind:'pull',number:84,state:'open'});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert result["requests"] == [{
|
||||
"path": "api/v1/repos/stackchain/api/issues/84/preview/subscription?kind=pull",
|
||||
"method": "GET",
|
||||
}]
|
||||
assert result["renders"] == [
|
||||
{"status": "loading", "watching": False},
|
||||
{"status": "ready", "watching": False},
|
||||
]
|
||||
assert result["refreshes"] == 0
|
||||
|
||||
|
||||
def test_detail_watch_mutation_is_single_flight_and_refreshes_following_after_confirmation():
|
||||
script = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const createDetailWatch = globalThis.createDetailWatch;
|
||||
const state={{requests:[],renders:[],refreshes:0}};
|
||||
let finishMutation;
|
||||
const feature=createDetailWatch({{
|
||||
fetchJson:async (path,options={{}}) => {{
|
||||
state.requests.push({{path,method:options.method || 'GET'}});
|
||||
if (!options.method) return {{watching:false}};
|
||||
return new Promise(resolve => {{finishMutation=() => resolve({{watching:true,following_synced:true,following_count:3}});}});
|
||||
}},
|
||||
refreshFollowing:async () => {{state.refreshes += 1;}},
|
||||
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{
|
||||
const item={{repository:'stackchain/api',kind:'issue',number:42,state:'open'}};
|
||||
await feature.open(item);
|
||||
const first=feature.toggle();
|
||||
const second=feature.toggle();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
state.samePromise=first === second;
|
||||
finishMutation();
|
||||
await Promise.all([first,second]);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["samePromise"] is True
|
||||
assert [request["method"] for request in result["requests"]] == ["GET", "PUT"]
|
||||
assert result["refreshes"] == 1
|
||||
assert result["renders"][-2]["status"] == "watching"
|
||||
assert result["renders"][-1]["status"] == "watched"
|
||||
assert result["renders"][-1]["watching"] is True
|
||||
|
||||
|
||||
def test_detail_watch_partial_sync_never_claims_success_or_refreshes_following():
|
||||
script = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const createDetailWatch = globalThis.createDetailWatch;
|
||||
const state={{renders:[],refreshes:0}};
|
||||
const feature=createDetailWatch({{
|
||||
fetchJson:async (_path,options={{}}) => options.method
|
||||
? {{watching:true,following_synced:false,error:'Following could not sync.'}}
|
||||
: {{watching:false}},
|
||||
refreshFollowing:async () => {{state.refreshes += 1;}},
|
||||
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.open({{repository:'stackchain/api',kind:'issue',number:42,state:'open'}});
|
||||
try {{ await feature.toggle(); }} catch (error) {{ state.rejected=error.message; }}
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["rejected"] == "Following could not sync."
|
||||
assert result["refreshes"] == 0
|
||||
assert result["renders"][-1]["status"] == "error"
|
||||
assert result["renders"][-1]["watching"] is False
|
||||
assert all(state["status"] != "watched" for state in result["renders"])
|
||||
|
||||
|
||||
def test_detail_watch_is_wired_into_both_mobile_work_details_and_bundle():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
|
||||
workflow = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text()
|
||||
search_preview = (ROOT / "frontend" / "search-preview.js").read_text()
|
||||
|
||||
assert '<button id="watch-issue-detail" type="button">Watch issue</button>' in html
|
||||
assert '<button id="watch-pull-detail" type="button">Watch pull request</button>' in html
|
||||
assert 'id="issue-watch-status"' in html
|
||||
assert 'id="pull-watch-status"' in html
|
||||
assert 'static/search-preview.js' in bundle
|
||||
assert 'root.createDetailWatch' in search_preview
|
||||
assert 'function bindDetailWatch(' in dashboard
|
||||
assert 'issueDetailWatch.open(item)' in dashboard
|
||||
assert 'pullDetailWatch.open(item)' in dashboard
|
||||
assert '#watch-issue-detail' in css
|
||||
assert '#watch-pull-detail' in css
|
||||
assert 'tests/e2e/test_mobile_detail_watch_release.py' in workflow
|
||||
|
||||
|
||||
def test_detail_watch_ignores_a_late_subscription_response_for_the_previous_item():
|
||||
script = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const createDetailWatch = globalThis.createDetailWatch;
|
||||
const state={{renders:[]}};
|
||||
const completions={{}};
|
||||
const feature=createDetailWatch({{
|
||||
fetchJson:path => new Promise(resolve => {{completions[path]=resolve;}}),
|
||||
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{
|
||||
const first=feature.open({{repository:'stackchain/api',kind:'issue',number:1,state:'open'}});
|
||||
const second=feature.open({{repository:'stackchain/api',kind:'issue',number:2,state:'open'}});
|
||||
completions[Object.keys(completions)[1]]({{watching:true}});
|
||||
await second;
|
||||
completions[Object.keys(completions)[0]]({{watching:false}});
|
||||
await first;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
ready = [state for state in result["renders"] if state["status"] == "ready"]
|
||||
assert len(ready) == 1
|
||||
assert ready[0]["watching"] is True
|
||||
|
||||
|
||||
def test_detail_watch_reports_subscription_load_failure_as_recoverable_error():
|
||||
script = f"""
|
||||
require({json.dumps(str(MODULE))});
|
||||
const state={{renders:[]}};
|
||||
const feature=globalThis.createDetailWatch({{
|
||||
fetchJson:async () => {{throw new Error('temporarily unavailable');}},
|
||||
onState:(status,watching,error) => state.renders.push({{status,watching,error:error?.message}}),
|
||||
}});
|
||||
(async () => {{
|
||||
try {{ await feature.open({{repository:'stackchain/api',kind:'issue',number:1,state:'open'}}); }}
|
||||
catch (error) {{ state.rejected=error.message; }}
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["rejected"] == "temporarily unavailable"
|
||||
assert result["renders"][-1] == {
|
||||
"status": "error", "watching": False, "error": "temporarily unavailable"
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.following_store import FollowingStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_confirmed_watch_updates_account_following_collection(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"a" * 32)
|
||||
detail = {
|
||||
"repository": "stackchain/api",
|
||||
"kind": "issue",
|
||||
"number": 42,
|
||||
"title": "Make mobile review useful",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
}
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
assert (repository, kind, number) == ("stackchain/api", "issue", 42)
|
||||
return detail
|
||||
|
||||
async def set_subscription(repository, number, watching):
|
||||
return {"watching": watching}
|
||||
|
||||
async def user():
|
||||
return {"login": "Timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
watched = await client.put(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
following = await client.get("/api/v1/following")
|
||||
|
||||
assert watched.status_code == 200
|
||||
assert watched.json() == {
|
||||
"watching": True,
|
||||
"following_synced": True,
|
||||
"following_revision": 1,
|
||||
"following_count": 1,
|
||||
}
|
||||
assert following.status_code == 200
|
||||
assert following.headers["cache-control"] == "no-store"
|
||||
assert following.json() == {"revision": 1, "degraded": False, "refresh_failures": 0, "items": [{
|
||||
"repository": "stackchain/api",
|
||||
"kind": "issue",
|
||||
"number": 42,
|
||||
"title": "Make mobile review useful",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"has_unseen_change": False,
|
||||
}]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_confirmed_pull_watch_round_trips_through_following(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"p" * 32)
|
||||
detail = {
|
||||
"repository": "stackchain/api", "kind": "pull", "number": 84,
|
||||
"title": "Ship typed Following", "state": "open",
|
||||
"updated_at": "2026-08-23T05:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/pulls/84",
|
||||
}
|
||||
previews = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
previews.append((repository, kind, number))
|
||||
return detail
|
||||
|
||||
async def set_subscription(repository, number, watching):
|
||||
return {"watching": watching}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
watched = await client.put(
|
||||
"/api/v1/repos/stackchain/api/issues/84/preview/subscription?kind=pull"
|
||||
)
|
||||
following = await client.get("/api/v1/following")
|
||||
|
||||
assert watched.status_code == 200
|
||||
assert following.status_code == 200
|
||||
assert following.json()["items"][0]["kind"] == "pull"
|
||||
assert previews == [
|
||||
("stackchain/api", "pull", 84),
|
||||
("stackchain/api", "pull", 84),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_watched_pull_can_load_read_only_review_context_without_assignment(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"r" * 32)
|
||||
store.set_watching("timmy", {
|
||||
"repository": "stackchain/api", "kind": "pull", "number": 84,
|
||||
"title": "Review watched changes", "state": "open",
|
||||
"updated_at": "2026-08-24T05:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/pulls/84",
|
||||
}, True)
|
||||
review = {
|
||||
"head_sha": "abc123", "ci_state": "success",
|
||||
"files": [{"filename": "src/api.py", "status": "modified", "patch": "+safe"}],
|
||||
}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def load_review(repository, number):
|
||||
assert (repository, number) == ("stackchain/api", 84)
|
||||
return review
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", load_review)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/following/stackchain/api/pulls/84/review-data"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == review
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unwatched_pull_review_context_is_not_exposed(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"u" * 32)
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def unexpected_review(*_args):
|
||||
raise AssertionError("unwatched pull must be rejected before upstream access")
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", unexpected_review)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/following/stackchain/api/pulls/84/review-data"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1)
|
||||
store.set_watching("timmy", {
|
||||
"repository": "stackchain/api", "number": 7, "title": "Already followed",
|
||||
"state": "open", "updated_at": "2026-08-23T02:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7",
|
||||
}, True)
|
||||
calls = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
return {
|
||||
"repository": repository, "kind": kind, "number": number,
|
||||
"title": "Another issue", "state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42", "claimable": True,
|
||||
}
|
||||
|
||||
async def set_subscription(*args):
|
||||
calls.append(args)
|
||||
return {"watching": True}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.put(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert calls == []
|
||||
assert store.get("timmy")["items"][0]["number"] == 7
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_write_failure_compensates_confirmed_gitea_watch(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"c" * 32)
|
||||
calls = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
return {
|
||||
"repository": repository, "kind": kind, "number": number,
|
||||
"title": "Recoverable watch", "state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42", "claimable": True,
|
||||
}
|
||||
|
||||
async def set_subscription(repository, number, watching):
|
||||
calls.append(watching)
|
||||
return {"watching": watching}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
def fail_write(*args):
|
||||
raise OSError("disk unavailable")
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(store, "set_watching", fail_write)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.put(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"error": "Watch status was not changed. Please retry."}
|
||||
assert calls == [True, False]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_uncompensated_following_write_reports_authoritative_partial_outcome(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"d" * 32)
|
||||
calls = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
return {
|
||||
"repository": repository, "kind": kind, "number": number,
|
||||
"title": "Split watch", "state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42", "claimable": True,
|
||||
}
|
||||
|
||||
async def set_subscription(repository, number, watching):
|
||||
calls.append(watching)
|
||||
return {"watching": True}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(store, "set_watching", lambda *args: (_ for _ in ()).throw(OSError()))
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.put(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"watching": True,
|
||||
"following_synced": False,
|
||||
"error": "Watching in Gitea, but Following could not sync. Retry this action.",
|
||||
}
|
||||
assert calls == [True, False]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_refreshes_changed_items_and_preserves_failed_items(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"e" * 32)
|
||||
first = {
|
||||
"repository": "stackchain/api", "number": 42, "title": "Old title",
|
||||
"state": "open", "updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}
|
||||
failed = {**first, "number": 43, "title": "Last known",
|
||||
"url": "https://forge.example/stackchain/api/issues/43"}
|
||||
store.set_watching("timmy", failed, True)
|
||||
store.set_watching("timmy", first, True)
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
assert (repository, kind) == ("stackchain/api", "issue")
|
||||
if number == 43:
|
||||
return {**failed, "kind": "issue", "title": ""}
|
||||
return {**first, "kind": "issue", "title": "Fresh title", "state": "closed",
|
||||
"updated_at": "2026-08-23T04:00:00Z"}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/following")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["degraded"] is True
|
||||
assert payload["refresh_failures"] == 1
|
||||
assert [(item["number"], item["has_unseen_change"]) for item in payload["items"]] == [
|
||||
(42, True), (43, False)
|
||||
]
|
||||
assert payload["items"][0]["title"] == "Fresh title"
|
||||
assert payload["items"][1]["title"] == "Last known"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32)
|
||||
item = {
|
||||
"repository": "stackchain/api", "kind": "pull", "number": 42, "title": "Changed",
|
||||
"state": "open", "updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}
|
||||
store.set_watching("timmy", item, True)
|
||||
store.refresh("timmy", [{**item, "updated_at": "2026-08-23T04:00:00Z"}])
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.put(
|
||||
"/api/v1/following/stackchain/api/issues/42/seen?kind=pull",
|
||||
json={"updated_at": "2026-08-23T04:00:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"][0]["has_unseen_change"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_keep_restores_only_the_loaded_revision(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32)
|
||||
item = {
|
||||
"repository": "stackchain/api", "kind": "pull", "number": 42, "title": "Changed",
|
||||
"state": "open", "updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/pulls/42",
|
||||
}
|
||||
store.set_watching("timmy", item, True)
|
||||
changed = {**item, "updated_at": "2026-08-23T04:00:00Z"}
|
||||
store.refresh("timmy", [changed])
|
||||
store.acknowledge("timmy", item["repository"], item["number"], changed["updated_at"], kind="pull")
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.put(
|
||||
"/api/v1/following/stackchain/api/issues/42/keep?kind=pull",
|
||||
json={"updated_at": changed["updated_at"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"][0]["has_unseen_change"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_closed_following_issue_can_be_unwatched_but_not_newly_watched(monkeypatch, tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
|
||||
closed = {
|
||||
"repository": "stackchain/api", "number": 42, "title": "Finished work",
|
||||
"state": "closed", "updated_at": "2026-08-23T04:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}
|
||||
store.set_watching("timmy", closed, True)
|
||||
mutations = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
return {**closed, "kind": "issue"}
|
||||
|
||||
async def set_subscription(repository, number, watching):
|
||||
mutations.append((repository, number, watching))
|
||||
return {"watching": watching}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
watch = await client.put(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
unwatch = await client.delete(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
|
||||
assert watch.status_code == 404
|
||||
assert unwatch.status_code == 200
|
||||
assert unwatch.json() == {
|
||||
"watching": False,
|
||||
"following_synced": True,
|
||||
"following_revision": 2,
|
||||
"following_count": 0,
|
||||
}
|
||||
assert mutations == [("stackchain/api", 42, False)]
|
||||
assert store.get("timmy")["items"] == []
|
||||
@@ -0,0 +1,615 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
MODULE = ROOT / "frontend" / "following.js"
|
||||
|
||||
|
||||
def run(script: str) -> dict:
|
||||
harness = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{ renders:[], counts:[], opened:[], requests:[] }};
|
||||
const feature = createFollowing({{
|
||||
fetchJson: async path => {{
|
||||
state.requests.push(path);
|
||||
return {{revision:3,items:[{{repository:'stackchain/api',number:42,title:'Quiet issue',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge.example/issue/42',has_unseen_change:false}}]}};
|
||||
}},
|
||||
render: snapshot => state.renders.push(snapshot),
|
||||
onCount: count => state.counts.push(count),
|
||||
onOpen: item => state.opened.push(item),
|
||||
}});
|
||||
(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_following_loads_account_collection_and_opens_existing_preview():
|
||||
result = run("""
|
||||
await feature.load();
|
||||
feature.open(0);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert result["requests"] == ["api/v1/following"]
|
||||
assert result["counts"] == [0]
|
||||
assert result["renders"][-1]["status"] == "ready"
|
||||
assert result["renders"][-1]["items"][0]["title"] == "Quiet issue"
|
||||
assert result["opened"] == [{
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Quiet issue",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/issue/42",
|
||||
"has_unseen_change": False,
|
||||
"kind": "issue",
|
||||
"following": True,
|
||||
}]
|
||||
|
||||
|
||||
def test_following_opens_pull_requests_in_the_pull_workspace():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const opened=[];
|
||||
const feature=createFollowing({{
|
||||
fetchJson:async () => ({{revision:1,items:[{{
|
||||
repository:'stackchain/api',kind:'pull',number:84,title:'Typed review',state:'open',
|
||||
updated_at:'2026-08-23T05:00:00Z',url:'https://forge.example/pulls/84',has_unseen_change:true
|
||||
}}]}}),
|
||||
onOpen:async item => opened.push(item), onAcknowledge:async () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.startReview();
|
||||
process.stdout.write(JSON.stringify({{opened,session:feature.session()}}));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["opened"][0]["kind"] == "pull"
|
||||
assert result["opened"][0]["following"] is True
|
||||
assert result["session"]["items"][0]["kind"] == "pull"
|
||||
|
||||
|
||||
def test_following_route_loads_authoritative_state_before_starting_review():
|
||||
following = MODULE.read_text()
|
||||
start = following.index("async route()")
|
||||
route = following[start:following.index("open()", start)]
|
||||
|
||||
assert "await feature.load().then(feature.startReview)" in route
|
||||
assert ".catch(() => false)" in route
|
||||
assert "show();" in route
|
||||
|
||||
|
||||
def test_dashboard_dispatches_following_route_to_changed_first_review():
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
start = dashboard.index("async function openWorkQueueRoute")
|
||||
route_handler = dashboard[start:dashboard.index("qs('#work-milestone-filter')", start)]
|
||||
assert "if (filter === 'following')" in route_handler
|
||||
assert "return followingQueue.route();" in route_handler
|
||||
assert route_handler.index("followingQueue.route") < route_handler.index("selectWorkQueue")
|
||||
|
||||
|
||||
def test_following_preview_wires_keep_for_later_action_and_retryable_failure():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
following = MODULE.read_text()
|
||||
|
||||
assert 'id="keep-following-for-later"' in html
|
||||
assert 'id="keep-following-status"' in html
|
||||
assert "followingQueue.preview(state)" in dashboard
|
||||
assert "query('#close-search-preview').click()" in following
|
||||
assert "state?.item?.following !== true" in following
|
||||
assert "feature.keepForLater(activeReviewItem)" in following
|
||||
assert "Kept for later" in following
|
||||
assert "Could not keep this change for later" in following
|
||||
assert "changeRevision(item, 'keep')" in following
|
||||
assert "keepForLater:feature.keepForLater" in following
|
||||
|
||||
|
||||
def test_following_conversation_marks_messages_newer_than_last_review():
|
||||
preview_module = ROOT / "frontend" / "search-preview.js"
|
||||
script = f"""
|
||||
require({json.dumps(str(preview_module))});
|
||||
const nodes={{
|
||||
'#search-preview-comments':{{innerHTML:'',textContent:''}},
|
||||
'#search-preview-conversation-status':{{textContent:''}},
|
||||
'#retry-search-preview-conversation':{{hidden:true}},
|
||||
'#load-older-search-preview-comments':{{hidden:true,disabled:false}},
|
||||
}};
|
||||
const document={{querySelector:selector=>nodes[selector]}};
|
||||
global.renderSearchPreviewConversation({{
|
||||
status:'ready', reviewedAt:'2026-08-23T03:00:00Z', comments:[
|
||||
{{id:1,author:'Timmy',created_at:'2026-08-23T02:00:00Z',body:'Earlier'}},
|
||||
{{id:2,author:'Alex',created_at:'2026-08-23T04:00:00Z',body:'New decision'}},
|
||||
]
|
||||
}},document,String,String,String);
|
||||
process.stdout.write(JSON.stringify({{html:nodes['#search-preview-comments'].innerHTML,status:nodes['#search-preview-conversation-status'].textContent}}));
|
||||
"""
|
||||
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["html"].count("new-since-review") == 1
|
||||
assert "New since last review" in result["html"]
|
||||
assert result["status"] == "2 messages · 1 new since last review."
|
||||
|
||||
|
||||
def test_following_revision_is_acknowledged_only_after_conversation_context_loads():
|
||||
preview_module = ROOT / "frontend" / "search-preview.js"
|
||||
script = f"""
|
||||
const createSearchPreview=require({json.dumps(str(preview_module))});
|
||||
let unavailable=true;
|
||||
const opened=[];
|
||||
const preview=createSearchPreview({{
|
||||
fetchJson:async item=>({{...item,title:'Changed issue'}}),
|
||||
fetchConversation:async()=>{{if(unavailable)throw new Error('offline');return {{comments:[]}};}},
|
||||
onOpened:async item=>opened.push(item.updated_at),
|
||||
onState:()=>{{}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await preview.open({{
|
||||
repository:'stackchain/api',kind:'issue',number:42,following:true,
|
||||
reviewed_at:'2026-08-23T03:00:00Z',updated_at:'2026-08-23T04:00:00Z'
|
||||
}});
|
||||
const afterFailure=[...opened];
|
||||
unavailable=false;
|
||||
await preview.retryConversation();
|
||||
process.stdout.write(JSON.stringify({{afterFailure,opened}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result == {
|
||||
"afterFailure": [],
|
||||
"opened": ["2026-08-23T04:00:00Z"],
|
||||
}
|
||||
|
||||
|
||||
def test_following_pull_revision_waits_for_changes_and_ci_context():
|
||||
preview_module = ROOT / "frontend" / "search-preview.js"
|
||||
script = f"""
|
||||
const createSearchPreview=require({json.dumps(str(preview_module))});
|
||||
let unavailable=true;
|
||||
const opened=[];
|
||||
const states=[];
|
||||
const preview=createSearchPreview({{
|
||||
fetchJson:async item=>({{...item,title:'Changed pull request'}}),
|
||||
fetchConversation:async()=>({{comments:[]}}),
|
||||
fetchReview:async()=>{{if(unavailable)throw new Error('diff offline');return {{ci_state:'success',files:[{{filename:'src/api.py',diff_lines:['+safe'],diff_available:true}}]}};}},
|
||||
onOpened:async item=>opened.push(item.updated_at),
|
||||
onState:state=>states.push({{status:state.review?.status,ci:state.review?.data?.ci_state}}),
|
||||
}});
|
||||
(async()=>{{
|
||||
await preview.open({{
|
||||
repository:'stackchain/api',kind:'pull',number:84,following:true,
|
||||
reviewed_at:'2026-08-23T03:00:00Z',updated_at:'2026-08-23T04:00:00Z'
|
||||
}});
|
||||
const afterFailure=[...opened];
|
||||
unavailable=false;
|
||||
await preview.retryReview();
|
||||
process.stdout.write(JSON.stringify({{afterFailure,opened,last:states.at(-1)}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result == {
|
||||
"afterFailure": [],
|
||||
"opened": ["2026-08-23T04:00:00Z"],
|
||||
"last": {"status": "ready", "ci": "success"},
|
||||
}
|
||||
|
||||
|
||||
def test_watched_pull_changes_render_bounded_diff_and_ci_state():
|
||||
preview_module = ROOT / "frontend" / "search-preview.js"
|
||||
script = f"""
|
||||
require({json.dumps(str(preview_module))});
|
||||
const nodes={{
|
||||
'#search-preview-review':{{hidden:true}},
|
||||
'#search-preview-review-status':{{textContent:''}},
|
||||
'#search-preview-files':{{innerHTML:''}},
|
||||
'#retry-search-preview-review':{{hidden:true}},
|
||||
}};
|
||||
const document={{querySelector:selector=>nodes[selector]}};
|
||||
global.renderSearchPreviewReview({{status:'ready',data:{{ci_state:'success',files:[{{
|
||||
filename:'src/api.py',status:'modified',additions:1,deletions:0,
|
||||
diff_available:true,diff_lines:['@@ -1 +1 @@','-unsafe','+safe']
|
||||
}}]}}}},document,value=>String(value).replaceAll('<','<'));
|
||||
process.stdout.write(JSON.stringify({{
|
||||
hidden:nodes['#search-preview-review'].hidden,
|
||||
status:nodes['#search-preview-review-status'].textContent,
|
||||
html:nodes['#search-preview-files'].innerHTML,
|
||||
retry:nodes['#retry-search-preview-review'].hidden,
|
||||
}}));
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["hidden"] is False
|
||||
assert result["status"] == "CI passed · 1 changed file."
|
||||
assert "src/api.py" in result["html"]
|
||||
assert "pull-diff-line added" in result["html"]
|
||||
assert result["retry"] is True
|
||||
|
||||
|
||||
def test_following_typed_identity_prevents_issue_pull_collisions():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const acknowledged=[];
|
||||
const items=['issue','pull'].map(kind => ({{
|
||||
repository:'stackchain/api',kind,number:84,title:kind,state:'closed',
|
||||
updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true
|
||||
}}));
|
||||
const feature=createFollowing({{
|
||||
fetchJson:async () => ({{revision:1,items}}),
|
||||
onOpen:async () => {{}}, onAcknowledge:async item => acknowledged.push(item.kind),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.previewLoaded({{...items[1]}});
|
||||
feature.retire(items[1]);
|
||||
process.stdout.write(JSON.stringify({{acknowledged,items:feature.items()}}));
|
||||
}})().catch(error => {{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["acknowledged"] == ["pull"]
|
||||
assert [(item["kind"], item["has_unseen_change"]) for item in result["items"]] == [
|
||||
("issue", True)
|
||||
]
|
||||
|
||||
|
||||
def test_following_opens_explicitly_but_never_becomes_work_recommendation():
|
||||
launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(launcher))});
|
||||
const calls = [];
|
||||
const feature = createLauncher({{
|
||||
getCounts:() => ({{following:7}}),
|
||||
openFollowing:() => {{ calls.push('following'); return 'opened-following'; }},
|
||||
selectFilter:name => calls.push(name), firstAction:() => null,
|
||||
announce:() => {{}}, openFindWork:() => calls.push('find'),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{opened:feature.open('following'),recommended:feature.recommend(),calls}}));
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
assert result == {
|
||||
"opened": "opened-following",
|
||||
"recommended": {"name": "find", "count": 0, "label": "Find Work"},
|
||||
"calls": ["following"],
|
||||
}
|
||||
|
||||
|
||||
def test_following_counts_unseen_changes_and_acknowledges_after_preview_loads():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{counts:[], opened:[], acknowledged:[], renders:[]}};
|
||||
let finishPreview;
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:4,items:[
|
||||
{{repository:'stackchain/api',number:42,title:'Changed',state:'open',updated_at:'2026-08-23T04:00:00Z',url:'https://forge/42',has_unseen_change:true}},
|
||||
{{repository:'stackchain/api',number:43,title:'Quiet',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge/43',has_unseen_change:false}}
|
||||
]}}),
|
||||
render:value => state.renders.push(value),
|
||||
onCount:value => state.counts.push(value),
|
||||
onOpen:item => new Promise(resolve => {{ finishPreview=() => {{ state.opened.push(item.number); resolve(); }}; }}),
|
||||
onAcknowledge:async item => state.acknowledged.push(item.updated_at),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
const opening=feature.open(0);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
state.before={{acknowledged:[...state.acknowledged],counts:[...state.counts]}};
|
||||
finishPreview();
|
||||
await opening;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
assert result["before"] == {"acknowledged": [], "counts": [1]}
|
||||
assert result["opened"] == [42]
|
||||
assert result["acknowledged"] == ["2026-08-23T04:00:00Z"]
|
||||
assert result["counts"] == [1, 0]
|
||||
assert result["renders"][-1]["items"][0]["has_unseen_change"] is False
|
||||
|
||||
|
||||
def test_following_review_captures_only_current_unseen_changes_in_display_order():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{opened:[], acknowledged:[]}};
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:7,items:[
|
||||
{{repository:'stackchain/api',number:42,title:'Newest',state:'open',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/web',number:9,title:'Also changed',state:'open',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/api',number:41,title:'Quiet',state:'open',updated_at:'2026-08-22T05:00:00Z',has_unseen_change:false}}
|
||||
]}}),
|
||||
onOpen:async item => state.opened.push(item.number),
|
||||
onAcknowledge:async item => state.acknowledged.push(item.number),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
const started=await feature.startReview();
|
||||
process.stdout.write(JSON.stringify({{started,session:feature.session(),state}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["started"] is True
|
||||
assert [item["number"] for item in result["session"]["items"]] == [42, 9]
|
||||
assert all(item["kind"] == "issue" for item in result["session"]["items"])
|
||||
assert all(item["following"] is True for item in result["session"]["items"])
|
||||
assert result["session"]["more"] is False
|
||||
assert result["state"] == {"opened": [42], "acknowledged": [42]}
|
||||
|
||||
|
||||
def test_following_review_acknowledges_each_loaded_revision_and_reports_completion():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{acknowledged:[], renders:[], counts:[]}};
|
||||
const items=[
|
||||
{{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
|
||||
];
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:7,items}}),
|
||||
render:value => state.renders.push(value), onCount:value => state.counts.push(value),
|
||||
onOpen:async () => {{}}, onAcknowledge:async item => state.acknowledged.push(item.number),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.startReview();
|
||||
await feature.previewLoaded(feature.session().items[1]);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["acknowledged"] == [42, 9]
|
||||
assert result["counts"][-1] == 0
|
||||
assert result["renders"][-1]["reviewSummary"] == {"reviewed": 2, "remaining": 0}
|
||||
|
||||
|
||||
def test_following_keep_for_later_preserves_revision_and_advances_without_looping():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{opened:[], acknowledged:[], kept:[], counts:[]}};
|
||||
const items=[
|
||||
{{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
|
||||
];
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:7,items}}),
|
||||
onOpen:async item => state.opened.push(item.number),
|
||||
onAcknowledge:async item => state.acknowledged.push(item.number),
|
||||
onKeep:async item => state.kept.push(item.number),
|
||||
onCount:value => state.counts.push(value),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.startReview();
|
||||
const kept = await feature.keepForLater(feature.session().items[0]);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
kept, session:feature.session(), items:feature.items(), state
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["kept"]["number"] == 9
|
||||
assert [item["number"] for item in result["session"]["items"]] == [9]
|
||||
assert result["items"][0]["has_unseen_change"] is True
|
||||
assert result["state"] == {
|
||||
"opened": [42, 9], "acknowledged": [42, 9], "kept": [42], "counts": [2, 1, 2, 1]
|
||||
}
|
||||
|
||||
|
||||
def test_following_review_notifies_prepare_today_when_final_revision_is_loaded():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const completed = [];
|
||||
const items=[
|
||||
{{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
|
||||
];
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:7,items}}),
|
||||
onOpen:async () => {{}},
|
||||
onAcknowledge:async () => {{}},
|
||||
onReviewComplete:() => completed.push('following'),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.startReview();
|
||||
await feature.previewLoaded(feature.session().items[1]);
|
||||
const beforeFinish = completed.slice();
|
||||
feature.finishReview();
|
||||
feature.finishReview();
|
||||
process.stdout.write(JSON.stringify({{beforeFinish, completed}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
assert json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout) == {"beforeFinish": [], "completed": ["following"]}
|
||||
|
||||
|
||||
def test_following_retirement_removes_closed_item_and_keeps_review_moving():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{renders:[], counts:[], completed:[]}};
|
||||
const items=[
|
||||
{{repository:'stackchain/api',number:42,title:'Closed first',state:'closed',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/web',number:9,title:'Closed second',state:'closed',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
|
||||
];
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:7,items}}),
|
||||
render:value => state.renders.push(value), onCount:value => state.counts.push(value),
|
||||
onOpen:async () => {{}}, onAcknowledge:async () => {{}},
|
||||
onReviewComplete:() => state.completed.push('following'),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.startReview();
|
||||
const first=feature.session().items[0];
|
||||
const next=feature.retire(first);
|
||||
await feature.previewLoaded(next);
|
||||
const final=feature.retire(next);
|
||||
const handoff=feature.finishReview();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
next:next.number, final, handoff, items:feature.items(), session:feature.session(), state
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result["next"] == 9
|
||||
assert result["final"] is None
|
||||
assert result["handoff"] is True
|
||||
assert result["items"] == []
|
||||
assert result["session"] is None
|
||||
assert result["state"]["counts"][-1] == 0
|
||||
assert result["state"]["completed"] == ["following"]
|
||||
|
||||
|
||||
def test_following_review_never_clears_activity_newer_than_the_loaded_preview():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const state = {{acknowledged:[], counts:[]}};
|
||||
let loads=0;
|
||||
const original=[
|
||||
{{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
|
||||
{{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
|
||||
];
|
||||
const refreshed=[
|
||||
{{...original[0],has_unseen_change:false}},
|
||||
{{...original[1],updated_at:'2026-08-23T07:00:00Z',has_unseen_change:true}}
|
||||
];
|
||||
const feature = createFollowing({{
|
||||
fetchJson:async () => ({{revision:++loads,items:loads === 1 ? original : refreshed}}),
|
||||
onCount:value => state.counts.push(value), onOpen:async () => {{}},
|
||||
onAcknowledge:async item => state.acknowledged.push(item.updated_at),
|
||||
}});
|
||||
(async () => {{
|
||||
await feature.load();
|
||||
await feature.startReview();
|
||||
const captured=feature.session().items[1];
|
||||
await feature.load();
|
||||
const cleared=await feature.previewLoaded(captured);
|
||||
process.stdout.write(JSON.stringify({{cleared,state}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result == {
|
||||
"cleared": False,
|
||||
"state": {
|
||||
"acknowledged": ["2026-08-23T06:00:00Z"],
|
||||
"counts": [2, 1, 1],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_following_review_controls_are_wired_into_the_phone_preview_flow():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
following = MODULE.read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
service_worker = (ROOT / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert 'id="review-following"' in html
|
||||
assert '>Review new activity</button>' in html
|
||||
assert ".following-review-action" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "query('#review-following').addEventListener('click'" in following
|
||||
assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard
|
||||
assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard
|
||||
assert "changeRevision(item, 'seen')" in following
|
||||
assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard
|
||||
assert "followingQueue.returnToFollowing()" in dashboard
|
||||
assert "'Back to Following'" in dashboard
|
||||
|
||||
|
||||
def test_following_open_issue_exposes_only_action_and_continue_planning_dispositions():
|
||||
script = f"""
|
||||
const createFollowing = require({json.dumps(str(MODULE))});
|
||||
const disposition = createFollowing.planningDisposition;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
active:disposition({{following:true,kind:'issue',state:'open',claimable:true}}),
|
||||
generic:disposition({{following:false,kind:'issue',state:'open',claimable:true}}),
|
||||
pull:disposition({{following:true,kind:'pull',state:'open',assigned_to_me:true}}),
|
||||
}}));
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
).stdout)
|
||||
|
||||
assert result == {
|
||||
"active": {
|
||||
"active": True,
|
||||
"today": "Add to Today & next",
|
||||
"later": "Later & next",
|
||||
"week": "Week Ahead & next",
|
||||
},
|
||||
"generic": {"active": False},
|
||||
"pull": {"active": False},
|
||||
}
|
||||
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
service_worker = (ROOT / "frontend" / "service-worker.js").read_text()
|
||||
assert "followingQueue.preview(state);" in dashboard
|
||||
assert "following-disposition-mode" in MODULE.read_text()
|
||||
assert "controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true" in MODULE.read_text()
|
||||
assert ".following-disposition-mode" in css
|
||||
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
||||
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
||||
assert "stackchain-dashboard-shell-v138" in service_worker
|
||||
|
||||
|
||||
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||
following = MODULE.read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert "function attachFollowing(onOpen, hooks = {})" in following
|
||||
assert "hooks.onCount?.(count, feature.items())" in following
|
||||
assert "hooks.onStatus?.(state.status)" in following
|
||||
assert "onReviewComplete:hooks.onReviewComplete" in following
|
||||
assert "review:feature.startReview" in following
|
||||
assert "const completed = feature.finishReview()" in following
|
||||
assert "if (completed) return 'completed-following'" in following
|
||||
assert "followingQueue.load().catch(() => {})" in dashboard
|
||||
assert "queueCounts.following = count" in dashboard
|
||||
assert "queueCounts.followingUnavailable = status === 'error'" in dashboard
|
||||
assert "preparationItems.following = items.filter" in dashboard
|
||||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in dashboard
|
||||
assert "mobileStartDay.completePhase('following')" in dashboard
|
||||
@@ -0,0 +1,177 @@
|
||||
import sqlite3
|
||||
|
||||
from src.following_store import FollowingStore
|
||||
|
||||
|
||||
KEY = b"f" * 32
|
||||
ITEM = {
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Make mobile review useful",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}
|
||||
|
||||
|
||||
def test_confirmed_watch_is_account_scoped_idempotent_and_encrypted(tmp_path):
|
||||
path = tmp_path / "following.sqlite3"
|
||||
store = FollowingStore(path, encryption_key=KEY, limit=50)
|
||||
|
||||
first = store.set_watching("Timmy", ITEM, True)
|
||||
repeated = store.set_watching("timmy", ITEM, True)
|
||||
|
||||
assert first == repeated == {
|
||||
"revision": 1,
|
||||
"items": [{**ITEM, "kind": "issue", "has_unseen_change": False}],
|
||||
}
|
||||
assert store.get("other") == {"revision": 0, "items": []}
|
||||
stored = sqlite3.connect(path).execute(
|
||||
"SELECT items FROM following_issues WHERE login = ?", ("timmy",)
|
||||
).fetchone()[0]
|
||||
assert ITEM["title"] not in stored
|
||||
assert ITEM["repository"] not in stored
|
||||
|
||||
|
||||
def test_refresh_marks_changed_items_unseen_and_sorts_them_first(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
quiet = {**ITEM, "number": 7, "title": "Quiet", "updated_at": "2026-08-23T02:00:00Z"}
|
||||
store.set_watching("timmy", quiet, True)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
|
||||
snapshot = store.refresh("timmy", [{
|
||||
**quiet, "title": "Changed upstream", "state": "closed",
|
||||
"updated_at": "2026-08-23T04:00:00Z",
|
||||
}])
|
||||
|
||||
assert [(item["number"], item["has_unseen_change"]) for item in snapshot["items"]] == [
|
||||
(7, True), (42, False)
|
||||
]
|
||||
assert snapshot["items"][0]["title"] == "Changed upstream"
|
||||
assert snapshot["items"][0]["state"] == "closed"
|
||||
|
||||
|
||||
def test_refresh_explains_change_from_last_reviewed_snapshot(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
|
||||
closed = store.refresh("timmy", [{
|
||||
**ITEM,
|
||||
"title": "Make mobile review trustworthy",
|
||||
"state": "closed",
|
||||
"updated_at": "2026-08-23T04:00:00Z",
|
||||
}])
|
||||
|
||||
assert closed["items"][0]["reviewed_at"] == ITEM["updated_at"]
|
||||
assert closed["items"][0]["change_summary"] == "Closed since last review"
|
||||
|
||||
store.acknowledge(
|
||||
"timmy", ITEM["repository"], ITEM["number"], "2026-08-23T04:00:00Z"
|
||||
)
|
||||
title_change = store.refresh("timmy", [{
|
||||
**ITEM,
|
||||
"title": "Make mobile review understandable",
|
||||
"state": "closed",
|
||||
"updated_at": "2026-08-23T05:00:00Z",
|
||||
}])
|
||||
|
||||
assert title_change["items"][0]["reviewed_at"] == "2026-08-23T04:00:00Z"
|
||||
assert title_change["items"][0]["change_summary"] == "Title changed since last review"
|
||||
|
||||
|
||||
def test_acknowledgement_is_revision_conditional_and_later_change_is_unseen(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
|
||||
store.refresh("timmy", [changed])
|
||||
|
||||
stale = store.acknowledge("timmy", ITEM["repository"], ITEM["number"], ITEM["updated_at"])
|
||||
assert stale["items"][0]["has_unseen_change"] is True
|
||||
seen = store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
|
||||
assert seen["items"][0]["has_unseen_change"] is False
|
||||
|
||||
store.refresh("timmy", [{**ITEM, "updated_at": "2026-08-23T05:00:00Z"}])
|
||||
assert store.get("timmy")["items"][0]["has_unseen_change"] is True
|
||||
|
||||
|
||||
def test_keep_for_later_restores_the_exact_seen_revision_as_unseen(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
|
||||
store.refresh("timmy", [changed])
|
||||
store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
|
||||
|
||||
snapshot = store.keep_unseen(
|
||||
"timmy", ITEM["repository"], ITEM["number"], changed["updated_at"]
|
||||
)
|
||||
|
||||
assert snapshot["items"][0]["has_unseen_change"] is True
|
||||
assert store.get("timmy")["items"][0]["has_unseen_change"] is True
|
||||
|
||||
|
||||
def test_reopening_a_kept_revision_acknowledges_it_again(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
|
||||
store.refresh("timmy", [changed])
|
||||
store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
|
||||
store.keep_unseen("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"])
|
||||
|
||||
snapshot = store.acknowledge(
|
||||
"timmy", ITEM["repository"], ITEM["number"], changed["updated_at"]
|
||||
)
|
||||
|
||||
assert snapshot["items"][0]["has_unseen_change"] is False
|
||||
|
||||
|
||||
def test_reconfirmed_watch_does_not_mark_an_unseen_change_as_seen(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"}
|
||||
store.refresh("timmy", [changed])
|
||||
|
||||
snapshot = store.set_watching("timmy", changed, True)
|
||||
|
||||
assert snapshot["items"][0]["has_unseen_change"] is True
|
||||
|
||||
|
||||
def test_following_preserves_kind_and_uses_it_in_item_identity(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
issue = {**ITEM, "kind": "issue"}
|
||||
pull = {
|
||||
**ITEM,
|
||||
"kind": "pull",
|
||||
"title": "Review the mobile flow",
|
||||
"url": "https://forge.example/stackchain/api/pulls/42",
|
||||
}
|
||||
|
||||
store.set_watching("timmy", issue, True)
|
||||
snapshot = store.set_watching("timmy", pull, True)
|
||||
|
||||
assert [(item["kind"], item["number"]) for item in snapshot["items"]] == [
|
||||
("pull", 42),
|
||||
("issue", 42),
|
||||
]
|
||||
|
||||
|
||||
def test_following_reads_legacy_items_as_issues(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
store.set_watching("timmy", ITEM, True)
|
||||
|
||||
assert store.get("timmy")["items"][0]["kind"] == "issue"
|
||||
|
||||
|
||||
def test_acknowledgement_only_marks_the_requested_item_kind_seen(tmp_path):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
|
||||
for kind in ("issue", "pull"):
|
||||
store.set_watching("timmy", {**ITEM, "kind": kind}, True)
|
||||
store.refresh("timmy", [{**ITEM, "kind": kind, "updated_at": "2026-08-23T06:00:00Z"}])
|
||||
|
||||
snapshot = store.acknowledge(
|
||||
"timmy", ITEM["repository"], ITEM["number"], "2026-08-23T06:00:00Z", kind="pull"
|
||||
)
|
||||
|
||||
assert {item["kind"]: item["has_unseen_change"] for item in snapshot["items"]} == {
|
||||
"issue": True,
|
||||
"pull": False,
|
||||
}
|
||||
@@ -50,7 +50,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
|
||||
assert set(first.feature_bundles) == {
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
|
||||
"today-timer", "security-center",
|
||||
"today-timer", "security-center", "planning",
|
||||
}
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
@@ -71,6 +71,11 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createQueueToday" not in first.runtime_bytes
|
||||
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createPlanToday" in first.feature_bundles["planning"].runtime_bytes
|
||||
assert b"function createFollowing" in first.feature_bundles["planning"].runtime_bytes
|
||||
assert b"function createFollowing" not in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createTomorrowPlan" in first.feature_bundles["planning"].runtime_bytes
|
||||
assert b"function createPlanToday" not in first.runtime_bytes
|
||||
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
|
||||
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
|
||||
@@ -129,7 +134,7 @@ def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch():
|
||||
assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [workspace.runtime_name]
|
||||
assert build.dashboard_html.count(f'<script src="{workspace.runtime_name}"></script>') == 0
|
||||
assert len(build.runtime_gzip_bytes) <= 100 * 1024
|
||||
assert len(workspace.runtime_gzip_bytes) <= 110 * 1024
|
||||
assert len(workspace.runtime_gzip_bytes) <= 111 * 1024
|
||||
|
||||
shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0]
|
||||
assert f"BASE + '{workspace.runtime_name}'" not in shell_block
|
||||
|
||||
@@ -100,6 +100,32 @@ async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_dist
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authored_pull_page_loads_open_created_pulls_with_a_distinct_reason():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(200, headers={"X-Total-Count": "3"}, json=[{
|
||||
"id": 901, "number": 41, "title": "Ship mobile flow", "state": "open",
|
||||
"user": {"login": "timmy"},
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/dashboard/pulls/41",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.work_page("authored", page=1)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&created=true&type=pulls&limit=50&page=1"
|
||||
]
|
||||
assert result["total"] == 3
|
||||
assert result["items"][0]["work_reasons"] == ["authored_by_me"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_context_merges_self_assigned_and_filed_streams_without_duplicates(monkeypatch):
|
||||
async def page(stream, page=1, limit=50):
|
||||
@@ -628,7 +654,7 @@ async def test_confirmed_claim_is_removed_from_retained_available_snapshot(monke
|
||||
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
assert page == 1
|
||||
totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73}
|
||||
totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73, "authored": 12}
|
||||
return {
|
||||
"items": [], "page": 1, "total": totals[stream],
|
||||
"has_more": True, "stream": stream,
|
||||
@@ -646,6 +672,7 @@ async def test_initial_work_collections_expose_independent_pagination(monkeypatc
|
||||
assert pulls.pagination == {
|
||||
"pull": {"page": 1, "total": 61, "has_more": True},
|
||||
"review": {"page": 1, "total": 73, "has_more": True},
|
||||
"authored": {"page": 1, "total": 12, "has_more": True},
|
||||
}
|
||||
|
||||
|
||||
@@ -666,11 +693,12 @@ async def test_work_collections_include_supported_review_request_search(monkeypa
|
||||
("filed", 1, 50),
|
||||
("pull", 1, 50),
|
||||
("review", 1, 50),
|
||||
("authored", 1, 50),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_requests_merge_assignment_and_review_responsibilities(monkeypatch):
|
||||
async def test_pull_requests_merge_assignment_review_and_authored_responsibilities(monkeypatch):
|
||||
assigned = {
|
||||
"id": 11,
|
||||
"number": 7,
|
||||
@@ -683,10 +711,24 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
|
||||
"title": "Review mobile",
|
||||
"repository": {"full_name": "stackchain/mobile"},
|
||||
}
|
||||
authored_only = {
|
||||
"id": 13,
|
||||
"number": 9,
|
||||
"title": "Ship authored flow",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
}
|
||||
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
items = [assigned] if stream == "pull" else [assigned.copy(), review_only]
|
||||
reason = "assigned_to_me" if stream == "pull" else "review_requested"
|
||||
items = {
|
||||
"pull": [assigned],
|
||||
"review": [assigned.copy(), review_only],
|
||||
"authored": [assigned.copy(), authored_only],
|
||||
}[stream]
|
||||
reason = {
|
||||
"pull": "assigned_to_me",
|
||||
"review": "review_requested",
|
||||
"authored": "authored_by_me",
|
||||
}[stream]
|
||||
return {
|
||||
"stream": stream,
|
||||
"items": [{**item, "work_reasons": [reason]} for item in items],
|
||||
@@ -697,9 +739,44 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
|
||||
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
assert [pull["id"] for pull in pulls] == [11, 12]
|
||||
assert pulls[0]["work_reasons"] == ["assigned_to_me", "review_requested"]
|
||||
assert [pull["id"] for pull in pulls] == [11, 12, 13]
|
||||
assert pulls[0]["work_reasons"] == [
|
||||
"assigned_to_me", "review_requested", "authored_by_me",
|
||||
]
|
||||
assert pulls[1]["work_reasons"] == ["review_requested"]
|
||||
assert pulls[2]["work_reasons"] == ["authored_by_me"]
|
||||
assert pulls.pagination["authored"] == {
|
||||
"page": 1, "total": 2, "has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authored_pull_failure_keeps_other_pull_work_and_marks_only_that_stream_unavailable(monkeypatch):
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
if stream == "authored":
|
||||
raise httpx.ConnectError("authored search unavailable")
|
||||
item = {
|
||||
"id": 11 if stream == "pull" else 12,
|
||||
"number": 7 if stream == "pull" else 8,
|
||||
"title": "Assigned" if stream == "pull" else "Review",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"work_reasons": ["assigned_to_me" if stream == "pull" else "review_requested"],
|
||||
}
|
||||
return {
|
||||
"stream": stream, "items": [item], "page": page,
|
||||
"total": 1, "has_more": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
assert [pull["id"] for pull in pulls] == [11, 12]
|
||||
assert pulls.pagination["pull"].get("unavailable", False) is False
|
||||
assert pulls.pagination["review"].get("unavailable", False) is False
|
||||
assert pulls.pagination["authored"] == {
|
||||
"page": 1, "total": 0, "has_more": False, "unavailable": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -218,6 +218,110 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
|
||||
assert response.json()["claimable"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_preview_subscription_reads_server_truth_without_caching(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
calls.append(("preview", repository, kind, number))
|
||||
return {"repository": repository, "kind": kind, "number": number,
|
||||
"state": "open", "claimable": True}
|
||||
|
||||
async def subscription(repository, number):
|
||||
calls.append(("subscription", repository, number))
|
||||
return {"watching": True}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_subscription", subscription, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {"watching": True}
|
||||
assert calls == [
|
||||
("preview", "stackchain/api", "issue", 42),
|
||||
("subscription", "stackchain/api", 42),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)])
|
||||
async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth(
|
||||
monkeypatch, tmp_path, method, watching
|
||||
):
|
||||
calls = []
|
||||
store = main.FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
calls.append(("preview", repository, kind, number))
|
||||
return {"repository": repository, "kind": kind, "number": number,
|
||||
"title": "Assigned issue", "state": "open", "claimable": False,
|
||||
"assignees": ["alexander"],
|
||||
"updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/issue/42"}
|
||||
|
||||
async def set_subscription(repository, number, desired):
|
||||
calls.append(("set", repository, number, desired))
|
||||
return {"watching": desired}
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
expected_count = 1 if watching else 0
|
||||
assert response.json() == {
|
||||
"watching": watching,
|
||||
"following_synced": True,
|
||||
"following_revision": expected_count,
|
||||
"following_count": expected_count,
|
||||
}
|
||||
assert calls == [
|
||||
("preview", "stackchain/api", "issue", 42),
|
||||
("set", "stackchain/api", 42, watching),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_subscription_mutation_uses_current_login_and_requires_confirmation():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/subscriptions/check"):
|
||||
return httpx.Response(200, json={"subscribed": True, "ignored": False})
|
||||
return httpx.Response(201, json={})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.set_issue_subscription("stackchain/api", 42, True)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {"watching": True}
|
||||
assert requests == [
|
||||
("GET", "/api/v1/user"),
|
||||
("PUT", "/api/v1/repos/stackchain/api/issues/42/subscriptions/timmy"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/42/subscriptions/check"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("kind", ["issue", "pull"])
|
||||
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(
|
||||
@@ -682,6 +786,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
|
||||
"title": "Repair queue",
|
||||
"body": "Keep mobile operators moving.",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"html_url": "http://127.0.0.1:3000/stackchain/api/issues/42",
|
||||
"user": {"login": "alex"},
|
||||
"labels": [{"name": "P1"}, None],
|
||||
@@ -701,6 +806,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
|
||||
"title": "Repair queue",
|
||||
"body": "Keep mobile operators moving.",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-23T03:00:00Z",
|
||||
"author": "alex",
|
||||
"labels": ["P1"],
|
||||
"assignees": [],
|
||||
|
||||
@@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert "stackchain-dashboard-shell-v138" in worker
|
||||
@@ -0,0 +1,289 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
APP_BADGE = ROOT / "frontend" / "mobile-app-badge.js"
|
||||
INDEX = ROOT / "frontend" / "index.html"
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||
CSS = ROOT / "frontend" / "dashboard.css"
|
||||
DEVICE_SETUP = ROOT / "frontend" / "mobile-device-setup.js"
|
||||
|
||||
|
||||
def run_badge(scenario: str) -> dict:
|
||||
assert APP_BADGE.exists(), "mobile app badge controller is not implemented"
|
||||
script = f"""
|
||||
const createMobileAppBadge = require({json.dumps(str(APP_BADGE))});
|
||||
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_app_badge_opt_in_reconciles_only_authoritative_unread_totals_without_churn():
|
||||
result = run_badge("""
|
||||
const listeners = {};
|
||||
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
|
||||
const status = {textContent:''};
|
||||
const container = {hidden:false};
|
||||
const values = [];
|
||||
let clears = 0;
|
||||
const storage = new Map();
|
||||
const controller = createMobileAppBadge({
|
||||
control, status, container,
|
||||
navigator: {
|
||||
async setAppBadge(value) { values.push(value); },
|
||||
async clearAppBadge() { clears++; },
|
||||
},
|
||||
storage: {
|
||||
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
|
||||
setItem(key, value) { storage.set(key, value); },
|
||||
removeItem(key) { storage.delete(key); },
|
||||
},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile('updates', 7, true);
|
||||
await controller.reconcile('updates', 12, false);
|
||||
control.checked = true;
|
||||
await listeners.change();
|
||||
await controller.reconcile('updates', 7, true);
|
||||
await controller.reconcile('updates', 0, true);
|
||||
console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-badge.enabled.v1'), status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"values": [7],
|
||||
"clears": 1,
|
||||
"saved": "true",
|
||||
"status": "App icon badge is on and up to date.",
|
||||
}
|
||||
|
||||
|
||||
def test_app_badge_combines_authoritative_update_and_following_channels_independently():
|
||||
result = run_badge("""
|
||||
const values = [];
|
||||
let clears = 0;
|
||||
const controller = createMobileAppBadge({
|
||||
control:{checked:true, disabled:false, addEventListener() {}},
|
||||
status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{
|
||||
async setAppBadge(value) { values.push(value); },
|
||||
async clearAppBadge() { clears++; },
|
||||
},
|
||||
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile('updates', 3, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
await controller.reconcile('following', 0, true);
|
||||
await controller.reconcile('following', 4, false);
|
||||
await controller.reconcile('updates', 0, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
await controller.reconcile('updates', 5, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
console.log(JSON.stringify({values, clears}));
|
||||
""")
|
||||
assert result == {"values": [3, 5, 3, 2, 7], "clears": 1}
|
||||
|
||||
|
||||
def test_app_badge_hides_unsupported_device_control_without_touching_storage():
|
||||
result = run_badge("""
|
||||
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};
|
||||
const status = {textContent:''};
|
||||
const container = {hidden:false};
|
||||
let reads = 0;
|
||||
const controller = createMobileAppBadge({
|
||||
control, status, container, navigator:{},
|
||||
storage:{getItem() { reads++; return 'true'; }},
|
||||
});
|
||||
const started = controller.start();
|
||||
console.log(JSON.stringify({started, hidden:container.hidden, disabled:control.disabled, reads, status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"started": False,
|
||||
"hidden": True,
|
||||
"disabled": True,
|
||||
"reads": 0,
|
||||
"status": "App icon badges are unavailable in this browser.",
|
||||
}
|
||||
|
||||
|
||||
def test_app_badge_is_packaged_touch_safe_and_reconciled_from_server_total():
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert '<script src="static/mobile-app-badge.js"></script>' in index
|
||||
assert 'id="app-badge-control"' in index
|
||||
assert 'id="app-badge-status" role="status"' in index
|
||||
assert ".app-badge-control { min-height:44px;" in css
|
||||
assert "const appBadge = createMobileAppBadge({" in dashboard
|
||||
assert "appBadge.start();" in dashboard
|
||||
assert "appBadge.reconcile('updates', pagination.total, true);" in dashboard
|
||||
assert "appBadge.reconcile('following', count, true);" in dashboard
|
||||
assert "Show review attention on the app icon" in index
|
||||
|
||||
|
||||
def test_app_badge_exposes_device_setup_readiness_and_enable_action():
|
||||
result = run_badge("""
|
||||
const control = {checked:false, disabled:false, addEventListener() {}};
|
||||
const status = {textContent:''};
|
||||
const saved = new Map();
|
||||
const values = [];
|
||||
const controller = createMobileAppBadge({
|
||||
control, status, container:{hidden:false},
|
||||
navigator:{async setAppBadge(value) { values.push(value); }, async clearAppBadge() {}},
|
||||
storage:{
|
||||
getItem(key) { return saved.get(key) || null; },
|
||||
setItem(key, value) { saved.set(key, value); },
|
||||
removeItem(key) { saved.delete(key); },
|
||||
},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile('updates', 4, true);
|
||||
const before = controller.readiness();
|
||||
await controller.enable();
|
||||
const after = controller.readiness();
|
||||
console.log(JSON.stringify({before, after, checked:control.checked, values}));
|
||||
""")
|
||||
assert result == {
|
||||
"before": {
|
||||
"state": "incomplete",
|
||||
"detail": "Show review attention without opening Stackchain.",
|
||||
},
|
||||
"after": {
|
||||
"state": "complete",
|
||||
"detail": "The app icon shows review attention.",
|
||||
},
|
||||
"checked": True,
|
||||
"values": [4],
|
||||
}
|
||||
|
||||
|
||||
def test_device_setup_offers_app_badge_as_an_optional_supported_step():
|
||||
index = INDEX.read_text()
|
||||
setup = DEVICE_SETUP.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
assert 'id="device-setup-app-badge-status" role="status"' in index
|
||||
assert 'id="device-setup-app-badge" type="button"' in index
|
||||
assert "['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable']" in setup
|
||||
assert "appBadge:options.appBadgeReadiness()," in setup
|
||||
assert "appBadgeReadiness:() => appBadge.readiness()," in dashboard
|
||||
assert "enableAppBadge:() => appBadge.enable()," in dashboard
|
||||
|
||||
|
||||
def test_app_badge_api_failure_is_reported_without_rejecting_live_refresh():
|
||||
result = run_badge("""
|
||||
const control = {checked:true, disabled:false, addEventListener() {}};
|
||||
const status = {textContent:''};
|
||||
const controller = createMobileAppBadge({
|
||||
control, status, container:{hidden:false},
|
||||
navigator:{async setAppBadge() { throw new Error('platform failure'); }, async clearAppBadge() {}},
|
||||
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
|
||||
});
|
||||
controller.start();
|
||||
const reconciled = await controller.reconcile('updates', 3, true);
|
||||
console.log(JSON.stringify({reconciled, status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"reconciled": False,
|
||||
"status": "Could not update the app icon badge. Stackchain still has the confirmed count.",
|
||||
}
|
||||
|
||||
|
||||
def test_disabling_badge_keeps_preference_off_when_platform_clear_fails():
|
||||
result = run_badge("""
|
||||
const listeners = {};
|
||||
const control = {checked:true, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
|
||||
const status = {textContent:''};
|
||||
const saved = new Map([['stackchain.app-badge.enabled.v1', 'true']]);
|
||||
const controller = createMobileAppBadge({
|
||||
control, status, container:{hidden:false},
|
||||
navigator:{async setAppBadge() {}, async clearAppBadge() { throw new Error('platform failure'); }},
|
||||
storage:{getItem(key) { return saved.get(key) || null; }, setItem(key, value) { saved.set(key, value); }, removeItem(key) { saved.delete(key); }},
|
||||
});
|
||||
controller.start();
|
||||
control.checked = false;
|
||||
const changed = await listeners.change();
|
||||
console.log(JSON.stringify({changed, saved:saved.has('stackchain.app-badge.enabled.v1'), status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"changed": False,
|
||||
"saved": False,
|
||||
"status": "App icon badge is off, but the browser could not clear the old count.",
|
||||
}
|
||||
|
||||
|
||||
def test_app_badge_synchronizes_opt_in_with_the_active_service_worker():
|
||||
result = run_badge("""
|
||||
const listeners = {};
|
||||
const messages = [];
|
||||
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
|
||||
const controller = createMobileAppBadge({
|
||||
control, status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
|
||||
storage:{getItem() { return null; }, setItem() {}, removeItem() {}},
|
||||
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
|
||||
});
|
||||
controller.start();
|
||||
control.checked = true;
|
||||
await listeners.change();
|
||||
control.checked = false;
|
||||
await listeners.change();
|
||||
console.log(JSON.stringify(messages));
|
||||
""")
|
||||
assert result == [
|
||||
{"type": "stackchain-app-badge-preference", "enabled": True},
|
||||
{"type": "stackchain-app-badge-preference", "enabled": False},
|
||||
]
|
||||
|
||||
|
||||
def test_app_badge_synchronizes_authoritative_channel_counts_with_worker():
|
||||
result = run_badge("""
|
||||
const messages = [];
|
||||
const controller = createMobileAppBadge({
|
||||
control:{checked:true, disabled:false, addEventListener() {}}, status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
|
||||
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
|
||||
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile('updates', 3, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
await controller.reconcile('following', 9, false);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
console.log(JSON.stringify(messages));
|
||||
""")
|
||||
assert result == [
|
||||
{"type": "stackchain-app-badge-preference", "enabled": True},
|
||||
{"type": "stackchain-app-badge-count", "channel": "updates", "count": 3},
|
||||
{"type": "stackchain-app-badge-count", "channel": "following", "count": 2},
|
||||
]
|
||||
|
||||
|
||||
def test_enabling_badge_synchronizes_counts_confirmed_while_opted_out():
|
||||
result = run_badge("""
|
||||
const listeners = {};
|
||||
const messages = [];
|
||||
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
|
||||
const controller = createMobileAppBadge({
|
||||
control, status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
|
||||
storage:{getItem() { return null; }, setItem() {}, removeItem() {}},
|
||||
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile('updates', 3, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
control.checked = true;
|
||||
await listeners.change();
|
||||
console.log(JSON.stringify(messages));
|
||||
""")
|
||||
assert result == [
|
||||
{"type": "stackchain-app-badge-preference", "enabled": True},
|
||||
{"type": "stackchain-app-badge-count", "channel": "updates", "count": 3},
|
||||
{"type": "stackchain-app-badge-count", "channel": "following", "count": 2},
|
||||
]
|
||||
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert "stackchain-dashboard-shell-v138" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "controller.recoverPermission('deadline')" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert "stackchain-dashboard-shell-v138" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -28,31 +28,39 @@ class Element {{
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_first_task_activation_is_account_bound_and_completes_only_when_today_is_active():
|
||||
def test_first_task_activation_is_account_bound_and_completes_only_after_a_today_outcome():
|
||||
result = run_node(
|
||||
"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key => values.get(key) || null, setItem:(key,value) => values.set(key,value)};
|
||||
const sheet = new Element();
|
||||
const coach = new Element(); coach.hidden = true;
|
||||
const receipt = new Element(); receipt.hidden = true;
|
||||
let login = 'Timmy';
|
||||
let hasWork = false;
|
||||
let todayActive = false;
|
||||
let hideReceipt;
|
||||
const controller = createFirstTask({
|
||||
storage, getLogin:() => login, hasWork:() => hasWork, isTodayActive:() => todayActive,
|
||||
isOnline:() => true, mediaQuery:{matches:true}, sheet, title:new Element(),
|
||||
isOnline:() => true, mediaQuery:{matches:true}, sheet, title:new Element(), coach, receipt,
|
||||
findButton:new Element(), createButton:new Element(), setupButton:new Element(),
|
||||
closeButton:new Element(), status:new Element(), onFind() {}, onCreate() {}, onSetup() {},
|
||||
setTimer:callback => { hideReceipt = callback; return {unref() {}}; },
|
||||
});
|
||||
const before = [controller.required(), controller.open(), sheet.open];
|
||||
hasWork = true;
|
||||
const awaitingStart = controller.refresh();
|
||||
const stillRequired = controller.required();
|
||||
todayActive = true;
|
||||
const completed = controller.refresh();
|
||||
const timmyRequired = controller.required();
|
||||
const coaching = controller.refresh();
|
||||
const afterStart = {required:controller.required(), sheetOpen:sheet.open, coachHidden:coach.hidden, stored:values.get('stackchain.first-task.v1:timmy')};
|
||||
const completed = controller.completeOutcome();
|
||||
const afterOutcome = {required:controller.required(), coachHidden:coach.hidden, receiptHidden:receipt.hidden, stored:values.get('stackchain.first-task.v1:timmy')};
|
||||
hideReceipt();
|
||||
const receiptHiddenAfterTimeout = receipt.hidden;
|
||||
login = 'alexander'; hasWork = false; todayActive = false;
|
||||
const alexanderRequired = controller.required();
|
||||
process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, completed, sheetOpen:sheet.open, timmyRequired, alexanderRequired, values:[...values]}));
|
||||
process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, coaching, afterStart, completed, afterOutcome, receiptHiddenAfterTimeout, alexanderRequired}));
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -60,11 +68,22 @@ process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, compl
|
||||
"before": [True, True, True],
|
||||
"awaitingStart": "awaiting-start",
|
||||
"stillRequired": True,
|
||||
"completed": "completed",
|
||||
"sheetOpen": False,
|
||||
"timmyRequired": False,
|
||||
"coaching": "coaching",
|
||||
"afterStart": {
|
||||
"required": False,
|
||||
"sheetOpen": False,
|
||||
"coachHidden": False,
|
||||
"stored": "coaching",
|
||||
},
|
||||
"completed": True,
|
||||
"afterOutcome": {
|
||||
"required": False,
|
||||
"coachHidden": True,
|
||||
"receiptHidden": False,
|
||||
"stored": "complete",
|
||||
},
|
||||
"receiptHiddenAfterTimeout": True,
|
||||
"alexanderRequired": True,
|
||||
"values": [["stackchain.first-task.v1:timmy", "complete"]],
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +118,34 @@ process.stdout.write(JSON.stringify({
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_adopts_remote_completion_without_crossing_accounts():
|
||||
result = run_node(
|
||||
"""
|
||||
const values = new Map(); let login='timmy'; let todayActive=false;
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
const controller=createFirstTask({
|
||||
storage,getLogin:()=>login,hasWork:()=>false,isTodayActive:()=>todayActive,isOnline:()=>true,
|
||||
mediaQuery:{matches:true},sheet:new Element(),title:new Element(),coach:new Element(),receipt:new Element(),
|
||||
findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(),
|
||||
status:new Element(),
|
||||
});
|
||||
const adopted=controller.adoptRemote('complete');
|
||||
const timmy={required:controller.required(),stored:values.get('stackchain.first-task.v1:timmy')};
|
||||
login='alexander'; const alexanderRequired=controller.required();
|
||||
todayActive=true; controller.refresh(); controller.completeOutcome();
|
||||
process.stdout.write(JSON.stringify({adopted,timmy,alexanderRequired,
|
||||
alexander:values.get('stackchain.first-task.v1:alexander')}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"adopted": True,
|
||||
"timmy": {"required": False, "stored": "complete"},
|
||||
"alexanderRequired": True,
|
||||
"alexander": "complete",
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_activation_routes_existing_flows_and_keeps_create_available_offline():
|
||||
result = run_node(
|
||||
"""
|
||||
@@ -143,6 +190,54 @@ process.stdout.write(JSON.stringify({offline, calls, sheetOpen:sheet.open}));
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_coaching_reveals_its_mobile_today_host():
|
||||
result = run_node(
|
||||
"""
|
||||
const values = new Map([['stackchain.first-task.v1:timmy', 'coaching']]);
|
||||
const host = new Element(); host.hidden = true;
|
||||
const coach = new Element(); coach.hidden = true; coach.closest = () => host;
|
||||
const controller = createFirstTask({
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)},
|
||||
getLogin:()=> 'timmy',hasWork:()=>true,isTodayActive:()=>true,isOnline:()=>true,
|
||||
mediaQuery:{matches:true},sheet:new Element(),title:new Element(),coach,receipt:new Element(),
|
||||
findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(),status:new Element(),
|
||||
});
|
||||
controller.refresh();
|
||||
process.stdout.write(JSON.stringify({coachHidden:coach.hidden,hostHidden:host.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"coachHidden": False, "hostHidden": False}
|
||||
|
||||
|
||||
def test_first_task_exposes_completion_operation_before_showing_local_success():
|
||||
result = run_node(
|
||||
"""
|
||||
const values = new Map([['stackchain.first-task.v1:timmy', 'coaching']]);
|
||||
let dispatched = 0;
|
||||
const controller = createFirstTask({
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)},
|
||||
getLogin:()=> 'timmy',hasWork:()=>true,isTodayActive:()=>true,isOnline:()=>true,
|
||||
mediaQuery:{matches:true},sheet:new Element(),title:new Element(),coach:new Element(),receipt:new Element(),
|
||||
findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(),status:new Element(),
|
||||
eventTarget:{dispatchEvent:()=>{dispatched += 1}}, setTimer:()=>({unref(){}}),
|
||||
});
|
||||
const before = globalThis.stackchainFirstTaskCompleting();
|
||||
const globalProbe = globalThis.stackchainFirstTaskCompleting();
|
||||
const completed = controller.completeOutcome();
|
||||
process.stdout.write(JSON.stringify({before,globalProbe,completed,stored:values.get('stackchain.first-task.v1:timmy'),dispatched}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"before": True,
|
||||
"globalProbe": True,
|
||||
"completed": True,
|
||||
"stored": "complete",
|
||||
"dispatched": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
|
||||
html = await dashboard()
|
||||
@@ -155,9 +250,14 @@ async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
|
||||
assert '<script src="static/mobile-first-task.js"></script>' in html
|
||||
assert "const mobileFirstTask = createMobileFirstTask({" in html
|
||||
assert "isTodayActive: () => workSession.checkpointed()" in html
|
||||
|
||||
assert "shouldActivate: () => mobileFirstTask.required()" in html
|
||||
assert "openActivation: () => mobileFirstTask.open()" in html
|
||||
assert "mobileFirstTask.refresh()" in html
|
||||
assert 'data-mobile-first-task-coach hidden' in html
|
||||
assert 'Complete your first task' in html
|
||||
assert 'id="mobile-first-task-receipt" role="status" aria-live="polite" hidden' in html
|
||||
assert "advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete'))" in html
|
||||
assert '.mobile-first-task { box-sizing:border-box; width:100%;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-first-task-actions button { min-height:48px;' in html
|
||||
@@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert "stackchain-dashboard-shell-v138" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
@@ -146,6 +146,61 @@ process.stdout.write(JSON.stringify(controller.briefing()));
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_today_reviews_unseen_following_after_filed_before_today():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
const opened = [];
|
||||
const controller = createStartDay({{
|
||||
getCounts: () => ({{filed:1, following:2, today:3}}),
|
||||
openQueue: name => opened.push(name),
|
||||
}});
|
||||
const briefing = controller.briefing();
|
||||
controller.startNext();
|
||||
process.stdout.write(JSON.stringify({{briefing, opened}}));
|
||||
"""
|
||||
|
||||
output = run_node(script)
|
||||
assert output == {
|
||||
"briefing": {
|
||||
"total": 3,
|
||||
"next": "filed",
|
||||
"label": "Review Filed",
|
||||
"summary": "3 items before Today · 3 planned",
|
||||
"phases": [
|
||||
{"name": "filed", "label": "Filed", "count": 1},
|
||||
{"name": "following", "label": "Following", "count": 2},
|
||||
],
|
||||
},
|
||||
"opened": ["filed"],
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_today_keeps_failed_following_refresh_retryable_before_today():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
const opened = [];
|
||||
const controller = createStartDay({{
|
||||
getCounts: () => ({{followingUnavailable:true, today:2}}),
|
||||
openQueue: name => opened.push(name),
|
||||
}});
|
||||
const briefing = controller.briefing();
|
||||
controller.startNext();
|
||||
process.stdout.write(JSON.stringify({{briefing, opened}}));
|
||||
"""
|
||||
|
||||
output = run_node(script)
|
||||
assert output == {
|
||||
"briefing": {
|
||||
"total": 0,
|
||||
"next": "following",
|
||||
"label": "Retry Following",
|
||||
"summary": "Following needs retry before Today · 2 planned",
|
||||
"phases": [{"name": "following", "label": "Following unavailable", "count": 0}],
|
||||
},
|
||||
"opened": ["following"],
|
||||
}
|
||||
|
||||
|
||||
def test_start_day_view_renders_refreshed_phases_and_launches_primary_action():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
@@ -352,13 +407,14 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||
assert "mobileStartDay.finish()" in html
|
||||
assert "openQueue: name =>" in html
|
||||
assert "if (sheet.open) sheet.close();" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||
assert "Checking Agenda, Attention, Updates, Filed, and Following" in html
|
||||
assert "mobileStartDay.render();" in html
|
||||
assert ".mobile-start-day-action { width:100%; min-height:48px;" in html
|
||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v123" in service_worker
|
||||
assert "stackchain-dashboard-shell-v138" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -371,9 +427,9 @@ async def test_primary_mobile_work_action_opens_a_unique_item_prepare_today_brie
|
||||
assert "qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';" in html
|
||||
assert "qs('#mobile-queue-sheet').showModal();" in html
|
||||
assert "qs('#mobile-start-day-action').focus();" in html
|
||||
assert "let mobilePreparationItems = {};" in html
|
||||
assert "getPhaseItems: () => mobilePreparationItems" in html
|
||||
assert "mobilePreparationItems = {" in html
|
||||
assert "let preparationItems = {};" in html
|
||||
assert "getPhaseItems: () => preparationItems" in html
|
||||
assert "preparationItems = {" in html
|
||||
assert "delivery:draftInbox.partition(lastDrafts).deliveries" in html
|
||||
assert "agenda:agendaMyWork(activeMyWork)" in html
|
||||
assert "attention:activeMyWork.filter(item => item.needs_attention)" in html
|
||||
|
||||
@@ -834,7 +834,8 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
assert '<script src="static/mobile-queue-launcher.js"></script>' in html
|
||||
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
|
||||
assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() :" in html
|
||||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||
assert '.mobile-queue-sheet' in html
|
||||
assert '.my-work-actions { display:none;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
@@ -872,7 +873,7 @@ async def test_dashboard_wires_work_dock_and_app_shortcut_to_live_queue_recommen
|
||||
assert "queueLauncher: mobileQueueLauncher" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "continueWork: () => mobileWorkEntry.open()" in html
|
||||
assert "mobileQueueCounts = counts;" in html
|
||||
assert "queueCounts = counts;" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
|
||||
|
||||
@@ -893,7 +894,8 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() :" in html
|
||||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
|
||||
@@ -129,6 +129,15 @@ process.stdout.write(JSON.stringify(values));
|
||||
assert result == {"--mobile-today-clearance": "168px"}
|
||||
|
||||
|
||||
def test_visible_mobile_today_hud_reserves_measured_scroll_clearance_without_timer_class():
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert (
|
||||
'body:has(.mobile-today-hud:not([hidden]):not([data-overlay-hidden="true"])) '
|
||||
"{ padding-bottom:calc(var(--mobile-today-clearance, 166px)"
|
||||
) in css
|
||||
|
||||
|
||||
def test_mobile_command_bar_keeps_primary_actions_visible_and_secondary_actions_modal():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
+445
-5
@@ -100,6 +100,53 @@ process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
|
||||
assert result[1]["reason"] == "Filed by you"
|
||||
|
||||
|
||||
def test_authored_pull_is_classified_for_the_my_prs_queue():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
"pull_requests": [{
|
||||
"number": 41, "title": "Ship mobile flow", "repository": "stackchain/dashboard",
|
||||
"state": "open", "user": "timmy", "assignees": [],
|
||||
"work_reasons": ["authored_by_me"], "updated_at": "2026-08-24T12:00:00Z",
|
||||
}],
|
||||
}
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
result = json.loads(completed.stdout)
|
||||
assert result[0]["is_authored"] is True
|
||||
assert result[0]["reason"] == "Authored by you"
|
||||
assert result[0]["kind"] == "pull"
|
||||
|
||||
|
||||
def test_my_prs_filter_and_count_include_only_authored_pulls():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = buildMyWork({{
|
||||
user:{{login:'timmy'}},
|
||||
pull_requests:[
|
||||
{{number:41,title:'Authored',repository:'stackchain/dashboard',state:'open',user:'timmy',
|
||||
assignees:[],work_reasons:['authored_by_me']}},
|
||||
{{number:42,title:'Review',repository:'stackchain/dashboard',state:'open',user:'alex',
|
||||
assignees:[],work_reasons:['review_requested']}},
|
||||
],
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
authored:buildMyWork.filterMyWork(items,'authored').map(item=>item.number),
|
||||
counts:buildMyWork.countMyWork(items),
|
||||
}}));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
result = json.loads(completed.stdout)
|
||||
assert result["authored"] == [41]
|
||||
assert result["counts"]["authored"] == 1
|
||||
|
||||
|
||||
def test_completed_child_resolves_only_a_canonical_reciprocal_parent_task():
|
||||
script = f"""
|
||||
const sheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
@@ -676,6 +723,34 @@ process.stdout.write(JSON.stringify(routes.parse('#/my-work/agenda/protect-today
|
||||
assert json.loads(result.stdout) == {
|
||||
"kind": "queue", "filter": "agenda", "action": "protect-today"
|
||||
}
|
||||
|
||||
|
||||
def test_following_route_waits_for_hydration_then_opens_once():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
const listeners = {{}};
|
||||
const location = {{hash:'#/my-work/following'}};
|
||||
const calls = [];
|
||||
const controller = routes.createController({{
|
||||
location,
|
||||
history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
|
||||
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
|
||||
onQueue: (queue, action) => calls.push([queue, action]),
|
||||
onInvalid: () => calls.push(['invalid']),
|
||||
}});
|
||||
controller.start();
|
||||
controller.setItems([]);
|
||||
controller.sync();
|
||||
process.stdout.write(JSON.stringify({{route:routes.parse(location.hash), calls}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"route": {"kind": "queue", "filter": "following"},
|
||||
"calls": [["following", None]],
|
||||
}
|
||||
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
|
||||
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
||||
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"
|
||||
@@ -1031,6 +1106,24 @@ async def test_agenda_activation_checks_all_issue_pages_before_showing_empty_sta
|
||||
assert "selectedWorkFilter === 'agenda' && workPagination.issue?.has_more" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_my_prs_is_a_thumb_safe_mobile_queue_and_desktop_filter():
|
||||
html = await dashboard()
|
||||
markup = (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
|
||||
css = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
assert 'data-work-filter="authored"' in markup
|
||||
assert 'data-work-count="authored"' in markup
|
||||
assert 'data-mobile-queue="authored"' in markup
|
||||
assert 'data-mobile-queue-count="authored"' in markup
|
||||
assert '<strong>My PRs</strong>' in markup
|
||||
assert ".mobile-queue-list button" in css and "min-height:56px" in css
|
||||
assert "['all', 'today', 'agenda', 'attention', 'filed', 'authored'" in html
|
||||
assert "['issue', 'pull', 'review', 'authored'].includes(selectedWorkFilter)" in html
|
||||
assert "authored: 'pull requests'" in html
|
||||
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
|
||||
html = await dashboard()
|
||||
@@ -3829,7 +3922,7 @@ async def test_checkpointed_detail_defer_completes_today_and_advertises_next_ite
|
||||
|
||||
assert "continueSession: item => completeTodayItem(item, {" in html
|
||||
assert "successMessage: 'Deferred to Later. Next Today item opened.'" in html
|
||||
assert "advance: () => runTodayTransition('complete')" in html
|
||||
assert "advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete'))" in html
|
||||
assert "button.textContent = active ? 'Later today & next' : 'Later today'" in html
|
||||
assert "button.textContent = active ? 'Tomorrow & next' : 'Tomorrow'" in html
|
||||
assert "button.textContent = active ? 'Choose date & time & next' : 'Choose date & time'" in html
|
||||
@@ -4960,7 +5053,8 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"all": 3, "attention": 1, "filed": 0, "issue": 1, "pull": 1, "review": 1, "update": 0
|
||||
"all": 3, "attention": 1, "filed": 0, "authored": 0,
|
||||
"issue": 1, "pull": 1, "review": 1, "update": 0
|
||||
}
|
||||
|
||||
|
||||
@@ -6137,7 +6231,8 @@ process.stdout.write(JSON.stringify({{
|
||||
assert output["updates"][1]["kind"] == "update"
|
||||
assert output["updates"][1]["update_reason"] == ""
|
||||
assert output["counts"] == {
|
||||
"all": 2, "attention": 2, "filed": 0, "issue": 1, "pull": 0, "review": 0, "update": 2
|
||||
"all": 2, "attention": 2, "filed": 0, "authored": 0,
|
||||
"issue": 1, "pull": 0, "review": 0, "update": 2
|
||||
}
|
||||
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
|
||||
|
||||
@@ -7396,6 +7491,80 @@ Promise.all([first, duplicate]).then(async comments => {{
|
||||
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
|
||||
|
||||
|
||||
def test_pull_sheet_preserves_context_draft_until_confirmed_update():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
|
||||
const calls = [];
|
||||
let fail = true;
|
||||
const controller = createPullSheet({{
|
||||
storage,
|
||||
fetchJson: async (url, options={{}}) => {{
|
||||
const body = JSON.parse(options.body);
|
||||
calls.push({{url, method:options.method, body}});
|
||||
if (fail) {{ fail = false; throw new Error('network down'); }}
|
||||
return {{number:7,title:body.title,body:body.body,head_sha:body.expected_head_sha,state:'open'}};
|
||||
}},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api',number:7}};
|
||||
const draft = {{title:'Clear review brief',body:'Review mobile handoff.',expectedHeadSha:'abc1234'}};
|
||||
(async () => {{
|
||||
await controller.updateContent(item, draft).catch(() => null);
|
||||
const kept = controller.loadEditDraft(item);
|
||||
const result = await controller.updateContent(item, draft);
|
||||
process.stdout.write(JSON.stringify({{kept,result,after:controller.loadEditDraft(item),calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["kept"] == {
|
||||
"title": "Clear review brief",
|
||||
"body": "Review mobile handoff.",
|
||||
"expectedHeadSha": "abc1234",
|
||||
}
|
||||
assert output["after"] is None
|
||||
assert output["result"]["head_sha"] == "abc1234"
|
||||
assert output["calls"] == [
|
||||
{
|
||||
"url": "api/v1/repos/stackchain/api/pulls/7/content",
|
||||
"method": "PATCH",
|
||||
"body": {
|
||||
"title": "Clear review brief",
|
||||
"body": "Review mobile handoff.",
|
||||
"expected_head_sha": "abc1234",
|
||||
},
|
||||
},
|
||||
{
|
||||
"url": "api/v1/repos/stackchain/api/pulls/7/content",
|
||||
"method": "PATCH",
|
||||
"body": {
|
||||
"title": "Clear review brief",
|
||||
"body": "Review mobile handoff.",
|
||||
"expected_head_sha": "abc1234",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_overview_exposes_author_only_context_editor_with_retry_safe_status():
|
||||
html = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
|
||||
source = await dashboard()
|
||||
pull_source = PULL_SHEET.read_text()
|
||||
|
||||
assert 'id="edit-pull-content"' in html
|
||||
assert 'id="pull-edit-form"' in html
|
||||
assert 'id="pull-edit-title"' in html
|
||||
assert 'id="pull-edit-body"' in html
|
||||
assert 'id="save-pull-content"' in html
|
||||
assert "detail.author === getLogin()" in pull_source
|
||||
assert "controller.loadEditDraft(item)" in pull_source
|
||||
assert "controller.updateContent(item" in pull_source
|
||||
assert "Your change is still here; retry." in pull_source
|
||||
assert "controller.setReviewDetail(detail)" in pull_source
|
||||
|
||||
|
||||
def test_pull_sheet_removes_one_pull_from_snapshot_without_touching_other_work():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
@@ -7435,6 +7604,9 @@ const elements = new Map([
|
||||
['#load-pull-handoff', {{disabled:true}}],
|
||||
['#release-pull', {{disabled:true,textContent:''}}],
|
||||
['#pull-handoff-status', {{textContent:''}}],
|
||||
['#edit-pull-content', {{hidden:false}}],
|
||||
['#pull-edit-form', {{hidden:false}}],
|
||||
['#pull-edit-status', {{textContent:'stale'}}],
|
||||
]);
|
||||
const select = {{textContent:'stale', appendChild:node => children.push(node)}};
|
||||
const doc = {{
|
||||
@@ -7511,6 +7683,123 @@ const item = {{repository:'stackchain/api',number:7}};
|
||||
assert output["released"] == {"assignees": []}
|
||||
|
||||
|
||||
def test_pull_sheet_loads_reviewers_and_single_flights_review_request():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const calls = [];
|
||||
let finish;
|
||||
const controller = createPullSheet({{
|
||||
storage: null,
|
||||
fetchJson: (url, options={{}}) => {{
|
||||
calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
|
||||
if (url.endsWith('/review-candidates')) return Promise.resolve([{{login:'casey',name:'Casey'}}]);
|
||||
return new Promise(resolve => {{ finish = resolve; }});
|
||||
}}
|
||||
}});
|
||||
const notified = [];
|
||||
controller.onReviewRequested = result => notified.push(result);
|
||||
const item = {{repository:'stackchain/api',number:7}};
|
||||
(async () => {{
|
||||
const candidates = await controller.loadReviewCandidates(item);
|
||||
const first = controller.requestReview(item, 'casey', 'abc1234');
|
||||
const duplicate = controller.requestReview(item, 'casey', 'abc1234');
|
||||
finish({{reviewer:'casey',requested_reviewers:['sam','casey']}});
|
||||
const results = await Promise.all([first, duplicate]);
|
||||
process.stdout.write(JSON.stringify({{calls,candidates,results,same:first===duplicate,notified}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output == {
|
||||
"calls": [
|
||||
{"url": "api/v1/repos/stackchain/api/pulls/7/review-candidates", "method": "GET", "body": None},
|
||||
{"url": "api/v1/repos/stackchain/api/pulls/7/request-review", "method": "POST", "body": {
|
||||
"reviewer": "casey", "expected_head_sha": "abc1234"
|
||||
}},
|
||||
],
|
||||
"candidates": [{"login": "casey", "name": "Casey"}],
|
||||
"results": [
|
||||
{"reviewer": "casey", "requested_reviewers": ["sam", "casey"]},
|
||||
{"reviewer": "casey", "requested_reviewers": ["sam", "casey"]},
|
||||
],
|
||||
"same": True,
|
||||
"notified": [{"reviewer": "casey", "requested_reviewers": ["sam", "casey"]}],
|
||||
}
|
||||
|
||||
|
||||
def test_pull_sheet_single_flights_pending_review_cancellation():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const calls = [];
|
||||
let finish;
|
||||
const controller = createPullSheet({{
|
||||
storage:null,
|
||||
fetchJson:(url, options={{}}) => {{
|
||||
calls.push({{url, method:options.method, body:JSON.parse(options.body)}});
|
||||
return new Promise(resolve => {{ finish = resolve; }});
|
||||
}}
|
||||
}});
|
||||
const notified = [];
|
||||
controller.onReviewCancelled = result => notified.push(result);
|
||||
const item = {{repository:'stackchain/api',number:7}};
|
||||
(async () => {{
|
||||
const first = controller.cancelReview(item, 'sam', 'abc1234');
|
||||
const duplicate = controller.cancelReview(item, 'sam', 'abc1234');
|
||||
finish({{reviewer:'sam',head_sha:'abc1234',requested_reviewers:['casey']}});
|
||||
const results = await Promise.all([first, duplicate]);
|
||||
process.stdout.write(JSON.stringify({{calls,results,same:first===duplicate,notified}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output == {
|
||||
"calls": [{
|
||||
"url": "api/v1/repos/stackchain/api/pulls/7/request-review",
|
||||
"method": "DELETE",
|
||||
"body": {"reviewer": "sam", "expected_head_sha": "abc1234"},
|
||||
}],
|
||||
"results": [{"reviewer": "sam", "head_sha": "abc1234", "requested_reviewers": ["casey"]}] * 2,
|
||||
"same": True,
|
||||
"notified": [{"reviewer": "sam", "head_sha": "abc1234", "requested_reviewers": ["casey"]}],
|
||||
}
|
||||
|
||||
|
||||
def test_pull_sheet_exposes_touch_safe_review_request_flow():
|
||||
root = PULL_SHEET.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert 'id="pull-review-request"' in html
|
||||
assert 'id="load-pull-reviewers"' in html
|
||||
assert 'id="pull-review-recipient"' in html
|
||||
assert 'id="confirm-pull-review"' in html
|
||||
assert 'id="pull-review-request-status" class="small" aria-live="assertive"' in html
|
||||
assert '.pull-review-request select, .pull-review-request button { min-height:44px;' in css
|
||||
assert "createPullSheet.bindOwnershipControls(document, pullController" in dashboard
|
||||
assert "pullController.setReviewDetail(detail)" in dashboard
|
||||
|
||||
|
||||
def test_pull_sheet_exposes_mobile_reviewer_status_and_updated_review_action():
|
||||
root = PULL_SHEET.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert 'id="pull-reviewer-statuses"' in html
|
||||
assert 'id="request-updated-pull-review"' in html
|
||||
assert 'id="pull-reviewer-status" class="small" aria-live="polite"' in html
|
||||
assert ".pull-reviewer-status" in css
|
||||
assert "overflow-wrap:anywhere" in css
|
||||
assert "#request-updated-pull-review { min-height:44px;" in css
|
||||
assert "createPullSheet.review(detail, pullReviewState, document)" in dashboard
|
||||
assert "reviewers.some(reviewer => reviewer.status === 'outdated')" in PULL_SHEET.read_text()
|
||||
|
||||
|
||||
def test_pull_sheet_surfaces_pending_merge_confirmation_guidance():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
@@ -7557,6 +7846,126 @@ process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)
|
||||
assert output[3]["allowed"] is False and "conflict" in output[3]["reason"].lower()
|
||||
|
||||
|
||||
def test_pull_sheet_renders_reviewer_decisions_and_blocks_unresolved_requests():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const base = {{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'new'}};
|
||||
const states = [
|
||||
[{{login:'casey', status:'waiting', head_sha:'new', blocking:true}}],
|
||||
[{{login:'sam', status:'changes_requested', head_sha:'new', blocking:true}}],
|
||||
[{{login:'lee', status:'outdated', head_sha:'old', blocking:true}}],
|
||||
[{{login:'pat', status:'approved', head_sha:'new', blocking:false}}],
|
||||
];
|
||||
process.stdout.write(JSON.stringify({{
|
||||
eligibility:states.map(reviewers => createPullSheet.mergeEligibility({{...base, reviewers}})),
|
||||
html:createPullSheet.renderReviewerStatuses(states.flat(), value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')),
|
||||
}}));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["eligibility"] == [
|
||||
{"allowed": False, "reason": "Waiting for @casey’s review"},
|
||||
{"allowed": False, "reason": "@sam requested changes"},
|
||||
{"allowed": False, "reason": "@lee’s review is outdated after new commits"},
|
||||
{"allowed": True, "reason": "Ready to merge"},
|
||||
]
|
||||
assert "@casey" in output["html"] and "Waiting for review" in output["html"]
|
||||
assert "Changes requested" in output["html"]
|
||||
assert "Outdated after new commits" in output["html"]
|
||||
assert "Approved current head" in output["html"]
|
||||
|
||||
|
||||
def test_pull_sheet_offers_cancel_only_for_pending_review_requests():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const html = createPullSheet.renderReviewerStatuses([
|
||||
{{login:'sam',status:'waiting'}},
|
||||
{{login:'casey',status:'approved'}},
|
||||
{{login:'lee',status:'commented'}},
|
||||
], value => String(value));
|
||||
process.stdout.write(html);
|
||||
"""
|
||||
html = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
|
||||
assert html.count('data-cancel-review-request=') == 1
|
||||
assert 'data-cancel-review-request="sam"' in html
|
||||
assert "Cancel request" in html
|
||||
assert 'data-cancel-review-request="casey"' not in html
|
||||
assert 'data-cancel-review-request="lee"' not in html
|
||||
|
||||
|
||||
def test_pull_sheet_pending_review_cancellation_is_touch_safe_and_opens_replacement_picker():
|
||||
root = PULL_SHEET.parents[1]
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
script = PULL_SHEET.read_text()
|
||||
|
||||
assert ".cancel-review-request { min-height:44px;" in css
|
||||
assert "controller.cancelReview(selected, reviewer, detail.head_sha)" in script
|
||||
assert "Choose a replacement reviewer." in script
|
||||
assert "qs('#pull-review-request').open = true" in script
|
||||
|
||||
|
||||
def test_pull_sheet_renders_bounded_feedback_grouped_by_file():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const html = createPullSheet.renderReviewerStatuses([{{
|
||||
login:'sam', status:'changes_requested', head_sha:'abc123', blocking:true,
|
||||
summary:'Please <split> this helper.',
|
||||
comments:[
|
||||
{{path:'src/api.py', body:'Handle <empty>.', line:12}},
|
||||
{{path:'src/api.py', body:'Keep the return explicit.'}},
|
||||
{{path:'frontend/app.js', body:'Show the error.'}},
|
||||
],
|
||||
}}], value => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'));
|
||||
process.stdout.write(html);
|
||||
"""
|
||||
html = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
|
||||
assert "Review feedback" in html
|
||||
assert "Please <split> this helper." in html
|
||||
assert html.count('data-review-feedback-file="src/api.py"') == 1
|
||||
assert 'data-review-feedback-file="frontend/app.js"' in html
|
||||
assert "Handle <empty>." in html
|
||||
assert "Line 12" in html
|
||||
assert "View in changes" in html
|
||||
assert "Please <split>" not in html
|
||||
|
||||
|
||||
def test_pull_sheet_marks_inline_feedback_as_on_demand_and_retryable():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const render = state => createPullSheet.renderReviewerStatuses([{{
|
||||
login:'sam', review_id:8, status:'changes_requested', head_sha:'abc1234',
|
||||
blocking:true, summary:'Please fix this.', feedback_state:state,
|
||||
}}], value => String(value));
|
||||
process.stdout.write(JSON.stringify({{idle:render(undefined), failed:render('error')}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert "Load inline feedback" in output["idle"]
|
||||
assert 'data-load-review-feedback="8"' in output["idle"]
|
||||
assert "Feedback unavailable" in output["failed"]
|
||||
assert "Retry feedback" in output["failed"]
|
||||
assert "Address feedback" not in output["idle"]
|
||||
|
||||
|
||||
def test_pull_sheet_binds_on_demand_feedback_with_visible_retry_state():
|
||||
script = PULL_SHEET.read_text()
|
||||
|
||||
assert "event.target.closest?.('[data-load-review-feedback]')" in script
|
||||
assert "await controller.loadFeedback(item, detail, reviewer" in script
|
||||
assert "reviewer.feedback_state = 'loading'" in script
|
||||
assert "reviewer.feedback_state = 'loaded'" in script
|
||||
assert "reviewer.feedback_state = 'error'" in script
|
||||
assert "result.head_sha !== detail.head_sha" in script
|
||||
css = (Path(__file__).parents[1] / "frontend" / "dashboard.css").read_text()
|
||||
assert "[data-load-review-feedback] { min-height:44px; width:100%;" in css
|
||||
|
||||
|
||||
def test_pull_sheet_persists_head_scoped_file_review_and_gates_merge():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
@@ -7632,6 +8041,37 @@ Promise.allSettled([first, concurrent]).then(async failed => {{
|
||||
]
|
||||
|
||||
|
||||
def test_pull_sheet_feedback_is_single_flight_cached_by_head_and_review():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const calls = [];
|
||||
let resolveFeedback;
|
||||
const sheet = createPullSheet({{storage:null, fetchJson:url => {{
|
||||
calls.push(url);
|
||||
return new Promise(resolve => {{ resolveFeedback = resolve; }});
|
||||
}}}});
|
||||
const item = {{repository:'stackchain/api', number:7}};
|
||||
const detail = {{head_sha:'abc1234'}};
|
||||
const reviewer = {{review_id:8, head_sha:'abc1234'}};
|
||||
const first = sheet.loadFeedback(item, detail, reviewer);
|
||||
const concurrent = sheet.loadFeedback(item, detail, reviewer);
|
||||
resolveFeedback({{review_id:8, head_sha:'abc1234', reviewed_head_sha:'abc1234', comments:[{{id:99}}]}});
|
||||
Promise.all([first, concurrent]).then(async results => {{
|
||||
const cached = await sheet.loadFeedback(item, detail, reviewer);
|
||||
process.stdout.write(JSON.stringify({{same:first === concurrent, results, cached, calls}}));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
output = json.loads(result.stdout)
|
||||
assert output["same"] is True
|
||||
assert output["cached"] == output["results"][0]
|
||||
assert output["calls"] == [
|
||||
"api/v1/repos/stackchain/api/pulls/7/reviews/8/feedback?expected_head_sha=abc1234"
|
||||
]
|
||||
|
||||
|
||||
def test_pull_sheet_refreshes_status_only_without_clearing_review_progress():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
@@ -7788,7 +8228,7 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut
|
||||
assert "window.confirm(confirmation)" in html
|
||||
assert "issueController.close(selectedIssue)" in html
|
||||
assert "lastMyWork.filter" in html
|
||||
assert "if (issueTrigger?.isConnected) issueTrigger.focus()" in html
|
||||
assert "setTimeout(()=>issueTrigger?.focus())" in html
|
||||
assert "e.key === 'Escape' && selectedIssue" in html
|
||||
|
||||
|
||||
@@ -8107,7 +8547,7 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
assert 'data-work-count="review"' in html
|
||||
assert 'data-work-count="update"' in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert "['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert "['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert 'data-work-count="draft"' in html
|
||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||
|
||||
@@ -549,5 +549,5 @@ async def test_offline_today_issue_queues_closure_before_completing_and_advancin
|
||||
admission = handler.index("await closeOfflineIssue(closing)")
|
||||
sheet_close = handler.index("closeIssueSheet()")
|
||||
assert admission < sheet_close
|
||||
assert "Issue closure queued for reconnect." in handler
|
||||
assert "Issue close queued." in handler
|
||||
assert "The issue remains in Today; retry." in handler
|
||||
@@ -0,0 +1,16 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RUNBOOK = Path(__file__).parents[1] / "docs" / "ops-fundamentals.md"
|
||||
|
||||
|
||||
def test_disk_capacity_runbook_requires_safe_remediation_and_release_verification():
|
||||
text = " ".join(RUNBOOK.read_text().split())
|
||||
|
||||
assert "Disk capacity incident" in text
|
||||
assert "85%" in text
|
||||
assert "df -h / /var/lib/gitea" in text
|
||||
assert "disposable caches and abandoned temporary environments" in text
|
||||
assert "must not delete Gitea data" in text
|
||||
assert "systemctl is-active gitea act_runner" in text
|
||||
assert "repository test suite" in text
|
||||
@@ -407,10 +407,18 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
assert "taskOverlayHistory.leave();" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
|
||||
html = await dashboard()
|
||||
transition = html.split("function performTodayTransition", 1)[1].split("const todayReadiness", 1)[0]
|
||||
|
||||
assert "qs('#plan-today-sheet').hidden = true;" in transition
|
||||
|
||||
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -33,6 +33,8 @@ process.stdout.write(JSON.stringify(databases));
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-app-badge-preference-v1",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ const clear = createPrivateDeviceDataPurger({{
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
|
||||
+619
-1
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -7,6 +8,170 @@ from src import gitea_proxy, main
|
||||
from src.security_event_store import SecurityEventStoreError
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_author_can_publish_assigned_draft_at_expected_head(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def publish(repository, number, expected_head_sha):
|
||||
calls.append((repository, number, expected_head_sha))
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": "Ship mobile flow",
|
||||
"head_sha": expected_head_sha,
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "publish_authored_assigned_pull", publish, raising=False
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/ready",
|
||||
json={"expected_head_sha": "abc1234"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["draft"] is False
|
||||
assert calls == [("stackchain/api", 7, "abc1234")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_publishes_only_authored_assigned_draft_and_verifies_ready_head():
|
||||
requests = []
|
||||
pull = {
|
||||
"number": 7,
|
||||
"title": "WIP: Ship mobile flow",
|
||||
"state": "open",
|
||||
"draft": True,
|
||||
"merged": False,
|
||||
"user": {"login": "alex"},
|
||||
"assignees": [{"login": "alex"}],
|
||||
"head": {"sha": "abc1234"},
|
||||
}
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path, request.content))
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "alex"})
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "GET":
|
||||
return httpx.Response(200, json=pull)
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "PATCH":
|
||||
assert json.loads(request.content) == {"title": "Ship mobile flow"}
|
||||
pull.update({"title": "Ship mobile flow", "draft": False})
|
||||
return httpx.Response(201, json=pull)
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.publish_authored_assigned_pull(
|
||||
"stackchain/api", 7, "abc1234"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"repository": "stackchain/api",
|
||||
"number": 7,
|
||||
"title": "Ship mobile flow",
|
||||
"head_sha": "abc1234",
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
}
|
||||
assert [item[:2] for item in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_author_can_update_assigned_open_pull_context(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def update(repository, number, title, body, expected_head_sha):
|
||||
calls.append((repository, number, title, body, expected_head_sha))
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"head_sha": expected_head_sha,
|
||||
"state": "open",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "update_authored_assigned_pull", update, raising=False
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/content",
|
||||
json={
|
||||
"title": "Clarify mobile handoff",
|
||||
"body": "Explain the reviewer path.",
|
||||
"expected_head_sha": "abc1234",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["title"] == "Clarify mobile handoff"
|
||||
assert calls == [
|
||||
(
|
||||
"stackchain/api",
|
||||
7,
|
||||
"Clarify mobile handoff",
|
||||
"Explain the reviewer path.",
|
||||
"abc1234",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_pull_context_update_requires_author_assignment_and_matching_head():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path, request.content))
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "alex"})
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"number": 7, "title": "Old context", "body": "Old body", "state": "open",
|
||||
"merged": False, "user": {"login": "alex"}, "assignees": [{"login": "alex"}],
|
||||
"head": {"sha": "abc1234"},
|
||||
"html_url": "https://forge.example/stackchain/api/pulls/7",
|
||||
})
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "PATCH":
|
||||
assert json.loads(request.content) == {
|
||||
"title": "Clear review brief", "body": "Review the mobile handoff."
|
||||
}
|
||||
return httpx.Response(200, json={
|
||||
"number": 7, "title": "Clear review brief", "body": "Review the mobile handoff.",
|
||||
"state": "open", "head": {"sha": "abc1234"},
|
||||
"html_url": "https://forge.example/stackchain/api/pulls/7",
|
||||
})
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.update_authored_assigned_pull(
|
||||
"stackchain/api", 7, "Clear review brief", "Review the mobile handoff.", "abc1234"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["title"] == "Clear review brief"
|
||||
assert [request[:2] for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
|
||||
async def assigned(repository, number):
|
||||
@@ -127,7 +292,152 @@ async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monk
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_pull_check_status_skips_files_reviews_and_diff():
|
||||
async def test_assigned_pull_feedback_endpoint_authorizes_and_returns_one_review(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
calls.append(("assigned", repository, number))
|
||||
return True
|
||||
|
||||
async def feedback(repository, number, review_id, expected_head_sha):
|
||||
calls.append(("feedback", repository, number, review_id, expected_head_sha))
|
||||
return {
|
||||
"review_id": review_id, "head_sha": expected_head_sha,
|
||||
"reviewed_head_sha": expected_head_sha, "comments": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "pull_review_feedback", feedback, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/reviews/8/feedback?expected_head_sha=abc1234"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["review_id"] == 8
|
||||
assert calls == [
|
||||
("assigned", "stackchain/api", 7),
|
||||
("feedback", "stackchain/api", 7, 8, "abc1234"),
|
||||
]
|
||||
|
||||
|
||||
def test_reviewer_statuses_distinguish_waiting_current_and_outdated_decisions():
|
||||
pull = {
|
||||
"requested_reviewers": [{"login": "casey"}],
|
||||
"head": {"sha": "new-head"},
|
||||
}
|
||||
reviews = [
|
||||
{"id": 1, "user": {"login": "casey"}, "state": "APPROVED", "commit_id": "old-head"},
|
||||
{"id": 2, "user": {"login": "sam"}, "state": "REQUEST_CHANGES", "commit_id": "new-head"},
|
||||
{"id": 3, "user": {"login": "lee"}, "state": "COMMENT", "commit_id": "new-head"},
|
||||
{"id": 4, "user": {"login": "pat"}, "state": "APPROVED", "commit_id": "new-head"},
|
||||
]
|
||||
|
||||
assert gitea_proxy._normalize_reviewer_statuses(pull, reviews, "new-head") == [
|
||||
{"login": "casey", "status": "waiting", "head_sha": "new-head", "blocking": True},
|
||||
{"review_id": 3, "login": "lee", "status": "commented", "head_sha": "new-head", "blocking": False},
|
||||
{"review_id": 4, "login": "pat", "status": "approved", "head_sha": "new-head", "blocking": False},
|
||||
{"review_id": 2, "login": "sam", "status": "changes_requested", "head_sha": "new-head", "blocking": True},
|
||||
]
|
||||
|
||||
pull["requested_reviewers"] = []
|
||||
assert gitea_proxy._normalize_reviewer_statuses(pull, reviews[:1], "new-head") == [
|
||||
{"review_id": 1, "login": "casey", "status": "outdated", "head_sha": "old-head", "blocking": True},
|
||||
]
|
||||
|
||||
|
||||
def test_reviewer_status_exposes_only_bounded_nonempty_feedback_summary():
|
||||
pull = {"requested_reviewers": []}
|
||||
reviews = [
|
||||
{
|
||||
"id": 8,
|
||||
"user": {"login": "sam"},
|
||||
"state": "REQUEST_CHANGES",
|
||||
"commit_id": "abc123",
|
||||
"body": "Please split this helper. " + ("x" * 900),
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"user": {"login": "lee"},
|
||||
"state": "COMMENT",
|
||||
"commit_id": "abc123",
|
||||
"body": " ",
|
||||
},
|
||||
]
|
||||
|
||||
statuses = gitea_proxy._normalize_reviewer_statuses(pull, reviews, "abc123")
|
||||
|
||||
sam = next(item for item in statuses if item["login"] == "sam")
|
||||
lee = next(item for item in statuses if item["login"] == "lee")
|
||||
assert sam["summary"].startswith("Please split this helper.")
|
||||
assert len(sam["summary"]) == 500
|
||||
assert "summary" not in lee
|
||||
|
||||
|
||||
def test_review_comments_are_bounded_and_require_a_file_and_body():
|
||||
comments = [
|
||||
{"id": 1, "path": "src/api.py", "body": "Handle the empty state", "new_position": 4},
|
||||
{"id": 2, "path": "src/api.py", "body": "x" * 900, "old_position": 7},
|
||||
{"id": 3, "path": "", "body": "missing file"},
|
||||
{"id": 4, "path": "src/ignored.py", "body": " "},
|
||||
"malformed",
|
||||
] + [
|
||||
{"id": index, "path": f"src/{index}.py", "body": "bounded"}
|
||||
for index in range(5, 40)
|
||||
]
|
||||
|
||||
normalized = gitea_proxy._normalize_review_comments(comments)
|
||||
|
||||
assert len(normalized) == 20
|
||||
assert normalized[0] == {
|
||||
"id": 1, "path": "src/api.py", "body": "Handle the empty state", "line": 4,
|
||||
}
|
||||
assert normalized[1]["line"] == 7
|
||||
assert len(normalized[1]["body"]) == 500
|
||||
assert all(item["path"] and item["body"] for item in normalized)
|
||||
|
||||
|
||||
def test_review_feedback_preserves_stable_review_and_comment_identities():
|
||||
pull = {"requested_reviewers": []}
|
||||
reviews = [{
|
||||
"id": 42,
|
||||
"user": {"login": "sam"},
|
||||
"state": "REQUEST_CHANGES",
|
||||
"commit_id": "abc123",
|
||||
}]
|
||||
|
||||
statuses = gitea_proxy._normalize_reviewer_statuses(pull, reviews, "abc123")
|
||||
comments = gitea_proxy._normalize_review_comments([
|
||||
{"id": 99, "path": "src/api.py", "body": "Handle the empty state", "new_position": 4},
|
||||
{"id": -1, "path": "src/other.py", "body": "Invalid identity is omitted"},
|
||||
])
|
||||
|
||||
assert statuses == [{
|
||||
"review_id": 42,
|
||||
"login": "sam",
|
||||
"status": "changes_requested",
|
||||
"head_sha": "abc123",
|
||||
"blocking": True,
|
||||
}]
|
||||
assert comments[0]["id"] == 99
|
||||
assert "id" not in comments[1]
|
||||
|
||||
|
||||
def test_inline_feedback_is_loaded_only_for_each_reviewers_latest_decision():
|
||||
reviews = [
|
||||
{"id": 7, "user": {"login": "sam"}, "state": "REQUEST_CHANGES"},
|
||||
{"id": 8, "user": {"login": "sam"}, "state": "APPROVED"},
|
||||
{"id": 9, "user": {"login": "lee"}, "state": "COMMENT"},
|
||||
{"id": 10, "user": {"login": "pat"}, "state": "PENDING"},
|
||||
]
|
||||
|
||||
assert gitea_proxy._latest_feedback_review_ids(reviews) == {"lee": 9}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_pull_check_status_refreshes_reviewer_decisions_without_files_or_diff():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
@@ -136,7 +446,10 @@ async def test_gitea_pull_check_status_skips_files_reviews_and_diff():
|
||||
return httpx.Response(200, json={
|
||||
"state": "open", "draft": False, "mergeable": True, "merged": False,
|
||||
"head": {"sha": "abc123"},
|
||||
"requested_reviewers": [{"login": "casey"}],
|
||||
})
|
||||
if request.url.path.endswith("/pulls/7/reviews"):
|
||||
return httpx.Response(200, json=[])
|
||||
if request.url.path.endswith("/commits/abc123/status"):
|
||||
return httpx.Response(200, json={
|
||||
"state": "success",
|
||||
@@ -153,12 +466,16 @@ async def test_gitea_pull_check_status_skips_files_reviews_and_diff():
|
||||
assert requests == [
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("GET", "/api/v1/repos/stackchain/api/commits/abc123/status"),
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7/reviews"),
|
||||
]
|
||||
assert status == {
|
||||
"repository": "stackchain/api", "number": 7, "head_sha": "abc123",
|
||||
"state": "open", "draft": False, "mergeable": True, "merged": False,
|
||||
"ci_state": "success",
|
||||
"checks": [{"name": "tests", "state": "success", "description": "Passed", "url": ""}],
|
||||
"reviewers": [
|
||||
{"login": "casey", "status": "waiting", "head_sha": "abc123", "blocking": True},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +517,12 @@ async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
|
||||
"state": "open",
|
||||
"mergeable": True,
|
||||
"head": {"sha": "abc123"},
|
||||
"requested_reviewers": [{"login": "casey"}],
|
||||
})
|
||||
if path.endswith("/pulls/7/reviews"):
|
||||
return httpx.Response(200, json=[
|
||||
{"id": 8, "user": {"login": "sam"}, "state": "APPROVED", "commit_id": "abc123"},
|
||||
])
|
||||
if path.endswith("/pulls/7/files"):
|
||||
return httpx.Response(200, json=[
|
||||
{"filename": "src/api.py", "status": "modified", "additions": 1, "deletions": 1},
|
||||
@@ -230,9 +552,89 @@ async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
|
||||
assert "+new" in detail["files"][0]["diff_lines"]
|
||||
assert detail["files"][1]["diff_binary"] is True
|
||||
assert detail["files"][1]["diff_available"] is False
|
||||
assert detail["reviewers"] == [
|
||||
{"login": "casey", "status": "waiting", "head_sha": "abc123", "blocking": True},
|
||||
{"review_id": 8, "login": "sam", "status": "approved", "head_sha": "abc123", "blocking": False},
|
||||
]
|
||||
assert "conversation" not in detail
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_review_does_not_block_on_inline_feedback():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
path = request.url.path
|
||||
requests.append(path)
|
||||
if path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={
|
||||
"state": "open", "mergeable": True, "head": {"sha": "abc123"},
|
||||
"requested_reviewers": [],
|
||||
})
|
||||
if path.endswith("/pulls/7/reviews"):
|
||||
return httpx.Response(200, json=[
|
||||
{"id": 7, "user": {"login": "sam"}, "state": "COMMENT", "commit_id": "old-head"},
|
||||
{"id": 8, "user": {"login": "sam"}, "state": "REQUEST_CHANGES",
|
||||
"commit_id": "abc123", "body": "Please handle the empty state."},
|
||||
])
|
||||
if path.endswith("/pulls/7/files"):
|
||||
return httpx.Response(200, json=[{"filename": "src/api.py", "status": "modified"}])
|
||||
if path.endswith("/commits/abc123/status"):
|
||||
return httpx.Response(200, json={"state": "success"})
|
||||
if path.endswith("/pulls/7.diff"):
|
||||
return httpx.Response(200, text="")
|
||||
raise AssertionError(f"optional feedback must not block core review: {request.method} {path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
detail = await gitea_proxy.pull_completion_review("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert not any(path.endswith("/comments") for path in requests)
|
||||
assert detail["reviewers"] == [{
|
||||
"review_id": 8, "login": "sam", "status": "changes_requested", "head_sha": "abc123", "blocking": True,
|
||||
"summary": "Please handle the empty state.",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_review_feedback_loads_one_current_bounded_review():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
path = request.url.path
|
||||
requests.append(path)
|
||||
if path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={"head": {"sha": "abc123"}})
|
||||
if path.endswith("/pulls/7/reviews"):
|
||||
return httpx.Response(200, json=[
|
||||
{"id": 7, "user": {"login": "sam"}, "state": "COMMENT", "commit_id": "old-head"},
|
||||
{"id": 8, "user": {"login": "sam"}, "state": "REQUEST_CHANGES", "commit_id": "abc123"},
|
||||
])
|
||||
if path.endswith("/pulls/7/reviews/8/comments"):
|
||||
return httpx.Response(200, json=[
|
||||
{"id": 99, "path": "src/api.py", "body": "Return before parsing.", "new_position": 12},
|
||||
])
|
||||
raise AssertionError(f"unexpected request: {request.method} {path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
feedback = await gitea_proxy.pull_review_feedback(
|
||||
"stackchain/api", 7, 8, "abc123"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests[-1].endswith("/pulls/7/reviews/8/comments")
|
||||
assert feedback == {
|
||||
"review_id": 8,
|
||||
"head_sha": "abc123",
|
||||
"reviewed_head_sha": "abc123",
|
||||
"comments": [{"id": 99, "path": "src/api.py", "body": "Return before parsing.", "line": 12}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_conversation_endpoint_reuses_issue_thread_with_pull_authorization(monkeypatch):
|
||||
calls = []
|
||||
@@ -593,6 +995,39 @@ async def test_gitea_merge_rejects_failed_ci_without_upstream_mutation():
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("requested,reviews", [
|
||||
([{"login": "casey"}], []),
|
||||
([], [{"id": 9, "user": {"login": "casey"}, "state": "REQUEST_CHANGES", "commit_id": "abc123"}]),
|
||||
])
|
||||
async def test_gitea_merge_rejects_unresolved_reviewer_status_without_mutation(requested, reviews):
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 7, "state": "open", "draft": False, "mergeable": True,
|
||||
"merged": False, "head": {"sha": "abc123"},
|
||||
"requested_reviewers": requested,
|
||||
})
|
||||
if request.url.path.endswith("/commits/abc123/status"):
|
||||
return httpx.Response(200, json={"state": "success"})
|
||||
if request.url.path.endswith("/pulls/7/reviews"):
|
||||
return httpx.Response(200, json=reviews)
|
||||
raise AssertionError(f"unexpected mutation: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.PullNotMergeableError):
|
||||
await gitea_proxy.merge_assigned_pull("stackchain/api", 7, "abc123")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests[-1] == ("GET", "/api/v1/repos/stackchain/api/pulls/7/reviews")
|
||||
assert all(method == "GET" for method, _path in requests)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_ownership_endpoints_list_handoff_and_release(monkeypatch):
|
||||
calls = []
|
||||
@@ -640,6 +1075,69 @@ async def test_assigned_pull_ownership_endpoints_list_handoff_and_release(monkey
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_review_request_endpoints_list_and_submit(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
calls.append(("assigned", repository, number))
|
||||
return True
|
||||
|
||||
async def candidates(repository, number):
|
||||
calls.append(("candidates", repository, number))
|
||||
return [{"login": "casey", "name": "Casey"}]
|
||||
|
||||
async def request_review(repository, number, reviewer, expected_head_sha):
|
||||
calls.append(("request", repository, number, reviewer, expected_head_sha))
|
||||
return {"number": number, "reviewer": reviewer, "requested_reviewers": [reviewer]}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "pull_review_candidates", candidates, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "request_assigned_pull_review", request_review, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
listed = await client.get("/api/v1/repos/stackchain/api/pulls/7/review-candidates")
|
||||
requested = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/request-review",
|
||||
json={"reviewer": "casey", "expected_head_sha": "abc1234"},
|
||||
)
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.headers["cache-control"] == "no-store"
|
||||
assert listed.json() == [{"login": "casey", "name": "Casey"}]
|
||||
assert requested.status_code == 200
|
||||
assert requested.json()["requested_reviewers"] == ["casey"]
|
||||
assert calls == [
|
||||
("assigned", "stackchain/api", 7),
|
||||
("candidates", "stackchain/api", 7),
|
||||
("request", "stackchain/api", 7, "casey", "abc1234"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_cancel_review_endpoint_forwards_head_scoped_request(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def cancel(repository, number, reviewer, expected_head_sha):
|
||||
calls.append((repository, number, reviewer, expected_head_sha))
|
||||
return {
|
||||
"repository": repository, "number": number, "reviewer": reviewer,
|
||||
"head_sha": expected_head_sha, "requested_reviewers": ["casey"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "cancel_assigned_pull_review", cancel, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.request(
|
||||
"DELETE", "/api/v1/repos/stackchain/api/pulls/7/request-review",
|
||||
json={"reviewer": "sam", "expected_head_sha": "abc1234"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["requested_reviewers"] == ["casey"]
|
||||
assert calls == [("stackchain/api", 7, "sam", "abc1234")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_pull_handoff_preserves_coassignees_and_confirms_ownership_exit():
|
||||
requests = []
|
||||
@@ -709,6 +1207,124 @@ async def test_pull_handoff_candidates_are_bounded_and_exclude_invalid_or_curren
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_pull_review_request_filters_candidates_and_confirms_requested_reviewer():
|
||||
requests = []
|
||||
review_requested = False
|
||||
|
||||
async def handler(request):
|
||||
nonlocal review_requested
|
||||
requests.append((request.method, request.url.path, request.content))
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/assignees"):
|
||||
return httpx.Response(200, json=[
|
||||
{"login": "timmy", "full_name": "Timmy"},
|
||||
{"login": "alex", "full_name": "Alexander"},
|
||||
{"login": "sam", "full_name": "Sam"},
|
||||
{"login": "casey", "full_name": "Casey"},
|
||||
])
|
||||
if request.url.path.endswith("/pulls/7"):
|
||||
requested = [{"login": "sam"}]
|
||||
if review_requested:
|
||||
requested.append({"login": "casey"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 7, "state": "open", "merged": False,
|
||||
"head": {"sha": "abc123"}, "user": {"login": "alex"},
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"requested_reviewers": requested,
|
||||
})
|
||||
if request.method == "POST" and request.url.path.endswith("/requested_reviewers"):
|
||||
review_requested = True
|
||||
return httpx.Response(201, json={})
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
candidates = await gitea_proxy.pull_review_candidates("stackchain/api", 7)
|
||||
result = await gitea_proxy.request_assigned_pull_review(
|
||||
"stackchain/api", 7, "casey", "abc123"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert candidates == [{"login": "casey", "name": "Casey"}]
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 7, "head_sha": "abc123",
|
||||
"requested_reviewers": ["sam", "casey"], "reviewer": "casey",
|
||||
}
|
||||
mutation = next(item for item in requests if item[0] == "POST")
|
||||
assert mutation[1] == "/api/v1/repos/stackchain/api/pulls/7/requested_reviewers"
|
||||
assert mutation[2] == b'{"reviewers":["casey"]}'
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_cancel_pending_pull_review_confirms_authoritative_removal():
|
||||
requests = []
|
||||
cancelled = False
|
||||
|
||||
async def handler(request):
|
||||
nonlocal cancelled
|
||||
requests.append((request.method, request.url.path, request.content))
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 7, "state": "open", "merged": False,
|
||||
"head": {"sha": "abc123"}, "assignees": [{"login": "timmy"}],
|
||||
"requested_reviewers": [{"login": "casey"}] if cancelled else [{"login": "sam"}, {"login": "casey"}],
|
||||
})
|
||||
if request.method == "DELETE" and request.url.path.endswith("/requested_reviewers"):
|
||||
assert request.content == b'{"reviewers":["sam"]}'
|
||||
cancelled = True
|
||||
return httpx.Response(204)
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.cancel_assigned_pull_review(
|
||||
"stackchain/api", 7, "sam", "abc123"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 7, "head_sha": "abc123",
|
||||
"requested_reviewers": ["casey"], "reviewer": "sam",
|
||||
}
|
||||
assert [method for method, _path, _body in requests] == ["GET", "GET", "DELETE", "GET"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_cancel_pending_pull_review_rejects_head_drift_before_mutation():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request.method)
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 7, "state": "open", "merged": False,
|
||||
"head": {"sha": "new-head"}, "assignees": [{"login": "timmy"}],
|
||||
"requested_reviewers": [{"login": "sam"}],
|
||||
})
|
||||
if request.method == "DELETE":
|
||||
return httpx.Response(204)
|
||||
raise AssertionError(request.url.path)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.IssueNotAvailableError):
|
||||
await gitea_proxy.cancel_assigned_pull_review(
|
||||
"stackchain/api", 7, "sam", "old-head"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == ["GET", "GET"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_pull_release_removes_current_login_case_insensitively():
|
||||
async def handler(request):
|
||||
@@ -745,6 +1361,8 @@ async def test_gitea_merge_returns_the_exact_merge_commit_for_release_tracking()
|
||||
})
|
||||
if request.url.path.endswith("/commits/abc123/status"):
|
||||
return httpx.Response(200, json={"state": "success"})
|
||||
if request.url.path.endswith("/pulls/7/reviews"):
|
||||
return httpx.Response(200, json=[])
|
||||
if request.url.path.endswith("/pulls/7/merge"):
|
||||
return httpx.Response(200, json={"merged": True, "sha": "merge456"})
|
||||
raise AssertionError(request.url.path)
|
||||
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user