Compare commits
240
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33d7f4b884 | ||
|
|
05dd6c1e9c | ||
|
|
42f2a077b2 | ||
|
|
0bb76bdc8b | ||
|
|
efafaaddd2 | ||
|
|
a9ffc2d879 | ||
|
|
1fdbba1ca1 | ||
|
|
4b88e1a533 | ||
|
|
7a31ea5cba | ||
|
|
6633ba5244 | ||
|
|
5b954a7a53 | ||
|
|
c30206bfb2 | ||
|
|
cf9a02ae38 | ||
|
|
9f5aa33e95 | ||
|
|
ae3749bab9 | ||
|
|
c1c4ff8e31 | ||
|
|
ff41eb1d57 | ||
|
|
f23839f2b9 | ||
|
|
7cab4e8749 | ||
|
|
8a3316a5c4 | ||
|
|
631eb2e584 | ||
|
|
ed246ce6dd | ||
|
|
fc35b17d24 | ||
|
|
cee3d93864 | ||
|
|
fca99fee86 | ||
|
|
3dd0ba2024 | ||
|
|
871e35c10b | ||
|
|
5c9420ed9c | ||
|
|
8edc83893f | ||
|
|
a226addcc4 | ||
|
|
2402af9e82 | ||
|
|
31f95589cb | ||
|
|
2ac6b1f56b | ||
|
|
6449ead83d | ||
|
|
322f025794 | ||
|
|
9ab22b97f8 | ||
|
|
35747cf9fb | ||
|
|
f094438dab | ||
|
|
04f06042b8 | ||
|
|
a476170fca | ||
|
|
5a579362ff | ||
|
|
d797179844 | ||
|
|
a500915223 | ||
|
|
59a2e4f616 | ||
|
|
e125019f57 | ||
|
|
6ba823abd2 | ||
|
|
f44fa6e579 | ||
|
|
87ff83bd2d | ||
|
|
7dd396154d | ||
|
|
af5fe9b69d | ||
|
|
807364c63b | ||
|
|
6315323a48 | ||
|
|
6a9271cabb | ||
|
|
a1c56bd644 | ||
|
|
ebc3b9ee4d | ||
|
|
19772b5797 | ||
|
|
6314efb82b | ||
|
|
5cda95eeb2 | ||
|
|
0c71dea00c | ||
|
|
53539e0cdf | ||
|
|
9a24aff537 | ||
|
|
43769460fc | ||
|
|
2e62eb09f5 | ||
|
|
1ef3e291c2 | ||
|
|
3694f2501b | ||
|
|
c3d58d7111 | ||
|
|
da45cc36a3 | ||
|
|
c04cba29d9 | ||
|
|
cacecac865 | ||
|
|
10f7cc832a | ||
|
|
e5f37a866c | ||
|
|
5a4fbd1564 | ||
|
|
7bcab03b8a | ||
|
|
51fe9e5af2 | ||
|
|
2406d24cb8 | ||
|
|
745d2529e5 | ||
|
|
af9699e362 | ||
|
|
71b38687c4 | ||
|
|
0a8f33dc83 | ||
|
|
0f45b7aead | ||
|
|
a44bf46bef | ||
|
|
ec86bf55a8 | ||
|
|
7636e30482 | ||
|
|
bd054998b2 | ||
|
|
3c65bc1b31 | ||
|
|
bc082f2bd9 | ||
|
|
9ae0a89970 | ||
|
|
43163ed926 | ||
|
|
66a2b78860 | ||
|
|
20d4f1ef7e | ||
|
|
70281bb56a | ||
|
|
7b736bc4c6 | ||
|
|
fa3e5e3bf0 | ||
|
|
bc0d47e8e7 | ||
|
|
741c27eacf | ||
|
|
3896f17a58 | ||
|
|
9d14fa5ffb | ||
|
|
83be8fcdbe | ||
|
|
35f3999787 | ||
|
|
378291d3c8 | ||
|
|
584752ed24 | ||
|
|
d54f58bf36 | ||
|
|
84302d6f1e | ||
|
|
2e0d64907b | ||
|
|
80042ce561 | ||
|
|
931f656f78 | ||
|
|
18f56583ba | ||
|
|
6e2f5d1120 | ||
|
|
9bd755f521 | ||
|
|
fd2f801f3e | ||
|
|
e21cc58cca | ||
|
|
d6212b2812 | ||
|
|
6528b2e73f | ||
|
|
a9d586e689 | ||
|
|
7c86f28346 | ||
|
|
707ee4c1bc | ||
|
|
82982e5741 | ||
|
|
db08c7e75f | ||
|
|
1a3df5d7a7 | ||
|
|
fdba0554df | ||
|
|
d2a85cd5ea | ||
|
|
1f034a6f64 | ||
|
|
de6d6d0e61 | ||
|
|
8dc3880167 | ||
|
|
b93e63ab70 | ||
|
|
8c82644515 | ||
|
|
6c6cd3c71b | ||
|
|
4f6cebae7c | ||
|
|
341a3283ea | ||
|
|
9c276fa251 | ||
|
|
940f7c05d2 | ||
|
|
d34c082da2 | ||
|
|
1c086be09b | ||
|
|
4cfcb92eec | ||
|
|
b484bbef03 | ||
|
|
623817eb7d | ||
|
|
11347e5fe0 | ||
|
|
bb1712279c | ||
|
|
e7d5196987 | ||
|
|
64ff5127d8 | ||
|
|
4ac5a0f499 | ||
|
|
715df380e0 | ||
|
|
1fc70f6890 | ||
|
|
523968f5a3 | ||
|
|
af27bb06e0 | ||
|
|
4410af09a6 | ||
|
|
880f60831a | ||
|
|
e860d16d29 | ||
|
|
abf8e23685 | ||
|
|
02a472f841 | ||
|
|
c5e6e17fc1 | ||
|
|
8a12524595 | ||
|
|
cbe4017bdc | ||
|
|
fcc9ead33e | ||
|
|
0d29b29bef | ||
|
|
dce9ae04c9 | ||
|
|
9ec88179c3 | ||
|
|
93890e72de | ||
|
|
8f0df3d393 | ||
|
|
fc79e4523d | ||
|
|
741d545199 | ||
|
|
9320016b50 | ||
|
|
b069379df0 | ||
|
|
8387c57ceb | ||
|
|
72842ecbcd | ||
|
|
c503994be1 | ||
|
|
5e6328a8d0 | ||
|
|
4209834d6d | ||
|
|
46888d2876 | ||
|
|
7d7abefb0b | ||
|
|
2796808759 | ||
|
|
a2f83b0955 | ||
|
|
6b56188d30 | ||
|
|
467c8c665d | ||
|
|
1b311c9cd4 | ||
|
|
71d06347fd | ||
|
|
1de6d73e75 | ||
|
|
46c75f0b22 | ||
|
|
5cdb3f7b3e | ||
|
|
15d197327a | ||
|
|
df8385b325 | ||
|
|
47c6412a12 | ||
|
|
6cac778acd | ||
|
|
a8a93b2f3b | ||
|
|
ef37a66fd7 | ||
|
|
52e2c26304 | ||
|
|
3817d2c67b | ||
|
|
ad6c1edf80 | ||
|
|
d2b2c7bada | ||
|
|
9ba03613d4 | ||
|
|
1132075318 | ||
|
|
16b1d8670f | ||
|
|
e5c5c8ec31 | ||
|
|
87e0d74414 | ||
|
|
f031a37e8d | ||
|
|
057bec7dc2 | ||
|
|
654ae8714e | ||
|
|
a0dd044995 | ||
|
|
c93a682dfb | ||
|
|
5675ca5d41 | ||
|
|
0767ccaa9d | ||
|
|
c6eabb5fc7 | ||
|
|
77fab85a66 | ||
|
|
dcf26c896c | ||
|
|
fba1f4238d | ||
|
|
9e301924b4 | ||
|
|
8edd542379 | ||
|
|
0fbe047e03 | ||
|
|
995d8f8527 | ||
|
|
fabd6123f8 | ||
|
|
2553e7121b | ||
|
|
681e10c7db | ||
|
|
d96a77c5d5 | ||
|
|
97eb928187 | ||
|
|
80a4096118 | ||
|
|
f04a547e19 | ||
|
|
ea3712ca3e | ||
|
|
7421a8f62d | ||
|
|
982c2a1fb7 | ||
|
|
3e7d6f7111 | ||
|
|
32394fcd8f | ||
|
|
ea3194cdca | ||
|
|
85043b4af6 | ||
|
|
dc202bde11 | ||
|
|
c4ba78c8cb | ||
|
|
e35d7dea35 | ||
|
|
b81d695585 | ||
|
|
75da76c6f5 | ||
|
|
f78d69fba5 | ||
|
|
8ce64d2aa7 | ||
|
|
48f75cff05 | ||
|
|
58260d8a26 | ||
|
|
54ff44462e | ||
|
|
a87a5a4a90 | ||
|
|
c91194c283 | ||
|
|
5806d47b61 | ||
|
|
95f24a4e90 | ||
|
|
c016261b1a | ||
|
|
bbc3e10525 | ||
|
|
9737300d55 |
No files matched your search
+22
-1
@@ -36,9 +36,30 @@ jobs:
|
||||
name: release-bundle
|
||||
path: dist/
|
||||
|
||||
browser-journey:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-release
|
||||
env:
|
||||
STACKCHAIN_RUN_RELEASE_E2E: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.11" }
|
||||
- name: Download assembled release bundle
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist
|
||||
- name: Install browser test dependencies
|
||||
run: |
|
||||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise mobile offline filing from the release artifact
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, build-release]
|
||||
needs: [lint, build-release, browser-journey]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -24,19 +24,25 @@ For online delivery, the screenshot uploads before the comment is posted—to th
|
||||
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
|
||||
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
|
||||
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
|
||||
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
|
||||
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
|
||||
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
|
||||
preview when the operator later chooses a repository. The source Draft remains available until its
|
||||
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
|
||||
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
|
||||
either stage. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
|
||||
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
|
||||
to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before
|
||||
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
|
||||
bundle when the operator later chooses a repository. A scrollable thumbnail tray lets the operator
|
||||
review every restored image before filing; **Move earlier**, **Move later**, and **Remove selected**
|
||||
change the durable evidence order without changing the issue title or note. The active screenshot's
|
||||
optional **Evidence note** stays paired with that image through reorder, Draft restore, offline delivery,
|
||||
and retry, then appears as a Markdown-safe caption immediately before its uploaded image. The selected screenshot can be cropped, privacy-redacted, highlighted, and marked with touch-drawn arrows before a flattened derivative replaces it; undo, reset, and cancel keep editing reversible without changing its note or bundle position. The source Draft remains
|
||||
available until its evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob.
|
||||
Repository-aware durable admission likewise stores the evidence bundle with its account-bound outbox
|
||||
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
|
||||
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
|
||||
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
|
||||
delivery time.
|
||||
Delivery creates the issue exactly once, then uploads and comments with
|
||||
the image; after a partial failure, retry resumes with the confirmed issue instead of
|
||||
creating a duplicate.
|
||||
Delivery creates the issue exactly once, then uploads each image under a checkpointed per-image
|
||||
identity and posts one ordered Markdown evidence comment. After a partial failure, retry resumes with the confirmed issue and from
|
||||
the first unconfirmed image instead of duplicating the issue or earlier uploads. The installed PWA
|
||||
Share Target accepts the same bounded multi-image bundle through sign-in continuation.
|
||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||
issue-comment API. In issue, pull-request, and unread-update conversations, typing
|
||||
at least two characters after `@` offers repository-scoped teammate suggestions;
|
||||
@@ -56,13 +62,34 @@ repository milestone and due date; the dashboard validates both and sends them w
|
||||
self-assignment in the single create request, so planned work appears in its release
|
||||
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
|
||||
account-bound title/description captures without selecting a repository or entering the
|
||||
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
|
||||
the same Gitea login, **Choose repository** restores the capture to the normal planning
|
||||
and durable delivery flow. A different or unconfirmed account can only copy or discard
|
||||
mutation outbox. Drafts marks them **Needs filing** and saves locally first. After a fresh
|
||||
reconnect confirms the same Gitea login, a bounded, revisioned collection synchronizes the title,
|
||||
description, blockers, and ordered screenshot evidence; another signed-in device can then use
|
||||
**Choose repository** to continue the normal planning and durable delivery flow. Draft cards report
|
||||
whether they are synced or still pending locally. Concurrent changes surface a conflict, and
|
||||
successful discard or outbox admission propagates deletion so a stale device cannot resurrect the
|
||||
draft. Synchronization is limited to 20 drafts and 12 MiB of decoded evidence per account; failure
|
||||
never blocks local capture. A different or unconfirmed account can only copy or discard
|
||||
the private content. Issue capture and authored mobile actions (issue
|
||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||
another worker replays a confirmed result instead of posting duplicate content. The ordered,
|
||||
another worker replays a confirmed result instead of posting duplicate content. Mobile Search previews
|
||||
let operators assign an eligible issue directly into Later at an exact local return time without filling
|
||||
Today or interrupting active work. Cancel and browser Back preserve the Search preview without assigning;
|
||||
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
||||
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
||||
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
||||
Named mobile Search views preserve the query, type, status, and optional repository scope. They are
|
||||
bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another
|
||||
device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently
|
||||
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
|
||||
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
||||
`.stackchain-state/saved-searches.sqlite3` path.
|
||||
Search previews also
|
||||
let operators assign an eligible issue and add it to Today without starting or replacing active work.
|
||||
The queue action keeps the Search query, filters, results, and scroll position available for continued
|
||||
planning, reports an existing Today item without duplicating it, and uses the same capacity, sync, and
|
||||
offline-warm path as other Today admission flows. The ordered,
|
||||
five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
|
||||
and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
|
||||
time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,
|
||||
@@ -81,7 +108,11 @@ an open, unassigned issue update also offers **Take ownership & start**, which c
|
||||
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
|
||||
the session, and opens the issue. The adjacent **Take ownership** action remains available for
|
||||
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
|
||||
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
|
||||
**Mark read & next** remains the explicit acknowledgement path. During an online Updates pass,
|
||||
Stackchain preloads at most the next surviving conversation from the fixed snapshot while the
|
||||
current one is being read. Advancing consumes that account-bound result without another detail
|
||||
request; failures fall back to the normal foreground retry path, and offline triage never speculates.
|
||||
Delivery or local-admission
|
||||
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||
@@ -199,6 +230,9 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
# three individual alerts followed by one private digest that opens Updates. A later
|
||||
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent.
|
||||
# Browser push services must resolve exclusively to public IP addresses. Stackchain
|
||||
# validates endpoints at enrollment and again before delivery, rejects redirects,
|
||||
# and removes legacy subscriptions that resolve to private or reserved networks.
|
||||
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
|
||||
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
|
||||
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
||||
@@ -207,6 +241,10 @@ export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
||||
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
|
||||
# within each device; one slow device does not delay healthy devices behind it.
|
||||
export STACKCHAIN_PUSH_POLL_SECONDS=30
|
||||
# Unread updates and deadline reminders run on independent, fixed-cadence
|
||||
# workers, so a slow channel cannot delay the other or add drift to its ticks.
|
||||
# On each phone, Device Setup can enable deadline reminders and choose any local
|
||||
# reminder hour from 00:00 through 23:00; the server-confirmed choice is restored.
|
||||
export STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS=10
|
||||
export STACKCHAIN_PUSH_MAX_CONCURRENCY=8
|
||||
# Maximum individual alerts per device and poll before one digest covers the rest.
|
||||
@@ -271,10 +309,16 @@ session linked to the credential is revoked atomically; unrelated credentials an
|
||||
sessions remain valid. Removing the current device's passkey keeps its current
|
||||
session active, so the sheet warns that the recovery token will be required after
|
||||
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
|
||||
normally keeps the passkey available for the next sign-in.
|
||||
normally keeps the passkey available for the next sign-in. Passkey assertion counters
|
||||
advance with an atomic compare-and-swap across workers: stale or equal nonzero counters
|
||||
fail before a session or authorization grant is issued, while counterless authenticators
|
||||
remain compatible. The bounded **Passkey counter anomaly** Security activity entry names
|
||||
only the device label and attempted action; repeated alerts should prompt removal and
|
||||
re-enrollment of that passkey.
|
||||
|
||||
High-impact actions—merging a pull request, closing an assigned issue, revoking a
|
||||
remote device, or signing out every device—require a passkey assertion or the
|
||||
High-impact actions—merging a pull request, closing an assigned issue, permanently
|
||||
deleting an authored comment, revoking a remote device, or signing out every device—
|
||||
require a passkey assertion or the
|
||||
operator access token again.
|
||||
The server issues a random 90-second grant bound to the active session, exact action,
|
||||
and exact target. Only its digest is stored, and the grant is consumed atomically on
|
||||
@@ -365,7 +409,10 @@ account and never contain the Gitea token. Find Work uses the same worker-shared
|
||||
`STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB` overrides
|
||||
`STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3`. One expiring lease bounds each
|
||||
catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry
|
||||
bursts, and confirmed claims are removed from every worker's retained catalog. Releasing
|
||||
bursts, and confirmed claims are removed from every worker's retained catalog. On a cold
|
||||
catalog, non-owner workers wait within the bounded foreground deadline for the lease owner
|
||||
to publish; if that owner releases or abandons the lease, a waiter can take over the scan
|
||||
within its remaining request budget. Releasing
|
||||
an assignment invalidates the shared catalog so the newly available issue can be discovered
|
||||
by the next authoritative scan. Each bounded opaque revision token includes the
|
||||
store generation, so a token from a different deployment or before replacement of the store
|
||||
@@ -388,6 +435,14 @@ filter, preserves the selected release lane, shows the current draft count, resp
|
||||
the device safe area, and moves out of the way while a full-screen task is open.
|
||||
Desktop layout is unchanged.
|
||||
|
||||
Mobile **Search** provides touch-sized **Repository**, **Type**, and **Status** controls.
|
||||
Operators can find and select a repository visible to their Gitea account, then scope results
|
||||
to that exact repository, issues, pull requests, open work, closed work, or all accessible work;
|
||||
the server applies every scope before pagination. Clearing Repository returns to organization-wide
|
||||
Search. The selected scope is bounded and addressable, survives preview/back, reload, sharing,
|
||||
and sign-in continuation, and a scope change cancels obsolete requests before restarting at the
|
||||
first page. Repository lookup failure leaves organization-wide Search usable.
|
||||
|
||||
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
|
||||
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
|
||||
accepts a valid future local date and time and returns the item at that exact instant; the picker
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
function createAgendaReplan({ now = () => new Date(), update }) {
|
||||
let items = [];
|
||||
let index = 0;
|
||||
let pending = false;
|
||||
let active = false;
|
||||
|
||||
const dayKey = date => [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
const dueDay = value => {
|
||||
const match = String(value || '').match(/^(\d{4}-\d{2}-\d{2})(?:$|T)/);
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
const current = () => active ? items[index] || null : null;
|
||||
const snapshot = () => ({
|
||||
active,
|
||||
index,
|
||||
total: items.length,
|
||||
current: current()?.key || null,
|
||||
pending,
|
||||
});
|
||||
const advance = () => {
|
||||
index += 1;
|
||||
if (index >= items.length) active = false;
|
||||
return { ok:true, done:!active };
|
||||
};
|
||||
const change = async dueDate => {
|
||||
const item = current();
|
||||
if (!item) return { ok:false, error:'No overdue deadline selected.' };
|
||||
if (pending) return { ok:false, error:'Deadline update already in progress.' };
|
||||
pending = true;
|
||||
try {
|
||||
await update(item, dueDate);
|
||||
return advance();
|
||||
} catch (error) {
|
||||
return { ok:false, error:error?.message || 'Deadline could not be updated.' };
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start(overdue) {
|
||||
items = (overdue || []).slice().sort((left, right) =>
|
||||
dueDay(left.due_date).localeCompare(dueDay(right.due_date)) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
);
|
||||
index = 0;
|
||||
pending = false;
|
||||
active = items.length > 0;
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
current,
|
||||
keep() {
|
||||
if (pending) return Promise.resolve({ ok:false, error:'Deadline update already in progress.' });
|
||||
if (!current()) return Promise.resolve({ ok:false, error:'No overdue deadline selected.' });
|
||||
return Promise.resolve(advance());
|
||||
},
|
||||
tomorrow() {
|
||||
const date = new Date(now());
|
||||
date.setDate(date.getDate() + 1);
|
||||
return change(dayKey(date) + 'T23:59:59Z');
|
||||
},
|
||||
choose(value) {
|
||||
const chosen = String(value || '');
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(chosen) || chosen <= dayKey(now())) {
|
||||
return Promise.resolve({ ok:false, error:'Choose a future date.' });
|
||||
}
|
||||
return change(chosen + 'T23:59:59Z');
|
||||
},
|
||||
cancel() {
|
||||
active = false;
|
||||
pending = false;
|
||||
return snapshot();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAgendaReplan;
|
||||
@@ -0,0 +1,33 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createAgendaSessionLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createAgendaSessionLauncher(options) {
|
||||
let pending = null;
|
||||
|
||||
function open() {
|
||||
if (pending) return pending;
|
||||
options.selectAgenda();
|
||||
pending = Promise.resolve()
|
||||
.then(() => options.discover())
|
||||
.then(complete => {
|
||||
if (!options.isAgendaSelected()) return 'cancelled';
|
||||
if (complete === false || options.hasMore()) {
|
||||
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
|
||||
return 'incomplete';
|
||||
}
|
||||
const opened = options.hasCheckpoint() ? options.resume() : options.start();
|
||||
if (!opened) options.announce('No deadlines are ready in Agenda.');
|
||||
return opened ? 'opened' : 'empty';
|
||||
})
|
||||
.catch(() => {
|
||||
if (options.isAgendaSelected()) {
|
||||
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
|
||||
}
|
||||
return 'incomplete';
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {open};
|
||||
});
|
||||
@@ -91,11 +91,18 @@ function createIssueSyncStore({
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const preservedBundle = current.operationId === replacement.operationId &&
|
||||
current.attachments?.every(value => value?.data || value?.blob) &&
|
||||
replacement.attachments?.every(value => value?.stored && !value.data && !value.blob)
|
||||
? current.attachments : null;
|
||||
if (current.operationId === replacement.operationId &&
|
||||
(current.attachment?.data || current.attachment?.blob) &&
|
||||
replacement.attachment?.stored &&
|
||||
!replacement.attachment.data && !replacement.attachment.blob) {
|
||||
replacement = { ...replacement, attachment: current.attachment };
|
||||
!replacement.attachment.data && !replacement.attachment.blob || preservedBundle) {
|
||||
replacement = {
|
||||
...replacement,
|
||||
...(preservedBundle ? {attachments:preservedBundle} : {attachment:current.attachment}),
|
||||
};
|
||||
incoming.set(current.id, replacement);
|
||||
}
|
||||
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
@@ -353,6 +360,9 @@ function createBackgroundIssueSync({
|
||||
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
|
||||
}
|
||||
if (typeof delivered.url === 'string' && /^https?:\/\//.test(delivered.url)) {
|
||||
return { id: item.id, status, kind: 'issue', url: delivered.url };
|
||||
}
|
||||
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
|
||||
}
|
||||
|
||||
@@ -439,6 +449,8 @@ function createBackgroundIssueSync({
|
||||
title: item.title,
|
||||
body: item.body,
|
||||
label_ids: item.labelIds,
|
||||
...(item.unassigned ? { unassigned: true } : {}),
|
||||
...(item.assignee ? { assignee: item.assignee } : {}),
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
@@ -466,6 +478,13 @@ function createBackgroundIssueSync({
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
function evidenceMarkdown(attachment, markdown, index) {
|
||||
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
if (!note) return markdown;
|
||||
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
|
||||
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
|
||||
}
|
||||
|
||||
async function deliverReplyRead(item) {
|
||||
let current = item;
|
||||
if (!current.replyConfirmed) {
|
||||
@@ -525,8 +544,31 @@ function createBackgroundIssueSync({
|
||||
deliveredIssue = await requestStage(item, request.url, request.options);
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
|
||||
}
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const blockers = Array.isArray(item.blockers) ? item.blockers : [];
|
||||
let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length);
|
||||
for (let index = deliveredBlockers; index < blockers.length; index += 1) {
|
||||
const blocker = blockers[index];
|
||||
await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/blockers',
|
||||
{
|
||||
method:'PATCH',
|
||||
headers:{
|
||||
Accept:'application/json', 'Content-Type':'application/json',
|
||||
'Idempotency-Key':stageOperationId(item.operationId, 'blocker-' + index),
|
||||
},
|
||||
body:JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}),
|
||||
},
|
||||
);
|
||||
deliveredBlockers = index + 1;
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue, deliveredBlockers }));
|
||||
}
|
||||
const attachments = (Array.isArray(item.attachments) ? item.attachments : [item.attachment]).filter(Boolean);
|
||||
if (!attachments.length) return deliveredIssue;
|
||||
const attachmentMarkdowns = Array.isArray(item.attachmentMarkdowns)
|
||||
? item.attachmentMarkdowns.slice(0, attachments.length)
|
||||
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
|
||||
for (let index = attachmentMarkdowns.length; index < attachments.length; index += 1) {
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
|
||||
@@ -534,19 +576,27 @@ function createBackgroundIssueSync({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
'Idempotency-Key': stageOperationId(
|
||||
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
|
||||
),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
body: attachmentMultipart(attachments[index]),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const markdown = String(uploaded?.markdown || '');
|
||||
if (!markdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
|
||||
attachmentMarkdowns.push(markdown);
|
||||
await checkpointClaim(item, current => ({
|
||||
...current, deliveredIssue, attachmentMarkdowns:attachmentMarkdowns.slice(),
|
||||
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||
}));
|
||||
}
|
||||
const attachmentMarkdown = attachmentMarkdowns.map((markdown, index) =>
|
||||
evidenceMarkdown(attachments[index], markdown, index)).join('\n\n');
|
||||
await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
|
||||
@@ -638,7 +688,9 @@ function createBackgroundIssueSync({
|
||||
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
|
||||
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
||||
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
|
||||
await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||
const error = new Error('Issue closure was not confirmed.');
|
||||
error.status = 422;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
function createBatchFindWork({
|
||||
capacity,
|
||||
claim,
|
||||
queue,
|
||||
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
|
||||
persistEstimate = () => {},
|
||||
onProgress = () => {},
|
||||
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
||||
owner = () => '',
|
||||
journalName = 'find-work-batch',
|
||||
queueFailureReason = 'assigned but Today sync is unavailable',
|
||||
autoMount = true,
|
||||
}) {
|
||||
let request = null;
|
||||
const journalKey = () => 'stackchain.' + journalName + '.v1.' +
|
||||
encodeURIComponent(String(owner() || ''));
|
||||
|
||||
function readJournal() {
|
||||
if (!storage || !String(owner() || '')) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(journalKey()) || 'null');
|
||||
return value?.owner === String(owner()) && Array.isArray(value.items) ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function writeJournal(value) {
|
||||
if (!storage || !String(owner() || '')) return;
|
||||
storage.setItem(journalKey(), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clearJournal() {
|
||||
if (storage && String(owner() || '')) storage.removeItem(journalKey());
|
||||
}
|
||||
|
||||
function result(status, selected, available, queued = [], failed = []) {
|
||||
return { status, selected, available, queued, failed };
|
||||
}
|
||||
|
||||
function key(item) {
|
||||
return String(item.repository || '') + '#' + String(item.number || '');
|
||||
}
|
||||
|
||||
async function processJournal(journal) {
|
||||
const selected = journal.items;
|
||||
const queued = [];
|
||||
const failed = [];
|
||||
for (let index = 0; index < selected.length; index += 1) {
|
||||
const entry = selected[index];
|
||||
const item = entry.item;
|
||||
if (entry.state === 'queued') {
|
||||
queued.push(key(item));
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const confirmed = entry.confirmed || await claim(item);
|
||||
if (!entry.confirmed) {
|
||||
entry.confirmed = confirmed;
|
||||
entry.state = 'assigned';
|
||||
writeJournal(journal);
|
||||
}
|
||||
const queueResult = await queue(confirmed, journal.context);
|
||||
if (queueResult === 'queued' || queueResult === 'exists') {
|
||||
queued.push(key(item));
|
||||
entry.state = 'queued';
|
||||
if (Number.isInteger(entry.estimate) && entry.estimate > 0) {
|
||||
persistEstimate(confirmed, entry.estimate);
|
||||
}
|
||||
writeJournal(journal);
|
||||
} else {
|
||||
failed.push({ key: key(item), reason: queueFailureReason, assigned: true });
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
||||
}
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
}
|
||||
const outcome = result('complete', selected.length, journal.available, queued, failed);
|
||||
if (!failed.length) clearJournal();
|
||||
else writeJournal(journal);
|
||||
onProgress({ ...outcome, processed: selected.length });
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function run(items, estimates = {}, context = null) {
|
||||
if (request) return request;
|
||||
const selected = Array.isArray(items) ? items.slice() : [];
|
||||
const available = Math.max(0, Number(capacity()) || 0);
|
||||
if (selected.length > available) {
|
||||
const outcome = result('full', selected.length, available);
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
const budget = timeBudget() || {};
|
||||
if (Number.isInteger(budget.capacity_minutes) && budget.capacity_minutes > 0) {
|
||||
const remaining = Math.max(0, budget.capacity_minutes - (Number(budget.planned_minutes) || 0));
|
||||
const invalid = selected.map(key).filter(id =>
|
||||
!Number.isInteger(estimates[id]) || estimates[id] <= 0
|
||||
);
|
||||
const requested = selected.reduce((sum, item) => sum +
|
||||
(Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0 ? estimates[key(item)] : 0), 0);
|
||||
if (invalid.length) {
|
||||
const outcome = { ...result('estimates-required', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, invalid };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
if (requested > remaining) {
|
||||
const outcome = { ...result('over-budget', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, over_minutes: requested - remaining };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
}
|
||||
const journal = {
|
||||
owner: String(owner() || ''), available, context,
|
||||
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
|
||||
};
|
||||
writeJournal(journal);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (request) return request;
|
||||
const journal = readJournal();
|
||||
if (!journal) return Promise.resolve(null);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const journal = readJournal();
|
||||
return journal ? journal.items.filter(item => item.state !== 'queued').length : 0;
|
||||
}
|
||||
|
||||
function mountRecovery(button, opener) {
|
||||
const show = () => {
|
||||
const journal = readJournal();
|
||||
button.hidden = !journal;
|
||||
if (journal) button.textContent = 'Resume ' + journal.items.filter(item => item.state !== 'queued').length + ' interrupted';
|
||||
};
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
const outcome = await resume();
|
||||
button.disabled = false;
|
||||
show();
|
||||
if (outcome) {
|
||||
button.previousElementSibling.textContent = outcome.failed.length ?
|
||||
outcome.failed.length + ' still need recovery.' : outcome.queued.length + ' queued · batch recovered.';
|
||||
}
|
||||
});
|
||||
opener.addEventListener('click', show);
|
||||
}
|
||||
|
||||
if (autoMount && typeof document !== 'undefined') mountRecovery(
|
||||
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
|
||||
);
|
||||
return { run, resume, pending, mountRecovery };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|
||||
+73
-17
@@ -15,6 +15,15 @@
|
||||
return current;
|
||||
};
|
||||
|
||||
filterCommands.searchUrl = function searchUrl(query, page, scope, continuation) {
|
||||
const streamPages = continuation ?
|
||||
(continuation.issues ? '&issues_page=' + encodeURIComponent(continuation.issues) : '') +
|
||||
(continuation.pulls ? '&pulls_page=' + encodeURIComponent(continuation.pulls) : '') : '';
|
||||
return 'api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page +
|
||||
'&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state) +
|
||||
(scope.repository ? '&repository=' + encodeURIComponent(scope.repository) : '') + streamPages;
|
||||
};
|
||||
|
||||
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
|
||||
const search = options.search;
|
||||
const onState = options.onState;
|
||||
@@ -22,6 +31,46 @@
|
||||
let timer = null;
|
||||
let generation = 0;
|
||||
let activeController = null;
|
||||
let scope = { kind:'all', state:'all' };
|
||||
let state = { status: 'idle', query: '', items: [], more: false, next: 1, scope };
|
||||
|
||||
function publish(next) {
|
||||
state = next;
|
||||
onState(next);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function requestPage(query, page, current, append, continuation) {
|
||||
const requestController = new AbortController();
|
||||
activeController = requestController;
|
||||
try {
|
||||
const requestScope = { ...scope };
|
||||
const result = await search(
|
||||
query, requestController.signal, page, requestScope, continuation
|
||||
);
|
||||
const partial = result.partial === true;
|
||||
const incoming = result.items || result;
|
||||
const combined = append ? state.items.concat(incoming) : incoming;
|
||||
const items = [...new Map((combined || []).map(item =>
|
||||
[`${item.kind}:${item.repository}:${item.number}`, item]
|
||||
)).values()];
|
||||
if (current === generation) return publish({
|
||||
status: 'ready', query, items, partial,
|
||||
more: !!result.has_more,
|
||||
next: result.next_page,
|
||||
continuation:result.continuation,
|
||||
failedStreams:result.failed_streams || [],
|
||||
scope:requestScope,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (current === generation) return publish(append
|
||||
? { ...state, status: 'ready' }
|
||||
: { status: 'error', query, items: [], error, scope:{ ...scope } });
|
||||
} finally {
|
||||
if (activeController === requestController) activeController = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setQuery(value) {
|
||||
@@ -32,25 +81,32 @@
|
||||
if (activeController !== null) activeController.abort();
|
||||
activeController = null;
|
||||
if (query.length < 2) {
|
||||
onState({ status: 'idle', query, items: [] });
|
||||
publish({ status: 'idle', query, items: [], more: false, next: 1, scope:{ ...scope } });
|
||||
return;
|
||||
}
|
||||
onState({ status: 'loading', query, items: [] });
|
||||
timer = setTimeout(async () => {
|
||||
const requestController = new AbortController();
|
||||
activeController = requestController;
|
||||
try {
|
||||
const result = await search(query, requestController.signal);
|
||||
const items = Array.isArray(result) ? result : result.items;
|
||||
const partial = !Array.isArray(result) && result.partial === true;
|
||||
if (current === generation) onState({ status: 'ready', query, items, partial });
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (current === generation) onState({ status: 'error', query, items: [], error });
|
||||
} finally {
|
||||
if (activeController === requestController) activeController = null;
|
||||
}
|
||||
}, delay);
|
||||
publish({ status: 'loading', query, items: [], more: false, next: 1, scope:{ ...scope } });
|
||||
timer = setTimeout(() => requestPage(query, 1, current, false), delay);
|
||||
},
|
||||
setScope(value) {
|
||||
const repository = typeof value?.repository === 'string' &&
|
||||
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
|
||||
? value.repository.trim() : '';
|
||||
const next = {
|
||||
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
|
||||
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
|
||||
...(repository ? { repository } : {}),
|
||||
};
|
||||
if (next.kind === scope.kind && next.state === scope.state &&
|
||||
(next.repository || '') === (scope.repository || '')) return;
|
||||
const query = state.query;
|
||||
scope = next;
|
||||
this.setQuery(query);
|
||||
},
|
||||
loadMore() {
|
||||
if (state.status !== 'ready' || !state.more || activeController) return Promise.resolve(state);
|
||||
const current = generation;
|
||||
const page = state.next;
|
||||
return requestPage(state.query, page, current, true, state.continuation);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,6 +37,8 @@ function createContextPoller({
|
||||
let retainedSnapshot = null;
|
||||
let nextDelayMs = intervalMs;
|
||||
let failureStreak = 0;
|
||||
let nextRetryAt = null;
|
||||
let lastSuccessAt = null;
|
||||
|
||||
function snapshotDelay(snapshot) {
|
||||
const freshness = snapshot && snapshot.freshness;
|
||||
@@ -69,8 +71,10 @@ function createContextPoller({
|
||||
function schedule(delayMs = intervalMs) {
|
||||
cancelTimer();
|
||||
if (stopped || isHidden()) return;
|
||||
nextRetryAt = Date.now() + delayMs;
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
nextRetryAt = null;
|
||||
refresh();
|
||||
}, delayMs);
|
||||
}
|
||||
@@ -125,6 +129,7 @@ function createContextPoller({
|
||||
.then((snapshot) => {
|
||||
if (activeRequest !== requestState) return retainedSnapshot;
|
||||
failureStreak = 0;
|
||||
lastSuccessAt = Date.now();
|
||||
nextDelayMs = snapshotDelay(snapshot);
|
||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||
@@ -165,6 +170,14 @@ function createContextPoller({
|
||||
start: refresh,
|
||||
refresh,
|
||||
setVisible,
|
||||
getState() {
|
||||
return {
|
||||
refreshing: Boolean(activeRequest),
|
||||
nextRetryAt,
|
||||
lastSuccessAt,
|
||||
failureStreak,
|
||||
};
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelTimer();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
function createConversationActionHydrator({ load, activate }) {
|
||||
let actions = null;
|
||||
let pending = null;
|
||||
const wiredRoots = new WeakSet();
|
||||
|
||||
function ensure() {
|
||||
if (actions) return Promise.resolve(actions);
|
||||
if (!pending) {
|
||||
pending = load().then(() => {
|
||||
actions = activate();
|
||||
return actions;
|
||||
}).catch(error => {
|
||||
pending = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
function show({ root, state, paint, wire, retry }) {
|
||||
if (actions) {
|
||||
retry.hidden = true;
|
||||
if (!wiredRoots.has(root)) {
|
||||
wire(actions);
|
||||
wiredRoots.add(root);
|
||||
}
|
||||
paint(state, actions);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
paint(state, null);
|
||||
retry.hidden = true;
|
||||
return ensure().then(controller => {
|
||||
if (!wiredRoots.has(root)) {
|
||||
wire(controller);
|
||||
wiredRoots.add(root);
|
||||
}
|
||||
paint(state, controller);
|
||||
return true;
|
||||
}).catch(() => {
|
||||
retry.hidden = false;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return { show, ready: () => Boolean(actions) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationActionHydrator;
|
||||
@@ -5,7 +5,40 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
|
||||
return todayWork.read().length < todayWork.limit;
|
||||
}
|
||||
|
||||
function complete(issue) {
|
||||
function capacity(value) {
|
||||
if (String(value ?? '').trim() === '') return { valid: false, reason: 'required' };
|
||||
const minutes = Number(value);
|
||||
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) {
|
||||
return { valid: false, reason: 'range' };
|
||||
}
|
||||
const plan = todayWork.planning();
|
||||
const capacityMinutes = plan.capacity_minutes;
|
||||
if (!Number.isInteger(capacityMinutes)) {
|
||||
return { valid: true, minutes, capacityMinutes: null, plannedMinutes: null,
|
||||
remainingMinutes: null, projectedMinutes: null, fits: true };
|
||||
}
|
||||
const plannedMinutes = todayWork.read().reduce(
|
||||
(total, identity) => total + (Number.isInteger(plan.estimates[identity]) ? plan.estimates[identity] : 0), 0
|
||||
);
|
||||
const remainingMinutes = capacityMinutes - plannedMinutes;
|
||||
const projectedMinutes = remainingMinutes - minutes;
|
||||
return { valid: true, minutes, capacityMinutes, plannedMinutes, remainingMinutes,
|
||||
projectedMinutes, fits: projectedMinutes >= 0 };
|
||||
}
|
||||
|
||||
function capacityMessage(result) {
|
||||
if (!result.valid) return result.reason === 'required'
|
||||
? 'Required only for Create & start.'
|
||||
: 'Use a whole number from 5 to 1440 minutes.';
|
||||
if (result.capacityMinutes === null) {
|
||||
return result.minutes + ' min · Set a Today budget to see remaining time.';
|
||||
}
|
||||
return result.fits
|
||||
? result.minutes + ' min · ' + result.projectedMinutes + ' min will remain in Today.'
|
||||
: result.minutes + ' min · ' + Math.abs(result.projectedMinutes) + ' min over Today capacity.';
|
||||
}
|
||||
|
||||
function complete(issue, estimateMinutes) {
|
||||
const identity = todayWork.identity(issue);
|
||||
if (!identity || completed.has(identity)) return 'exists';
|
||||
const added = todayWork.add(issue);
|
||||
@@ -17,10 +50,25 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
|
||||
announce('Created, but Today could not be saved on this device.');
|
||||
return 'unavailable';
|
||||
}
|
||||
let estimatedPlan = null;
|
||||
if (Number.isInteger(estimateMinutes)) {
|
||||
estimatedPlan = todayWork.planning();
|
||||
estimatedPlan.estimates[identity] = estimateMinutes;
|
||||
if (!todayWork.replacePlanning(estimatedPlan)) {
|
||||
announce('Created and added to Today, but its estimate could not be saved on this device.');
|
||||
return 'estimate-unavailable';
|
||||
}
|
||||
}
|
||||
if (!todaySync.enqueue('add', identity)) {
|
||||
announce('Created and saved to Today on this device, but account sync is unavailable.');
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
if (estimatedPlan) {
|
||||
if (!todaySync.enqueueConfiguration(estimatedPlan.capacity_minutes, estimatedPlan.estimates)) {
|
||||
announce('Created and estimated on this device, but Today plan sync is unavailable.');
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
}
|
||||
completed.add(identity);
|
||||
refresh();
|
||||
todaySync.flush();
|
||||
@@ -30,7 +78,7 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
|
||||
return 'started';
|
||||
}
|
||||
|
||||
return { available, complete };
|
||||
return { available, capacity, capacityMessage, complete };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart;
|
||||
@@ -19,21 +19,165 @@ function normalizeSharedContent(value = {}) {
|
||||
return { title, body };
|
||||
}
|
||||
|
||||
function buildRelatedDraft(value = {}, template = null) {
|
||||
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(value.repository || ''))
|
||||
? String(value.repository) : '';
|
||||
const labelIds = Array.from(new Set((Array.isArray(value.labelIds) ? value.labelIds : [])
|
||||
.filter(id => Number.isInteger(id) && id > 0))).slice(0, 20);
|
||||
const draft = {repository, title:'', body:'', labelIds};
|
||||
const milestoneId = Number(value.milestoneId);
|
||||
if (Number.isInteger(milestoneId) && milestoneId > 0) draft.milestoneId = milestoneId;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(String(value.dueDate || ''))) draft.dueDate = String(value.dueDate);
|
||||
if (value.unassigned === true) draft.unassigned = true;
|
||||
else if (/^[A-Za-z0-9_.-]+$/.test(String(value.assignee || ''))) {
|
||||
draft.assignee = String(value.assignee);
|
||||
draft.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
const templateId = String(value.templateId || '').slice(0, 80);
|
||||
if (templateId && template && String(template.id || '') === templateId) {
|
||||
draft.templateId = templateId;
|
||||
draft.templateName = String(value.templateName || template.name || 'Issue template').trim().slice(0, 80);
|
||||
draft.capturedBody = '';
|
||||
draft.body = String(template.body || '').trim().slice(0, 9000);
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
|
||||
const NO_OWNER = '__unassigned__';
|
||||
const select = documentRef.querySelector('#create-issue-assignee');
|
||||
const status = documentRef.querySelector('#create-issue-assignee-status');
|
||||
const getRepository = () => documentRef.querySelector('#create-issue-repository').value;
|
||||
const loadOwners = repository => issueCapture.loadOwners(repository);
|
||||
let ownerRequest = 0;
|
||||
function reset(repository, selected = {}) {
|
||||
select.replaceChildren();
|
||||
const me = documentRef.createElement('option');
|
||||
me.value = '';
|
||||
me.textContent = 'Me';
|
||||
select.appendChild(me);
|
||||
const noOwner = documentRef.createElement('option');
|
||||
noOwner.value = NO_OWNER;
|
||||
noOwner.textContent = 'No owner';
|
||||
select.appendChild(noOwner);
|
||||
if (selected.unassigned === true) select.value = NO_OWNER;
|
||||
if (selected.assignee) {
|
||||
const option = documentRef.createElement('option');
|
||||
option.value = selected.assignee;
|
||||
option.textContent = (selected.assigneeName || selected.assignee) + ' (@' + selected.assignee + ')';
|
||||
option.dataset.name = selected.assigneeName || selected.assignee;
|
||||
select.appendChild(option);
|
||||
select.value = selected.assignee;
|
||||
}
|
||||
select.dataset.repository = '';
|
||||
status.textContent = repository ? 'Open the owner picker to load eligible teammates.' : 'Choose a repository first.';
|
||||
}
|
||||
|
||||
async function load(repository) {
|
||||
if (!repository || select.dataset.repository === repository) return;
|
||||
const request = ++ownerRequest;
|
||||
const selected = select.value;
|
||||
const selectedName = select.selectedOptions?.[0]?.dataset.name || '';
|
||||
status.textContent = 'Loading eligible teammates…';
|
||||
try {
|
||||
const owners = await loadOwners(repository);
|
||||
if (request !== ownerRequest || getRepository() !== repository) return;
|
||||
const selectedStillEligible = owners.some(owner => owner?.login === selected);
|
||||
reset(repository, selected === NO_OWNER ? {unassigned:true} :
|
||||
(selectedStillEligible ? {assignee:selected, assigneeName:selectedName} : {}));
|
||||
owners.forEach(owner => {
|
||||
if (!owner?.login || owner.login === selected) return;
|
||||
const option = documentRef.createElement('option');
|
||||
option.value = owner.login;
|
||||
option.textContent = (owner.name || owner.login) + ' (@' + owner.login + ')';
|
||||
option.dataset.name = owner.name || owner.login;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.dataset.repository = repository;
|
||||
status.textContent = owners.length ? 'Choose yourself, no owner, or an eligible teammate.' :
|
||||
'Choose yourself or no owner; no eligible teammates are available.';
|
||||
} catch (_error) {
|
||||
if (request !== ownerRequest || getRepository() !== repository) return;
|
||||
status.textContent = 'Teammates could not be loaded. The issue will stay assigned to you.';
|
||||
}
|
||||
}
|
||||
function updateActions(hasRepository, hasBlockers, canStart) {
|
||||
const submit = documentRef.querySelector('#submit-new-issue');
|
||||
const start = documentRef.querySelector('#create-and-start-issue');
|
||||
const hasNoOwner = select.value === NO_OWNER;
|
||||
const hasTeammateOwner = Boolean(select.value) && !hasNoOwner;
|
||||
submit.disabled = !hasRepository;
|
||||
submit.textContent = hasNoOwner ? 'Create unassigned' :
|
||||
(hasTeammateOwner ? 'Create & assign' : 'Create & assign to me');
|
||||
start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || hasNoOwner || !canStart;
|
||||
start.title = hasBlockers ? 'Blocked work cannot start until its blockers are complete.' :
|
||||
(hasNoOwner ? 'No owner work cannot be added to your Today queue.' :
|
||||
(hasTeammateOwner ? 'Work assigned to a teammate cannot be added to your Today queue.' : ''));
|
||||
}
|
||||
function fields() {
|
||||
return {
|
||||
assignee: select.value === NO_OWNER ? '' : select.value,
|
||||
assigneeName: select.value === NO_OWNER ? '' : (select.selectedOptions?.[0]?.dataset.name || ''),
|
||||
unassigned: select.value === NO_OWNER,
|
||||
};
|
||||
}
|
||||
function draft(labelIds, blockers, trim = false) {
|
||||
const value = id => documentRef.querySelector(id).value;
|
||||
const clean = input => trim ? input.trim() : input;
|
||||
return {
|
||||
repository:value('#create-issue-repository'),
|
||||
title:clean(value('#create-issue-title')), body:clean(value('#create-issue-body')),
|
||||
labelIds, milestoneId:Number(value('#create-issue-milestone')) || null,
|
||||
dueDate:value('#create-issue-due-date'), ...fields(), blockers,
|
||||
};
|
||||
}
|
||||
select.addEventListener('focus', () => {
|
||||
const repository = getRepository();
|
||||
if (repository) load(repository);
|
||||
});
|
||||
select.addEventListener('change', onChange);
|
||||
return { reset, load, updateActions, fields, draft };
|
||||
}
|
||||
|
||||
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
|
||||
let pending = null;
|
||||
let issueTemplates = [];
|
||||
let activeTemplate = null;
|
||||
let templateRequest = 0;
|
||||
let duplicateRequest = 0;
|
||||
let repositorySearchRequest = 0;
|
||||
let filingMetadataRequest = 0;
|
||||
let blockerSearchRequest = 0;
|
||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||
let acknowledgedDuplicateKey = '';
|
||||
const repositoryPageRequests = new Map();
|
||||
const safeLabelIds = value => Array.from(new Set(
|
||||
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||
)).slice(0, 20);
|
||||
const safeBlockers = value => {
|
||||
const seen = new Set();
|
||||
return (Array.isArray(value) ? value : []).reduce((items, blocker) => {
|
||||
const repository = String(blocker?.repository || '').trim();
|
||||
const number = Number(blocker?.number);
|
||||
const key = repository + '#' + number;
|
||||
if (items.length >= 5 || seen.has(key) ||
|
||||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
||||
!Number.isInteger(number) || number < 1) return items;
|
||||
seen.add(key);
|
||||
items.push({repository, number,
|
||||
title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)});
|
||||
return items;
|
||||
}, []);
|
||||
};
|
||||
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
|
||||
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
|
||||
const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
|
||||
const safeAssignee = value => /^[A-Za-z0-9_.-]+$/.test(String(value || '')) ? String(value) : '';
|
||||
const safeEstimate = value => Number.isInteger(Number(value)) && Number(value) >= 5 && Number(value) <= 1440 ?
|
||||
Number(value) : null;
|
||||
|
||||
function loadStored() {
|
||||
try {
|
||||
@@ -46,10 +190,28 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
labelIds: safeLabelIds(parsed.labelIds),
|
||||
operationId: String(parsed.operationId || '').slice(0, 128),
|
||||
};
|
||||
if (parsed.unassigned === true) draft.unassigned = true;
|
||||
if (typeof parsed.templateName === 'string' && parsed.templateName.trim()) {
|
||||
draft.templateName = parsed.templateName.trim().slice(0, 80);
|
||||
draft.templateId = String(parsed.templateId || '').slice(0, 80);
|
||||
draft.capturedBody = String(parsed.capturedBody || '').slice(0, 10000);
|
||||
}
|
||||
const assignee = safeAssignee(parsed.assignee);
|
||||
if (assignee) {
|
||||
draft.assignee = assignee;
|
||||
draft.assigneeName = String(parsed.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
const milestoneId = safeMilestoneId(parsed.milestoneId);
|
||||
const dueDate = safeDueDate(parsed.dueDate);
|
||||
if (milestoneId !== null) draft.milestoneId = milestoneId;
|
||||
if (dueDate) draft.dueDate = dueDate;
|
||||
const estimateMinutes = safeEstimate(parsed.estimateMinutes);
|
||||
if (estimateMinutes !== null) draft.estimateMinutes = estimateMinutes;
|
||||
if (['create', 'create-and-start'].includes(parsed.completionIntent)) {
|
||||
draft.completionIntent = parsed.completionIntent;
|
||||
}
|
||||
const blockers = safeBlockers(parsed.blockers);
|
||||
if (blockers.length) draft.blockers = blockers;
|
||||
return draft;
|
||||
} catch (_error) {
|
||||
return {...emptyDraft(), operationId: ''};
|
||||
@@ -69,13 +231,33 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: safeLabelIds(draft?.labelIds),
|
||||
};
|
||||
if (draft?.unassigned === true) safe.unassigned = true;
|
||||
if (typeof draft?.templateName === 'string' && draft.templateName.trim()) {
|
||||
safe.templateName = draft.templateName.trim().slice(0, 80);
|
||||
safe.templateId = String(draft.templateId || '').slice(0, 80);
|
||||
safe.capturedBody = String(draft.capturedBody || '').slice(0, 10000);
|
||||
}
|
||||
const assignee = safe.unassigned ? '' : safeAssignee(draft?.assignee);
|
||||
if (assignee) {
|
||||
safe.assignee = assignee;
|
||||
safe.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
const milestoneId = safeMilestoneId(draft?.milestoneId);
|
||||
const dueDate = safeDueDate(draft?.dueDate);
|
||||
if (milestoneId !== null) safe.milestoneId = milestoneId;
|
||||
if (dueDate) safe.dueDate = dueDate;
|
||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
|
||||
const estimateMinutes = safeEstimate(draft?.estimateMinutes);
|
||||
if (estimateMinutes !== null) safe.estimateMinutes = estimateMinutes;
|
||||
if (['create', 'create-and-start'].includes(draft?.completionIntent)) {
|
||||
safe.completionIntent = draft.completionIntent;
|
||||
}
|
||||
const blockers = safeBlockers(draft?.blockers);
|
||||
if (blockers.length) safe.blockers = blockers;
|
||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName', 'unassigned',
|
||||
'templateName', 'templateId', 'capturedBody', 'estimateMinutes', 'completionIntent']
|
||||
.every(key => (previous[key] || '') === (safe[key] || '')) &&
|
||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
|
||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) &&
|
||||
JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []);
|
||||
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
|
||||
return safe;
|
||||
}
|
||||
@@ -126,6 +308,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function pendingFollowUp() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(followUpStorageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(parsed.repository || '')) ? String(parsed.repository) : '';
|
||||
const title = String(parsed.title || '').trim().slice(0, 240);
|
||||
const body = String(parsed.body || '').trim().slice(0, 9500);
|
||||
return title || body ? {repository, title, body, labelIds: []} : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function acceptFollowUp() {
|
||||
const followUp = pendingFollowUp();
|
||||
if (!followUp) return loadDraft();
|
||||
const accepted = saveDraft(followUp);
|
||||
try { storage.removeItem(followUpStorageKey); }
|
||||
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function discardFollowUp() {
|
||||
try { storage.removeItem(followUpStorageKey); }
|
||||
catch (_error) { /* The existing capture remains authoritative. */ }
|
||||
}
|
||||
|
||||
function stageFollowUp(value) {
|
||||
const followUp = {repository: String(value?.repository || ''), title: String(value?.title || ''), body: String(value?.body || '')};
|
||||
if (!followUp.title && !followUp.body) return {status: 'empty'};
|
||||
try { storage.setItem(followUpStorageKey, JSON.stringify(followUp)); }
|
||||
catch (_error) { /* The in-page flow can still continue. */ }
|
||||
const existing = loadDraft();
|
||||
if (existing.title || existing.body) return {status: 'conflict'};
|
||||
acceptFollowUp();
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function loadLabels(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||
@@ -145,6 +363,266 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
);
|
||||
}
|
||||
|
||||
function loadOwners(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return fetchJson('api/v1/repos/' + encoded + '/issue-assignees').then(owners =>
|
||||
Array.isArray(owners) ? owners : []
|
||||
);
|
||||
}
|
||||
|
||||
function loadTemplates(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return fetchJson('api/v1/repos/' + encoded + '/issue-templates').then(templates =>
|
||||
Array.isArray(templates) ? templates.slice(0, 20) : []
|
||||
);
|
||||
}
|
||||
|
||||
async function loadFilingMetadata(repository) {
|
||||
const selectedRepository = String(repository || '');
|
||||
const encoded = selectedRepository.split('/').map(encodeURIComponent).join('/');
|
||||
const request = ++filingMetadataRequest;
|
||||
const payload = await fetchJson(
|
||||
'api/v1/repos/' + encoded + '/issue-filing-metadata'
|
||||
);
|
||||
if (request !== filingMetadataRequest) {
|
||||
return {status:'stale', repository:selectedRepository};
|
||||
}
|
||||
const section = name => {
|
||||
const value = payload?.[name];
|
||||
const available = value?.available === true;
|
||||
return {
|
||||
available,
|
||||
items:available && Array.isArray(value?.items) ? value.items : [],
|
||||
...(!available && typeof value?.error === 'string' ? {error:value.error} : {}),
|
||||
};
|
||||
};
|
||||
return {
|
||||
status:'ready', repository:selectedRepository,
|
||||
labels:section('labels'), milestones:section('milestones'),
|
||||
templates:section('templates'),
|
||||
};
|
||||
}
|
||||
|
||||
function applyTemplate(draft, template, availableLabels = []) {
|
||||
const source = {...(draft || {})};
|
||||
const capturedBody = source.templateName ? String(source.capturedBody || '') : String(source.body || '');
|
||||
if (!template) {
|
||||
delete source.templateName;
|
||||
delete source.templateId;
|
||||
delete source.capturedBody;
|
||||
return {...source, body: capturedBody};
|
||||
}
|
||||
const scaffold = String(template.body || '').trim().slice(0, 9000);
|
||||
const body = [capturedBody.trim(), scaffold].filter(Boolean).join('\n\n---\n\n').slice(0, 10000);
|
||||
const validByName = new Map((Array.isArray(availableLabels) ? availableLabels : [])
|
||||
.filter(label => Number.isInteger(label?.id) && typeof label?.name === 'string')
|
||||
.map(label => [label.name.toLowerCase(), label.id]));
|
||||
const templateLabelIds = (Array.isArray(template.labels) ? template.labels : [])
|
||||
.map(name => validByName.get(String(name).toLowerCase())).filter(Boolean);
|
||||
return {
|
||||
...source,
|
||||
title: source.title || String(template.title || '').trim().slice(0, 255),
|
||||
body,
|
||||
labelIds: safeLabelIds([...(source.labelIds || []), ...templateLabelIds]),
|
||||
templateId: String(template.id || '').slice(0, 80),
|
||||
templateName: String(template.name || 'Issue template').trim().slice(0, 80),
|
||||
capturedBody,
|
||||
};
|
||||
}
|
||||
|
||||
function setTemplateState(draft) {
|
||||
activeTemplate = draft?.templateName ? {
|
||||
templateName:draft.templateName, templateId:draft.templateId,
|
||||
capturedBody:draft.capturedBody || '',
|
||||
} : null;
|
||||
}
|
||||
|
||||
function templateFields(draft) {
|
||||
return activeTemplate ? {...draft, ...activeTemplate} : draft;
|
||||
}
|
||||
|
||||
function restoreTemplate(draft, availableLabels) {
|
||||
const restored = applyTemplate(templateFields(draft), null, availableLabels);
|
||||
activeTemplate = null;
|
||||
return restored;
|
||||
}
|
||||
|
||||
async function loadTemplateOptions(repository, elements, selectedId = '') {
|
||||
const request = ++templateRequest;
|
||||
issueTemplates = [];
|
||||
elements.select.innerHTML = '<option value="">Blank issue</option>';
|
||||
elements.field.hidden = true;
|
||||
if (!repository) return;
|
||||
elements.status.textContent = 'Loading issue types…';
|
||||
try {
|
||||
const templates = await loadTemplates(repository);
|
||||
if (request !== templateRequest || elements.getRepository() !== repository) return;
|
||||
issueTemplates = templates;
|
||||
templates.forEach(template => {
|
||||
const option = elements.document.createElement('option');
|
||||
option.value = template.id;
|
||||
option.textContent = template.name;
|
||||
elements.select.appendChild(option);
|
||||
});
|
||||
elements.field.hidden = templates.length === 0;
|
||||
elements.select.value = templates.some(template => template.id === selectedId) ? selectedId : '';
|
||||
elements.status.textContent = templates.length ?
|
||||
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
|
||||
} catch (_error) {
|
||||
if (request !== templateRequest || elements.getRepository() !== repository) return;
|
||||
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
|
||||
}
|
||||
}
|
||||
|
||||
function selectTemplate(identifier, draft, availableLabels) {
|
||||
const template = issueTemplates.find(item => item.id === identifier) || null;
|
||||
const applied = applyTemplate(templateFields(draft), template, availableLabels);
|
||||
activeTemplate = template ? {
|
||||
templateId:applied.templateId, templateName:applied.templateName,
|
||||
capturedBody:applied.capturedBody,
|
||||
} : null;
|
||||
return {draft: applied, template};
|
||||
}
|
||||
|
||||
function relatedDraft(draft) {
|
||||
const template = issueTemplates.find(item => String(item?.id || '') === String(draft?.templateId || '')) || null;
|
||||
return buildRelatedDraft(draft, template);
|
||||
}
|
||||
|
||||
function bindTemplatePicker(elements, callbacks) {
|
||||
let labels = [];
|
||||
elements.select.addEventListener('change', event => {
|
||||
const {draft, template} = selectTemplate(event.target.value, callbacks.getDraft(), labels);
|
||||
callbacks.setDraft(draft);
|
||||
const selected = new Set(draft.labelIds || []);
|
||||
elements.labelInputs().forEach(input => { input.checked = selected.has(Number(input.value)); });
|
||||
elements.status.textContent = template ?
|
||||
template.name + (template.about ? ' — ' + template.about : '') : 'Blank issue selected.';
|
||||
callbacks.changed();
|
||||
});
|
||||
return {
|
||||
fields:templateFields,
|
||||
reset:setTemplateState,
|
||||
setLabels(value) { labels = Array.isArray(value) ? value : []; },
|
||||
load(repository, selectedId) {
|
||||
return loadTemplateOptions(repository, elements, selectedId);
|
||||
},
|
||||
render(repository, section, selectedId = '') {
|
||||
++templateRequest;
|
||||
issueTemplates = [];
|
||||
elements.select.innerHTML = '<option value="">Blank issue</option>';
|
||||
elements.field.hidden = true;
|
||||
if (!repository || elements.getRepository() !== repository) return;
|
||||
if (section?.available !== true) {
|
||||
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
|
||||
return;
|
||||
}
|
||||
issueTemplates = Array.isArray(section.items) ? section.items.slice(0, 20) : [];
|
||||
issueTemplates.forEach(template => {
|
||||
const option = elements.document.createElement('option');
|
||||
option.value = template.id;
|
||||
option.textContent = template.name;
|
||||
elements.select.appendChild(option);
|
||||
});
|
||||
elements.field.hidden = issueTemplates.length === 0;
|
||||
elements.select.value = issueTemplates.some(template => template.id === selectedId) ? selectedId : '';
|
||||
elements.status.textContent = issueTemplates.length ?
|
||||
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
|
||||
},
|
||||
changeRepository(draft) {
|
||||
const restored = restoreTemplate(draft, labels);
|
||||
callbacks.setDraft(restored);
|
||||
return restored;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bindFilingMetadata(elements, templatePicker) {
|
||||
function renderLabels(repository, section, selectedIds = []) {
|
||||
elements.labelList.replaceChildren();
|
||||
if (!repository) {
|
||||
elements.labelStatus.textContent = 'Choose a repository to load labels.';
|
||||
return;
|
||||
}
|
||||
if (section?.available !== true) {
|
||||
templatePicker.setLabels([]);
|
||||
elements.labelStatus.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
|
||||
return;
|
||||
}
|
||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||
const labels = (Array.isArray(section.items) ? section.items : []).slice().sort((left, right) =>
|
||||
Number(priorities.has(String(right?.name || '').toLowerCase())) -
|
||||
Number(priorities.has(String(left?.name || '').toLowerCase()))
|
||||
);
|
||||
templatePicker.setLabels(labels);
|
||||
const selected = new Set(selectedIds.map(Number));
|
||||
labels.forEach(label => {
|
||||
const option = elements.document.createElement('label');
|
||||
option.className = 'create-issue-label-option';
|
||||
const input = elements.document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.name = 'create-issue-label';
|
||||
input.value = String(Number(label.id));
|
||||
input.checked = selected.has(Number(label.id));
|
||||
const name = elements.document.createElement('span');
|
||||
name.textContent = label.name;
|
||||
option.append(input, name);
|
||||
elements.labelList.appendChild(option);
|
||||
});
|
||||
elements.labelStatus.textContent = labels.length ?
|
||||
'Select labels to triage this issue.' : 'This repository has no labels.';
|
||||
}
|
||||
|
||||
function renderMilestones(repository, section, selectedId = null) {
|
||||
elements.milestoneSelect.replaceChildren();
|
||||
const blank = elements.document.createElement('option');
|
||||
blank.value = '';
|
||||
blank.textContent = 'No milestone';
|
||||
elements.milestoneSelect.appendChild(blank);
|
||||
if (!repository) {
|
||||
elements.milestoneStatus.textContent = 'Choose a repository to load milestones.';
|
||||
return;
|
||||
}
|
||||
if (section?.available !== true) {
|
||||
elements.milestoneStatus.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
|
||||
return;
|
||||
}
|
||||
const milestones = Array.isArray(section.items) ? section.items : [];
|
||||
milestones.forEach(milestone => {
|
||||
const option = elements.document.createElement('option');
|
||||
option.value = String(Number(milestone.id));
|
||||
option.textContent = milestone.title;
|
||||
elements.milestoneSelect.appendChild(option);
|
||||
});
|
||||
if (selectedId) elements.milestoneSelect.value = String(selectedId);
|
||||
elements.milestoneStatus.textContent = milestones.length ?
|
||||
'Choose the release lane for this issue.' : 'This repository has no open milestones.';
|
||||
}
|
||||
|
||||
async function load(repository, selected = {}) {
|
||||
renderLabels(repository, null, selected.labelIds || []);
|
||||
renderMilestones(repository, null, selected.milestoneId);
|
||||
templatePicker.render(repository, null, selected.templateId);
|
||||
if (!repository) return;
|
||||
elements.labelStatus.textContent = 'Loading labels…';
|
||||
elements.milestoneStatus.textContent = 'Loading milestones…';
|
||||
elements.templateStatus.textContent = 'Loading issue types…';
|
||||
try {
|
||||
const metadata = await loadFilingMetadata(repository);
|
||||
if (metadata.status !== 'ready' || elements.getRepository() !== repository) return;
|
||||
renderLabels(repository, metadata.labels, selected.labelIds || []);
|
||||
renderMilestones(repository, metadata.milestones, selected.milestoneId);
|
||||
templatePicker.render(repository, metadata.templates, selected.templateId);
|
||||
} catch (_error) {
|
||||
if (elements.getRepository() !== repository) return;
|
||||
renderLabels(repository, {available:false}, selected.labelIds || []);
|
||||
renderMilestones(repository, {available:false}, selected.milestoneId);
|
||||
templatePicker.render(repository, {available:false}, selected.templateId);
|
||||
}
|
||||
}
|
||||
return {load};
|
||||
}
|
||||
|
||||
function loadRepositoryPage(page) {
|
||||
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
||||
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
||||
@@ -179,6 +657,21 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
}
|
||||
|
||||
async function searchBlockers(value) {
|
||||
const query = String(value || '').trim().slice(0, 80);
|
||||
const request = ++blockerSearchRequest;
|
||||
if (query.length < 2) return {status:'idle', items:[]};
|
||||
try {
|
||||
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(query) + '&limit=20');
|
||||
if (request !== blockerSearchRequest) return {status:'stale', items:[]};
|
||||
return {status:'ready', items:(Array.isArray(payload?.items) ? payload.items : [])
|
||||
.filter(item => item?.kind === 'issue' && item?.state === 'open').slice(0, 20)};
|
||||
} catch (error) {
|
||||
if (request !== blockerSearchRequest) return {status:'stale', items:[]};
|
||||
return {status:'failed', items:[], error};
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateKey(draft) {
|
||||
const repository = String(draft?.repository || '').trim();
|
||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||
@@ -239,6 +732,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: saved.title, body: saved.body, label_ids: saved.labelIds,
|
||||
...(saved.unassigned ? {unassigned:true} : {}),
|
||||
...(saved.assignee ? {assignee: saved.assignee} : {}),
|
||||
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
|
||||
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
|
||||
}),
|
||||
@@ -250,13 +745,18 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
|
||||
searchRepositories, findDuplicates,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadTemplates,
|
||||
loadFilingMetadata, applyTemplate, setTemplateState, templateFields, restoreTemplate, loadTemplateOptions,
|
||||
selectTemplate, buildRelatedDraft:relatedDraft, bindTemplatePicker, bindFilingMetadata, loadRepositoryPage,
|
||||
searchRepositories, searchBlockers, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
|
||||
};
|
||||
}
|
||||
|
||||
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
||||
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
|
||||
createIssueCapture.buildRelatedDraft = buildRelatedDraft;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
+251
-10
@@ -5,11 +5,31 @@ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text);
|
||||
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
|
||||
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.live-data-status-sheet[hidden] { display:none; }
|
||||
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
|
||||
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
|
||||
.issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.issue-filing-receipt[hidden] { display:none; }
|
||||
.issue-filing-receipt-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.issue-filing-receipt-panel h2, .issue-filing-receipt-panel h3 { margin:.25rem 0; }
|
||||
.issue-filing-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
.issue-filing-receipt-actions button, .issue-filing-receipt-actions .button-link { box-sizing:border-box; display:flex; align-items:center; justify-content:center; min-width:0; min-height:44px; width:100%; text-align:center; }
|
||||
@media (max-width:359px) { .issue-filing-receipt-actions { grid-template-columns:1fr; } }
|
||||
.live-data-status-feeds { display:grid; gap:8px; margin:14px 0; }
|
||||
.live-data-status-feed { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.live-data-status-feed strong, .live-data-status-feed span { overflow-wrap:anywhere; }
|
||||
.live-data-status-actions { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||
.app-menu { margin-left:auto; }
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||
.notification-undo[hidden] { display:none; }
|
||||
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
|
||||
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.draft-capacity-sheet[hidden] { display:none; }
|
||||
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
@@ -35,6 +55,9 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; }
|
||||
.device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.device-setup-step p { margin:5px 0 0; }
|
||||
.device-setup-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; }
|
||||
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
|
||||
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
|
||||
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
|
||||
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
|
||||
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
|
||||
@@ -74,6 +97,14 @@ main { position: relative; z-index: 1; display: grid; grid-template-columns: 300
|
||||
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
||||
a { color: #60a5fa; text-decoration: none; }
|
||||
.muted { color: #94a3b8; }
|
||||
.empty-work-start { display:grid; gap:12px; margin:12px 0; padding:16px; border:1px solid #31577f; border-radius:14px; background:#0b1b2f; }
|
||||
.empty-work-start[hidden] { display:none; }
|
||||
.empty-work-start p { margin:6px 0 0; }
|
||||
.empty-work-start-actions { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); gap:8px; }
|
||||
.empty-work-start-actions button { min-width:0; min-height:44px; white-space:normal; }
|
||||
@media (max-width:420px) {
|
||||
.empty-work-start-actions { grid-template-columns:1fr; }
|
||||
}
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
||||
.kv .label { color: #94a3b8; }
|
||||
.kv .value { color: #e5e7eb; }
|
||||
@@ -90,11 +121,52 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||||
#cmd-palette.open { display: block; }
|
||||
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.cmd-palette-header-actions { display:flex; gap:8px; }
|
||||
.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
|
||||
.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; }
|
||||
.cmd-search-scope label { font-size:12px; color:#cbd5e1; }
|
||||
.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
|
||||
.cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
|
||||
.cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; }
|
||||
.saved-searches { margin-top:8px; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
|
||||
.saved-search-header, .saved-search-create, .saved-search-row, .saved-search-row-actions { display:flex; align-items:center; gap:8px; }
|
||||
.saved-search-header { justify-content:space-between; }
|
||||
.saved-search-create { margin-top:6px; }
|
||||
.saved-search-create input { flex:1; min-width:0; }
|
||||
.saved-search-list { display:grid; gap:4px; margin-top:6px; }
|
||||
.saved-search-row { justify-content:space-between; border-top:1px solid #1f3a5f; padding-top:4px; }
|
||||
.saved-search-open { flex:1; min-width:0; text-align:left; }
|
||||
.saved-search-action { min-height:44px; }
|
||||
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
|
||||
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
|
||||
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
|
||||
.cmd-select-result { min-height:44px; width:100%; display:grid; grid-template-columns:28px 1fr auto; gap:10px; align-items:center; text-align:left; }
|
||||
.cmd-select-result input { width:22px; height:22px; }
|
||||
.cmd-select-result[disabled] { cursor:not-allowed; opacity:.62; }
|
||||
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||
.search-batch-actions[hidden] { display:none; }
|
||||
.search-batch-actions span { grid-column:1 / -1; }
|
||||
.search-batch-actions button, .search-batch-recovery { min-height:44px; }
|
||||
.search-batch-recovery { width:100%; font-weight:700; }
|
||||
.search-batch-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.search-batch-estimate-review[hidden] { display:none; }
|
||||
.search-batch-estimate-review > div:first-child, #search-batch-estimate-list { display:grid; gap:8px; }
|
||||
.search-batch-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
|
||||
.search-batch-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.search-batch-estimate-review input { min-height:44px; width:76px; }
|
||||
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-batch-estimate-actions button { min-height:44px; }
|
||||
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.search-release-review[hidden] { display:none; }
|
||||
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
|
||||
.search-release-review label { display:grid; gap:6px; }
|
||||
.search-release-review select, .search-release-review input { min-height:44px; width:100%; }
|
||||
.search-release-row { padding:8px 0; border-bottom:1px solid #2a496e; }
|
||||
.search-release-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-release-actions button { min-height:44px; }
|
||||
|
||||
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
|
||||
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
||||
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
|
||||
@@ -205,12 +277,19 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
|
||||
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.queue-finder { display:grid; gap:6px; margin:10px 0; }
|
||||
.queue-finder > label { font-weight:700; }
|
||||
.queue-finder-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.queue-finder input, .queue-finder button { min-height:44px; box-sizing:border-box; }
|
||||
.queue-finder input { min-width:0; width:100%; padding:8px 10px; border:1px solid #31577f; border-radius:10px; background:#08111f; color:var(--text); font:inherit; }
|
||||
.queue-finder button { padding-inline:14px; }
|
||||
#search-older-work { width:100%; }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
|
||||
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.draft-actions button { min-height:44px; width:100%; }
|
||||
.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px; width:100%; }
|
||||
.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.delivery-center h3, .delivery-center p { margin:0; }
|
||||
.delivery-center button { min-height:44px; flex:0 0 auto; }
|
||||
@@ -218,21 +297,38 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; }
|
||||
.draft-section > h3 { grid-column:1/-1; margin:8px 0 0; }
|
||||
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
|
||||
.create-issue-actions button { min-height:44px; width:100%; }
|
||||
.create-issue-actions #create-issue-status { grid-column:1/-1; }
|
||||
.create-issue-estimate { display:grid; gap:6px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#101d31; }
|
||||
.create-issue-estimate > span:first-of-type { display:flex; align-items:center; gap:8px; }
|
||||
.create-issue-estimate input { min-height:44px; width:min(9rem,60vw); }
|
||||
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
|
||||
.draft-filing-session[hidden] { display:none; }
|
||||
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.draft-filing-session-actions button { min-height:44px; width:100%; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; }
|
||||
.agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.agenda-replan-launch p { margin:4px 0 0; }
|
||||
.agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; }
|
||||
.agenda-replan-actions label { grid-column:1/-1; }
|
||||
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; }
|
||||
.protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.protect-today p { margin:4px 0; }
|
||||
.protect-today button { min-height:44px; flex:0 0 auto; }
|
||||
@media(max-width:360px) { .agenda-replan-launch { align-items:stretch; flex-direction:column; } .agenda-replan-actions { grid-template-columns:1fr; } .agenda-replan-actions label { grid-column:auto; } }
|
||||
@media(max-width:430px) { .protect-today { align-items:stretch; flex-direction:column; } .protect-today button { width:100%; } }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.update-selection-controls button { min-height:44px; }
|
||||
.update-selector { min-height:44px; display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
.update-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.selection-scope-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; max-width:100%; }
|
||||
.selection-scope-actions button { min-height:44px; max-width:100%; }
|
||||
.update-selector { min-height:44px; }
|
||||
.work-selector { min-height:44px; }
|
||||
.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
.update-selector input, .work-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.my-work-card.selection-active { border-color:#31577f; }
|
||||
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
@@ -265,6 +361,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.retry-work-route[hidden] { display:none; }
|
||||
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-bulk button { min-height:44px; width:100%; }
|
||||
.my-work-bulk-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.my-work-bulk-actions button { min-height:44px; width:100%; }
|
||||
.my-work-bulk-actions #bulk-mark-read { grid-column:1 / -1; }
|
||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.review-sheet.open { display:flex; }
|
||||
@@ -333,7 +432,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.update-sheet-header button { min-height:44px; }
|
||||
.update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.update-new-activity { scroll-margin-block:18px 96px; border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||
.update-new-activity::before { content:'New activity'; display:inline-block; margin-bottom:6px; padding:2px 7px; border-radius:999px; background:#1d4ed8; color:#fff; font-size:12px; font-weight:700; }
|
||||
.update-new-activity-jump { position:sticky; bottom:92px; z-index:4; min-height:44px; width:100%; margin:10px 0; border-color:#60a5fa; background:#17345a; }
|
||||
.update-reply { display:grid; gap:8px; margin-top:16px; }
|
||||
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
|
||||
.update-reply button { min-height:44px; width:100%; }
|
||||
@@ -350,7 +453,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-ownership-actions button { min-width:0; width:100%; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions #mute-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-more-actions { margin-top:14px; }
|
||||
.update-more-actions > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-decision-bar { display:grid; grid-template-columns:repeat(auto-fit,minmax(76px,1fr)); gap:6px; margin-top:14px; padding:8px; background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.update-decision-bar button, .update-decision-bar summary { min-height:44px; min-width:0; padding-inline:6px; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.issue-sheet.open { display:flex; }
|
||||
@@ -413,6 +521,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
|
||||
#issue-sheet.read-only .issue-comment-composer,
|
||||
#issue-sheet.read-only #issue-planning,
|
||||
#issue-sheet.read-only #issue-handoff,
|
||||
#issue-sheet.read-only #release-issue,
|
||||
#issue-sheet.read-only #close-issue,
|
||||
#issue-sheet.read-only .detail-defer,
|
||||
#issue-sheet.read-only #issue-blockers,
|
||||
#issue-sheet.read-only #load-older-issue-comments,
|
||||
#issue-sheet.read-only #retry-issue-comment-actions { display:none; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
@@ -436,9 +553,38 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.find-work-header-actions { display:flex; gap:8px; }
|
||||
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
|
||||
.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
|
||||
.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.find-work-search input, .find-work-search button { min-height:44px; }
|
||||
.find-work-filters { border:1px solid #2a496e; border-radius:10px; padding:10px; }
|
||||
.find-work-filters summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
|
||||
.find-work-filter-panel, .find-work-filter-options { display:grid; gap:8px; }
|
||||
.find-work-filter-panel fieldset { min-width:0; border:0; padding:6px 0; margin:0; }
|
||||
.find-work-filter-options { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
|
||||
.find-work-filter-option { min-height:44px; display:flex; align-items:center; gap:8px; padding:6px; overflow-wrap:anywhere; }
|
||||
.find-work-filter-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||
#clear-find-work-filters { min-height:44px; width:100%; }
|
||||
.find-work-list { display:grid; gap:10px; }
|
||||
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||
.find-work-select { display:flex; align-items:center; gap:10px; min-height:44px; }
|
||||
.find-work-select input { width:22px; height:22px; }
|
||||
.fill-find-work-today { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-batch-actions { position:sticky; bottom:0; z-index:2; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:12px 0 calc(12px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||
.find-work-batch-actions[hidden] { display:none; }
|
||||
.find-work-batch-actions span { grid-column:1 / -1; }
|
||||
.find-work-batch-actions button { min-height:44px; }
|
||||
.find-work-batch-recovery { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.find-work-estimate-review[hidden] { display:none; }
|
||||
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
|
||||
.find-work-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
|
||||
.find-work-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.find-work-estimate-review input { min-height:44px; width:76px; }
|
||||
.find-work-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.find-work-estimate-actions button { min-height:44px; }
|
||||
.find-work-card button { width:100%; font-weight:700; }
|
||||
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
|
||||
@@ -451,8 +597,23 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
|
||||
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
|
||||
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.search-preview-conversation { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-conversation h2 { margin:0; font-size:1rem; }
|
||||
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||
.search-preview-conversation button { min-height:44px; width:100%; }
|
||||
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-reply[hidden] { display:none; }
|
||||
.search-preview-reply h2 { margin:0; font-size:1rem; }
|
||||
.search-preview-reply textarea { box-sizing:border-box; width:100%; min-height:110px; resize:vertical; scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom)); }
|
||||
.search-preview-reply-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.search-preview-reply-actions button { min-height:44px; }
|
||||
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
|
||||
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
@media (max-width:420px) {
|
||||
.search-preview-primary-actions { grid-template-columns:1fr; }
|
||||
}
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||
.markdown-content > :first-child { margin-top:0; }
|
||||
@@ -478,6 +639,49 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-filing { display:grid; gap:12px; min-width:0; }
|
||||
.create-issue-filing[hidden] { display:none; }
|
||||
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
||||
#create-issue-attachment-preview { grid-template-columns:1fr; }
|
||||
#create-issue-attachment-image { grid-row:auto; width:100%; height:auto; max-height:320px; }
|
||||
.issue-evidence-tray { display:flex; gap:8px; max-width:100%; padding:2px 0 6px; overflow-x:auto; overscroll-behavior-inline:contain; scrollbar-width:thin; }
|
||||
.issue-evidence-tray[hidden] { display:none; }
|
||||
.issue-evidence-thumbnail { position:relative; flex:0 0 64px; min-width:44px; min-height:64px; padding:3px; border:2px solid transparent; border-radius:10px; background:#10213a; }
|
||||
.issue-evidence-thumbnail[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||
.issue-evidence-thumbnail img { display:block; width:56px; height:56px; border-radius:6px; object-fit:cover; }
|
||||
.issue-evidence-thumbnail span { position:absolute; right:3px; bottom:3px; min-width:20px; padding:1px 4px; border-radius:999px; background:#07101d; color:#fff; font-size:12px; text-align:center; }
|
||||
.issue-evidence-note { display:grid; gap:6px; min-width:0; }
|
||||
.issue-evidence-note textarea { box-sizing:border-box; width:100%; min-height:72px; padding:10px; resize:vertical; border:1px solid #2a496e; border-radius:8px; background:#07101d; color:#e5e7eb; font:inherit; }
|
||||
.issue-evidence-note textarea:focus-visible { outline:2px solid #60a5fa; outline-offset:2px; }
|
||||
.issue-evidence-review-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.issue-evidence-review-actions button { min-width:44px; min-height:44px; }
|
||||
.issue-evidence-editor { position:fixed; inset:0; z-index:70; display:grid; place-items:end center; overflow-x:hidden; background:rgba(2,6,15,.92); }
|
||||
.issue-evidence-editor[hidden] { display:none; }
|
||||
.issue-evidence-editor-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; display:grid; gap:12px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); background:#0b1526; border:1px solid #2a496e; border-radius:14px 14px 0 0; }
|
||||
.issue-evidence-editor-panel header { display:flex; align-items:start; justify-content:space-between; gap:12px; }
|
||||
.issue-evidence-editor-panel h3 { margin:0 0 4px; }
|
||||
.issue-evidence-editor canvas { display:block; max-width:100%; width:100%; max-height:60dvh; object-fit:contain; touch-action:none; border:1px solid #365b86; border-radius:8px; background:#020617; }
|
||||
.issue-evidence-editor-tools { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
|
||||
.issue-evidence-editor button { min-width:44px; min-height:44px; }
|
||||
.issue-evidence-editor button[aria-pressed="true"] { border-color:#60a5fa; background:#173a64; color:#fff; }
|
||||
.issue-evidence-editor-apply { width:100%; }
|
||||
@media(max-width:320px) { .issue-evidence-editor-panel { padding:12px; } .issue-evidence-editor-tools { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
.issue-filing-review { position:fixed; inset:0; z-index:72; display:grid; place-items:end center; overflow-x:hidden; background:rgba(2,6,15,.92); }
|
||||
.issue-filing-review[hidden] { display:none; }
|
||||
.issue-filing-review-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.issue-filing-review-panel h2, .issue-filing-review-panel h3, .issue-filing-review-panel p { margin:.2rem 0; }
|
||||
.issue-filing-review-summary { display:grid; gap:10px; margin:0; min-width:0; }
|
||||
.issue-filing-review-summary > div { display:grid; gap:4px; padding:10px; border:1px solid #2a496e; border-radius:10px; min-width:0; }
|
||||
.issue-filing-review-summary dt { color:#94a3b8; font-size:12px; font-weight:700; text-transform:uppercase; }
|
||||
.issue-filing-review-summary dd { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.issue-filing-review ol { display:grid; grid-template-columns:repeat(auto-fit,minmax(72px,1fr)); gap:8px; margin:0; padding:0; list-style:none; overflow-wrap:anywhere; }
|
||||
.issue-filing-evidence-selector { min-height:44px; width:100%; padding:4px; border:2px solid #31577f; border-radius:10px; background:#08111f; }
|
||||
.issue-filing-evidence-selector[aria-pressed="true"] { border-color:#93c5fd; box-shadow:0 0 0 2px rgba(96,165,250,.35); }
|
||||
.issue-filing-evidence-selector img { display:block; width:100%; height:64px; object-fit:cover; border-radius:6px; }
|
||||
.issue-filing-evidence-preview { display:grid; gap:8px; margin:10px 0 0; padding:10px; border:1px solid #31577f; border-radius:12px; background:#020617; }
|
||||
.issue-filing-evidence-preview[hidden] { display:none; }
|
||||
.issue-filing-evidence-preview > img { display:block; width:100%; max-height:42dvh; object-fit:contain; border-radius:8px; background:#000; }
|
||||
.issue-filing-evidence-preview figcaption { display:grid; gap:3px; min-width:0; overflow-wrap:anywhere; }
|
||||
.issue-filing-review-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.issue-filing-review-actions button { min-height:44px; width:100%; }
|
||||
@media(max-width:320px) { .issue-filing-review-panel { padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); } }
|
||||
.create-issue-repository-more { min-height:44px; width:100%; }
|
||||
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
|
||||
.create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
@@ -485,8 +689,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
#create-issue-repository-results[hidden] { display:none; }
|
||||
.create-issue-repository-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
|
||||
.create-issue-repository-result:last-child { border-bottom:0; }
|
||||
.create-issue-blockers { display:grid; gap:8px; min-width:0; }
|
||||
#create-issue-blocker-search { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
#create-issue-blocker-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; }
|
||||
#create-issue-blocker-results[hidden] { display:none; }
|
||||
.create-issue-blocker-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
|
||||
#create-issue-blocker-selected { display:grid; gap:8px; margin:0; padding:0; list-style:none; min-width:0; }
|
||||
.create-issue-blocker-selected { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; min-width:0; overflow-wrap:anywhere; }
|
||||
.create-issue-blocker-selected button { min-height:44px; }
|
||||
.create-issue-form label { display:grid; gap:6px; }
|
||||
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.create-issue-owner { min-width:0; }
|
||||
.create-issue-owner select { min-height:44px; width:100%; }
|
||||
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
|
||||
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
@@ -509,6 +723,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
|
||||
.conversation-more { min-height:44px; width:100%; margin:8px 0; }
|
||||
.conversation-actions-retry { min-height:44px; max-width:100%; margin:8px 0; }
|
||||
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
|
||||
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
|
||||
.pull-review-file { margin-top:8px; }
|
||||
@@ -530,12 +745,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.mobile-task-dock { display:none; }
|
||||
.mobile-task-dock[hidden] { display:none; }
|
||||
|
||||
.mobile-today-hud { display:none; }
|
||||
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
.mobile-task-action[hidden] { display:none; }
|
||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; max-width:100%; padding:1px 5px; border-radius:999px; background:#31577f; font-size:10px; line-height:16px; white-space:nowrap; }
|
||||
.mobile-task-deadline { padding:1px 5px; border-radius:999px; color:#fef3c7; background:#92400e; font-size:10px; line-height:14px; }
|
||||
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
|
||||
.attention-interruption button { min-height:44px; flex:0 0 auto; }
|
||||
@media (max-width: 600px) {
|
||||
@@ -557,11 +774,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.my-work { margin:0; }
|
||||
.attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); }
|
||||
.my-work-header { align-items:flex-start; }
|
||||
.my-work-actions { width:100%; flex-wrap:nowrap; }
|
||||
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
|
||||
.my-work-actions { display:none; }
|
||||
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.queue-finder { position:sticky; top:64px; z-index:5; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); padding:8px; background:rgba(11,21,38,.98); border:1px solid #2a496e; border-radius:10px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
|
||||
.my-work-card { min-width:0; overflow-x:hidden; }
|
||||
@@ -578,6 +795,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
|
||||
.update-sheet-panel { padding-bottom:calc(82px + env(safe-area-inset-bottom)); }
|
||||
.update-gesture-status { display:block; padding:6px 10px; border-radius:999px; text-align:center; background:#102641; }
|
||||
.update-sheet-panel[data-triage-gesture="keep"] { box-shadow:inset 8px 0 #60a5fa; }
|
||||
.update-sheet-panel[data-triage-gesture="read"] { box-shadow:inset -8px 0 #34d399; }
|
||||
.update-decision-bar { position:fixed; inset:auto 0 0; z-index:57; margin:0; padding-bottom:calc(8px + env(safe-area-inset-bottom)); }
|
||||
.update-more-actions > summary { display:none; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-blocker-row { grid-template-columns:1fr; }
|
||||
.issue-blocker-remove { width:100%; }
|
||||
@@ -585,22 +808,31 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
|
||||
#cmd-palette.open { display:flex; flex-direction:column; }
|
||||
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
|
||||
#close-command-palette { min-height:44px; }
|
||||
#close-command-palette, #cmd-load-more { min-height:44px; }
|
||||
#cmd-input { flex:0 0 auto; min-height:44px; }
|
||||
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
|
||||
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; }
|
||||
#cmd-search-repository { min-height:44px; width:100%; }
|
||||
.saved-search-create { align-items:stretch; }
|
||||
.saved-search-create input, .saved-search-open, .saved-search-row-actions button { min-height:44px; }
|
||||
.saved-search-row { align-items:stretch; }
|
||||
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.create-issue-panel.composer-keyboard-active,
|
||||
.issue-sheet-panel.composer-keyboard-active,
|
||||
.pull-sheet-panel.composer-keyboard-active,
|
||||
.review-sheet-panel.composer-keyboard-active,
|
||||
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px; }
|
||||
.composer-keyboard-active .create-issue-form,
|
||||
.composer-keyboard-active .issue-comment-composer,
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .review-inline-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
|
||||
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
@@ -610,6 +842,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||
.mobile-queue-list small { color:var(--muted); }
|
||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
|
||||
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
||||
.mobile-update-outcome { margin-top:12px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
|
||||
.mobile-update-outcome p { margin:0 0 10px; }
|
||||
.mobile-update-outcome button { width:100%; min-height:44px; }
|
||||
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
|
||||
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
|
||||
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
@@ -618,6 +855,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-today-hud button { min-height:44px; max-width:100%; }
|
||||
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
|
||||
}
|
||||
@media (min-width:701px) { .update-gesture-status { display:none; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.update-sheet-panel[data-triage-gesture] { transition:none; }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
+1644
-315
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
function showDraftCapacityDialog(captures) {
|
||||
function showDraftCapacityDialog(captures, qs) {
|
||||
const state = captures.capacity();
|
||||
if (!state.full) return false;
|
||||
const oldest = state.oldest;
|
||||
@@ -11,7 +11,10 @@ function showDraftCapacityDialog(captures) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindDraftCapacityDialog() {
|
||||
function bindDraftCapacityDialog({
|
||||
qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView,
|
||||
unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture,
|
||||
}) {
|
||||
qs('#keep-editing-draft').addEventListener('click', () => {
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
qs('#save-unfiled-issue').focus();
|
||||
@@ -40,10 +43,10 @@ function bindDraftCapacityDialog() {
|
||||
const button = qs('#replace-oldest-draft');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const draft = {
|
||||
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
|
||||
attachment:await createIssueAttachmentController.serialize(),
|
||||
};
|
||||
const draft = currentIssueCaptureDraft();
|
||||
const evidence = await createIssueAttachmentController.serialize();
|
||||
Object.assign(draft,
|
||||
Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence});
|
||||
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
issueCapture.clearDraft();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
function createDraftFilingSession({list}) {
|
||||
let orderedIds = [];
|
||||
let currentId = '';
|
||||
let reviewTrigger = null;
|
||||
|
||||
function visibleIds() {
|
||||
const available = new Set(list().map(item => item.id));
|
||||
@@ -49,17 +50,42 @@ function createDraftFilingSession({list}) {
|
||||
|
||||
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
|
||||
session.attach = (qs, dependencies) => {
|
||||
const openCapture = async captureId => {
|
||||
const openCapture = async (captureId, trigger = null) => {
|
||||
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
|
||||
dependencies.issueCapture.saveDraft(resumed);
|
||||
const issueCapture = dependencies.capture?.() || dependencies.issueCapture;
|
||||
issueCapture.saveDraft(resumed);
|
||||
dependencies.setResumedId(captureId);
|
||||
await dependencies.openSheet();
|
||||
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
|
||||
await dependencies.openSheet(false);
|
||||
const evidence = resumed.attachments || resumed.attachment;
|
||||
dependencies.attachment[evidence ? 'restore' : 'clear'](evidence);
|
||||
dependencies.setFilingMode(true);
|
||||
session.render();
|
||||
if (trigger) {
|
||||
await dependencies.review(resumed);
|
||||
reviewTrigger = trigger;
|
||||
const submitter = resumed.completionIntent === 'create-and-start' ?
|
||||
qs('#create-and-start-issue') : qs('#submit-new-issue');
|
||||
const disabled = submitter.disabled;
|
||||
submitter.disabled = false;
|
||||
qs('#create-issue-form').requestSubmit(submitter);
|
||||
submitter.disabled = disabled;
|
||||
}
|
||||
};
|
||||
return Object.assign(session, {
|
||||
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
|
||||
reviewCapture(captureId, trigger) {
|
||||
session.start(captureId);
|
||||
return openCapture(captureId, trigger);
|
||||
},
|
||||
open(captureId, trigger = null) {
|
||||
session.start(captureId);
|
||||
return openCapture(captureId, trigger);
|
||||
},
|
||||
takeTrigger() {
|
||||
const trigger = reviewTrigger;
|
||||
reviewTrigger = null;
|
||||
return trigger;
|
||||
},
|
||||
render() {
|
||||
const state = session.current();
|
||||
qs('#draft-filing-session').hidden = !state;
|
||||
|
||||
+10
-1
@@ -251,7 +251,16 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
return { drafts, deliveries, counts, retryable };
|
||||
}
|
||||
|
||||
return { list, discard, partition };
|
||||
function deliveryLabel(item) {
|
||||
if (item.quarantined) return 'Identity protected';
|
||||
if (item.status === 'completion') return 'Created · ready to start';
|
||||
if (item.status === 'attention') return 'Needs attention';
|
||||
if (item.status === 'sending') return 'Sending';
|
||||
if (item.status === 'authorization') return 'Awaiting authorization';
|
||||
return 'Queued for sync';
|
||||
}
|
||||
|
||||
return { list, discard, partition, deliveryLabel };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
|
||||
+405
-21
@@ -12,7 +12,7 @@
|
||||
<canvas id="bg"></canvas>
|
||||
<header>
|
||||
<div class="app-brand"><span class="obi"></span><strong>Stackchain</strong><span class="muted">Dashboard</span></div>
|
||||
<div class="app-live-status"><span class="dot"></span><span class="small" id="status">Live</span></div>
|
||||
<button class="app-live-status" id="open-live-data-status" type="button" aria-controls="live-data-status-sheet" aria-expanded="false"><span class="dot"></span><span class="small" id="status">Live</span></button>
|
||||
<details class="app-menu">
|
||||
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
|
||||
<div class="app-menu-panel">
|
||||
@@ -26,6 +26,20 @@
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
|
||||
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
|
||||
<div class="live-data-status-header">
|
||||
<div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div>
|
||||
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
|
||||
</div>
|
||||
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
|
||||
<p id="live-data-status-retry" class="small muted"></p>
|
||||
<div class="live-data-status-actions">
|
||||
<button id="refresh-live-data" type="button">Refresh now</button>
|
||||
<span id="live-data-status-result" class="small" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="offline-status" id="offline-status" role="status" aria-live="polite" hidden>
|
||||
Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
|
||||
</div>
|
||||
@@ -82,6 +96,16 @@
|
||||
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
|
||||
<div class="device-setup-deadline-controls">
|
||||
<label for="device-setup-deadline-hour">Local hour</label>
|
||||
<select id="device-setup-deadline-hour" aria-label="Deadline reminder local hour"></select>
|
||||
<label for="device-setup-deadline-days">Warn me</label>
|
||||
<select id="device-setup-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select>
|
||||
<button class="device-setup-action" id="device-setup-deadline" type="button">Enable</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
@@ -109,7 +133,9 @@
|
||||
<div class="work-filters" aria-label="Filter My Work">
|
||||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
||||
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
|
||||
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">0</span></button>
|
||||
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
|
||||
<button class="work-filter" data-work-filter="filed" aria-pressed="false">Filed <span data-work-count="filed">0</span></button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||
@@ -128,6 +154,10 @@
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
|
||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
|
||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||
@@ -163,22 +193,80 @@
|
||||
</div>
|
||||
</section>
|
||||
<div class="update-selection-controls" id="update-selection-controls" hidden>
|
||||
<button id="select-work" type="button">Select work</button>
|
||||
<button id="cancel-work-selection" type="button" hidden>Cancel selection</button>
|
||||
<button id="select-updates" type="button">Select updates</button>
|
||||
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="selection-scope-actions" id="selection-scope-actions" hidden>
|
||||
<button id="select-matching-work" type="button">Select all matches</button>
|
||||
<button id="clear-work-selection" type="button">Clear selection</button>
|
||||
</div>
|
||||
<form class="queue-finder" id="queue-finder" role="search">
|
||||
<label for="queue-find-input">Find in <span id="queue-find-label">All</span></label>
|
||||
<div class="queue-finder-row">
|
||||
<input id="queue-find-input" type="search" autocomplete="off" aria-label="Find in active work queue" placeholder="Repository, #number, or title" />
|
||||
<button id="clear-queue-find" type="button" hidden>Clear</button>
|
||||
</div>
|
||||
<div class="small" id="queue-find-status" role="status" aria-live="polite"></div>
|
||||
<button id="search-older-work" type="button" hidden>Search older work</button>
|
||||
</form>
|
||||
<section class="empty-work-start" id="empty-work-start" aria-labelledby="empty-work-start-heading" hidden>
|
||||
<div>
|
||||
<strong id="empty-work-start-heading">Ready for something new?</strong>
|
||||
<p class="small muted">Find an available issue to own, or create the work that needs doing.</p>
|
||||
</div>
|
||||
<div class="empty-work-start-actions">
|
||||
<button id="empty-work-find" type="button">Find and start work</button>
|
||||
<button id="empty-work-create" type="button">Create an issue</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="agenda-replan" id="agenda-replan" aria-labelledby="agenda-replan-heading" hidden>
|
||||
<div class="agenda-replan-launch">
|
||||
<div><strong id="agenda-replan-heading">Overdue deadlines</strong><p class="small muted">Make the Agenda credible without leaving the queue.</p></div>
|
||||
<button id="start-agenda-replan" type="button">Replan overdue</button>
|
||||
</div>
|
||||
<div id="agenda-replan-controls" hidden>
|
||||
<div class="small" id="agenda-replan-progress" role="status" aria-live="assertive"></div>
|
||||
<div class="agenda-replan-actions">
|
||||
<button id="agenda-replan-keep" type="button">Keep date & next</button>
|
||||
<button id="agenda-replan-tomorrow" type="button">Tomorrow & next</button>
|
||||
<label for="agenda-replan-date">Choose a future date</label>
|
||||
<input id="agenda-replan-date" type="date" />
|
||||
<button id="agenda-replan-choose" type="button">Choose date & next</button>
|
||||
<button id="agenda-replan-cancel" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="protect-today" id="protect-today-panel" aria-labelledby="protect-today-heading" hidden>
|
||||
<div>
|
||||
<strong id="protect-today-heading">Deadlines need a plan</strong>
|
||||
<p class="small muted">Put overdue and due-today work first, then review what fits.</p>
|
||||
<div class="small" id="protect-today-status" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<button id="protect-today" type="button">Protect Today</button>
|
||||
</section>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
||||
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
||||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
|
||||
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
||||
<div class="small" id="later-sync-status" aria-live="polite">Later is saved on this device.</div>
|
||||
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
|
||||
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
||||
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
||||
<button id="bulk-mark-read" type="button"></button>
|
||||
<div class="my-work-bulk-actions">
|
||||
<button id="batch-add-today" type="button" hidden>Add to Today</button>
|
||||
<button data-batch-defer="today" type="button" hidden>Later today</button>
|
||||
<button data-batch-defer="tomorrow" type="button" hidden>Tomorrow</button>
|
||||
<button id="defer-selected-today" data-defer-selected="today" type="button">Later today</button>
|
||||
<button id="defer-selected-tomorrow" data-defer-selected="tomorrow" type="button">Tomorrow</button>
|
||||
<button id="bulk-mark-read" type="button"></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="sidebar">
|
||||
@@ -337,13 +425,99 @@
|
||||
<button id="add-plan-preview" type="button">Add to Today & back</button>
|
||||
</div>
|
||||
|
||||
<div class="issue-filing-receipt" id="issue-filing-receipt" role="dialog" aria-modal="true" aria-labelledby="issue-filing-receipt-heading" hidden>
|
||||
<section class="issue-filing-receipt-panel">
|
||||
<div class="small">Issue created</div>
|
||||
<h2 id="issue-filing-receipt-heading" tabindex="-1">Ready to hand off</h2>
|
||||
<div id="issue-filing-receipt-key" class="small"></div>
|
||||
<h3 id="issue-filing-receipt-title"></h3>
|
||||
<p id="issue-filing-receipt-ownership"></p>
|
||||
<p id="issue-filing-receipt-status" class="small" aria-live="polite"></p>
|
||||
<div class="issue-filing-receipt-actions">
|
||||
<button id="issue-filing-receipt-filed" type="button">View in Filed</button>
|
||||
<a id="issue-filing-receipt-open" class="button-link" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<button id="issue-filing-receipt-share" type="button">Share link</button>
|
||||
<button id="issue-filing-receipt-related" type="button">File related issue</button>
|
||||
<button id="issue-filing-receipt-another" type="button">File another</button>
|
||||
<button id="issue-filing-receipt-done" type="button">Done</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
||||
<div class="cmd-palette-header">
|
||||
<strong>Search work</strong>
|
||||
<button id="close-command-palette" type="button">Close</button>
|
||||
<div class="cmd-palette-header-actions">
|
||||
<button id="select-search-results" type="button">Select</button>
|
||||
<button id="close-command-palette" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
|
||||
<fieldset class="cmd-search-scope">
|
||||
<legend>Filter search results</legend>
|
||||
<label for="cmd-search-kind">Type</label>
|
||||
<select id="cmd-search-kind">
|
||||
<option value="all">All work</option>
|
||||
<option value="issue">Issues</option>
|
||||
<option value="pull">Pull requests</option>
|
||||
</select>
|
||||
<label for="cmd-search-state">Status</label>
|
||||
<select id="cmd-search-state">
|
||||
<option value="all">Any status</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
<label for="cmd-search-repository">Repository</label>
|
||||
<input id="cmd-search-repository" type="search" list="cmd-search-repositories" autocomplete="off" placeholder="All repositories" aria-describedby="cmd-repository-status" />
|
||||
<datalist id="cmd-search-repositories"></datalist>
|
||||
<span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span>
|
||||
</fieldset>
|
||||
<section class="saved-searches" aria-labelledby="saved-searches-heading">
|
||||
<div class="saved-search-header">
|
||||
<strong id="saved-searches-heading">Saved searches</strong>
|
||||
<span id="saved-search-status" class="small" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="saved-search-create">
|
||||
<label for="saved-search-name" class="sr-only">Saved search name</label>
|
||||
<input id="saved-search-name" type="text" maxlength="60" autocomplete="off" placeholder="Name this search" />
|
||||
<button id="save-current-search" class="saved-search-action" type="button">Save search</button>
|
||||
</div>
|
||||
<div id="saved-search-list" class="saved-search-list" aria-label="Saved searches"></div>
|
||||
</section>
|
||||
<div id="cmd-search-action-status" class="small" aria-live="polite"></div>
|
||||
<button id="resume-search-batch" class="search-batch-recovery" type="button" hidden>Resume batch</button>
|
||||
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
|
||||
<button id="cmd-load-more" hidden>More results</button>
|
||||
<div class="search-batch-actions" id="search-batch-actions" hidden>
|
||||
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
|
||||
<button id="cancel-search-selection" type="button">Cancel</button>
|
||||
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
|
||||
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
|
||||
<button id="queue-selected-search-results" type="button" disabled>Assign & add to Today</button>
|
||||
</div>
|
||||
<section class="search-batch-estimate-review" id="search-batch-estimate-review"
|
||||
aria-labelledby="search-batch-estimate-heading" hidden>
|
||||
<div><strong id="search-batch-estimate-heading">Fit this work into Today</strong>
|
||||
<span class="small" id="search-batch-estimate-summary" aria-live="polite"></span></div>
|
||||
<div id="search-batch-estimate-list"></div>
|
||||
<div class="search-batch-estimate-actions">
|
||||
<button id="cancel-search-batch-estimates" type="button">Back</button>
|
||||
<button id="confirm-search-batch-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="search-release-review" id="search-release-review"
|
||||
aria-labelledby="search-release-heading" hidden>
|
||||
<div><strong id="search-release-heading">Plan selected issues into a release</strong>
|
||||
<span class="small" id="search-release-summary" aria-live="polite"></span></div>
|
||||
<label>Open milestone<select id="search-release-milestone" required></select></label>
|
||||
<label>Shared due date <span class="small">(optional)</span>
|
||||
<input id="search-release-due-date" type="date" /></label>
|
||||
<div id="search-release-list"></div>
|
||||
<div class="search-release-actions">
|
||||
<button id="cancel-search-release" type="button">Back</button>
|
||||
<button id="confirm-search-release" type="button">Assign & plan</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
|
||||
@@ -358,12 +532,36 @@
|
||||
<div id="search-preview-status" class="small" aria-live="polite">Choose a search result.</div>
|
||||
<div id="search-preview-meta" class="row"></div>
|
||||
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
|
||||
<section id="search-preview-conversation" class="search-preview-conversation" aria-labelledby="search-preview-conversation-title">
|
||||
<h2 id="search-preview-conversation-title">Current conversation</h2>
|
||||
<div id="search-preview-comments"></div>
|
||||
<div id="search-preview-conversation-status" class="small" aria-live="polite"></div>
|
||||
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
|
||||
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
|
||||
</section>
|
||||
<section class="search-preview-reply" aria-labelledby="search-preview-reply-title" hidden>
|
||||
<h2 id="search-preview-reply-title">Reply</h2>
|
||||
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
<div id="search-preview-reply-status" class="small" aria-live="polite"></div>
|
||||
<div class="search-preview-reply-actions">
|
||||
<button id="send-search-preview-reply" type="button">Send</button>
|
||||
<button id="send-search-preview-reply-next" type="button">Send & next</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="search-preview-actions">
|
||||
<nav class="search-preview-navigation" aria-label="Search result navigation" hidden>
|
||||
<button id="previous-search-result" type="button">Previous</button>
|
||||
<span id="search-preview-position" aria-live="polite"></span>
|
||||
<button id="next-search-result" type="button">Next</button>
|
||||
</nav>
|
||||
<div class="search-preview-primary-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<button id="defer-search-result" type="button" hidden>Assign & defer</button>
|
||||
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
|
||||
</div>
|
||||
<button id="share-search-result" type="button" disabled>Share result</button>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -418,6 +616,7 @@
|
||||
<div id="issue-comments"></div>
|
||||
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
|
||||
<div id="issue-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<button class="conversation-actions-retry" id="retry-issue-comment-actions" type="button" hidden>Retry comment tools</button>
|
||||
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
|
||||
<h2 id="issue-comment-title">Add comment</h2>
|
||||
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||
@@ -510,12 +709,44 @@
|
||||
<section class="find-work-panel">
|
||||
<div class="find-work-header">
|
||||
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
|
||||
<button id="close-find-work" type="button">Close</button>
|
||||
<div class="find-work-header-actions"><button id="select-find-work" type="button">Select work</button><button id="close-find-work" type="button">Close</button></div>
|
||||
</div>
|
||||
<p class="small">Claim an available issue and continue it in My Work.</p>
|
||||
<form class="find-work-search" id="find-work-search-form" role="search">
|
||||
<label for="find-work-search">Search repository, #number, or title</label>
|
||||
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
|
||||
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
|
||||
<span id="find-work-match-status" class="small" aria-live="polite"></span>
|
||||
</form>
|
||||
<details class="find-work-filters" id="find-work-filters">
|
||||
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
|
||||
<div class="find-work-filter-panel" aria-label="Filter available work">
|
||||
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<button id="clear-find-work-filters" type="button">Clear filters</button>
|
||||
</div>
|
||||
</details>
|
||||
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
||||
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
||||
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
|
||||
aria-describedby="find-work-status" hidden>Resume assigning & queueing</button>
|
||||
<div class="find-work-list" id="find-work-list"></div>
|
||||
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
||||
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
|
||||
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
|
||||
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
|
||||
<div id="find-work-estimate-list"></div>
|
||||
<div class="find-work-estimate-actions">
|
||||
<button id="cancel-find-work-estimates" type="button">Back</button>
|
||||
<button id="confirm-find-work-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
|
||||
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
|
||||
<button id="cancel-find-work-selection" type="button">Cancel</button>
|
||||
<button id="claim-selected-work" type="button" disabled>Assign & queue Today</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -545,15 +776,25 @@
|
||||
<button id="file-new-issue" type="button">File now</button>
|
||||
</div>
|
||||
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
|
||||
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
|
||||
<strong id="create-issue-attachment-label">Evidence screenshots <span class="small">Optional · Up to 5 · PNG, JPEG, or WebP · 2 MB each</span></strong>
|
||||
<div class="issue-attachment-controls">
|
||||
<label class="issue-attachment-trigger" for="create-issue-attachment">Attach screenshot</label>
|
||||
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<label class="issue-attachment-trigger" for="create-issue-attachment">Add screenshots</label>
|
||||
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
|
||||
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="create-issue-attachment-meta"></span>
|
||||
<button id="remove-create-issue-attachment" type="button">Remove</button>
|
||||
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Evidence screenshots" hidden></div>
|
||||
<label class="issue-evidence-note" for="create-issue-evidence-note">
|
||||
<span id="create-issue-evidence-note-label">Evidence note (optional)</span>
|
||||
<textarea id="create-issue-evidence-note" maxlength="240" rows="2" disabled placeholder="What does this screenshot show?"></textarea>
|
||||
</label>
|
||||
<div class="issue-evidence-review-actions" aria-label="Reorder selected screenshot">
|
||||
<button id="move-create-issue-attachment-earlier" type="button">Move earlier</button>
|
||||
<button id="edit-create-issue-attachment" type="button">Edit screenshot</button>
|
||||
<button id="remove-create-issue-attachment" type="button">Remove selected</button>
|
||||
<button id="move-create-issue-attachment-later" type="button">Move later</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||
</section>
|
||||
@@ -577,6 +818,19 @@
|
||||
</div>
|
||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<label id="create-issue-template-field" for="create-issue-template" hidden>Issue type
|
||||
<select id="create-issue-template" aria-describedby="create-issue-template-status">
|
||||
<option value="">Blank issue</option>
|
||||
</select>
|
||||
<span id="create-issue-template-status" class="small" aria-live="polite">Blank issue selected.</span>
|
||||
</label>
|
||||
<label class="create-issue-owner" for="create-issue-assignee">Owner
|
||||
<select id="create-issue-assignee" aria-describedby="create-issue-assignee-status">
|
||||
<option value="">Me</option>
|
||||
<option value="__unassigned__">No owner</option>
|
||||
</select>
|
||||
<span id="create-issue-assignee-status" class="small" aria-live="polite">Open the owner picker to load eligible teammates.</span>
|
||||
</label>
|
||||
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
|
||||
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
||||
<option value="">No milestone</option>
|
||||
@@ -586,6 +840,15 @@
|
||||
<label for="create-issue-due-date">Due date <span class="small">Optional</span>
|
||||
<input id="create-issue-due-date" type="date" />
|
||||
</label>
|
||||
<section class="create-issue-blockers" aria-labelledby="create-issue-blockers-heading">
|
||||
<strong id="create-issue-blockers-heading">Blocked by <span class="small">Optional · Up to 5 open issues</span></strong>
|
||||
<label for="create-issue-blocker-search">Search open issues
|
||||
<input id="create-issue-blocker-search" type="search" maxlength="80" placeholder="Issue title or key" autocomplete="off" />
|
||||
</label>
|
||||
<div id="create-issue-blocker-results" role="listbox" aria-label="Open issue blocker results" hidden></div>
|
||||
<ul id="create-issue-blocker-selected" aria-label="Selected blockers"></ul>
|
||||
<div id="create-issue-blocker-status" class="small" aria-live="polite">No blockers selected.</div>
|
||||
</section>
|
||||
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
|
||||
<legend>Labels <span class="small">Optional</span></legend>
|
||||
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
|
||||
@@ -597,9 +860,14 @@
|
||||
<div id="create-issue-duplicate-list" class="create-issue-duplicate-list"></div>
|
||||
<button id="create-issue-anyway" type="button" hidden>Create anyway</button>
|
||||
</section>
|
||||
<label class="create-issue-estimate" for="create-issue-estimate">Work estimate for Today
|
||||
<span><input id="create-issue-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" placeholder="45" aria-describedby="create-issue-estimate-status" /> min</span>
|
||||
<span id="create-issue-estimate-status" class="small" aria-live="polite">Required only for Create & start.</span>
|
||||
</label>
|
||||
<div class="small">The issue will be assigned to you.</div>
|
||||
<div class="create-issue-actions">
|
||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||
<button id="create-follow-up-next" type="submit" hidden>Create follow-up & next</button>
|
||||
<button id="create-and-start-issue" type="submit">Create & start</button>
|
||||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||
</div>
|
||||
@@ -607,8 +875,66 @@
|
||||
<div id="create-issue-capture-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="issue-evidence-editor" id="issue-evidence-editor" role="dialog" aria-modal="true" aria-labelledby="issue-evidence-editor-heading" hidden>
|
||||
<div class="issue-evidence-editor-panel">
|
||||
<header>
|
||||
<div><h3 id="issue-evidence-editor-heading">Edit screenshot evidence</h3><span class="small">Edits stay on this device until you file.</span></div>
|
||||
<button id="cancel-issue-evidence-edit" type="button">Cancel</button>
|
||||
</header>
|
||||
<canvas id="issue-evidence-editor-canvas" role="img" aria-label="Screenshot editing canvas. Drag to crop, redact, highlight, or point with an arrow."></canvas>
|
||||
<canvas id="issue-evidence-editor-export" hidden></canvas>
|
||||
<div class="issue-evidence-editor-tools" role="toolbar" aria-label="Screenshot editing tools">
|
||||
<button id="crop-issue-evidence" type="button" aria-pressed="false">Crop</button>
|
||||
<button id="redact-issue-evidence" type="button" aria-pressed="true">Redact</button>
|
||||
<button id="highlight-issue-evidence" type="button" aria-pressed="false">Highlight</button>
|
||||
<button id="arrow-issue-evidence" type="button" aria-pressed="false">Arrow</button>
|
||||
<button id="undo-issue-evidence-edit" type="button">Undo</button>
|
||||
<button id="reset-issue-evidence-edit" type="button">Reset</button>
|
||||
</div>
|
||||
<div class="small" id="issue-evidence-editor-status" aria-live="polite">Drag over private information to hide it.</div>
|
||||
<button class="issue-evidence-editor-apply" id="apply-issue-evidence-edit" type="button">Apply flattened edit</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="issue-filing-review" id="issue-filing-review" role="dialog" aria-modal="true" aria-labelledby="issue-filing-review-heading" hidden>
|
||||
<div class="issue-filing-review-panel">
|
||||
<header>
|
||||
<div>
|
||||
<h2 id="issue-filing-review-heading">Review issue before filing</h2>
|
||||
<p class="small">Confirm the complete payload that will be saved for delivery.</p>
|
||||
</div>
|
||||
</header>
|
||||
<dl class="issue-filing-review-summary">
|
||||
<div><dt>Repository</dt><dd id="issue-filing-review-repository"></dd></div>
|
||||
<div><dt>Action</dt><dd id="issue-filing-review-intent"></dd></div>
|
||||
<div><dt>Issue type</dt><dd id="issue-filing-review-template"></dd></div>
|
||||
<div><dt>Title</dt><dd id="issue-filing-review-title"></dd></div>
|
||||
<div><dt>Note</dt><dd id="issue-filing-review-body"></dd></div>
|
||||
<div><dt>Planning</dt><dd id="issue-filing-review-metadata"></dd></div>
|
||||
<div><dt>Blocked by</dt><dd><ul id="issue-filing-review-blockers"></ul></dd></div>
|
||||
</dl>
|
||||
<section aria-labelledby="issue-filing-review-evidence-heading">
|
||||
<h3 id="issue-filing-review-evidence-heading">Evidence in filing order</h3>
|
||||
<p class="small muted" id="issue-filing-review-evidence-empty" hidden>No screenshots will be filed.</p>
|
||||
<ol id="issue-filing-review-evidence"></ol>
|
||||
<figure class="issue-filing-evidence-preview" id="issue-filing-review-evidence-preview" hidden>
|
||||
<img id="issue-filing-review-evidence-image" alt="" />
|
||||
<figcaption>
|
||||
<span class="small" id="issue-filing-review-evidence-position"></span>
|
||||
<strong id="issue-filing-review-evidence-filename"></strong>
|
||||
<span class="small" id="issue-filing-review-evidence-note"></span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
<div class="issue-filing-review-actions">
|
||||
<button id="back-to-issue-edit" type="button">Back to edit</button>
|
||||
<button id="confirm-issue-filing" type="button">Confirm filing</button>
|
||||
</div>
|
||||
<div class="small" id="issue-filing-review-status" aria-live="assertive"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="draft-capacity-sheet" id="draft-capacity-sheet" role="dialog" aria-modal="true" aria-labelledby="draft-capacity-heading" hidden>
|
||||
<section class="draft-capacity-panel">
|
||||
<h3 id="draft-capacity-heading">Drafts full — nothing was deleted.</h3>
|
||||
@@ -624,14 +950,15 @@
|
||||
</div>
|
||||
|
||||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||||
<section class="update-sheet-panel">
|
||||
<section class="update-sheet-panel" data-triage-gesture="">
|
||||
<div class="update-sheet-header">
|
||||
<div>
|
||||
<div class="small" id="update-sheet-key"></div>
|
||||
<h3 id="update-sheet-title">Unread update</h3>
|
||||
</div>
|
||||
<button id="keep-update-unread" type="button">Keep unread</button>
|
||||
</div>
|
||||
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
||||
<div id="update-gesture-status" class="update-gesture-status small" aria-live="polite">Swipe right to keep unread or left to mark read. Buttons remain available.</div>
|
||||
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
|
||||
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
|
||||
<div class="row">
|
||||
@@ -640,8 +967,11 @@
|
||||
</div>
|
||||
<h2>Full conversation</h2>
|
||||
<div id="update-comments"></div>
|
||||
<button class="update-new-activity-jump" id="jump-update-new-activity" type="button" hidden>Jump to new activity</button>
|
||||
<button class="conversation-more" id="load-older-update-comments" type="button" hidden>Load older messages</button>
|
||||
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<button class="update-retry" id="retry-update-conversation" type="button" hidden>Retry conversation</button>
|
||||
<button class="conversation-actions-retry" id="retry-update-comment-actions" type="button" hidden>Retry comment tools</button>
|
||||
<details>
|
||||
<summary><h2>Subject context</h2></summary>
|
||||
<div class="update-sheet-content muted markdown-content" id="update-subject-body"></div>
|
||||
@@ -667,17 +997,21 @@
|
||||
</div>
|
||||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="update-sheet-actions">
|
||||
<div class="update-ownership-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership & start</button>
|
||||
<details class="update-more-actions">
|
||||
<summary>More actions</summary>
|
||||
<div class="update-sheet-actions">
|
||||
<div class="update-ownership-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership & start</button>
|
||||
</div>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||
<button id="mute-update-next" type="button" hidden>Mute future updates & next</button>
|
||||
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
</details>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
@@ -685,9 +1019,21 @@
|
||||
<button type="button" data-work-session-complete="update" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
<div class="update-decision-bar" aria-label="Update decisions">
|
||||
<button id="keep-update-unread" type="button" aria-label="Keep unread and open next update">Keep unread</button>
|
||||
<button id="mark-update-read-next" type="button" aria-label="Mark read and open next update">Mark read</button>
|
||||
<button id="review-update-now" type="button" hidden>Review now</button>
|
||||
<button id="focus-update-reply" type="button">Reply</button>
|
||||
<button id="toggle-update-more" type="button" aria-expanded="false">More</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="notification-undo" id="notification-undo" role="status" aria-live="assertive" hidden>
|
||||
<span id="notification-undo-status"></span>
|
||||
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
|
||||
</div>
|
||||
|
||||
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
|
||||
<section class="pull-sheet-panel">
|
||||
<div class="pull-sheet-header">
|
||||
@@ -700,6 +1046,7 @@
|
||||
<h2>Full conversation</h2><div id="pull-comments"></div>
|
||||
<button class="conversation-more" id="load-older-pull-comments" type="button" hidden>Load older messages</button>
|
||||
<div id="pull-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<button class="conversation-actions-retry" id="retry-pull-comment-actions" type="button" hidden>Retry comment tools</button>
|
||||
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
|
||||
<h2 id="pull-comment-title">Add comment</h2>
|
||||
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||
@@ -857,12 +1204,21 @@
|
||||
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
|
||||
<section class="mobile-queue-panel">
|
||||
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
|
||||
<div id="mobile-update-outcome" class="mobile-update-outcome" role="status" aria-live="polite" hidden>
|
||||
<p id="mobile-update-outcome-summary"></p>
|
||||
<button id="review-kept-updates" type="button" hidden>Review kept unread</button>
|
||||
</div>
|
||||
<p class="small muted">Choose what to work through next.</p>
|
||||
<div class="mobile-queue-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
|
||||
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
@@ -872,20 +1228,25 @@
|
||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
|
||||
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
||||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues, 0 items">Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues: no active queues; no upcoming deadlines"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0 active</span></span><span class="mobile-task-deadline" id="mobile-deadline-count" hidden>0 due</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/feature-loader.js"></script>
|
||||
<script src="static/conversation-action-hydrator.js"></script>
|
||||
<script src="static/security-center.js"></script>
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/saved-searches.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/search-defer.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/unfiled-captures.js"></script>
|
||||
<script src="static/unfiled-draft-sync.js"></script>
|
||||
<script src="static/shared-image-capture.js"></script>
|
||||
<script src="static/draft-filing-session.js"></script>
|
||||
<script src="static/draft-capacity-dialog.js"></script>
|
||||
<script src="static/outbox-coordinator.js"></script>
|
||||
@@ -898,7 +1259,11 @@
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/agenda-replan.js"></script>
|
||||
<script src="static/protect-today.js"></script>
|
||||
<script src="static/notification-undo.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/work-selection.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-timer.js"></script>
|
||||
<script src="static/today-recap.js"></script>
|
||||
@@ -907,19 +1272,26 @@
|
||||
<script src="static/comment-next.js"></script>
|
||||
<script src="static/update-reply-read-next.js"></script>
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-readiness.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/today-rollover.js"></script>
|
||||
<script src="static/update-ownership.js"></script>
|
||||
<script src="static/update-follow-up.js"></script>
|
||||
<script src="static/later-work.js"></script>
|
||||
<script src="static/later-sync.js"></script>
|
||||
<script src="static/later-and-start.js"></script>
|
||||
<script src="static/detail-defer.js"></script>
|
||||
<script src="static/later-picker.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
<script src="static/batch-find-work.js"></script>
|
||||
<script src="static/search-batch-plan.js"></script>
|
||||
<script src="static/conversation.js"></script>
|
||||
<script src="static/comment-actions.js"></script>
|
||||
<script src="static/issue-evidence-review.js"></script>
|
||||
<script src="static/issue-evidence-editor.js"></script>
|
||||
<script src="static/issue-attachment.js"></script>
|
||||
<script src="static/issue-filing-review.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
@@ -930,15 +1302,27 @@
|
||||
<script src="static/work-route.js"></script>
|
||||
<script src="static/task-overlay-history.js"></script>
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script src="static/live-data-status.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/update-triage-session.js"></script>
|
||||
<script src="static/update-review-handoff.js"></script>
|
||||
<script src="static/update-read-position.js"></script>
|
||||
<script src="static/work-detail-position.js"></script>
|
||||
<script src="static/update-triage-launcher.js"></script>
|
||||
<script src="static/update-triage-gesture.js"></script>
|
||||
<script src="static/update-decision-transaction.js"></script>
|
||||
<script src="static/agenda-session-launcher.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
<script src="static/mobile-composer-viewport.js"></script>
|
||||
<script src="static/mention-composer.js"></script>
|
||||
<script src="static/push-notifications.js"></script>
|
||||
<script src="static/issue-filing-receipt.js"></script>
|
||||
<script src="static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+235
-111
@@ -1,55 +1,28 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
const review = typeof module === 'object' && module.exports ? require('./issue-evidence-review.js') : root.issueEvidenceReview;
|
||||
const editor = typeof module === 'object' && module.exports ? require('./issue-evidence-editor.js') : root.issueEvidenceEditor;
|
||||
const api = factory(review, editor);
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.issueAttachment = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
})(typeof self !== 'undefined' ? self : this, function(issueEvidenceReview, issueEvidenceEditor) {
|
||||
'use strict';
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_PIXELS = issueEvidenceReview.MAX_PIXELS;
|
||||
const MAX_FILES = 5;
|
||||
const MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.';
|
||||
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
function namedBlob(blob, name) {
|
||||
if (typeof File === 'function') {
|
||||
return new File([blob], name, { type: blob.type });
|
||||
}
|
||||
Object.defineProperty(blob, 'name', { value: name, configurable: true });
|
||||
return blob;
|
||||
function normalizeNote(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
}
|
||||
|
||||
async function optimizeImage(file, environment = {}) {
|
||||
if (file.size <= MAX_BYTES) return file;
|
||||
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
|
||||
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
|
||||
if (typeof decode !== 'function') {
|
||||
throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.');
|
||||
}
|
||||
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await decode(file);
|
||||
const canvas = makeCanvas();
|
||||
const context = canvas && canvas.getContext && canvas.getContext('2d');
|
||||
if (!context || !bitmap.width || !bitmap.height) throw new Error('decode');
|
||||
let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92);
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise(resolve => {
|
||||
canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86);
|
||||
});
|
||||
if (!blob) throw new Error('encode');
|
||||
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
|
||||
scale *= 0.8;
|
||||
}
|
||||
} catch (_error) {
|
||||
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
|
||||
} finally {
|
||||
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
|
||||
}
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
function escapeMarkdown(value) {
|
||||
return value.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
|
||||
}
|
||||
|
||||
const optimizeImage = issueEvidenceReview.optimizeImage;
|
||||
|
||||
function multipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
@@ -65,6 +38,8 @@
|
||||
|
||||
function create(options) {
|
||||
const upload = options.upload;
|
||||
const maxFiles = options.maxFiles === MAX_FILES ? MAX_FILES : 1;
|
||||
const inspectPixels = options.inspectPixels === true;
|
||||
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
|
||||
const createOperationId = options.createOperationId || (() => {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
|
||||
@@ -72,10 +47,7 @@
|
||||
}
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
});
|
||||
let selected = null;
|
||||
let confirmed = null;
|
||||
let serialized = null;
|
||||
let operationId = null;
|
||||
let selected = [];
|
||||
let selectionGeneration = 0;
|
||||
|
||||
function commitSelection(file) {
|
||||
@@ -83,14 +55,18 @@
|
||||
file.size <= 0 || file.size > MAX_BYTES) {
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
}
|
||||
selected = file;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
operationId = createOperationId();
|
||||
if (selected.length >= maxFiles) {
|
||||
if (maxFiles === 1) selected = [];
|
||||
else throw new Error(MAX_FILES_MESSAGE);
|
||||
}
|
||||
selected.push({file, note:'', confirmed:null, serialized:null, operationId:createOperationId()});
|
||||
return state();
|
||||
}
|
||||
|
||||
function select(file) {
|
||||
if (selected.length >= maxFiles && maxFiles > 1) {
|
||||
throw new Error(MAX_FILES_MESSAGE);
|
||||
}
|
||||
const generation = ++selectionGeneration;
|
||||
if (!file || !IMAGE_TYPES.has(file.type)) {
|
||||
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
|
||||
@@ -98,7 +74,7 @@
|
||||
if (!Number.isFinite(file.size) || file.size <= 0) {
|
||||
throw new Error('Choose a screenshot that is 2 MB or smaller.');
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
if (file.size > MAX_BYTES || inspectPixels) {
|
||||
return Promise.resolve(optimizeSelectedImage(file)).then(optimized => {
|
||||
if (generation !== selectionGeneration) return state();
|
||||
return commitSelection(optimized);
|
||||
@@ -109,87 +85,167 @@
|
||||
|
||||
function clear() {
|
||||
selectionGeneration += 1;
|
||||
selected = null;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
operationId = null;
|
||||
selected = [];
|
||||
}
|
||||
|
||||
function remove(index) {
|
||||
const position = Number(index);
|
||||
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
|
||||
selectionGeneration += 1;
|
||||
selected.splice(position, 1);
|
||||
return state();
|
||||
}
|
||||
|
||||
function move(fromIndex, toIndex) {
|
||||
const from = Number(fromIndex);
|
||||
const to = Number(toIndex);
|
||||
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 ||
|
||||
from >= selected.length || to < 0 || to >= selected.length || from === to) return state();
|
||||
const [item] = selected.splice(from, 1);
|
||||
selected.splice(to, 0, item);
|
||||
return state();
|
||||
}
|
||||
|
||||
function replace(index, file) {
|
||||
const position = Number(index);
|
||||
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
|
||||
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
|
||||
file.size <= 0 || file.size > MAX_BYTES) {
|
||||
throw new Error('The edited screenshot must be a PNG, JPEG, or WebP image no larger than 2 MB.');
|
||||
}
|
||||
selectionGeneration += 1;
|
||||
selected[position] = {
|
||||
...selected[position], file, confirmed:null, serialized:null, operationId:createOperationId(),
|
||||
};
|
||||
return state();
|
||||
}
|
||||
|
||||
function setNote(index, value) {
|
||||
const position = Number(index);
|
||||
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
|
||||
selected[position].note = normalizeNote(value);
|
||||
selected[position].serialized = null;
|
||||
return state();
|
||||
}
|
||||
|
||||
function note(index) {
|
||||
const position = Number(index);
|
||||
return Number.isInteger(position) && selected[position] ? selected[position].note : '';
|
||||
}
|
||||
|
||||
function restore(value) {
|
||||
if (Array.isArray(value)) {
|
||||
clear();
|
||||
if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE);
|
||||
value.forEach(attachment => restoreOne(attachment));
|
||||
return state();
|
||||
}
|
||||
clear();
|
||||
restoreOne(value);
|
||||
return state();
|
||||
}
|
||||
|
||||
function restoreOne(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '');
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!blob && !data) {
|
||||
clear();
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
const padding = (data.match(/=*$/) || [''])[0].length;
|
||||
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
||||
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
|
||||
serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
|
||||
return state();
|
||||
commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
|
||||
const item = selected[selected.length - 1];
|
||||
item.note = normalizeNote(value?.note);
|
||||
item.serialized = {
|
||||
...(blob ? { filename, contentType, blob } : { filename, contentType, data }),
|
||||
...(item.note ? {note:item.note} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function state() {
|
||||
return selected ? {
|
||||
name: selected.name,
|
||||
size: selected.size,
|
||||
uploaded: Boolean(confirmed),
|
||||
} : null;
|
||||
const values = selected.map(item => ({
|
||||
name: item.file.name,
|
||||
size: item.file.size,
|
||||
uploaded: Boolean(item.confirmed),
|
||||
}));
|
||||
return values.length > 1 ? values : (values[0] || null);
|
||||
}
|
||||
|
||||
function serializeItem(item) {
|
||||
if (!item.serialized) item.serialized = {
|
||||
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
|
||||
...(item.note ? {note:item.note} : {}),
|
||||
};
|
||||
return { ...item.serialized };
|
||||
}
|
||||
|
||||
async function serialize() {
|
||||
if (!selected) return null;
|
||||
if (!serialized) {
|
||||
serialized = {
|
||||
filename: selected.name,
|
||||
contentType: selected.type,
|
||||
blob: selected.blob || selected,
|
||||
};
|
||||
}
|
||||
return { ...serialized };
|
||||
if (!selected.length) return null;
|
||||
const values = selected.map(serializeItem);
|
||||
return values.length > 1 ? values : values[0];
|
||||
}
|
||||
|
||||
async function prepareComment(item, body) {
|
||||
async function prepareComment(target, body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!selected) return text;
|
||||
if (!confirmed) {
|
||||
const attachment = await serialize();
|
||||
confirmed = await upload({
|
||||
repository: item.repository,
|
||||
number: item.number,
|
||||
filename: attachment.filename,
|
||||
content_type: attachment.contentType,
|
||||
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
|
||||
operation_id: operationId,
|
||||
});
|
||||
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
|
||||
confirmed = null;
|
||||
throw new Error('The server did not confirm the screenshot upload.');
|
||||
if (!selected.length) return text;
|
||||
const markdown = [];
|
||||
for (const evidence of selected) {
|
||||
if (!evidence.confirmed) {
|
||||
const attachment = serializeItem(evidence);
|
||||
evidence.confirmed = await upload({
|
||||
repository: target.repository,
|
||||
number: target.number,
|
||||
filename: attachment.filename,
|
||||
content_type: attachment.contentType,
|
||||
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
|
||||
operation_id: evidence.operationId,
|
||||
});
|
||||
if (!evidence.confirmed || typeof evidence.confirmed.markdown !== 'string' || !evidence.confirmed.markdown) {
|
||||
evidence.confirmed = null;
|
||||
throw new Error('The server did not confirm the screenshot upload.');
|
||||
}
|
||||
}
|
||||
if (evidence.note) {
|
||||
markdown.push('**Screenshot ' + (markdown.length + 1) + ' — ' + escapeMarkdown(evidence.note) + '**\n\n' +
|
||||
evidence.confirmed.markdown);
|
||||
} else markdown.push(evidence.confirmed.markdown);
|
||||
}
|
||||
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
|
||||
const evidence = markdown.join('\n\n');
|
||||
return text ? text + '\n\n' + evidence : evidence;
|
||||
}
|
||||
|
||||
return { select, restore, clear, state, serialize, prepareComment };
|
||||
return { select, restore, remove, move, replace, setNote, note, clear, state, serialize, prepareComment };
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
const controller = create(options);
|
||||
const controller = create({
|
||||
...options, inspectPixels:true,
|
||||
maxFiles:options.maxFiles || (options.input?.multiple ? MAX_FILES : 1),
|
||||
});
|
||||
const clearSelection = controller.clear;
|
||||
const restoreSelection = controller.restore;
|
||||
const reviewEnabled = Boolean(options.tray && options.earlier && options.later);
|
||||
let previewUrl = '';
|
||||
let selectionSequence = 0;
|
||||
const review = reviewEnabled ? issueEvidenceReview.create({
|
||||
...options, edit:options.editor?.edit, controller,
|
||||
}) : null;
|
||||
|
||||
function setBusy(busy) {
|
||||
options.input.disabled = Boolean(busy);
|
||||
options.remove.disabled = Boolean(busy);
|
||||
function setBusy(value) {
|
||||
options.input.disabled = Boolean(value);
|
||||
options.remove.disabled = Boolean(value);
|
||||
if (options.editor?.edit) options.editor.edit.disabled = Boolean(value) || !controller.state();
|
||||
review?.setBusy(value);
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
selectionSequence += 1;
|
||||
if (previewUrl) options.revokeObjectURL(previewUrl);
|
||||
previewUrl = '';
|
||||
review?.clear();
|
||||
if (options.editor?.edit) options.editor.edit.disabled = true;
|
||||
options.image.src = '';
|
||||
options.preview.hidden = true;
|
||||
options.input.value = '';
|
||||
@@ -203,29 +259,53 @@
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
if (options.editor?.edit) options.editor.edit.disabled = false;
|
||||
options.status.textContent = optimized ? 'Screenshot optimized and ready to upload.' :
|
||||
(options.readyMessage || 'Screenshot ready to upload with this comment.');
|
||||
}
|
||||
|
||||
options.input.addEventListener('change', event => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
const files = Array.from(event.target.files || []);
|
||||
const sequence = ++selectionSequence;
|
||||
let result;
|
||||
try {
|
||||
result = controller.select(file);
|
||||
} catch (error) {
|
||||
if (!files.length) return;
|
||||
let first;
|
||||
try { first = controller.select(files[0]); }
|
||||
catch (error) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
return;
|
||||
}
|
||||
if (!result || typeof result.then !== 'function') {
|
||||
showPreview(file, false);
|
||||
if (!reviewEnabled && files.length === 1 && (!first || typeof first.then !== 'function')) {
|
||||
showPreview(files[0], false);
|
||||
return;
|
||||
}
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
options.input.disabled = true;
|
||||
return result.then(() => controller.serialize()).then(value => {
|
||||
if (sequence === selectionSequence) showPreview(value.blob, true);
|
||||
let optimized = Boolean(first && typeof first.then === 'function');
|
||||
if (optimized) {
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
options.input.disabled = true;
|
||||
}
|
||||
const selectAll = files.slice(1).reduce((pending, file) => pending.then(async () => {
|
||||
const result = controller.select(file);
|
||||
if (result && typeof result.then === 'function') {
|
||||
optimized = true;
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
options.input.disabled = true;
|
||||
await result;
|
||||
}
|
||||
}), Promise.resolve(first));
|
||||
return selectAll.then(() => controller.serialize()).then(value => {
|
||||
if (sequence !== selectionSequence) return;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
const latest = values[values.length - 1];
|
||||
if (reviewEnabled) {
|
||||
review.render(values, values.length - 1, optimized);
|
||||
} else {
|
||||
showPreview(latest.blob, optimized);
|
||||
if (values.length > 1) {
|
||||
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
|
||||
options.status.textContent = values.length + ' screenshots ready to file in this order.';
|
||||
}
|
||||
}
|
||||
}).catch(error => {
|
||||
if (sequence === selectionSequence) {
|
||||
options.status.textContent = error.message;
|
||||
@@ -236,24 +316,68 @@
|
||||
});
|
||||
});
|
||||
options.remove.addEventListener('click', () => {
|
||||
clearPreview();
|
||||
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
|
||||
const current = controller.state();
|
||||
const count = Array.isArray(current) ? current.length : (current ? 1 : 0);
|
||||
if (count <= 1) {
|
||||
clearPreview();
|
||||
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
|
||||
return Promise.resolve();
|
||||
}
|
||||
const removedIndex = reviewEnabled ? review.activeIndex() : count - 1;
|
||||
controller.remove(removedIndex);
|
||||
selectionSequence += 1;
|
||||
review?.afterRemoval(removedIndex, count);
|
||||
return controller.serialize().then(value => {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
if (reviewEnabled) {
|
||||
review.render(values, review.activeIndex());
|
||||
} else {
|
||||
const latest = values[values.length - 1];
|
||||
showPreview(latest.blob, false);
|
||||
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
|
||||
}
|
||||
options.status.textContent = options.removedMessage || 'Latest screenshot removed. Your text is unchanged.';
|
||||
});
|
||||
});
|
||||
|
||||
function restorePreview(value) {
|
||||
clearPreview();
|
||||
const restored = controller.restore(value);
|
||||
previewUrl = value.blob ? options.createObjectURL(value.blob) :
|
||||
'data:' + value.contentType + ';base64,' + value.data;
|
||||
const restored = restoreSelection(value);
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
if (reviewEnabled) {
|
||||
review.render(values, values.length - 1);
|
||||
return restored;
|
||||
}
|
||||
const latest = values[values.length - 1];
|
||||
previewUrl = latest.blob ? options.createObjectURL(latest.blob) :
|
||||
'data:' + latest.contentType + ';base64,' + latest.data;
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
|
||||
options.meta.textContent = values.length > 1 ?
|
||||
values.length + ' screenshots ready · latest: ' + latest.filename :
|
||||
restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
||||
options.status.textContent = values.length > 1 ?
|
||||
values.length + ' screenshots ready to file in this order.' :
|
||||
(options.readyMessage || 'Screenshot ready to upload with this comment.');
|
||||
return restored;
|
||||
}
|
||||
|
||||
if (review && options.editor && issueEvidenceEditor) {
|
||||
issueEvidenceEditor.mount({
|
||||
...options.editor,
|
||||
controller,
|
||||
getActiveIndex:review.activeIndex,
|
||||
optimizeImage,
|
||||
onApplied: async index => {
|
||||
const value = await controller.serialize();
|
||||
review.render(value, index);
|
||||
options.status.textContent = 'Edited screenshot flattened and ready to file.';
|
||||
},
|
||||
});
|
||||
options.editor.edit.disabled = !controller.state();
|
||||
}
|
||||
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
|
||||
}
|
||||
|
||||
return { create, mount, multipart, optimizeImage, MAX_BYTES };
|
||||
return { create, mount, multipart, optimizeImage, MAX_BYTES, MAX_PIXELS };
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.issueEvidenceEditor = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
function boundedRect(value, bounds) {
|
||||
const x = Math.max(bounds.x, Math.min(bounds.x + bounds.width - 1, Math.round(Number(value.x) || 0)));
|
||||
const y = Math.max(bounds.y, Math.min(bounds.y + bounds.height - 1, Math.round(Number(value.y) || 0)));
|
||||
const width = Math.max(1, Math.min(bounds.x + bounds.width - x, Math.round(Number(value.width) || 0)));
|
||||
const height = Math.max(1, Math.min(bounds.y + bounds.height - y, Math.round(Number(value.height) || 0)));
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function boundedPoint(value, bounds) {
|
||||
return {
|
||||
x: Math.max(bounds.x, Math.min(bounds.x + bounds.width, Math.round(Number(value.x) || 0))),
|
||||
y: Math.max(bounds.y, Math.min(bounds.y + bounds.height, Math.round(Number(value.y) || 0))),
|
||||
};
|
||||
}
|
||||
|
||||
function createModel(size) {
|
||||
const full = { x:0, y:0, width:Math.max(1, Math.round(size.width)), height:Math.max(1, Math.round(size.height)) };
|
||||
let crop = { ...full };
|
||||
let redactions = [];
|
||||
let annotations = [];
|
||||
let history = [];
|
||||
|
||||
function remember() {
|
||||
history.push({
|
||||
crop:{...crop}, redactions:redactions.map(value => ({...value})),
|
||||
annotations:annotations.map(value => ({...value})),
|
||||
});
|
||||
}
|
||||
function intersects(annotation, bounds) {
|
||||
const left = annotation.type === 'arrow' ? Math.min(annotation.startX, annotation.endX) : annotation.x;
|
||||
const top = annotation.type === 'arrow' ? Math.min(annotation.startY, annotation.endY) : annotation.y;
|
||||
const right = annotation.type === 'arrow' ? Math.max(annotation.startX, annotation.endX) : annotation.x + annotation.width;
|
||||
const bottom = annotation.type === 'arrow' ? Math.max(annotation.startY, annotation.endY) : annotation.y + annotation.height;
|
||||
return left < bounds.x + bounds.width && top < bounds.y + bounds.height && right > bounds.x && bottom > bounds.y;
|
||||
}
|
||||
function clampAnnotation(annotation) {
|
||||
if (annotation.type === 'highlight') {
|
||||
const x = Math.max(crop.x, annotation.x);
|
||||
const y = Math.max(crop.y, annotation.y);
|
||||
const right = Math.min(crop.x + crop.width, annotation.x + annotation.width);
|
||||
const bottom = Math.min(crop.y + crop.height, annotation.y + annotation.height);
|
||||
return { type:'highlight', x, y, width:Math.max(1, right - x), height:Math.max(1, bottom - y) };
|
||||
}
|
||||
const start = boundedPoint({x:annotation.startX, y:annotation.startY}, crop);
|
||||
const end = boundedPoint({x:annotation.endX, y:annotation.endY}, crop);
|
||||
return { type:'arrow', startX:start.x, startY:start.y, endX:end.x, endY:end.y };
|
||||
}
|
||||
function setCrop(value) {
|
||||
remember();
|
||||
crop = boundedRect(value, crop);
|
||||
redactions = redactions.filter(rectangle =>
|
||||
rectangle.x < crop.x + crop.width && rectangle.y < crop.y + crop.height &&
|
||||
rectangle.x + rectangle.width > crop.x && rectangle.y + rectangle.height > crop.y
|
||||
).map(rectangle => boundedRect(rectangle, crop));
|
||||
annotations = annotations.filter(annotation => intersects(annotation, crop)).map(clampAnnotation);
|
||||
return snapshot();
|
||||
}
|
||||
function addRedaction(value) {
|
||||
remember();
|
||||
redactions.push(boundedRect(value, crop));
|
||||
return snapshot();
|
||||
}
|
||||
function addHighlight(value) {
|
||||
remember();
|
||||
annotations.push({ type:'highlight', ...boundedRect(value, crop) });
|
||||
return snapshot();
|
||||
}
|
||||
function addArrow(value) {
|
||||
remember();
|
||||
const start = boundedPoint({x:value.startX, y:value.startY}, crop);
|
||||
const end = boundedPoint({x:value.endX, y:value.endY}, crop);
|
||||
annotations.push({ type:'arrow', startX:start.x, startY:start.y, endX:end.x, endY:end.y });
|
||||
return snapshot();
|
||||
}
|
||||
function undo() {
|
||||
const previous = history.pop();
|
||||
if (previous) {
|
||||
crop = previous.crop;
|
||||
redactions = previous.redactions;
|
||||
annotations = previous.annotations;
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
function reset() {
|
||||
remember();
|
||||
crop = { ...full };
|
||||
redactions = [];
|
||||
annotations = [];
|
||||
return snapshot();
|
||||
}
|
||||
function snapshot() {
|
||||
return {
|
||||
crop:{...crop}, redactions:redactions.map(value => ({...value})),
|
||||
annotations:annotations.map(value => ({...value})),
|
||||
};
|
||||
}
|
||||
return { addArrow, addHighlight, addRedaction, reset, setCrop, snapshot, undo };
|
||||
}
|
||||
|
||||
function namedBlob(blob, name) {
|
||||
if (typeof File === 'function') return new File([blob], name, {type:blob.type});
|
||||
Object.defineProperty(blob, 'name', {value:name, configurable:true});
|
||||
return blob;
|
||||
}
|
||||
|
||||
function paint(options) {
|
||||
const state = options.model.snapshot();
|
||||
const maxDimension = Number.isFinite(options.maxDimension) ? options.maxDimension : Infinity;
|
||||
const scale = Math.min(1, maxDimension / Math.max(state.crop.width, state.crop.height));
|
||||
const width = Math.max(1, Math.round(state.crop.width * scale));
|
||||
const height = Math.max(1, Math.round(state.crop.height * scale));
|
||||
options.canvas.width = width;
|
||||
options.canvas.height = height;
|
||||
const context = options.canvas.getContext('2d');
|
||||
if (!context) throw new Error('Screenshot editing is unavailable in this browser.');
|
||||
context.drawImage(options.source, state.crop.x, state.crop.y, state.crop.width, state.crop.height, 0, 0, width, height);
|
||||
context.fillStyle = '#000000';
|
||||
state.redactions.forEach(rectangle => context.fillRect(
|
||||
Math.round((rectangle.x - state.crop.x) * scale),
|
||||
Math.round((rectangle.y - state.crop.y) * scale),
|
||||
Math.round(rectangle.width * scale),
|
||||
Math.round(rectangle.height * scale),
|
||||
));
|
||||
state.annotations.forEach(annotation => {
|
||||
if (annotation.type === 'highlight') {
|
||||
context.fillStyle = 'rgba(250, 204, 21, 0.38)';
|
||||
context.fillRect(
|
||||
Math.round((annotation.x - state.crop.x) * scale),
|
||||
Math.round((annotation.y - state.crop.y) * scale),
|
||||
Math.round(annotation.width * scale),
|
||||
Math.round(annotation.height * scale),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const startX = (annotation.startX - state.crop.x) * scale;
|
||||
const startY = (annotation.startY - state.crop.y) * scale;
|
||||
const endX = (annotation.endX - state.crop.x) * scale;
|
||||
const endY = (annotation.endY - state.crop.y) * scale;
|
||||
const angle = Math.atan2(endY - startY, endX - startX);
|
||||
const head = Math.max(10, Math.min(24, 16 * scale));
|
||||
context.save();
|
||||
context.strokeStyle = '#facc15';
|
||||
context.lineWidth = Math.max(4, 6 * scale);
|
||||
context.lineCap = 'round';
|
||||
context.lineJoin = 'round';
|
||||
context.beginPath();
|
||||
context.moveTo(Math.round(startX), Math.round(startY));
|
||||
context.lineTo(Math.round(endX), Math.round(endY));
|
||||
context.lineTo(endX - head * Math.cos(angle - Math.PI / 6), endY - head * Math.sin(angle - Math.PI / 6));
|
||||
context.moveTo(Math.round(endX), Math.round(endY));
|
||||
context.lineTo(endX - head * Math.cos(angle + Math.PI / 6), endY - head * Math.sin(angle + Math.PI / 6));
|
||||
context.stroke();
|
||||
context.restore();
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
async function flatten(options) {
|
||||
paint(options);
|
||||
const type = ['image/png', 'image/jpeg', 'image/webp'].includes(options.type) ? options.type : 'image/png';
|
||||
const blob = await new Promise(resolve => options.canvas.toBlob(resolve, type, type === 'image/png' ? undefined : 0.9));
|
||||
if (!blob || !blob.size) throw new Error('The edited screenshot could not be saved. Try again.');
|
||||
return namedBlob(blob, options.name || 'edited-screenshot.png');
|
||||
}
|
||||
|
||||
function attachmentBlob(value) {
|
||||
if (value.blob) return value.blob;
|
||||
const binary = atob(String(value.data || ''));
|
||||
return new Blob([Uint8Array.from(binary, character => character.charCodeAt(0))], {type:value.contentType});
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
let source = null;
|
||||
let model = null;
|
||||
let mode = 'redact';
|
||||
let start = null;
|
||||
let selectedIndex = -1;
|
||||
let previousFocus = null;
|
||||
|
||||
function status(message) { options.status.textContent = message; }
|
||||
function setMode(value) {
|
||||
mode = value;
|
||||
options.crop.setAttribute('aria-pressed', value === 'crop' ? 'true' : 'false');
|
||||
options.redact.setAttribute('aria-pressed', value === 'redact' ? 'true' : 'false');
|
||||
options.highlight.setAttribute('aria-pressed', value === 'highlight' ? 'true' : 'false');
|
||||
options.arrow.setAttribute('aria-pressed', value === 'arrow' ? 'true' : 'false');
|
||||
status({
|
||||
crop:'Drag around the part to keep.', redact:'Drag over private information to hide it.',
|
||||
highlight:'Drag around the detail to highlight.', arrow:'Drag toward the detail you want to point out.',
|
||||
}[value]);
|
||||
}
|
||||
function render() {
|
||||
if (source && model) paint({source, model, canvas:options.canvas, maxDimension:1600});
|
||||
}
|
||||
function close() {
|
||||
options.dialog.hidden = true;
|
||||
if (source && typeof source.close === 'function') source.close();
|
||||
source = null;
|
||||
model = null;
|
||||
start = null;
|
||||
(previousFocus || options.edit).focus();
|
||||
}
|
||||
async function open() {
|
||||
const value = await options.controller.serialize();
|
||||
const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
|
||||
selectedIndex = options.getActiveIndex();
|
||||
const selected = values[selectedIndex];
|
||||
if (!selected) return;
|
||||
previousFocus = options.document.activeElement;
|
||||
status('Opening screenshot editor…');
|
||||
try {
|
||||
const decode = options.decode || globalThis.createImageBitmap;
|
||||
if (typeof decode !== 'function') throw new Error('decode');
|
||||
source = await decode(attachmentBlob(selected));
|
||||
model = createModel({width:source.width, height:source.height});
|
||||
options.dialog.hidden = false;
|
||||
render();
|
||||
setMode('redact');
|
||||
options.redact.focus();
|
||||
} catch (_error) {
|
||||
source = null;
|
||||
status('This browser cannot edit the screenshot. Your original is unchanged.');
|
||||
}
|
||||
}
|
||||
function point(event) {
|
||||
const box = options.canvas.getBoundingClientRect();
|
||||
const state = model.snapshot();
|
||||
return {
|
||||
x:state.crop.x + Math.round((event.clientX - box.left) * state.crop.width / box.width),
|
||||
y:state.crop.y + Math.round((event.clientY - box.top) * state.crop.height / box.height),
|
||||
};
|
||||
}
|
||||
options.canvas.addEventListener('pointerdown', event => {
|
||||
if (!model) return;
|
||||
start = point(event);
|
||||
options.canvas.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
options.canvas.addEventListener('pointerup', event => {
|
||||
if (!model || !start) return;
|
||||
const end = point(event);
|
||||
const dragStart = start;
|
||||
const rectangle = {
|
||||
x:Math.min(dragStart.x, end.x), y:Math.min(dragStart.y, end.y),
|
||||
width:Math.abs(end.x - dragStart.x), height:Math.abs(end.y - dragStart.y),
|
||||
};
|
||||
start = null;
|
||||
const tooSmall = mode === 'arrow' ? Math.hypot(end.x - dragStart.x, end.y - dragStart.y) < 8 :
|
||||
rectangle.width < 3 || rectangle.height < 3;
|
||||
if (tooSmall) {
|
||||
status('Drag a larger area on the screenshot.');
|
||||
return;
|
||||
}
|
||||
if (mode === 'crop') model.setCrop(rectangle);
|
||||
else if (mode === 'redact') model.addRedaction(rectangle);
|
||||
else if (mode === 'highlight') model.addHighlight(rectangle);
|
||||
else model.addArrow({startX:dragStart.x, startY:dragStart.y, endX:end.x, endY:end.y});
|
||||
render();
|
||||
status({
|
||||
crop:'Crop applied. Undo or reset if needed.', redact:'Private area hidden with an opaque redaction.',
|
||||
highlight:'Highlight added.', arrow:'Arrow added.',
|
||||
}[mode]);
|
||||
});
|
||||
options.edit.addEventListener('click', open);
|
||||
options.crop.addEventListener('click', () => setMode('crop'));
|
||||
options.redact.addEventListener('click', () => setMode('redact'));
|
||||
options.highlight.addEventListener('click', () => setMode('highlight'));
|
||||
options.arrow.addEventListener('click', () => setMode('arrow'));
|
||||
options.undo.addEventListener('click', () => { if (model) { model.undo(); render(); status('Last edit undone.'); } });
|
||||
options.reset.addEventListener('click', () => { if (model) { model.reset(); render(); status('Crop, redactions, and annotations reset.'); } });
|
||||
options.cancel.addEventListener('click', close);
|
||||
options.dialog.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); close(); } });
|
||||
options.apply.addEventListener('click', async () => {
|
||||
if (!model || !source) return;
|
||||
options.apply.disabled = true;
|
||||
status('Applying flattened edit…');
|
||||
try {
|
||||
const current = await options.controller.serialize();
|
||||
const values = Array.isArray(current) ? current : [current];
|
||||
const selected = values[selectedIndex];
|
||||
let derivative = await flatten({
|
||||
source, model, canvas:options.exportCanvas, maxDimension:2048,
|
||||
name:selected.filename, type:selected.contentType,
|
||||
});
|
||||
if (options.optimizeImage) derivative = await options.optimizeImage(derivative);
|
||||
options.controller.replace(selectedIndex, derivative);
|
||||
await options.onApplied(selectedIndex);
|
||||
close();
|
||||
} catch (error) {
|
||||
status(error.message || 'The edited screenshot could not be applied. Your original is unchanged.');
|
||||
} finally {
|
||||
options.apply.disabled = false;
|
||||
}
|
||||
});
|
||||
return { close, open };
|
||||
}
|
||||
|
||||
return { createModel, flatten, mount, paint };
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.issueEvidenceReview = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_PIXELS = 12 * 1024 * 1024;
|
||||
const MAX_DIMENSION = 8192;
|
||||
|
||||
function namedBlob(blob, name) {
|
||||
if (typeof File === 'function') return new File([blob], name, { type: blob.type });
|
||||
Object.defineProperty(blob, 'name', { value: name, configurable: true });
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function optimizeImage(file, environment = {}) {
|
||||
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
|
||||
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
|
||||
if (typeof decode !== 'function') throw new Error('This browser cannot safely inspect the screenshot. Try cropping it and choose it again.');
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await decode(file);
|
||||
const width = Number(bitmap.width);
|
||||
const height = Number(bitmap.height);
|
||||
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
|
||||
throw new Error('decode');
|
||||
}
|
||||
const pixelScale = Math.min(1, Math.sqrt(MAX_PIXELS / (width * height)));
|
||||
const dimensionScale = Math.min(1, MAX_DIMENSION / Math.max(width, height));
|
||||
const byteScale = file.size > MAX_BYTES ? Math.sqrt(MAX_BYTES / file.size) * 0.92 : 1;
|
||||
let scale = Math.min(pixelScale, dimensionScale, byteScale);
|
||||
if (scale === 1 && file.size <= MAX_BYTES) return file;
|
||||
const canvas = makeCanvas();
|
||||
const context = canvas && canvas.getContext && canvas.getContext('2d');
|
||||
if (!context) throw new Error('decode');
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
canvas.width = Math.max(1, Math.floor(width * scale));
|
||||
canvas.height = Math.max(1, Math.floor(height * scale));
|
||||
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86));
|
||||
if (!blob) throw new Error('encode');
|
||||
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
|
||||
scale *= 0.8;
|
||||
}
|
||||
} catch (_error) {
|
||||
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
|
||||
} finally {
|
||||
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
|
||||
}
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const controller = options.controller;
|
||||
const documentRef = options.document || document;
|
||||
let activeIndex = 0;
|
||||
let busy = false;
|
||||
let previewUrl = '';
|
||||
let thumbnailUrls = [];
|
||||
|
||||
function revoke(url) {
|
||||
if (url && !url.startsWith('data:')) options.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function attachmentUrl(attachment) {
|
||||
return attachment?.blob ? options.createObjectURL(attachment.blob) :
|
||||
'data:' + attachment.contentType + ';base64,' + attachment.data;
|
||||
}
|
||||
|
||||
function count() {
|
||||
const value = controller.state();
|
||||
return Array.isArray(value) ? value.length : (value ? 1 : 0);
|
||||
}
|
||||
|
||||
function setBusy(value) {
|
||||
busy = Boolean(value);
|
||||
options.earlier.disabled = busy || activeIndex <= 0;
|
||||
options.later.disabled = busy || activeIndex >= count() - 1;
|
||||
if (options.note) options.note.disabled = busy;
|
||||
if (options.edit) options.edit.disabled = busy || !count();
|
||||
Array.from(options.tray.children || []).forEach(button => { button.disabled = busy; });
|
||||
}
|
||||
|
||||
function updateNote(values) {
|
||||
if (!options.note) return;
|
||||
options.note.value = controller.note(activeIndex);
|
||||
options.note.disabled = busy;
|
||||
if (options.noteLabel) {
|
||||
options.noteLabel.textContent = 'Evidence note for screenshot ' + (activeIndex + 1) + ' of ' +
|
||||
values.length + ' (optional)';
|
||||
}
|
||||
}
|
||||
|
||||
function update(values, optimized = false) {
|
||||
if (!values.length) return;
|
||||
activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1));
|
||||
revoke(previewUrl);
|
||||
previewUrl = attachmentUrl(values[activeIndex]);
|
||||
options.image.src = previewUrl;
|
||||
const current = values[activeIndex];
|
||||
const size = current.blob ? current.blob.size : Math.max(1, Math.floor(String(current.data || '').length * 3 / 4));
|
||||
options.meta.textContent = 'Screenshot ' + (activeIndex + 1) + ' of ' + values.length + ' · ' +
|
||||
current.filename + ' · ' + Math.ceil(size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
if (options.edit) options.edit.disabled = busy;
|
||||
Array.from(options.tray.children || []).forEach((button, index) =>
|
||||
button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false'));
|
||||
options.earlier.disabled = busy || activeIndex === 0;
|
||||
options.later.disabled = busy || activeIndex === values.length - 1;
|
||||
updateNote(values);
|
||||
options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' :
|
||||
values.length + ' screenshots ready to file in this order.';
|
||||
}
|
||||
|
||||
function render(value, index = activeIndex, optimized = false) {
|
||||
const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
|
||||
activeIndex = Math.max(0, Math.min(index, values.length - 1));
|
||||
thumbnailUrls.forEach(revoke);
|
||||
thumbnailUrls = [];
|
||||
options.tray.replaceChildren(...values.map((item, itemIndex) => {
|
||||
const button = documentRef.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'issue-evidence-thumbnail';
|
||||
button.setAttribute('aria-label', 'Review screenshot ' + (itemIndex + 1) + ' of ' + values.length + ': ' + item.filename);
|
||||
const thumbnail = documentRef.createElement('img');
|
||||
thumbnail.alt = '';
|
||||
thumbnail.src = attachmentUrl(item);
|
||||
thumbnailUrls.push(thumbnail.src);
|
||||
button.appendChild(thumbnail);
|
||||
const position = documentRef.createElement('span');
|
||||
position.textContent = String(itemIndex + 1);
|
||||
button.appendChild(position);
|
||||
button.addEventListener('click', () => { activeIndex = itemIndex; update(values); });
|
||||
return button;
|
||||
}));
|
||||
options.tray.hidden = !values.length;
|
||||
if (values.length) update(values, optimized);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
revoke(previewUrl);
|
||||
thumbnailUrls.forEach(revoke);
|
||||
previewUrl = '';
|
||||
thumbnailUrls = [];
|
||||
activeIndex = 0;
|
||||
options.tray.replaceChildren();
|
||||
options.tray.hidden = true;
|
||||
options.earlier.disabled = true;
|
||||
options.later.disabled = true;
|
||||
if (options.note) {
|
||||
options.note.value = '';
|
||||
options.note.disabled = true;
|
||||
}
|
||||
if (options.edit) options.edit.disabled = true;
|
||||
if (options.noteLabel) options.noteLabel.textContent = 'Evidence note (optional)';
|
||||
}
|
||||
|
||||
function afterRemoval(removedIndex, previousCount) {
|
||||
activeIndex = Math.min(removedIndex, previousCount - 2);
|
||||
}
|
||||
|
||||
function move(offset) {
|
||||
const total = count();
|
||||
const destination = activeIndex + offset;
|
||||
if (destination < 0 || destination >= total) return Promise.resolve();
|
||||
controller.move(activeIndex, destination);
|
||||
activeIndex = destination;
|
||||
return controller.serialize().then(value => {
|
||||
render(value, activeIndex);
|
||||
options.status.textContent = 'Screenshot moved to position ' + (activeIndex + 1) + ' of ' + total + '.';
|
||||
});
|
||||
}
|
||||
|
||||
options.earlier.addEventListener('click', () => move(-1));
|
||||
options.later.addEventListener('click', () => move(1));
|
||||
options.note?.addEventListener('input', event => {
|
||||
controller.setNote(activeIndex, event.target.value);
|
||||
});
|
||||
clear();
|
||||
return { activeIndex: () => activeIndex, afterRemoval, clear, render, setBusy };
|
||||
}
|
||||
|
||||
return { create, optimizeImage, MAX_PIXELS };
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createIssueFilingReceipt = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
function createIssueFilingReceipt({
|
||||
root, heading, key, title, ownership, openLink, shareButton, filedButton,
|
||||
relatedButton, fileAnotherButton, doneButton, status, navigator = {}, clipboard = navigator.clipboard,
|
||||
onFileRelated = function () {},
|
||||
onFileAnother = function () {},
|
||||
onViewFiled = function () {},
|
||||
}) {
|
||||
let active = null;
|
||||
let relatedPlan = null;
|
||||
let restoreFocus = null;
|
||||
|
||||
function close() {
|
||||
root.hidden = true;
|
||||
active = null;
|
||||
relatedPlan = null;
|
||||
const target = restoreFocus;
|
||||
restoreFocus = null;
|
||||
target?.focus?.();
|
||||
}
|
||||
|
||||
async function share() {
|
||||
if (!active) return;
|
||||
const payload = {
|
||||
title: active.repository + '#' + active.number + ' · ' + active.title,
|
||||
text: active.title,
|
||||
url: active.url,
|
||||
};
|
||||
if (typeof navigator.share === 'function') {
|
||||
try {
|
||||
await navigator.share(payload);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
}
|
||||
}
|
||||
if (typeof clipboard?.writeText !== 'function') {
|
||||
status.textContent = 'Issue link is ready in Open in Gitea.';
|
||||
return;
|
||||
}
|
||||
await clipboard.writeText(active.url);
|
||||
status.textContent = 'Issue link copied.';
|
||||
}
|
||||
|
||||
function show(issue, returnTarget, reusablePlan = null) {
|
||||
active = issue;
|
||||
relatedPlan = reusablePlan;
|
||||
restoreFocus = returnTarget || null;
|
||||
key.textContent = issue.repository + '#' + issue.number;
|
||||
title.textContent = issue.title;
|
||||
ownership.textContent = issue.assignees?.length
|
||||
? 'Assigned to @' + issue.assignees.join(', @') + '.'
|
||||
: 'Created with no owner.';
|
||||
openLink.href = issue.url;
|
||||
status.textContent = '';
|
||||
if (relatedButton) relatedButton.hidden = !relatedPlan;
|
||||
root.hidden = false;
|
||||
heading.focus();
|
||||
}
|
||||
|
||||
shareButton.addEventListener('click', share);
|
||||
filedButton?.addEventListener('click', () => {
|
||||
if (!active) return;
|
||||
const issue = active;
|
||||
close();
|
||||
onViewFiled(issue);
|
||||
});
|
||||
root.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || root.hidden) return;
|
||||
event.preventDefault();
|
||||
close();
|
||||
});
|
||||
doneButton.addEventListener('click', close);
|
||||
relatedButton?.addEventListener('click', () => {
|
||||
if (!relatedPlan) return;
|
||||
const plan = relatedPlan;
|
||||
close();
|
||||
onFileRelated(plan);
|
||||
});
|
||||
fileAnotherButton.addEventListener('click', () => {
|
||||
close();
|
||||
onFileAnother();
|
||||
});
|
||||
|
||||
return { show, close, share };
|
||||
}
|
||||
|
||||
return createIssueFilingReceipt;
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createIssueFilingReview = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const INTENT_LABELS = {
|
||||
'create-and-assign': 'Create & assign to me',
|
||||
'create-and-start': 'Create & start',
|
||||
'follow-up-and-next': 'Create follow-up & next',
|
||||
};
|
||||
|
||||
function clone(value) {
|
||||
if (typeof structuredClone === 'function') return structuredClone(value);
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
let reviewed = null;
|
||||
let trigger = null;
|
||||
let busy = false;
|
||||
let evidenceItems = [];
|
||||
const generatedUrls = new Set();
|
||||
|
||||
function releaseEvidenceUrls() {
|
||||
generatedUrls.forEach(url => options.revokeObjectURL?.(url));
|
||||
generatedUrls.clear();
|
||||
evidenceItems = [];
|
||||
}
|
||||
|
||||
function evidenceUrl(attachment) {
|
||||
if (attachment?.blob) {
|
||||
const url = options.createObjectURL(attachment.blob);
|
||||
generatedUrls.add(url);
|
||||
return url;
|
||||
}
|
||||
if (attachment?.data) {
|
||||
return 'data:' + String(attachment.contentType || 'application/octet-stream') +
|
||||
';base64,' + String(attachment.data);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function selectEvidence(index) {
|
||||
const selected = evidenceItems[index];
|
||||
if (!selected) return;
|
||||
evidenceItems.forEach((item, position) =>
|
||||
item.button.setAttribute('aria-pressed', position === index ? 'true' : 'false'));
|
||||
const filename = selected.attachment.filename || 'Screenshot';
|
||||
options.evidencePreview.hidden = false;
|
||||
options.evidenceImage.src = selected.url;
|
||||
options.evidenceImage.alt = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length + ': ' + filename;
|
||||
options.evidencePosition.textContent = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length;
|
||||
options.evidenceFilename.textContent = filename;
|
||||
options.evidenceNote.textContent = selected.attachment.note || 'No evidence note.';
|
||||
}
|
||||
|
||||
function renderVisualEvidence(attachments) {
|
||||
releaseEvidenceUrls();
|
||||
options.evidenceEmpty.hidden = attachments.length > 0;
|
||||
options.evidencePreview.hidden = true;
|
||||
options.evidenceImage.src = '';
|
||||
evidenceItems = attachments.map((attachment, index) => {
|
||||
const url = evidenceUrl(attachment);
|
||||
const item = options.document.createElement('li');
|
||||
const button = options.document.createElement('button');
|
||||
const image = options.document.createElement('img');
|
||||
const filename = attachment.filename || 'Screenshot';
|
||||
button.setAttribute('type', 'button');
|
||||
button.className = 'issue-filing-evidence-selector';
|
||||
button.setAttribute('aria-label', 'Preview evidence ' + (index + 1) + ' of ' + attachments.length + ': ' + filename);
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
image.src = url;
|
||||
image.alt = '';
|
||||
button.appendChild(image);
|
||||
item.appendChild(button);
|
||||
return { attachment, button, item, url };
|
||||
});
|
||||
evidenceItems.forEach((entry, index) =>
|
||||
entry.button.addEventListener('click', () => selectEvidence(index)));
|
||||
options.evidenceList.replaceChildren(...evidenceItems.map(entry => entry.item));
|
||||
if (evidenceItems.length) selectEvidence(0);
|
||||
}
|
||||
|
||||
function close() {
|
||||
options.sheet.hidden = true;
|
||||
const previousTrigger = trigger;
|
||||
trigger = null;
|
||||
reviewed = null;
|
||||
busy = false;
|
||||
releaseEvidenceUrls();
|
||||
options.confirmButton.disabled = false;
|
||||
previousTrigger?.focus();
|
||||
}
|
||||
|
||||
function render(payload) {
|
||||
const draft = payload.draft;
|
||||
options.repository.textContent = draft.repository || 'No repository';
|
||||
options.intent.textContent = draft.unassigned === true ? 'Create unassigned' :
|
||||
(INTENT_LABELS[payload.intent] || payload.intent);
|
||||
if (options.issueType) options.issueType.textContent = draft.templateName || 'Blank issue';
|
||||
options.title.textContent = draft.title;
|
||||
options.body.textContent = draft.body || 'No note provided.';
|
||||
const labels = (draft.labels || []).map(label => typeof label === 'string' ? label : label.name);
|
||||
const milestone = draft.milestone?.title || draft.milestoneTitle || 'No milestone';
|
||||
const dueDate = draft.dueDate || draft.due_date || 'No due date';
|
||||
const owner = draft.unassigned === true ? 'Owner: No owner' : (draft.assignee
|
||||
? 'Owner: ' + (draft.assigneeName || draft.assignee) + ' (@' + draft.assignee + ')'
|
||||
: 'Assigned to you');
|
||||
const planning = [];
|
||||
if (Number.isInteger(draft.estimateMinutes)) {
|
||||
planning.push('Estimate: ' + draft.estimateMinutes + ' min');
|
||||
const projected = draft.todayCapacity?.projectedMinutes;
|
||||
if (Number.isInteger(projected)) planning.push(projected >= 0
|
||||
? 'Today after start: ' + projected + ' min free'
|
||||
: 'Today after start: ' + Math.abs(projected) + ' min over capacity');
|
||||
}
|
||||
options.metadata.textContent = [
|
||||
labels.length ? 'Labels: ' + labels.join(', ') : 'No labels',
|
||||
'Milestone: ' + milestone,
|
||||
'Due: ' + dueDate,
|
||||
owner,
|
||||
...planning,
|
||||
].join(' · ');
|
||||
if (options.blockerList) {
|
||||
options.blockerList.replaceChildren(...(draft.blockers || []).map(blocker => {
|
||||
const item = options.document.createElement('li');
|
||||
item.textContent = blocker.repository + ' #' + blocker.number + ' — ' + blocker.title;
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
const attachments = (draft.attachments || (draft.attachment ? [draft.attachment] : [])).filter(Boolean);
|
||||
if (options.evidencePreview) {
|
||||
renderVisualEvidence(attachments);
|
||||
return;
|
||||
}
|
||||
options.evidenceList.replaceChildren(...attachments.map((attachment, index) => {
|
||||
const item = options.document.createElement('li');
|
||||
item.textContent = (index + 1) + '. ' + (attachment.filename || 'Screenshot') +
|
||||
(attachment.note ? ' — ' + attachment.note : '');
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
function open(payload, opener) {
|
||||
reviewed = clone(payload);
|
||||
trigger = opener || null;
|
||||
busy = false;
|
||||
options.confirmButton.disabled = false;
|
||||
render(reviewed);
|
||||
options.sheet.hidden = false;
|
||||
options.backButton.focus();
|
||||
}
|
||||
|
||||
options.backButton.addEventListener('click', close);
|
||||
options.confirmButton.addEventListener('click', async () => {
|
||||
if (!reviewed || busy) return;
|
||||
busy = true;
|
||||
options.confirmButton.disabled = true;
|
||||
try {
|
||||
await options.onConfirm(reviewed);
|
||||
close();
|
||||
} catch (error) {
|
||||
busy = false;
|
||||
options.confirmButton.disabled = false;
|
||||
if (options.status) options.status.textContent = error.message;
|
||||
options.confirmButton.focus();
|
||||
}
|
||||
});
|
||||
options.document.addEventListener('keydown', event => {
|
||||
if (!options.sheet.hidden && event.key === 'Escape' && !busy) {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
});
|
||||
return { close, open };
|
||||
}
|
||||
|
||||
return create;
|
||||
});
|
||||
+212
-40
@@ -11,10 +11,72 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
|
||||
if (blob) return { filename, contentType, blob };
|
||||
if (!data && value?.stored === true) return { filename, contentType, stored: true };
|
||||
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
const noteValue = note ? { note } : {};
|
||||
if (blob) return { filename, contentType, blob, ...noteValue };
|
||||
if (!data && value?.stored === true) return { filename, contentType, stored: true, ...noteValue };
|
||||
if (!data) return undefined;
|
||||
return { filename, contentType, data };
|
||||
return { filename, contentType, data, ...noteValue };
|
||||
}
|
||||
|
||||
function captureAttachments(values) {
|
||||
if (!Array.isArray(values)) return undefined;
|
||||
if (values.length > 5) throw new Error('You can attach up to 5 screenshots.');
|
||||
const attachments = values.map(captureAttachment);
|
||||
if (attachments.some(value => !value)) {
|
||||
throw new Error('Some screenshots are unavailable. Choose them again before queueing.');
|
||||
}
|
||||
return attachments.length ? attachments : undefined;
|
||||
}
|
||||
|
||||
function captureBlockers(values) {
|
||||
if (!Array.isArray(values)) return undefined;
|
||||
const seen = new Set();
|
||||
const blockers = [];
|
||||
for (const value of values) {
|
||||
const repository = String(value?.repository || '').trim();
|
||||
const number = Number(value?.number);
|
||||
const key = repository + '#' + number;
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
||||
!Number.isInteger(number) || number < 1 || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
blockers.push({
|
||||
repository, number,
|
||||
title: String(value?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255),
|
||||
});
|
||||
if (blockers.length === 5) break;
|
||||
}
|
||||
return blockers.length ? blockers : undefined;
|
||||
}
|
||||
|
||||
function captureEstimate(value) {
|
||||
const estimate = Number(value);
|
||||
return Number.isInteger(estimate) && estimate >= 5 && estimate <= 1440 ? estimate : undefined;
|
||||
}
|
||||
|
||||
function captureRelatedDraft(value) {
|
||||
const repository = String(value?.repository || '');
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return undefined;
|
||||
const plan = {
|
||||
repository, title:'', body:String(value?.body || '').slice(0, 9000),
|
||||
labelIds:Array.from(new Set((Array.isArray(value?.labelIds) ? value.labelIds : [])
|
||||
.filter(id => Number.isInteger(id) && id > 0))).slice(0, 20),
|
||||
};
|
||||
const milestoneId = Number(value?.milestoneId);
|
||||
if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(String(value?.dueDate || ''))) plan.dueDate = String(value.dueDate);
|
||||
if (value?.unassigned === true) plan.unassigned = true;
|
||||
else if (/^[A-Za-z0-9_.-]+$/.test(String(value?.assignee || ''))) {
|
||||
plan.assignee = String(value.assignee);
|
||||
plan.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
const templateId = String(value?.templateId || '').slice(0, 80);
|
||||
if (templateId) {
|
||||
plan.templateId = templateId;
|
||||
plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80);
|
||||
plan.capturedBody = '';
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function read() {
|
||||
@@ -51,11 +113,27 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
const assignee = /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
|
||||
? String(draft.assignee) : '';
|
||||
if (assignee) {
|
||||
item.assignee = assignee;
|
||||
item.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
if (draft?.unassigned === true) item.unassigned = true;
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
|
||||
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||
if (draft?.completionIntent === 'create-and-start' && !assignee && !item.unassigned) {
|
||||
item.completionIntent = 'create-and-start';
|
||||
item.estimateMinutes = captureEstimate(draft?.estimateMinutes);
|
||||
}
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
if (attachment) item.attachment = attachment;
|
||||
const attachments = captureAttachments(draft?.attachments);
|
||||
if (attachments) item.attachments = attachments;
|
||||
const blockers = captureBlockers(draft?.blockers);
|
||||
if (blockers) item.blockers = blockers;
|
||||
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
|
||||
if (relatedDraft) item.relatedDraft = relatedDraft;
|
||||
item.operationId = item.id;
|
||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||
item.milestoneId = Number(draft.milestoneId);
|
||||
@@ -65,14 +143,21 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
|
||||
function localIndexItem(item) {
|
||||
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
|
||||
const hasAttachmentBytes = item?.attachment?.data || item?.attachment?.blob;
|
||||
const hasBundleBytes = Array.isArray(item?.attachments) &&
|
||||
item.attachments.some(value => value?.data || value?.blob);
|
||||
if (!hasAttachmentBytes && !hasBundleBytes) return item;
|
||||
return {
|
||||
...item,
|
||||
attachment: {
|
||||
...(hasAttachmentBytes ? {attachment: {
|
||||
filename: item.attachment.filename,
|
||||
contentType: item.attachment.contentType,
|
||||
stored: true,
|
||||
},
|
||||
}} : {}),
|
||||
...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({
|
||||
filename:value.filename, contentType:value.contentType, stored:true,
|
||||
...(value.note ? {note:value.note} : {}),
|
||||
}))} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,50 +205,90 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
async function hydrateForEdit(id) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return null;
|
||||
if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) return { ...item };
|
||||
const storedBundle = Array.isArray(item.attachments) && item.attachments.some(value => value?.stored);
|
||||
const storedAttachment = item.attachment?.stored && !item.attachment.data && !item.attachment.blob;
|
||||
if (!storedAttachment && !storedBundle) return { ...item };
|
||||
if (!backgroundSync?.get) {
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
const durable = await backgroundSync.get(id);
|
||||
const attachment = captureAttachment(durable?.attachment);
|
||||
if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
|
||||
const attachments = captureAttachments(durable?.attachments);
|
||||
if (durable?.operationId !== item.operationId ||
|
||||
(storedAttachment && (!attachment?.data && !attachment?.blob)) ||
|
||||
(storedBundle && (!attachments || attachments.length !== item.attachments.length))) {
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
return { ...item, attachment };
|
||||
return {
|
||||
...item,
|
||||
...(attachment ? {attachment} : {}),
|
||||
...(attachments ? {attachments} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function prepareUpdate(id, draft) {
|
||||
let updated = null;
|
||||
const items = read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const nextRepository = String(draft?.repository || '');
|
||||
const nextTitle = String(draft?.title || '');
|
||||
const nextBody = String(draft?.body || '');
|
||||
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
||||
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
|
||||
const repository = String(draft?.repository || '');
|
||||
const title = String(draft?.title || '');
|
||||
const body = String(draft?.body || '');
|
||||
const labelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
||||
const unassigned = draft?.unassigned === true;
|
||||
const assignee = !unassigned && /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
|
||||
? String(draft.assignee) : undefined;
|
||||
const assigneeName = assignee
|
||||
? String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255)
|
||||
: undefined;
|
||||
const milestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
|
||||
? Number(draft.milestoneId) : undefined;
|
||||
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||
const dueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||
? String(draft.dueDate) : undefined;
|
||||
const nextAttachment = captureAttachment(draft?.attachment);
|
||||
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
|
||||
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
||||
|| attachmentChanged;
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
const attachments = captureAttachments(draft?.attachments);
|
||||
const blockers = captureBlockers(draft?.blockers);
|
||||
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
|
||||
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(attachment || null) ||
|
||||
JSON.stringify(item.attachments || null) !== JSON.stringify(attachments || null);
|
||||
const changed = item.repository !== repository || item.title !== title || item.body !== body
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(labelIds)
|
||||
|| Boolean(item.unassigned) !== unassigned
|
||||
|| item.assignee !== assignee || item.assigneeName !== assigneeName
|
||||
|| item.milestoneId !== milestoneId || item.dueDate !== dueDate
|
||||
|| item.estimateMinutes !== captureEstimate(draft?.estimateMinutes)
|
||||
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(blockers || null)
|
||||
|| JSON.stringify(item.relatedDraft || null) !== JSON.stringify(relatedDraft || null);
|
||||
updated = {
|
||||
...item,
|
||||
repository: nextRepository, title: nextTitle,
|
||||
body: nextBody, labelIds: nextLabelIds,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
|
||||
repository, title, body, labelIds,
|
||||
unassigned: unassigned || undefined,
|
||||
assignee, assigneeName, milestoneId, dueDate,
|
||||
attachment, attachments, blockers, relatedDraft,
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
if (draft?.completionIntent === 'create-and-start') updated.completionIntent = 'create-and-start';
|
||||
else delete updated.completionIntent;
|
||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||
if (nextDueDate === undefined) delete updated.dueDate;
|
||||
if (nextAttachment === undefined) delete updated.attachment;
|
||||
if (attachmentChanged) delete updated.attachmentMarkdown;
|
||||
if (draft?.completionIntent === 'create-and-start' && !assignee && !unassigned) {
|
||||
updated.completionIntent = 'create-and-start';
|
||||
updated.estimateMinutes = captureEstimate(draft?.estimateMinutes);
|
||||
} else {
|
||||
delete updated.completionIntent;
|
||||
delete updated.estimateMinutes;
|
||||
}
|
||||
if (assignee === undefined) {
|
||||
delete updated.assignee;
|
||||
delete updated.assigneeName;
|
||||
}
|
||||
if (!unassigned) delete updated.unassigned;
|
||||
if (milestoneId === undefined) delete updated.milestoneId;
|
||||
if (dueDate === undefined) delete updated.dueDate;
|
||||
if (attachment === undefined) delete updated.attachment;
|
||||
if (attachments === undefined) delete updated.attachments;
|
||||
if (blockers === undefined) delete updated.blockers;
|
||||
if (relatedDraft === undefined) delete updated.relatedDraft;
|
||||
if (attachmentChanged) {
|
||||
delete updated.attachmentMarkdown;
|
||||
delete updated.attachmentMarkdowns;
|
||||
}
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
@@ -213,6 +338,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
function evidenceMarkdown(attachment, markdown, index) {
|
||||
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
if (!note) return markdown;
|
||||
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
|
||||
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
|
||||
}
|
||||
|
||||
function attachmentMultipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
@@ -237,34 +369,68 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.unassigned ? { unassigned: true } : {}),
|
||||
...(item.assignee ? { assignee: item.assignee } : {}),
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
});
|
||||
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
|
||||
if (item.attachment || item.attachments || item.blockers?.length) {
|
||||
persistDeliveryStage(item.id, { deliveredIssue: issue });
|
||||
}
|
||||
}
|
||||
if (!item.attachment) return issue;
|
||||
let markdown = item.attachmentMarkdown;
|
||||
if (!markdown) {
|
||||
const blockers = Array.isArray(item.blockers) ? item.blockers : [];
|
||||
let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length);
|
||||
for (let index = deliveredBlockers; index < blockers.length; index += 1) {
|
||||
const blocker = blockers[index];
|
||||
await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/blockers',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'blocker-' + index),
|
||||
},
|
||||
body: JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}),
|
||||
},
|
||||
);
|
||||
deliveredBlockers = index + 1;
|
||||
persistDeliveryStage(item.id, { deliveredIssue:issue, deliveredBlockers });
|
||||
}
|
||||
const attachments = Array.isArray(item.attachments) ? item.attachments :
|
||||
(item.attachment ? [item.attachment] : []);
|
||||
if (!attachments.length) return issue;
|
||||
const markdowns = Array.isArray(item.attachmentMarkdowns)
|
||||
? item.attachmentMarkdowns.slice(0, attachments.length)
|
||||
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
|
||||
for (let index = markdowns.length; index < attachments.length; index += 1) {
|
||||
const uploaded = await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
'Idempotency-Key': stageOperationId(
|
||||
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
|
||||
),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
body: attachmentMultipart(attachments[index]),
|
||||
},
|
||||
);
|
||||
markdown = String(uploaded?.markdown || '');
|
||||
const markdown = String(uploaded?.markdown || '');
|
||||
if (!markdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
|
||||
markdowns.push(markdown);
|
||||
persistDeliveryStage(item.id, {
|
||||
deliveredIssue: issue, attachmentMarkdowns:markdowns.slice(),
|
||||
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||
});
|
||||
}
|
||||
const markdown = markdowns.map((value, index) =>
|
||||
evidenceMarkdown(attachments[index], value, index)).join('\n\n');
|
||||
await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
|
||||
{
|
||||
@@ -329,6 +495,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
const completions = [];
|
||||
const filings = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
@@ -337,17 +504,19 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const result = await sendItem(item, currentLogin);
|
||||
if (result.issue) {
|
||||
confirmed.push(result.issue);
|
||||
if (result.item?.relatedDraft) filings.push({issue:result.issue, relatedDraft:result.item.relatedDraft});
|
||||
if (result.item?.completionIntent) completions.push({
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
estimateMinutes: result.item.estimateMinutes,
|
||||
issue: result.issue,
|
||||
});
|
||||
}
|
||||
if (result.transient) break;
|
||||
}
|
||||
return { confirmed, completions, remaining: read(), blocked };
|
||||
return { confirmed, completions, filings, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
@@ -376,11 +545,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const result = await sendItem(queued, currentLogin);
|
||||
return {
|
||||
confirmed: result.issue ? [result.issue] : [],
|
||||
filings: result.issue && result.item?.relatedDraft ? [{issue:result.issue, relatedDraft:result.item.relatedDraft}] : [],
|
||||
completions: result.issue && result.item?.completionIntent ? [{
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
estimateMinutes: result.item.estimateMinutes,
|
||||
issue: result.issue,
|
||||
}] : [],
|
||||
remaining: read(), blocked: 0,
|
||||
@@ -425,6 +596,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
intent: item.completionIntent,
|
||||
ownerLogin: item.ownerLogin,
|
||||
operationId: item.operationId,
|
||||
estimateMinutes: item.estimateMinutes,
|
||||
issue: item.deliveredIssue,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -38,8 +38,12 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
readOnly(item) {
|
||||
return Boolean(item?.is_filed && !item?.is_assigned);
|
||||
},
|
||||
load(item) {
|
||||
return fetchJson(issuePath(item) + '/detail', {
|
||||
const access = this.readOnly(item) ? '?access=filed' : '';
|
||||
return fetchJson(issuePath(item) + '/detail' + access, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -90,7 +90,8 @@ function createLaterPicker({
|
||||
onState({ open:true, message:result.message, value:String(input || '') });
|
||||
return false;
|
||||
}
|
||||
const confirmed = onConfirm(item, result.value, context);
|
||||
const confirmed = typeof item?.confirm === 'function' ? item.confirm(result.value) :
|
||||
onConfirm(item, result.value, context);
|
||||
if (confirmed !== true && typeof confirmed !== 'function') return false;
|
||||
committed = true;
|
||||
afterClose = typeof confirmed === 'function' ? confirmed : null;
|
||||
|
||||
+17
-1
@@ -52,6 +52,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function deferMany(items, until) {
|
||||
const key = storageKey();
|
||||
const wake = new Date(until);
|
||||
const entries = (items || []).map(item => [identity(item), item]);
|
||||
if (!key) return 'unavailable';
|
||||
if (!entries.length || entries.some(([id]) => !id) || Number.isNaN(wake.getTime()) || wake <= now()) {
|
||||
return 'invalid';
|
||||
}
|
||||
const records = read();
|
||||
const wakeAt = wake.toISOString();
|
||||
entries.forEach(([id]) => { records[id] = wakeAt; });
|
||||
if (!write(records)) return 'unavailable';
|
||||
entries.forEach(([id]) => onChange('defer', id, wakeAt));
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function presetUntil(preset) {
|
||||
const current = new Date(now());
|
||||
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
|
||||
@@ -127,7 +143,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
return { active, later };
|
||||
}
|
||||
|
||||
return { identity, read, adopt, defer, restore, presetUntil, partition };
|
||||
return { identity, read, adopt, defer, deferMany, restore, presetUntil, partition };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
|
||||
@@ -0,0 +1,78 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.liveDataStatus = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const feeds = [
|
||||
['context', 'Work'],
|
||||
['notifications', 'Updates'],
|
||||
['events', 'Activity'],
|
||||
];
|
||||
|
||||
function boundedSeconds(value) {
|
||||
const seconds = Number(value);
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(Math.round(seconds), 86400) : null;
|
||||
}
|
||||
|
||||
function describe(freshness = {}) {
|
||||
const sections = freshness.sections || {};
|
||||
const hasSectionData = feeds.some(([key]) => Object.prototype.hasOwnProperty.call(sections, key));
|
||||
const described = feeds.map(([key, label]) => {
|
||||
const section = sections[key] || {};
|
||||
const state = section.revalidating ? 'refreshing' :
|
||||
(section.stale || section.degraded ? 'delayed' : 'live');
|
||||
return { key, label, state, ageSeconds: boundedSeconds(section.age_seconds) };
|
||||
});
|
||||
const delayed = described.filter(feed => feed.state === 'delayed');
|
||||
const refreshing = described.filter(feed => feed.state === 'refreshing');
|
||||
let summary = hasSectionData ? 'Live' : 'Live data unavailable';
|
||||
if (delayed.length === 1) summary = delayed[0].label + ' delayed';
|
||||
else if (delayed.length > 1) summary = delayed.length + ' data feeds delayed';
|
||||
else if (refreshing.length === 1) summary = refreshing[0].label + ' refreshing';
|
||||
else if (refreshing.length > 1) summary = 'Refreshing live data';
|
||||
const retryValues = described.map(feed => {
|
||||
const section = sections[feed.key] || {};
|
||||
return boundedSeconds(section.retry_in_seconds);
|
||||
}).filter(value => value !== null && value > 0);
|
||||
const aggregateRetry = boundedSeconds(freshness.retry_in_seconds);
|
||||
if (aggregateRetry !== null && aggregateRetry > 0) retryValues.push(aggregateRetry);
|
||||
return {
|
||||
summary,
|
||||
feeds: described,
|
||||
nextRetrySeconds: retryValues.length ? Math.min(...retryValues) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function createRefreshController({ button, output, refresh, onState = () => {} }) {
|
||||
let pending = null;
|
||||
function run() {
|
||||
if (pending) return pending;
|
||||
button.disabled = true;
|
||||
output.textContent = 'Refreshing live data…';
|
||||
onState('refreshing');
|
||||
let request;
|
||||
try {
|
||||
request = refresh();
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
pending = Promise.resolve(request).then(result => {
|
||||
if (!result) throw new Error('Live data remains unavailable.');
|
||||
output.textContent = 'Live data refreshed.';
|
||||
onState('success');
|
||||
return result;
|
||||
}).catch(error => {
|
||||
output.textContent = error && error.message ? error.message : 'Live data refresh failed.';
|
||||
onState('error');
|
||||
return null;
|
||||
}).finally(() => {
|
||||
button.disabled = false;
|
||||
pending = null;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
return { run };
|
||||
}
|
||||
|
||||
return { describe, createRefreshController };
|
||||
});
|
||||
+73
-10
@@ -4,7 +4,8 @@
|
||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||
function validShareContinuation(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
||||
const limits = { title: 200, text: 8000, url: 2048 };
|
||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200,
|
||||
preview: 200, search_kind: 5, search_state: 6, search_repository: 161 };
|
||||
const params = new URLSearchParams(value.slice(3));
|
||||
const entries = Array.from(params.entries());
|
||||
if (!entries.length) return './';
|
||||
@@ -14,6 +15,27 @@
|
||||
|| content.length > limits[name]
|
||||
));
|
||||
if (invalid) return './';
|
||||
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
|
||||
const search = params.get('search');
|
||||
const preview = params.get('preview');
|
||||
if (search !== null || preview !== null) {
|
||||
if (entries.some(([name]) => ![
|
||||
'search', 'preview', 'search_kind', 'search_state', 'search_repository'
|
||||
].includes(name))) return './';
|
||||
if (search === null || preview === null) return './';
|
||||
if (!/^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9]\d*$/.test(preview)) return './';
|
||||
const kind = params.get('search_kind');
|
||||
const state = params.get('search_state');
|
||||
const repository = params.get('search_repository');
|
||||
if (kind !== null && !['all', 'issue', 'pull'].includes(kind)) return './';
|
||||
if (state !== null && !['all', 'open', 'closed'].includes(state)) return './';
|
||||
if (repository !== null && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return './';
|
||||
return value;
|
||||
}
|
||||
const launch = params.get('launch');
|
||||
const shared = params.get('shared');
|
||||
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
|
||||
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -28,7 +50,31 @@
|
||||
const continuation = validShareContinuation(options.continuation);
|
||||
const setIntervalImpl = options.setIntervalImpl || setInterval;
|
||||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||||
const setTimeoutImpl = options.setTimeoutImpl || setTimeout;
|
||||
const clearTimeoutImpl = options.clearTimeoutImpl || clearTimeout;
|
||||
const requestTimeoutMs = options.requestTimeoutMs || 15000;
|
||||
let timer = null;
|
||||
let activeAttempt = false;
|
||||
|
||||
function setAttemptActive(active) {
|
||||
activeAttempt = active;
|
||||
button.disabled = active;
|
||||
if (passkeyButton) passkeyButton.disabled = active || !credentials?.get;
|
||||
}
|
||||
|
||||
async function requestWithDeadline(url, init) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeoutImpl(() => {
|
||||
const error = new Error('Sign-in request timed out');
|
||||
error.name = 'TimeoutError';
|
||||
controller.abort(error);
|
||||
}, requestTimeoutMs);
|
||||
try {
|
||||
return await fetchImpl(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeoutImpl(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
|
||||
@@ -58,7 +104,12 @@
|
||||
};
|
||||
}
|
||||
|
||||
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
|
||||
if (continuation !== './') {
|
||||
const continuationParams = new URLSearchParams(continuation.slice(3));
|
||||
status.textContent = continuationParams.has('preview')
|
||||
? 'Sign in to open the shared Search result.'
|
||||
: 'Sign in to continue your shared capture.';
|
||||
}
|
||||
|
||||
async function showReason(reason) {
|
||||
if (reason === 'session-expired') {
|
||||
@@ -100,18 +151,24 @@
|
||||
}
|
||||
|
||||
async function submit(accessToken, deviceLabel = 'This device') {
|
||||
if (activeAttempt) return false;
|
||||
setAttemptActive(true);
|
||||
status.textContent = 'Signing in…';
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl('api/v1/session', {
|
||||
response = await requestWithDeadline('api/v1/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }),
|
||||
});
|
||||
} catch (_error) {
|
||||
} catch (error) {
|
||||
form.reset();
|
||||
status.textContent = 'Sign-in failed. Check your connection and try again.';
|
||||
return;
|
||||
status.textContent = error?.name === 'TimeoutError'
|
||||
? 'Sign-in timed out. Check your connection and try again.'
|
||||
: 'Sign-in failed. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
setAttemptActive(false);
|
||||
}
|
||||
form.reset();
|
||||
if (response.ok) {
|
||||
@@ -130,9 +187,11 @@
|
||||
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
|
||||
return false;
|
||||
}
|
||||
if (activeAttempt) return false;
|
||||
setAttemptActive(true);
|
||||
status.textContent = 'Waiting for your passkey…';
|
||||
try {
|
||||
const optionsResponse = await fetchImpl('api/v1/passkeys/authentication/options', {
|
||||
const optionsResponse = await requestWithDeadline('api/v1/passkeys/authentication/options', {
|
||||
method: 'POST', headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
|
||||
@@ -143,7 +202,7 @@
|
||||
...item, id: decodeBase64Url(item.id),
|
||||
}));
|
||||
const credential = await credentials.get({ publicKey });
|
||||
const response = await fetchImpl('api/v1/passkeys/authentication/verify', {
|
||||
const response = await requestWithDeadline('api/v1/passkeys/authentication/verify', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -157,9 +216,13 @@
|
||||
if (!response.ok) throw new Error('Passkey verification failed');
|
||||
location.replace(continuation);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
status.textContent = 'Passkey sign-in was not completed. Try again or use the access token.';
|
||||
} catch (error) {
|
||||
status.textContent = error?.name === 'TimeoutError'
|
||||
? 'Passkey sign-in timed out. Check your connection and try again.'
|
||||
: 'Passkey sign-in was not completed. Try again or use the access token.';
|
||||
return false;
|
||||
} finally {
|
||||
setAttemptActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,38 @@
|
||||
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Continue work",
|
||||
"short_name": "Continue",
|
||||
"description": "Resume the highest-priority mobile work flow.",
|
||||
"url": "./?launch=continue",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
},
|
||||
{
|
||||
"name": "New issue",
|
||||
"short_name": "New",
|
||||
"description": "Capture work now and file it online or offline.",
|
||||
"url": "./?launch=new",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
},
|
||||
{
|
||||
"name": "Open Agenda",
|
||||
"short_name": "Agenda",
|
||||
"description": "Check every assigned deadline and continue the Agenda session.",
|
||||
"url": "./?launch=agenda",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "./",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {"title": "title", "text": "text", "url": "url"}
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.mobileAppShortcuts = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
const ACTIONS = new Set(['continue', 'new', 'agenda']);
|
||||
|
||||
function parse(search) {
|
||||
const action = new URLSearchParams(search || '').get('launch');
|
||||
return ACTIONS.has(action) ? action : null;
|
||||
}
|
||||
|
||||
function createController(options) {
|
||||
const launchAction = parse(options.search);
|
||||
let handled = false;
|
||||
|
||||
function action() { return launchAction; }
|
||||
|
||||
function run() {
|
||||
if (!launchAction || handled) return null;
|
||||
handled = true;
|
||||
if (launchAction === 'continue') return options.continueWork();
|
||||
if (launchAction === 'new') return options.newIssue();
|
||||
return options.agenda();
|
||||
}
|
||||
|
||||
return {action, run};
|
||||
}
|
||||
|
||||
return {parse, createController};
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
['install', options.installButton, options.installStatus, options.install],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
||||
];
|
||||
let trigger = options.launcher;
|
||||
|
||||
@@ -100,8 +101,10 @@ function mountMobileDeviceSetup(options) {
|
||||
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
|
||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
deadlineButton:qs('#device-setup-deadline'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), readyStatus:qs('#device-setup-ready-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
||||
readyStatus:qs('#device-setup-ready-status'),
|
||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
||||
promptStorage:options.promptStorage,
|
||||
@@ -118,9 +121,11 @@ function mountMobileDeviceSetup(options) {
|
||||
: qs('#push-updates').checked
|
||||
? {state:'complete', detail:'New update notifications are enabled.'}
|
||||
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
enablePush:options.enablePush,
|
||||
enableDeadline:options.enableDeadline,
|
||||
});
|
||||
}
|
||||
@@ -4,13 +4,14 @@
|
||||
else root.mobileLaunch = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const allowedFilters = new Set([
|
||||
'all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft',
|
||||
'all', 'today', 'attention', 'agenda', 'issue', 'pull', 'review', 'update', 'later', 'draft',
|
||||
]);
|
||||
|
||||
function chooseFilter({ saved, today = 0, attention = 0 } = {}) {
|
||||
function chooseFilter({ saved, today = 0, attention = 0, agenda = 0 } = {}) {
|
||||
if (allowedFilters.has(saved)) return saved;
|
||||
if (today > 0) return 'today';
|
||||
if (attention > 0) return 'attention';
|
||||
if (agenda > 0) return 'agenda';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileQueueLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileQueueLauncher(options) {
|
||||
const emptyMessages = {
|
||||
attention: 'No items need attention.',
|
||||
update: 'No unread updates are ready to open.',
|
||||
later: 'No deferred work is ready to open.',
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const continuation = [
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['update', 'Resume Updates'],
|
||||
['agenda', 'Open Agenda'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
];
|
||||
|
||||
function recommend() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const match = continuation.find(([name]) => Number(counts[name]) > 0);
|
||||
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
|
||||
const [name, label] = match;
|
||||
const count = Math.max(0, Number(counts[name]) || 0);
|
||||
return {name, count, label: label + ' (' + count + ')'};
|
||||
}
|
||||
|
||||
function open(name) {
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
if (name === 'filed' && options.openFiled) return options.openFiled();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
if (!action) {
|
||||
options.announce(emptyMessages[name] || 'No work is ready to open.');
|
||||
return 'empty';
|
||||
}
|
||||
action.click();
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
function continueWork() {
|
||||
const next = recommend();
|
||||
if (next.name === 'find') {
|
||||
options.openFindWork();
|
||||
return 'find';
|
||||
}
|
||||
return open(next.name);
|
||||
}
|
||||
|
||||
return { open, recommend, continueWork };
|
||||
});
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
function closeQueues() {
|
||||
if (options.queueSheet?.open) options.queueSheet.close();
|
||||
options.queueSheet?.open && options.queueSheet.close();
|
||||
}
|
||||
|
||||
function start() {
|
||||
@@ -42,8 +42,7 @@
|
||||
button.addEventListener('click', event => {
|
||||
launcher = event.currentTarget;
|
||||
select(name);
|
||||
if (name === 'queues' && options.queueSheet) openQueues();
|
||||
else options.actions[name]();
|
||||
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
|
||||
});
|
||||
});
|
||||
if (options.queueSheet) {
|
||||
@@ -56,7 +55,7 @@
|
||||
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
|
||||
row.addEventListener('click', () => {
|
||||
closeQueues();
|
||||
options.onSelectQueue?.(name);
|
||||
options.onSelectQueue?.(name, row);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -65,18 +64,15 @@
|
||||
}
|
||||
|
||||
function updateWork(mode) {
|
||||
const labels = {
|
||||
continue: ['Continue', 'Continue Today'],
|
||||
resume: ['Resume', 'Resume Today'],
|
||||
start: ['Start', 'Start Today'],
|
||||
plan: ['Plan', 'Plan Today'],
|
||||
work: ['Work', 'Work'],
|
||||
};
|
||||
const workMode = labels[mode] ? mode : 'work';
|
||||
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
|
||||
if (buttons.work) {
|
||||
buttons.work.setAttribute('aria-label', labels[workMode][1]);
|
||||
}
|
||||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', queue
|
||||
? (mode === 'update' ? 'Resume ' : 'Open ') + text
|
||||
: mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today'));
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
@@ -95,17 +91,39 @@
|
||||
}
|
||||
|
||||
function updateQueues(counts) {
|
||||
const names = ['today', 'attention', 'later', 'draft'];
|
||||
const names = 'today agenda attention update filed later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const total = names.reduce((sum, name) => sum + normalized[name], 0);
|
||||
const actionableNames = ['today', 'attention', 'update', 'filed', 'later', 'draft'];
|
||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
});
|
||||
options.queueRows?.update?.setAttribute(
|
||||
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
|
||||
);
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = String(total);
|
||||
options.queueBadge.hidden = total === 0;
|
||||
options.queueBadge.textContent = active + ' active';
|
||||
options.queueBadge.hidden = active === 0;
|
||||
}
|
||||
if (buttons.queues) buttons.queues.setAttribute('aria-label', 'Queues, ' + total + ' items');
|
||||
if (options.deadlineBadge) {
|
||||
options.deadlineBadge.textContent = normalized.agenda + ' due';
|
||||
options.deadlineBadge.hidden = normalized.agenda === 0;
|
||||
}
|
||||
if (options.queueRows?.agenda) {
|
||||
if (normalized.agenda > 0) options.queueRows.agenda.setAttribute('data-deadlines', 'true');
|
||||
else options.queueRows.agenda.removeAttribute('data-deadlines');
|
||||
}
|
||||
const label = active === 0 && normalized.agenda === 0
|
||||
? 'Queues: no active queues; no upcoming deadlines'
|
||||
: 'Queues: Today ' + normalized.today
|
||||
+ ', Agenda ' + normalized.agenda + ' due'
|
||||
+ ', Attention ' + normalized.attention
|
||||
+ ', Updates ' + normalized.update
|
||||
+ ', Filed ' + normalized.filed
|
||||
+ ', Later ' + normalized.later
|
||||
+ ', Drafts ' + normalized.draft
|
||||
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
|
||||
buttons.queues?.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
const recommended = options.queueLauncher && options.queueLauncher.recommend();
|
||||
if (recommended && recommended.name !== 'today') return recommended.name;
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
return 'work';
|
||||
return 'find';
|
||||
}
|
||||
|
||||
function open() {
|
||||
@@ -16,7 +18,8 @@
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else if (current === 'plan') options.planToday();
|
||||
else options.openFallback();
|
||||
else if (current === 'find') options.findWork();
|
||||
else options.queueLauncher.open(current);
|
||||
return current;
|
||||
}
|
||||
|
||||
|
||||
+188
-30
@@ -1,15 +1,26 @@
|
||||
function issueDueState(dueDate, now) {
|
||||
if (!dueDate) return null;
|
||||
const due = new Date(dueDate);
|
||||
if (Number.isNaN(due.getTime())) return null;
|
||||
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
function calendarDay(value) {
|
||||
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
|
||||
if (!match) return '';
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
return date.getFullYear() === Number(match[1]) && date.getMonth() === Number(match[2]) - 1 &&
|
||||
date.getDate() === Number(match[3]) ? match.slice(1, 4).join('-') : '';
|
||||
}
|
||||
|
||||
function localDay(value) {
|
||||
return value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(value.getDate()).padStart(2, '0');
|
||||
const dueDay = day(due);
|
||||
const today = day(now);
|
||||
}
|
||||
|
||||
function issueDueState(dueDate, now) {
|
||||
const dueDay = calendarDay(dueDate);
|
||||
if (!dueDay) return null;
|
||||
const today = localDay(now);
|
||||
if (dueDay < today) return { label: 'Overdue', priority: 2 };
|
||||
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
|
||||
return {
|
||||
label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
label: 'Due ' + new Date(
|
||||
Number(dueDay.slice(0, 4)), Number(dueDay.slice(5, 7)) - 1, Number(dueDay.slice(8, 10))
|
||||
).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
priority: 4,
|
||||
};
|
||||
}
|
||||
@@ -33,6 +44,19 @@ function needsAttention(item) {
|
||||
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
|
||||
}
|
||||
|
||||
function updateActionability(item) {
|
||||
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||
if ((item?.labels || []).some(label => priorityLabels.includes(String(label).toLowerCase()))) {
|
||||
return { priority: 0, reason: 'Critical' };
|
||||
}
|
||||
if (item?.is_review) return { priority: 1, reason: 'Review requested' };
|
||||
if (item?.is_assigned && ['Overdue', 'Due today'].includes(item?.due_label)) {
|
||||
return { priority: 2, reason: item.due_label };
|
||||
}
|
||||
if (item?.is_assigned) return { priority: 3, reason: 'Assigned to you' };
|
||||
return { priority: 4, reason: '' };
|
||||
}
|
||||
|
||||
function buildMyWork(data, now = new Date()) {
|
||||
const login = data.user?.login || '';
|
||||
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
||||
@@ -46,17 +70,19 @@ function buildMyWork(data, now = new Date()) {
|
||||
);
|
||||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
const isFiled = (item.work_reasons || []).includes('created_by_me');
|
||||
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||
const normalized = {
|
||||
...item,
|
||||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
is_filed: isFiled,
|
||||
is_assigned: assigned,
|
||||
has_update: false,
|
||||
...(due ? { due_label: due.label } : {}),
|
||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||
(due && due.priority < 4 ? due.label :
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))),
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work')))),
|
||||
_priority: priorityLabel ? 0 :
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
|
||||
};
|
||||
@@ -71,13 +97,15 @@ function buildMyWork(data, now = new Date()) {
|
||||
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
|
||||
const existing = byKey.get(subjectKind + ':' + key);
|
||||
if (existing) {
|
||||
const actionability = updateActionability(existing);
|
||||
existing.has_update = true;
|
||||
existing.needs_attention = true;
|
||||
existing.attention_reason = 'Unread update';
|
||||
existing.notification_id = update.id;
|
||||
existing.url = update.url || existing.url;
|
||||
existing.updated_at = update.updated_at || existing.updated_at;
|
||||
existing._priority = Math.min(existing._priority, 1);
|
||||
existing.update_reason = actionability.reason;
|
||||
existing._priority = actionability.priority;
|
||||
return;
|
||||
}
|
||||
if (!update.url) return;
|
||||
@@ -92,7 +120,8 @@ function buildMyWork(data, now = new Date()) {
|
||||
attention_reason: 'Unread update',
|
||||
notification_id: update.id,
|
||||
reason: 'Unread update',
|
||||
_priority: 1,
|
||||
update_reason: '',
|
||||
_priority: 4,
|
||||
};
|
||||
work.push(item);
|
||||
byKey.set(subjectKind + ':' + key, item);
|
||||
@@ -237,7 +266,8 @@ function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
return {
|
||||
let completing = null;
|
||||
const pager = {
|
||||
reset(next) {
|
||||
pagination = { ...pagination, ...(next || {}) };
|
||||
onPagination(pagination);
|
||||
@@ -271,18 +301,31 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
loadAll(getExisting) {
|
||||
if (completing) return completing;
|
||||
completing = (async () => {
|
||||
while (pagination.has_more) {
|
||||
const loaded = await pager.loadMore(getExisting());
|
||||
if (!loaded) return false;
|
||||
}
|
||||
return true;
|
||||
})().finally(() => { completing = null; });
|
||||
return completing;
|
||||
},
|
||||
};
|
||||
return pager;
|
||||
}
|
||||
|
||||
function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
let pagination = {};
|
||||
const pending = new Set();
|
||||
const completing = new Map();
|
||||
const labels = {
|
||||
issue: 'issues',
|
||||
pull: 'pull requests',
|
||||
review: 'review requests',
|
||||
};
|
||||
return {
|
||||
const pager = {
|
||||
reset(next) {
|
||||
Object.entries(next || {}).forEach(([stream, value]) => {
|
||||
const current = pagination[stream];
|
||||
@@ -331,7 +374,20 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
pending.delete(stream);
|
||||
}
|
||||
},
|
||||
loadAll(stream, getExisting) {
|
||||
if (completing.has(stream)) return completing.get(stream);
|
||||
const completion = (async () => {
|
||||
while (pagination[stream]?.has_more) {
|
||||
const loaded = await pager.loadMore(stream, getExisting());
|
||||
if (!loaded) return false;
|
||||
}
|
||||
return true;
|
||||
})().finally(() => completing.delete(stream));
|
||||
completing.set(stream, completion);
|
||||
return completion;
|
||||
},
|
||||
};
|
||||
return pager;
|
||||
}
|
||||
|
||||
function createNotificationReader({
|
||||
@@ -341,13 +397,52 @@ function createNotificationReader({
|
||||
loadSaved = () => null,
|
||||
loadConversation = null,
|
||||
onConversation = () => {},
|
||||
onConversationStatus = () => {},
|
||||
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
|
||||
getScope = () => '',
|
||||
}) {
|
||||
let selected = null;
|
||||
let loadVersion = 0;
|
||||
let marking = false;
|
||||
let conversationPager = null;
|
||||
let offlineHydrated = false;
|
||||
let prefetched = null;
|
||||
let prefetchKey = '';
|
||||
|
||||
async function hydrateConversation(item, version) {
|
||||
if (!loadConversation || !createPager || !item || offlineHydrated) return false;
|
||||
onConversationStatus('Loading conversation…');
|
||||
try {
|
||||
const initial = await loadConversation(item.notification_id, null);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
conversationPager = createPager({
|
||||
loadPage: page => loadConversation(item.notification_id, page),
|
||||
});
|
||||
const state = conversationPager.reset(initial);
|
||||
onConversation(state);
|
||||
onConversationStatus(state.comments.length + ' of ' +
|
||||
Math.max(state.total || 0, state.comments.length) + ' messages loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (selected === item && version === loadVersion) {
|
||||
conversationPager = null;
|
||||
onConversationStatus('Conversation temporarily unavailable. Retry.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function prefetch(item) {
|
||||
if (!item) { prefetched = null; prefetchKey = ''; return false; }
|
||||
const notificationId = item?.notification_id;
|
||||
if (!Number.isInteger(notificationId) || offlineHydrated) return false;
|
||||
const key = getScope() + notificationId;
|
||||
if (prefetchKey === key) return prefetched;
|
||||
if (prefetched) return false;
|
||||
prefetchKey = key;
|
||||
prefetched = load(notificationId);
|
||||
return prefetched;
|
||||
}
|
||||
|
||||
async function advanceAfterRead(items, current, queueing = false) {
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
@@ -362,17 +457,26 @@ function createNotificationReader({
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
return { items: updated, item: current, next: next || null };
|
||||
}
|
||||
|
||||
async function open(item, savedDetail = null) {
|
||||
selected = item;
|
||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||
const version = ++loadVersion;
|
||||
const preload = !offlineHydrated && prefetchKey === getScope() + item.notification_id ? prefetched : null;
|
||||
prefetched = null;
|
||||
prefetchKey = '';
|
||||
onOpen(item);
|
||||
onStatus('Loading update…');
|
||||
if (!preload) onStatus('Loading update…');
|
||||
try {
|
||||
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
|
||||
let detail;
|
||||
if (offlineHydrated) detail = savedDetail;
|
||||
else if (preload) detail = await preload.catch(() => {
|
||||
onStatus('Loading update…');
|
||||
return load(item.notification_id);
|
||||
});
|
||||
else detail = await load(item.notification_id);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
onDetail(detail);
|
||||
if (createPager && loadConversation && detail.conversation) {
|
||||
@@ -384,6 +488,9 @@ function createNotificationReader({
|
||||
conversationPager = null;
|
||||
}
|
||||
onStatus('Update ready.');
|
||||
if (!offlineHydrated && detail.conversation_available) {
|
||||
void hydrateConversation(item, version);
|
||||
}
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (selected === item && version === loadVersion) {
|
||||
@@ -395,6 +502,11 @@ function createNotificationReader({
|
||||
|
||||
return {
|
||||
open,
|
||||
prefetch,
|
||||
retryConversation() {
|
||||
if (!selected || offlineHydrated) return Promise.resolve(false);
|
||||
return hydrateConversation(selected, loadVersion);
|
||||
},
|
||||
commentPager() {
|
||||
return conversationPager;
|
||||
},
|
||||
@@ -404,7 +516,8 @@ function createNotificationReader({
|
||||
return true;
|
||||
},
|
||||
acceptReadAndNext(items, item = selected) {
|
||||
if (!item || selected !== item) return false;
|
||||
if (!item || (selected && selected !== item)) return false;
|
||||
selected = item;
|
||||
return advanceAfterRead(items, item);
|
||||
},
|
||||
async loadOlder() {
|
||||
@@ -477,7 +590,7 @@ function createNotificationReplier({
|
||||
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
|
||||
storage.setItem(keyFor(item), body);
|
||||
}
|
||||
catch (_error) { /* Keep the editable textarea as the fallback. */ }
|
||||
catch (_error) {}
|
||||
},
|
||||
async submit(item, body, attachment = null) {
|
||||
if (pending) return false;
|
||||
@@ -503,15 +616,15 @@ function createNotificationReplier({
|
||||
return { queued:true };
|
||||
}
|
||||
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
|
||||
catch (_error) { /* Confirmed delivery is authoritative. */ }
|
||||
catch (_error) {}
|
||||
onStatus('Reply posted. You can mark this update read when ready.');
|
||||
return delivery.confirmed[0];
|
||||
}
|
||||
const result = await post(item.notification_id, body, operationId);
|
||||
try { storage.removeItem(keyFor(item)); }
|
||||
catch (_error) { /* The posted reply is still authoritative. */ }
|
||||
catch (_error) {}
|
||||
try { storage.removeItem(operationKeyFor(item)); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
catch (_error) {}
|
||||
onStatus('Reply posted. You can mark this update read when ready.');
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -540,6 +653,7 @@ function createNotificationReplier({
|
||||
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
let filtered = items;
|
||||
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
|
||||
else if (selectedFilter === 'filed') filtered = items.filter((item) => item.is_filed);
|
||||
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||
@@ -552,6 +666,29 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
);
|
||||
}
|
||||
|
||||
function agendaMyWork(items, now = new Date()) {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const today = localDay(start);
|
||||
const tomorrowDate = new Date(start); tomorrowDate.setDate(tomorrowDate.getDate() + 1);
|
||||
const tomorrow = localDay(tomorrowDate);
|
||||
const afterTomorrowDate = new Date(start); afterTomorrowDate.setDate(afterTomorrowDate.getDate() + 2);
|
||||
const afterTomorrow = localDay(afterTomorrowDate);
|
||||
const horizonDate = new Date(start); horizonDate.setDate(horizonDate.getDate() + 7);
|
||||
const horizon = localDay(horizonDate);
|
||||
return (items || []).flatMap(item => {
|
||||
if (item?.kind !== 'issue' || !item.is_assigned || !item.due_date) return [];
|
||||
const due = calendarDay(item.due_date);
|
||||
if (!due || due >= horizon) return [];
|
||||
const group = due < today ? 'Overdue' : due < tomorrow ? 'Today' :
|
||||
due < afterTomorrow ? 'Tomorrow' : 'Next 7 days';
|
||||
return [{ ...item, agenda_group: group, _agenda_due: due }];
|
||||
}).sort((left, right) =>
|
||||
left._agenda_due.localeCompare(right._agenda_due) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
).map(({ _agenda_due, ...item }) => item);
|
||||
}
|
||||
|
||||
function milestoneLanes(items) {
|
||||
const lanes = new Map();
|
||||
(items || []).forEach(item => {
|
||||
@@ -626,13 +763,14 @@ function createWorkSessionCheckpoint({
|
||||
|
||||
function createWorkSession({
|
||||
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
|
||||
checkpointEnabled = () => true,
|
||||
checkpointEnabled = () => true, checkpointedEnabled = checkpointEnabled,
|
||||
onOpen, onProgress, onFinish,
|
||||
}) {
|
||||
let currentIdentity = '';
|
||||
let currentIndex = -1;
|
||||
let running = false;
|
||||
let durable = false;
|
||||
const activeCheckpoint = () => typeof checkpoint === 'function' ? checkpoint() : checkpoint;
|
||||
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||
const report = (items, index) => onProgress({
|
||||
@@ -647,7 +785,7 @@ function createWorkSession({
|
||||
durable = false;
|
||||
currentIdentity = '';
|
||||
currentIndex = -1;
|
||||
if (clearCheckpoint) checkpoint?.clear();
|
||||
if (clearCheckpoint) activeCheckpoint()?.clear();
|
||||
onFinish();
|
||||
return false;
|
||||
};
|
||||
@@ -655,7 +793,7 @@ function createWorkSession({
|
||||
if (!items.length || index < 0 || index >= items.length) return finish();
|
||||
currentIndex = index;
|
||||
currentIdentity = workIdentity(items[index]);
|
||||
if (durable) checkpoint?.save(currentIdentity, currentIndex);
|
||||
if (durable) activeCheckpoint()?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
@@ -663,9 +801,9 @@ function createWorkSession({
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
|
||||
checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity),
|
||||
end: () => finish(),
|
||||
resumable: () => Boolean(checkpoint?.read()),
|
||||
resumable: () => Boolean(activeCheckpoint()?.read()),
|
||||
reopen(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
@@ -674,13 +812,13 @@ function createWorkSession({
|
||||
if (index < 0) return false;
|
||||
currentIdentity = requestedIdentity;
|
||||
currentIndex = index;
|
||||
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
|
||||
if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
},
|
||||
resume(requested = null) {
|
||||
const saved = checkpoint?.read();
|
||||
const saved = activeCheckpoint()?.read();
|
||||
if (!saved) return false;
|
||||
durable = true;
|
||||
const items = queue();
|
||||
@@ -694,7 +832,7 @@ function createWorkSession({
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
durable = Boolean(checkpoint && checkpointEnabled());
|
||||
durable = Boolean(activeCheckpoint() && checkpointEnabled());
|
||||
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
|
||||
return openAt(items, requested >= 0 ? requested : 0);
|
||||
},
|
||||
@@ -742,7 +880,7 @@ function createWorkSession({
|
||||
if (!items.length) return null;
|
||||
if (action === 'start') return items[0];
|
||||
if (action === 'resume') {
|
||||
const saved = checkpoint?.read();
|
||||
const saved = activeCheckpoint()?.read();
|
||||
if (!saved) return null;
|
||||
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
|
||||
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
|
||||
@@ -813,10 +951,27 @@ function summarizeMyWork(items) {
|
||||
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
|
||||
}
|
||||
|
||||
function findQueueItems(items, query) {
|
||||
const needle = String(query || '').trim().toLowerCase();
|
||||
if (!needle) return (items || []).slice();
|
||||
return (items || []).filter(item => {
|
||||
const number = Number.isInteger(item?.number) ? '#' + item.number : '';
|
||||
return [item?.repository, item?.key, number, item?.title]
|
||||
.some(value => String(value || '').toLowerCase().includes(needle));
|
||||
});
|
||||
}
|
||||
|
||||
function filedFollowUpTarget(items) {
|
||||
const item = (items || []).find(candidate => candidate?.is_filed);
|
||||
if (!item) return null;
|
||||
return { kind:item.has_update ? 'update' : 'issue', item };
|
||||
}
|
||||
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
attention: items.filter(needsAttention).length,
|
||||
filed: items.filter((item) => item.is_filed).length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
@@ -826,6 +981,7 @@ function countMyWork(items) {
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.agendaMyWork = agendaMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
|
||||
@@ -836,6 +992,8 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.findQueueItems = findQueueItems;
|
||||
buildMyWork.filedFollowUpTarget = filedFollowUpTarget;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
function createNotificationUndo({ restore, onItems, onStatus, onOffer = () => {}, onClear = () => {} }) {
|
||||
let offered = null;
|
||||
let pending = false;
|
||||
|
||||
return {
|
||||
offer(item, items) {
|
||||
if (!item || !Number.isInteger(item.notification_id)) return false;
|
||||
offered = { item, items: Array.isArray(items) ? [...items] : [] };
|
||||
const label = item.key || 'Update';
|
||||
onStatus(label + ' marked read. Undo?');
|
||||
onOffer(item);
|
||||
return true;
|
||||
},
|
||||
async run() {
|
||||
if (!offered || pending) return false;
|
||||
const current = offered;
|
||||
const label = current.item.key || 'Update';
|
||||
pending = true;
|
||||
onStatus('Restoring ' + label + '…');
|
||||
try {
|
||||
await restore(current.item.notification_id);
|
||||
if (offered !== current) return false;
|
||||
const restored = [current.item].concat(current.items.filter(
|
||||
item => item?.notification_id !== current.item.notification_id
|
||||
));
|
||||
offered = null;
|
||||
onItems(restored, current.item);
|
||||
onStatus(label + ' is unread again.');
|
||||
onClear();
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not restore ' + label + '. Retry Undo.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
offered = null;
|
||||
onClear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function requestNotificationUnread(notificationId) {
|
||||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/unread', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Restore unread failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
function createDashboardNotificationUndo({ restore, getItems, setItems, getNotifications, setNotifications, refresh, select }) {
|
||||
let timer = null;
|
||||
const controller = createNotificationUndo({
|
||||
restore,
|
||||
onItems: (items, restored) => {
|
||||
setItems(items);
|
||||
const notifications = getNotifications();
|
||||
if (!notifications.some(item => item.id === restored.notification_id)) {
|
||||
setNotifications([{ id:restored.notification_id }].concat(notifications));
|
||||
}
|
||||
refresh();
|
||||
},
|
||||
onStatus: message => {
|
||||
select('#notification-undo-status').textContent = message;
|
||||
select('#my-work-action-status').textContent = message;
|
||||
},
|
||||
onOffer: item => {
|
||||
clearTimeout(timer);
|
||||
select('#notification-undo').hidden = false;
|
||||
select('#undo-notification').disabled = false;
|
||||
select('#undo-notification').setAttribute('aria-label', 'Undo marking ' + (item.key || 'update') + ' read');
|
||||
timer = setTimeout(() => controller.clear(), 10000);
|
||||
},
|
||||
onClear: () => { select('#notification-undo').hidden = true; },
|
||||
});
|
||||
return controller;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createNotificationUndo;
|
||||
module.exports.createDashboardNotificationUndo = createDashboardNotificationUndo;
|
||||
}
|
||||
+115
-6
@@ -1,14 +1,26 @@
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
|
||||
let available = [];
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let loadRequest = null;
|
||||
let loadGeneration = 0;
|
||||
let query = '';
|
||||
let filters = { repositories: [], labels: [] };
|
||||
let facetView;
|
||||
let claimRequest = null;
|
||||
const previewed = new Set();
|
||||
let selecting = false;
|
||||
const selected = new Map();
|
||||
|
||||
function itemKey(item) {
|
||||
return String(item?.repository || '') + '#' + String(item?.number || '');
|
||||
}
|
||||
|
||||
function emitSelection() {
|
||||
const state = { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
onSelection(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function apply(result, append) {
|
||||
const incoming = Array.isArray(result?.items) ? result.items : [];
|
||||
if (append) {
|
||||
@@ -31,13 +43,31 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
};
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
if (result?.facets) onFacets({
|
||||
repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [],
|
||||
labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [],
|
||||
});
|
||||
if (result?.facets && typeof document !== 'undefined') {
|
||||
facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value =>
|
||||
String(value).replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character])),
|
||||
next => { filters = next; loadPage(1, false); });
|
||||
facetView(result.facets);
|
||||
}
|
||||
}
|
||||
|
||||
function loadPage(page, append) {
|
||||
if (loadRequest) return loadRequest;
|
||||
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
|
||||
function loadPage(page, append, requestedQuery = query) {
|
||||
if (loadRequest && requestedQuery === query && append) return loadRequest;
|
||||
const generation = ++loadGeneration;
|
||||
const params = new URLSearchParams();
|
||||
params.set('facets', 'true');
|
||||
if (requestedQuery) params.set('q', requestedQuery);
|
||||
filters.repositories.forEach(value => params.append('repository', value));
|
||||
filters.labels.forEach(value => params.append('label', value));
|
||||
const queryPart = params.toString() ? '&' + params.toString() : '';
|
||||
const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (generation !== loadGeneration) return result;
|
||||
apply(result, append);
|
||||
if (result?.refresh_failed === true) {
|
||||
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
|
||||
@@ -45,8 +75,9 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
onStatus('Showing saved available work while the catalog refreshes…');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { loadRequest = null; });
|
||||
return loadRequest;
|
||||
}).finally(() => { if (loadRequest === request) loadRequest = null; });
|
||||
loadRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -60,9 +91,66 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
if (!pagination.has_more) return Promise.resolve(false);
|
||||
return loadPage(pagination.page + 1, true);
|
||||
},
|
||||
search(value) {
|
||||
query = String(value || '').trim();
|
||||
return loadPage(1, false, query);
|
||||
},
|
||||
query() {
|
||||
return query;
|
||||
},
|
||||
setFilters(next) {
|
||||
filters = {
|
||||
repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10),
|
||||
labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10),
|
||||
};
|
||||
return loadPage(1, false);
|
||||
},
|
||||
filters() {
|
||||
return { repositories: filters.repositories.slice(), labels: filters.labels.slice() };
|
||||
},
|
||||
|
||||
items() {
|
||||
return available.slice();
|
||||
},
|
||||
startSelection() {
|
||||
selecting = true;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
cancelSelection() {
|
||||
selecting = false;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
toggleSelection(item) {
|
||||
if (!selecting || !item) return emitSelection();
|
||||
const key = itemKey(item);
|
||||
if (selected.has(key)) selected.delete(key);
|
||||
else selected.set(key, item);
|
||||
return emitSelection();
|
||||
},
|
||||
fillSelection(limit) {
|
||||
limit = Math.max(0, Math.floor(Number(limit) || 0));
|
||||
if (!limit) return { selected: selected.size, added: 0, limit };
|
||||
selecting = true;
|
||||
const before = selected.size;
|
||||
available.some(item => {
|
||||
if (selected.size >= limit) return true;
|
||||
selected.set(itemKey(item), item);
|
||||
return false;
|
||||
});
|
||||
emitSelection();
|
||||
return { selected: selected.size, added: selected.size - before, limit };
|
||||
},
|
||||
isSelected(item) {
|
||||
return selected.has(itemKey(item));
|
||||
},
|
||||
selectedItems() {
|
||||
return Array.from(selected.values());
|
||||
},
|
||||
selection() {
|
||||
return { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
},
|
||||
togglePreview(item) {
|
||||
const key = itemKey(item);
|
||||
if (previewed.has(key)) previewed.delete(key);
|
||||
@@ -91,6 +179,8 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
pagination.total = Math.max(available.length, pagination.total - 1);
|
||||
pagination.has_more = available.length < pagination.total;
|
||||
previewed.delete(itemKey(item));
|
||||
selected.delete(itemKey(item));
|
||||
emitSelection();
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
onStatus('Assigned ' + key + ' to you.');
|
||||
@@ -101,4 +191,23 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
};
|
||||
}
|
||||
|
||||
function attachFindWorkFacets(root, escapeHtml, onChange) {
|
||||
const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value);
|
||||
root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') });
|
||||
root.querySelector('#clear-find-work-filters').onclick = () => {
|
||||
root.querySelectorAll('input').forEach(input => { input.checked = false; });
|
||||
onChange({ repositories:[], labels:[] });
|
||||
};
|
||||
return facets => {
|
||||
const active = { repositories:values('find-work-repository'), labels:values('find-work-label') };
|
||||
const render = (id, choices, selected, name) => {
|
||||
root.querySelector(id).innerHTML = choices.map(value => '<label class="find-work-filter-option"><input type="checkbox" name="' + name + '" value="' + escapeHtml(value) + '"' + (selected.includes(value) ? ' checked' : '') + ' />' + escapeHtml(value) + '</label>').join('');
|
||||
};
|
||||
render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository');
|
||||
render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label');
|
||||
const count = active.repositories.length + active.labels.length;
|
||||
root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : '';
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
|
||||
@@ -0,0 +1,41 @@
|
||||
function createPlanTodayReadiness({ identity, inspect, concurrency = 3 }) {
|
||||
const workerCount = Math.max(1, Math.floor(Number(concurrency) || 1));
|
||||
let generation = 0;
|
||||
|
||||
async function run(items) {
|
||||
const runGeneration = ++generation;
|
||||
const candidates = Array.from(items || []);
|
||||
const results = new Array(candidates.length);
|
||||
let next = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < candidates.length) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
const item = candidates[index];
|
||||
let result;
|
||||
try {
|
||||
result = await inspect(item);
|
||||
} catch (_error) {
|
||||
result = { status:'unverified' };
|
||||
}
|
||||
results[index] = [identity(item), result];
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from(
|
||||
{ length:Math.min(workerCount, candidates.length) },
|
||||
() => worker(),
|
||||
));
|
||||
if (runGeneration !== generation) return null;
|
||||
return Object.fromEntries(results);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
generation += 1;
|
||||
}
|
||||
|
||||
return { run, cancel };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanTodayReadiness;
|
||||
@@ -0,0 +1,46 @@
|
||||
function createProtectToday() {
|
||||
function propose({ agenda = [], today = [], identity, limit = 5 }) {
|
||||
const key = item => String(identity?.(item) || '');
|
||||
const urgent = [];
|
||||
const seen = new Set();
|
||||
for (const item of [...agenda, ...today]) {
|
||||
const id = key(item);
|
||||
const eligible = item?.kind === 'issue' && item.is_assigned === true && item.state !== 'closed' &&
|
||||
['Overdue', 'Today'].includes(item.agenda_group);
|
||||
if (!eligible || !id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
urgent.push(item);
|
||||
}
|
||||
urgent.sort((left, right) =>
|
||||
String(left.due_date || '').localeCompare(String(right.due_date || '')) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
);
|
||||
if (!urgent.length) return {
|
||||
selected:[], protected:[], displaced:[],
|
||||
summary:'No overdue or due-today work needs protection.',
|
||||
};
|
||||
const existing = [];
|
||||
for (const item of today) {
|
||||
const id = key(item);
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
existing.push(item);
|
||||
}
|
||||
const combined = [...urgent, ...existing];
|
||||
const selected = combined.slice(0, limit);
|
||||
const selectedIds = new Set(selected.map(key));
|
||||
const displaced = today.filter(item => !selectedIds.has(key(item)) && !urgent.some(candidate => key(candidate) === key(item)));
|
||||
return {
|
||||
selected,
|
||||
protected:urgent,
|
||||
displaced,
|
||||
summary:urgent.length + ' urgent ' + (urgent.length === 1 ? 'item' : 'items') + ' protected · ' +
|
||||
displaced.length + (displaced.length ? ' displaced for review' : ' displaced'),
|
||||
};
|
||||
}
|
||||
return { propose };
|
||||
}
|
||||
|
||||
const protectToday = createProtectToday();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = protectToday;
|
||||
@@ -2,10 +2,29 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createPushNotifications = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, notification, serviceWorker, fetchJson,
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
let configuration = null;
|
||||
|
||||
function formattedHour(value) {
|
||||
return `${String(Number(value)).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function enabledDeadlineText(hour, days) {
|
||||
const value = Number(days ?? 2);
|
||||
const horizon = value === 0 ? 'due today' : `next ${value} days`;
|
||||
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
|
||||
}
|
||||
|
||||
function deadlineReadiness() {
|
||||
if (!configuration?.available || deadlineControl?.disabled) {
|
||||
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
|
||||
}
|
||||
return configuration.deadline_enabled
|
||||
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
|
||||
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
|
||||
}
|
||||
|
||||
function applicationServerKey(value) {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
||||
if (typeof atob === 'function') {
|
||||
@@ -20,15 +39,17 @@
|
||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||
await subscription?.unsubscribe?.();
|
||||
control.checked = false;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
async function ensureSubscription() {
|
||||
const permission = await notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
control.checked = false;
|
||||
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
@@ -45,6 +66,12 @@
|
||||
});
|
||||
control.checked = true;
|
||||
status.textContent = 'New update notifications enabled for this device.';
|
||||
configuration.subscribed = true;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
await ensureSubscription();
|
||||
}
|
||||
|
||||
async function change() {
|
||||
@@ -60,20 +87,73 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function changeDeadline() {
|
||||
deadlineControl.disabled = true;
|
||||
if (deadlineHour) deadlineHour.disabled = true;
|
||||
if (deadlineDays) deadlineDays.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (deadlineControl.checked && !subscription) {
|
||||
deadlineControl.checked = false;
|
||||
deadlineStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9);
|
||||
const reminderDays = Number(deadlineDays?.value ?? configuration?.reminder_days ?? 2);
|
||||
await fetchJson('api/v1/push-subscription/deadlines', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour, reminder_days:reminderDays}),
|
||||
});
|
||||
configuration.deadline_enabled = deadlineControl.checked;
|
||||
configuration.reminder_hour = reminderHour;
|
||||
configuration.reminder_days = reminderDays;
|
||||
configuration.timezone = timezone;
|
||||
deadlineStatus.textContent = deadlineControl.checked
|
||||
? enabledDeadlineText(reminderHour, reminderDays)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
return true;
|
||||
} catch (error) {
|
||||
deadlineControl.checked = !deadlineControl.checked;
|
||||
deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
deadlineControl.disabled = false;
|
||||
if (deadlineHour) deadlineHour.disabled = false;
|
||||
if (deadlineDays) deadlineDays.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDeadline() {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!control || !notification || !serviceWorker) return;
|
||||
control.addEventListener('change', change);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
if (deadlineControl) deadlineControl.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
status.textContent = configuration.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
return {init, change};
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
(function (root, factory) {
|
||||
const createSavedSearches = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSavedSearches;
|
||||
if (root) root.createSavedSearches = createSavedSearches;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function createSavedSearches(options) {
|
||||
const fetchJson = options.fetchJson;
|
||||
const createId = options.createId || (() => crypto.randomUUID().replace(/-/g, ''));
|
||||
const onOpen = options.onOpen;
|
||||
const onState = options.onState || (() => {});
|
||||
let current = { revision:0, views:[] };
|
||||
|
||||
function snapshot() {
|
||||
return { revision:current.revision, views:current.views.map(view => ({ ...view })) };
|
||||
}
|
||||
|
||||
function publish(status, message) {
|
||||
onState({ ...snapshot(), status, ...(message ? { message } : {}) });
|
||||
}
|
||||
|
||||
function normalizeSearch(name, search, id) {
|
||||
const cleanName = String(name || '').trim();
|
||||
const query = String(search?.query || '').trim();
|
||||
if (!cleanName || cleanName.length > 60) throw new Error('Name must be between 1 and 60 characters.');
|
||||
if (query.length < 2 || query.length > 200) throw new Error('Search query must be between 2 and 200 characters.');
|
||||
return {
|
||||
id, name:cleanName, query,
|
||||
kind:['all', 'issue', 'pull'].includes(search?.kind) ? search.kind : 'all',
|
||||
state:['all', 'open', 'closed'].includes(search?.state) ? search.state : 'all',
|
||||
repository:String(search?.repository || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function replace(views) {
|
||||
publish('saving');
|
||||
try {
|
||||
current = await fetchJson('api/v1/saved-searches', {
|
||||
method:'PUT', headers:{ 'Content-Type':'application/json' },
|
||||
body:JSON.stringify({ revision:current.revision, views }),
|
||||
});
|
||||
publish('ready');
|
||||
return snapshot();
|
||||
} catch (error) {
|
||||
const conflict = error?.status === 409 && error?.payload?.detail?.snapshot;
|
||||
if (conflict) {
|
||||
current = conflict;
|
||||
publish('conflict', 'Saved searches changed on another device.');
|
||||
} else {
|
||||
publish('error', 'Saved searches could not sync. Search still works.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async load() {
|
||||
publish('loading');
|
||||
try {
|
||||
current = await fetchJson('api/v1/saved-searches');
|
||||
publish('ready');
|
||||
} catch (error) {
|
||||
publish('error', 'Saved searches could not load. Search still works.');
|
||||
}
|
||||
return snapshot();
|
||||
},
|
||||
save(name, search) {
|
||||
if (current.views.length >= 20) return Promise.reject(new Error('Saved searches are limited to 20.'));
|
||||
const created = normalizeSearch(name, search, createId());
|
||||
return replace(current.views.concat(created));
|
||||
},
|
||||
rename(id, name) {
|
||||
const existing = current.views.find(view => view.id === id);
|
||||
if (!existing) return Promise.reject(new Error('Saved search no longer exists.'));
|
||||
const changed = normalizeSearch(name, existing, existing.id);
|
||||
return replace(current.views.map(view => view.id === id ? changed : view));
|
||||
},
|
||||
remove(id) {
|
||||
if (!current.views.some(view => view.id === id)) return Promise.resolve(snapshot());
|
||||
return replace(current.views.filter(view => view.id !== id));
|
||||
},
|
||||
open(id) {
|
||||
const selected = current.views.find(view => view.id === id);
|
||||
if (selected) onOpen({ ...selected });
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
createSavedSearches.mount = function mountSavedSearches(document, fetch, search, applyScope, history) {
|
||||
const query = selector => document.querySelector(selector);
|
||||
const input = query('#cmd-input');
|
||||
const request = async (url, init) => {
|
||||
const response = await fetch(url, init);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.detail?.message || payload.detail || 'Saved Search request failed.');
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
let controller;
|
||||
function render(state) {
|
||||
const list = query('#saved-search-list');
|
||||
const status = query('#saved-search-status');
|
||||
list.replaceChildren();
|
||||
status.textContent = ({loading:'Loading…', saving:'Syncing…', ready:'',
|
||||
conflict:state.message, error:state.message})[state.status] || '';
|
||||
state.views.forEach(view => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'saved-search-row';
|
||||
const open = document.createElement('button');
|
||||
open.type = 'button'; open.className = 'saved-search-open saved-search-action';
|
||||
open.textContent = view.name; open.title = view.query;
|
||||
open.addEventListener('click', () => controller.open(view.id));
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'saved-search-row-actions';
|
||||
for (const action of ['Rename', 'Delete']) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.className = 'saved-search-action'; button.textContent = action;
|
||||
button.setAttribute('aria-label', action + ' ' + view.name);
|
||||
button.addEventListener('click', async () => {
|
||||
if (action === 'Rename') {
|
||||
const name = globalThis.prompt('Rename saved search', view.name);
|
||||
if (name !== null && name.trim()) await controller.rename(view.id, name).catch(() => {});
|
||||
} else if (globalThis.confirm('Delete saved search “' + view.name + '”?')) {
|
||||
await controller.remove(view.id).catch(() => {});
|
||||
}
|
||||
});
|
||||
actions.append(button);
|
||||
}
|
||||
row.append(open, actions); list.append(row);
|
||||
});
|
||||
}
|
||||
controller = createSavedSearches({ fetchJson:request, onState:render, onOpen:view => {
|
||||
search.setQuery('');
|
||||
input.value = view.query;
|
||||
applyScope(view);
|
||||
search.setQuery(view.query);
|
||||
history.update({query:view.query, scope:view});
|
||||
input.focus();
|
||||
}});
|
||||
query('#save-current-search').addEventListener('click', async () => {
|
||||
const name = query('#saved-search-name').value.trim();
|
||||
const current = {query:input.value.trim(), kind:query('#cmd-search-kind').value,
|
||||
state:query('#cmd-search-state').value, repository:query('#cmd-search-repository').value.trim()};
|
||||
if (!name || current.query.length < 2) {
|
||||
query('#saved-search-status').textContent = 'Enter a name and at least 2 search characters.';
|
||||
return;
|
||||
}
|
||||
await controller.save(name, current).then(() => { query('#saved-search-name').value = ''; })
|
||||
.catch(error => { query('#saved-search-status').textContent = error.message; });
|
||||
});
|
||||
return controller;
|
||||
};
|
||||
|
||||
return createSavedSearches;
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
(function (root, factory) {
|
||||
const createSearchBatchPlan = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchBatchPlan;
|
||||
if (root) {
|
||||
root.createSearchBatchPlan = createSearchBatchPlan;
|
||||
root.mountSearchBatchPlanning = createSearchBatchPlan.mount;
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function createSearchBatchPlan({
|
||||
limit = 50,
|
||||
resolve = item => Promise.resolve(item),
|
||||
claim = item => Promise.resolve(item),
|
||||
onChange = () => {},
|
||||
} = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
const selected = new Map();
|
||||
let active = false;
|
||||
|
||||
const identity = item => [item?.kind || '', item?.repository || '', item?.number || ''].join(':');
|
||||
const eligible = item => item?.kind === 'issue' && item?.state === 'open' &&
|
||||
Boolean(item.repository) && Number.isInteger(item.number);
|
||||
function snapshot() {
|
||||
return { active, count:selected.size, items:Array.from(selected.values()) };
|
||||
}
|
||||
function changed() {
|
||||
const state = snapshot();
|
||||
onChange(state);
|
||||
return state;
|
||||
}
|
||||
function start() {
|
||||
active = true;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
function cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
function toggle(item) {
|
||||
if (!active) return 'inactive';
|
||||
if (!eligible(item)) return 'ineligible';
|
||||
const key = identity(item);
|
||||
if (selected.has(key)) {
|
||||
selected.delete(key);
|
||||
changed();
|
||||
return 'removed';
|
||||
}
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.set(key, { ...item });
|
||||
changed();
|
||||
return 'selected';
|
||||
}
|
||||
async function prepare(item) {
|
||||
const detail = await resolve(item);
|
||||
if (detail?.assigned_to_me) return detail;
|
||||
if (detail?.claimable) return claim(detail);
|
||||
throw new Error('This issue is no longer available to assign.');
|
||||
}
|
||||
function releaseRepository() {
|
||||
const repositories = new Set(Array.from(selected.values()).map(item => item.repository));
|
||||
return repositories.size === 1 ? repositories.values().next().value : null;
|
||||
}
|
||||
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
|
||||
}
|
||||
|
||||
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
|
||||
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
|
||||
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute,
|
||||
laterWork = null, laterPicker = null
|
||||
) {
|
||||
const get = selector => document.querySelector(selector);
|
||||
let processor;
|
||||
let laterProcessor;
|
||||
let releaseProcessor;
|
||||
let releaseContext = null;
|
||||
const plan = createSearchBatchPlan({
|
||||
resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}),
|
||||
claim:detail => fetchJson(
|
||||
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(detail.number) + '/claim',
|
||||
{method:'PATCH', headers:{Accept:'application/json'}}
|
||||
),
|
||||
onChange:state => {
|
||||
get('#search-batch-actions').hidden = !state.active;
|
||||
get('#select-search-results').hidden = state.active;
|
||||
get('#queue-selected-search-results').disabled = state.count === 0;
|
||||
get('#defer-selected-search-results').disabled = state.count === 0;
|
||||
get('#plan-selected-search-results').disabled = state.count === 0;
|
||||
get('#search-selection-status').textContent = state.count ?
|
||||
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
|
||||
render();
|
||||
},
|
||||
});
|
||||
processor = batchFactory({
|
||||
capacity:() => Math.max(0, todayWork.limit - todayWork.read().length),
|
||||
owner:getOwner,
|
||||
timeBudget:() => {
|
||||
const planning = todayWork.planning();
|
||||
return {
|
||||
capacity_minutes:planning.capacity_minutes,
|
||||
planned_minutes:Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0),
|
||||
};
|
||||
},
|
||||
journalName:'search-today-batch',
|
||||
autoMount:false,
|
||||
claim:item => plan.prepare(item),
|
||||
queue:confirmed => queueToday(acceptIssue(confirmed)),
|
||||
persistEstimate:(confirmed, minutes) => {
|
||||
const planning = todayWork.planning();
|
||||
planning.estimates[todayWork.identity(confirmed)] = minutes;
|
||||
todayWork.replacePlanning(planning);
|
||||
todaySync.enqueueConfiguration(planning.capacity_minutes, planning.estimates);
|
||||
todaySync.flush();
|
||||
},
|
||||
onProgress:progress => {
|
||||
get('#queue-selected-search-results').disabled = progress.status === 'running';
|
||||
if (progress.status === 'running') {
|
||||
get('#search-selection-status').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
} else if (progress.status === 'full') {
|
||||
get('#search-selection-status').textContent = 'Today has ' + progress.available + ' remaining slot' +
|
||||
(progress.available === 1 ? '.' : 's.');
|
||||
} else if (progress.status === 'estimates-required') {
|
||||
get('#search-batch-estimate-summary').textContent = 'Estimate every selected issue.';
|
||||
} else if (progress.status === 'over-budget') {
|
||||
get('#search-batch-estimate-summary').textContent = progress.requested_minutes +
|
||||
' min selected · ' + progress.remaining_minutes + ' min remain.';
|
||||
} else if (progress.status === 'complete') {
|
||||
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||
progress.queued.length + ' queued · ' + progress.failed.length + ' need retry.' :
|
||||
progress.queued.length + ' added to Today.';
|
||||
get('#resume-search-batch').hidden = processor.pending() === 0;
|
||||
closeEstimateReview();
|
||||
if (!progress.failed.length) plan.cancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
laterProcessor = batchFactory({
|
||||
capacity:() => plan.limit,
|
||||
owner:getOwner,
|
||||
journalName:'search-later-batch',
|
||||
queueFailureReason:'assigned but Later could not be saved',
|
||||
autoMount:false,
|
||||
claim:item => plan.prepare(item),
|
||||
queue:(confirmed, context) => {
|
||||
const outcome = laterWork?.defer(acceptIssue(confirmed), context?.until);
|
||||
return outcome === 'deferred' ? 'queued' : outcome;
|
||||
},
|
||||
onProgress:progress => {
|
||||
get('#defer-selected-search-results').disabled = progress.status === 'running';
|
||||
if (progress.status === 'running') {
|
||||
get('#search-selection-status').textContent = 'Deferring ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
} else if (progress.status === 'complete') {
|
||||
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||
progress.queued.length + ' deferred · ' + progress.failed.length + ' need retry.' :
|
||||
progress.queued.length + ' added to Later.';
|
||||
restore();
|
||||
if (!progress.failed.length) plan.cancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
releaseProcessor = batchFactory({
|
||||
capacity:() => plan.limit,
|
||||
owner:getOwner,
|
||||
journalName:'search-release-batch',
|
||||
queueFailureReason:'assigned but release planning could not be confirmed',
|
||||
autoMount:false,
|
||||
claim:item => plan.prepare(item),
|
||||
queue:async (confirmed, context) => {
|
||||
const issuePath = 'api/v1/repos/' + confirmed.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(confirmed.number);
|
||||
const releasePlan = await fetchJson(issuePath + '/release-plan', {
|
||||
method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'},
|
||||
body:JSON.stringify({milestone_id:context.milestone_id, due_date:context.due_date || null}),
|
||||
});
|
||||
if (releasePlan?.number !== confirmed.number ||
|
||||
releasePlan?.milestone?.id !== context.milestone_id ||
|
||||
(context.due_date && releasePlan?.due_date !== context.due_date)) {
|
||||
throw new Error('Issue release plan was not confirmed.');
|
||||
}
|
||||
return 'queued';
|
||||
},
|
||||
onProgress:progress => {
|
||||
get('#confirm-search-release').disabled = progress.status === 'running';
|
||||
if (progress.status === 'running') {
|
||||
get('#search-release-summary').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
} else if (progress.status === 'complete') {
|
||||
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||
progress.queued.length + ' planned · ' + progress.failed.length + ' need retry.' :
|
||||
progress.queued.length + ' planned for ' + (releaseContext?.milestone_title || 'release') + '.';
|
||||
get('#resume-search-batch').hidden = releaseProcessor.pending() === 0;
|
||||
closeReleaseReview();
|
||||
if (!progress.failed.length) plan.cancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
function estimateValues() {
|
||||
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
|
||||
[input.dataset.searchBatchEstimate, Number(input.value)]
|
||||
));
|
||||
}
|
||||
function closeEstimateReview() {
|
||||
get('#search-batch-estimate-review').hidden = true;
|
||||
get('#search-batch-actions').hidden = !plan.snapshot().active;
|
||||
}
|
||||
function openEstimateReview(items) {
|
||||
const planning = todayWork.planning();
|
||||
const planned = Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
|
||||
const remaining = Math.max(0, planning.capacity_minutes - planned);
|
||||
get('#search-batch-estimate-summary').textContent = remaining +
|
||||
' min remain. Estimate before assigning.';
|
||||
get('#search-batch-estimate-list').innerHTML = items.map(item => {
|
||||
const id = String(item.repository || '') + '#' + String(item.number || '');
|
||||
const estimate = planning.estimates?.[id] || '';
|
||||
return '<div class="search-batch-estimate-row"><div><span class="small">' + escapeHtml(id) +
|
||||
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div><label class="small">' +
|
||||
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
|
||||
escapeAttribute(estimate) + '" data-search-batch-estimate="' + escapeAttribute(id) +
|
||||
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes" /> min</label></div>';
|
||||
}).join('');
|
||||
get('#search-batch-actions').hidden = true;
|
||||
get('#search-batch-estimate-review').hidden = false;
|
||||
document.querySelector('[data-search-batch-estimate]')?.focus();
|
||||
}
|
||||
function closeReleaseReview() {
|
||||
get('#search-release-review').hidden = true;
|
||||
get('#search-batch-actions').hidden = !plan.snapshot().active;
|
||||
}
|
||||
async function openReleaseReview() {
|
||||
const items = plan.snapshot().items;
|
||||
const repository = plan.releaseRepository();
|
||||
if (!items.length) return;
|
||||
if (!repository) {
|
||||
get('#search-selection-status').textContent = 'Select issues from one repository to plan a release.';
|
||||
return;
|
||||
}
|
||||
get('#plan-selected-search-results').disabled = true;
|
||||
get('#search-selection-status').textContent = 'Loading release milestones…';
|
||||
try {
|
||||
const milestones = await fetchJson(
|
||||
'api/v1/repos/' + repository.split('/').map(encodeURIComponent).join('/') + '/milestones',
|
||||
{headers:{Accept:'application/json'}}
|
||||
);
|
||||
const open = (Array.isArray(milestones) ? milestones : []).filter(item => item?.state !== 'closed');
|
||||
get('#search-release-milestone').innerHTML = '<option value="">Choose milestone</option>' + open.map(item =>
|
||||
'<option value="' + Number(item.id) + '">' + escapeHtml(item.title) + '</option>'
|
||||
).join('');
|
||||
get('#search-release-list').innerHTML = items.map(item => '<div class="search-release-row"><span>' +
|
||||
escapeHtml(item.repository + ' #' + item.number) + '</span><strong>' +
|
||||
escapeHtml(item.title || 'Untitled work') + '</strong></div>').join('');
|
||||
get('#search-release-summary').textContent = items.length + ' issues in ' + repository + '. Assignment starts only after confirmation.';
|
||||
get('#search-batch-actions').hidden = true;
|
||||
get('#search-release-review').hidden = false;
|
||||
get('#search-release-milestone').focus();
|
||||
} catch (error) {
|
||||
get('#search-selection-status').textContent = error?.message || 'Milestones could not be loaded. Try again.';
|
||||
} finally {
|
||||
get('#plan-selected-search-results').disabled = false;
|
||||
}
|
||||
}
|
||||
get('#select-search-results').addEventListener('click', () => plan.start());
|
||||
get('#cancel-search-selection').addEventListener('click', () => plan.cancel());
|
||||
get('#queue-selected-search-results').addEventListener('click', () => {
|
||||
const items = plan.snapshot().items;
|
||||
const capacity = todayWork.planning().capacity_minutes;
|
||||
if (Number.isInteger(capacity) && capacity > 0) openEstimateReview(items);
|
||||
else processor.run(items);
|
||||
});
|
||||
get('#defer-selected-search-results').addEventListener('click', event => {
|
||||
const items = plan.snapshot().items;
|
||||
if (!items.length) return;
|
||||
laterPicker?.open({ items, confirm:until => () => laterProcessor.run(
|
||||
items, {}, { until:new Date(until).toISOString() }
|
||||
) }, event.currentTarget, 'search-batch');
|
||||
});
|
||||
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
|
||||
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
|
||||
get('#confirm-search-release').addEventListener('click', () => {
|
||||
const milestoneSelect = get('#search-release-milestone');
|
||||
const milestoneId = Number(milestoneSelect.value);
|
||||
if (!Number.isInteger(milestoneId) || milestoneId < 1) {
|
||||
get('#search-release-summary').textContent = 'Choose an open milestone.';
|
||||
return;
|
||||
}
|
||||
const selectedOption = milestoneSelect.options?.[milestoneSelect.selectedIndex];
|
||||
const milestoneTitle = selectedOption?.textContent || selectedOption?.text || 'release';
|
||||
const dueDay = get('#search-release-due-date').value;
|
||||
releaseContext = {
|
||||
milestone_id:milestoneId, milestone_title:milestoneTitle,
|
||||
due_date:dueDay ? dueDay + 'T23:59:59Z' : null,
|
||||
};
|
||||
releaseProcessor.run(plan.snapshot().items, {}, releaseContext);
|
||||
});
|
||||
get('#cancel-search-batch-estimates').addEventListener('click', closeEstimateReview);
|
||||
get('#confirm-search-batch-estimates').addEventListener('click', () =>
|
||||
processor.run(plan.snapshot().items, estimateValues())
|
||||
);
|
||||
get('#resume-search-batch').addEventListener('click', () => {
|
||||
if (processor.pending()) return processor.resume();
|
||||
if (laterProcessor.pending()) return laterProcessor.resume();
|
||||
return releaseProcessor.resume();
|
||||
});
|
||||
get('#open-palette').addEventListener('click', () => restore());
|
||||
get('#cmd-results').addEventListener('change', event => {
|
||||
const index = event.target?.dataset?.searchSelect;
|
||||
if (index === undefined) return;
|
||||
const result = lookup(Number(index));
|
||||
if (result) plan.toggle(result);
|
||||
});
|
||||
function restore() {
|
||||
const pending = processor.pending() + laterProcessor.pending() + releaseProcessor.pending();
|
||||
get('#resume-search-batch').hidden = pending === 0;
|
||||
get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
|
||||
}
|
||||
function resultHtml(result, index) {
|
||||
const allowed = result.kind === 'issue' && result.state === 'open';
|
||||
const selected = plan.snapshot().items.some(item => plan.identity(item) === plan.identity(result));
|
||||
return '<label class="cmd-select-result"' + (allowed ? '' : ' aria-disabled="true"') + '>' +
|
||||
'<input type="checkbox" data-search-select="' + index + '" ' + (selected ? 'checked ' : '') +
|
||||
(allowed ? '' : 'disabled ') + 'aria-label="Select ' + escapeAttribute(result.title) + '" />' +
|
||||
'<span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' +
|
||||
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
|
||||
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
|
||||
}
|
||||
return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
|
||||
};
|
||||
|
||||
return createSearchBatchPlan;
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
function createSearchDefer({ claim, accept, defer, refresh, announce, formatTime }) {
|
||||
let request = null;
|
||||
|
||||
function run(detail, until) {
|
||||
if (request) return request;
|
||||
const wasClaimable = Boolean(detail?.claimable);
|
||||
request = Promise.resolve(wasClaimable ? claim(detail) : detail)
|
||||
.then(confirmed => {
|
||||
const item = accept(confirmed);
|
||||
const outcome = defer(item, until);
|
||||
refresh();
|
||||
if (outcome === 'deferred') {
|
||||
announce((wasClaimable ? 'Assigned and deferred until ' : 'Deferred until ') + formatTime(until) + '.');
|
||||
} else {
|
||||
announce(wasClaimable
|
||||
? 'Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry.'
|
||||
: 'Could not save Later on this device. Retry scheduling.');
|
||||
}
|
||||
return outcome;
|
||||
})
|
||||
.finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { run, pending:() => Boolean(request) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createSearchDefer;
|
||||
+304
-9
@@ -1,40 +1,225 @@
|
||||
(function (root, factory) {
|
||||
const createSearchPreview = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
||||
if (root) root.createSearchPreview = createSearchPreview;
|
||||
if (root) {
|
||||
root.createSearchPreview = createSearchPreview;
|
||||
root.safeSearchUrl = value => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||||
} catch (_) { return ''; }
|
||||
};
|
||||
root.searchPreviewUrl = (state, defaultScope, location) => {
|
||||
const { query, preview, scope = defaultScope } = state;
|
||||
const params = new URLSearchParams({
|
||||
search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number,
|
||||
search_kind:scope.kind, search_state:scope.state,
|
||||
});
|
||||
if (scope.repository) params.set('search_repository', scope.repository);
|
||||
return new URL('?' + params, location.origin + location.pathname).href;
|
||||
};
|
||||
root.searchPreviewPath = item => {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview?kind=' + encodeURIComponent(item.kind);
|
||||
};
|
||||
root.searchPreviewConversationPath = (item, page) => {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const query = new URLSearchParams({ kind:item.kind, limit:'20' });
|
||||
if (page) query.set('page', String(page));
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview/conversation?' + query.toString();
|
||||
};
|
||||
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||
'/comments?kind=' + encodeURIComponent(item.kind);
|
||||
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
|
||||
storage,
|
||||
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
postReply:(item,body,operationId) => fetchJson(root.searchPreviewReplyPath(item), {
|
||||
method:'POST', headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':operationId},
|
||||
body:JSON.stringify({body}),
|
||||
}),
|
||||
});
|
||||
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
|
||||
const comments = document.querySelector('#search-preview-comments');
|
||||
const status = document.querySelector('#search-preview-conversation-status');
|
||||
const retry = document.querySelector('#retry-search-preview-conversation');
|
||||
const older = document.querySelector('#load-older-search-preview-comments');
|
||||
if (!conversation) {
|
||||
comments.textContent = status.textContent = '';
|
||||
retry.hidden = older.hidden = true;
|
||||
return;
|
||||
}
|
||||
comments.innerHTML = (conversation.comments || []).map(comment =>
|
||||
'<article class="search-preview-comment" data-comment-id="' + Number(comment.id || 0) + '">' +
|
||||
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '</div>' +
|
||||
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
|
||||
).join('');
|
||||
retry.hidden = conversation.status !== 'error';
|
||||
older.hidden = conversation.status !== 'ready' || !conversation.olderPage;
|
||||
older.disabled = conversation.status === 'loading';
|
||||
if (conversation.status === 'loading') status.textContent = conversation.comments?.length
|
||||
? 'Loading older messages…' : 'Loading current conversation…';
|
||||
else if (conversation.status === 'error') status.textContent =
|
||||
'Conversation unavailable. Preview and planning actions still work.';
|
||||
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
|
||||
else status.textContent = conversation.comments.length +
|
||||
(conversation.comments.length === 1 ? ' message.' : ' messages.');
|
||||
};
|
||||
root.renderSearchPreviewReply = (state, detail, preview, document) => {
|
||||
const section = document.querySelector('.search-preview-reply');
|
||||
const input = document.querySelector('#search-preview-reply');
|
||||
const status = document.querySelector('#search-preview-reply-status');
|
||||
const buttons = ['#send-search-preview-reply', '#send-search-preview-reply-next']
|
||||
.map(selector => document.querySelector(selector));
|
||||
section.hidden = detail?.commentable !== true;
|
||||
status.textContent = '';
|
||||
if (section.hidden) {
|
||||
buttons.forEach(button => { button.disabled = true; });
|
||||
return;
|
||||
}
|
||||
input.value = preview.replyDraft();
|
||||
const replying = state.status === 'replying';
|
||||
buttons.forEach(button => { button.disabled = replying || !input.value.trim(); });
|
||||
if (replying) status.textContent = 'Sending reply…';
|
||||
else if (state.status === 'replied') status.textContent = 'Reply posted.';
|
||||
else if (state.status === 'reply-error') status.textContent =
|
||||
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
|
||||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, mutate, onState }) {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||
if (Array.isArray(session)) {
|
||||
getSession = session[0];
|
||||
loadMore = () => session[1].loadMore();
|
||||
onNavigate = session[2];
|
||||
navigationRoot = document;
|
||||
}
|
||||
let generation = 0;
|
||||
let current = null;
|
||||
let mutationRequest = null;
|
||||
let shareRequest = null;
|
||||
let moveRequest = null;
|
||||
let replyRequest = null;
|
||||
let conversation = null;
|
||||
|
||||
function sameItem(left, right) {
|
||||
return left && right && left.kind === right.kind && left.repository === right.repository &&
|
||||
Number(left.number) === Number(right.number);
|
||||
}
|
||||
|
||||
function replyKey(item, suffix) {
|
||||
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
|
||||
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
|
||||
}
|
||||
|
||||
function stored(key) {
|
||||
try { return storage?.getItem(key) || ''; }
|
||||
catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function save(key, value) {
|
||||
try {
|
||||
if (value) storage?.setItem(key, value);
|
||||
else storage?.removeItem(key);
|
||||
} catch (_) { /* Volatile drafts still remain in the textarea. */ }
|
||||
}
|
||||
|
||||
function navigation(item) {
|
||||
const session = typeof getSession === 'function' ? getSession() : null;
|
||||
const items = Array.isArray(session?.items) ? session.items : [];
|
||||
const index = items.findIndex(candidate => sameItem(candidate, item));
|
||||
if (index < 0) return null;
|
||||
return {
|
||||
position:index + 1,
|
||||
total:items.length,
|
||||
hasPrevious:index > 0,
|
||||
hasNext:index < items.length - 1 || session.more === true,
|
||||
};
|
||||
}
|
||||
|
||||
function publish(state) {
|
||||
const position = navigation(state.item || current);
|
||||
if (navigationRoot) {
|
||||
const bar = navigationRoot.querySelector('.search-preview-navigation');
|
||||
bar.hidden = !position;
|
||||
if (position) {
|
||||
navigationRoot.querySelector('#search-preview-position').textContent = position.position + ' of ' + position.total;
|
||||
navigationRoot.querySelector('#previous-search-result').disabled = !position.hasPrevious;
|
||||
navigationRoot.querySelector('#next-search-result').disabled = !position.hasNext;
|
||||
}
|
||||
}
|
||||
onState(position ? { ...state, navigation:position } : state);
|
||||
if (state.complete && navigationRoot) {
|
||||
navigationRoot.querySelector('#search-preview-status').textContent = 'Search pass complete.';
|
||||
}
|
||||
}
|
||||
|
||||
function run(action, pending, success, detail) {
|
||||
if (mutationRequest) return mutationRequest;
|
||||
onState({ status: pending, item: current, detail });
|
||||
publish({ status: pending, item: current, detail });
|
||||
mutationRequest = mutate(detail, action).then(result => {
|
||||
onState({ status: success, item: current, detail, result });
|
||||
publish({ status: success, item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
publish({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { mutationRequest = null; });
|
||||
return mutationRequest;
|
||||
}
|
||||
|
||||
return {
|
||||
function loadConversation(detail, requestGeneration, page) {
|
||||
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
|
||||
const previousComments = page && Array.isArray(conversation?.comments)
|
||||
? conversation.comments : [];
|
||||
conversation = { status:'loading', comments:previousComments, olderPage:page ?? null };
|
||||
publish({ status:'ready', item:current, detail, conversation });
|
||||
return fetchConversation(detail, page).then(result => {
|
||||
if (requestGeneration !== generation) return result;
|
||||
const incoming = Array.isArray(result?.comments) ? result.comments : [];
|
||||
const liveComments = Array.isArray(conversation?.comments) ? conversation.comments : previousComments;
|
||||
const comments = [...incoming, ...liveComments].filter((comment, index, all) =>
|
||||
all.findIndex(candidate => candidate?.id === comment?.id) === index);
|
||||
conversation = {
|
||||
status:'ready',
|
||||
comments,
|
||||
olderPage:result?.older_page ?? null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
conversation = {
|
||||
status:'error', comments:previousComments,
|
||||
olderPage:page ?? conversation?.olderPage ?? null, error,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
const api = {
|
||||
open(item) {
|
||||
generation += 1;
|
||||
const requestGeneration = generation;
|
||||
current = { ...item };
|
||||
onState({ status: 'loading', item: current });
|
||||
conversation = null;
|
||||
publish({ status: 'loading', item: current });
|
||||
return fetchJson(current).then(detail => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'ready', item: current, detail });
|
||||
current = { ...current, ...detail };
|
||||
if (typeof fetchConversation === 'function') {
|
||||
loadConversation(current, requestGeneration);
|
||||
} else {
|
||||
publish({ status: 'ready', item: current, detail });
|
||||
}
|
||||
}
|
||||
return detail;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'error', item: current, error });
|
||||
publish({ status: 'error', item: current, error });
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
@@ -44,6 +229,98 @@
|
||||
current = null;
|
||||
onState({ status: 'closed' });
|
||||
},
|
||||
previous() {
|
||||
const session = typeof getSession === 'function' ? getSession() : null;
|
||||
const items = Array.isArray(session?.items) ? session.items : [];
|
||||
const index = items.findIndex(item => sameItem(item, current));
|
||||
if (index <= 0) return Promise.resolve(current);
|
||||
const item = items[index - 1];
|
||||
if (typeof onNavigate === 'function') onNavigate(item);
|
||||
return api.open(item);
|
||||
},
|
||||
next() {
|
||||
if (moveRequest) return moveRequest;
|
||||
moveRequest = (async () => {
|
||||
let session = typeof getSession === 'function' ? getSession() : null;
|
||||
let items = Array.isArray(session?.items) ? session.items : [];
|
||||
let index = items.findIndex(item => sameItem(item, current));
|
||||
if (index < 0) return current;
|
||||
if (index === items.length - 1 && session?.more === true && typeof loadMore === 'function') {
|
||||
await loadMore();
|
||||
session = getSession();
|
||||
items = Array.isArray(session?.items) ? session.items : [];
|
||||
index = items.findIndex(item => sameItem(item, current));
|
||||
}
|
||||
if (index >= 0 && index < items.length - 1) {
|
||||
const item = items[index + 1];
|
||||
if (typeof onNavigate === 'function') onNavigate(item);
|
||||
return api.open(item);
|
||||
}
|
||||
publish({ status:'ready', item:current, detail:current, complete:true });
|
||||
return current;
|
||||
})().finally(() => { moveRequest = null; });
|
||||
return moveRequest;
|
||||
},
|
||||
retryConversation() {
|
||||
if (!current) return Promise.resolve(null);
|
||||
return loadConversation(current, generation);
|
||||
},
|
||||
loadOlderConversation() {
|
||||
if (!current || !conversation?.olderPage) return Promise.resolve(null);
|
||||
return loadConversation(current, generation, conversation.olderPage);
|
||||
},
|
||||
saveReplyDraft(body) {
|
||||
if (!current) return '';
|
||||
const key = replyKey(current, 'draft');
|
||||
const next = String(body || '');
|
||||
if (stored(key) !== next) save(replyKey(current, 'operation'), '');
|
||||
save(key, next);
|
||||
return next;
|
||||
},
|
||||
replyDraft() {
|
||||
return current ? stored(replyKey(current, 'draft')) : '';
|
||||
},
|
||||
reply({ advance = false } = {}) {
|
||||
if (replyRequest) return replyRequest;
|
||||
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
|
||||
const body = api.replyDraft().trim();
|
||||
if (!body) return Promise.reject(new Error('Write a reply first.'));
|
||||
const item = { ...current };
|
||||
const operationKey = replyKey(item, 'operation');
|
||||
let operationId = stored(operationKey);
|
||||
if (!operationId) {
|
||||
operationId = String(createOperationId?.() || Date.now()).slice(0, 128);
|
||||
save(operationKey, operationId);
|
||||
}
|
||||
publish({ status:'replying', item:current, detail:current, conversation });
|
||||
replyRequest = postReply(item, body, operationId).then(comment => {
|
||||
const comments = [...(conversation?.comments || [])];
|
||||
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
|
||||
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
|
||||
save(replyKey(item, 'draft'), '');
|
||||
save(operationKey, '');
|
||||
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
|
||||
return advance ? api.next().then(() => comment) : comment;
|
||||
}).catch(error => {
|
||||
publish({ status:'reply-error', item:current, detail:current, conversation, error });
|
||||
throw error;
|
||||
}).finally(() => { replyRequest = null; });
|
||||
return replyRequest;
|
||||
},
|
||||
share(url) {
|
||||
if (shareRequest) return shareRequest;
|
||||
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
|
||||
const detail = current;
|
||||
publish({ status:'sharing', item:current, detail });
|
||||
shareRequest = share(url).then(result => {
|
||||
publish({ status:result, item:current, detail });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
publish({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { shareRequest = null; });
|
||||
return shareRequest;
|
||||
},
|
||||
claim(detail) {
|
||||
return run('claim', 'claiming', 'claimed', detail);
|
||||
},
|
||||
@@ -51,5 +328,23 @@
|
||||
return run('reopen', 'reopening', 'reopened', detail);
|
||||
},
|
||||
};
|
||||
if (navigationRoot) {
|
||||
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
|
||||
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
|
||||
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
|
||||
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
|
||||
const reply = navigationRoot.querySelector('#search-preview-reply');
|
||||
reply?.addEventListener('input', event => {
|
||||
api.saveReplyDraft(event.target.value);
|
||||
const disabled = !event.target.value.trim();
|
||||
navigationRoot.querySelector('#send-search-preview-reply').disabled = disabled;
|
||||
navigationRoot.querySelector('#send-search-preview-reply-next').disabled = disabled;
|
||||
});
|
||||
reply?.addEventListener('focus', event => event.target.scrollIntoView({ block:'center', behavior:'smooth' }));
|
||||
navigationRoot.querySelector('#send-search-preview-reply')?.addEventListener('click', () => api.reply().catch(() => {}));
|
||||
navigationRoot.querySelector('#send-search-preview-reply-next')?.addEventListener('click', () =>
|
||||
api.reply({ advance:true }).catch(() => {}));
|
||||
}
|
||||
return api;
|
||||
};
|
||||
});
|
||||
@@ -128,8 +128,10 @@
|
||||
}
|
||||
const labels = {
|
||||
sign_in: 'Signed in', sign_out: 'Signed out', passkey_enrolled: 'Passkey enrolled',
|
||||
passkey_counter_anomaly: 'Passkey counter anomaly',
|
||||
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
comment_deleted: 'Comment deleted',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||
};
|
||||
@@ -141,6 +143,9 @@
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
const context = [event.device_label, event.method, event.target,
|
||||
event.kind === 'passkey_counter_anomaly'
|
||||
? 'Remove and re-enroll this passkey if the alert repeats'
|
||||
: null,
|
||||
event.status === 'pending' ? 'Outcome confirmation pending' : null]
|
||||
.filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
|
||||
+148
-2
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v94';
|
||||
const CACHE = 'stackchain-dashboard-shell-v102';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -14,13 +14,18 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/conversation-action-hydrator.js',
|
||||
BASE + 'static/security-center.js',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/saved-searches.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/search-defer.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/unfiled-draft-sync.js',
|
||||
BASE + 'static/shared-image-capture.js',
|
||||
BASE + 'static/draft-filing-session.js',
|
||||
BASE + 'static/draft-capacity-dialog.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
@@ -32,7 +37,11 @@ const SHELL = [
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/agenda-replan.js',
|
||||
BASE + 'static/protect-today.js',
|
||||
BASE + 'static/notification-undo.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/work-selection.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
@@ -41,19 +50,26 @@ const SHELL = [
|
||||
BASE + 'static/comment-next.js',
|
||||
BASE + 'static/update-reply-read-next.js',
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-readiness.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/today-rollover.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
BASE + 'static/update-follow-up.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/later-sync.js',
|
||||
BASE + 'static/later-and-start.js',
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/batch-find-work.js',
|
||||
BASE + 'static/search-batch-plan.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/comment-actions.js',
|
||||
BASE + 'static/issue-evidence-review.js',
|
||||
BASE + 'static/issue-evidence-editor.js',
|
||||
BASE + 'static/issue-attachment.js',
|
||||
BASE + 'static/issue-filing-review.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
@@ -64,20 +80,81 @@ const SHELL = [
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/task-overlay-history.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/live-data-status.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/update-triage-session.js',
|
||||
BASE + 'static/update-review-handoff.js',
|
||||
BASE + 'static/update-read-position.js',
|
||||
BASE + 'static/work-detail-position.js',
|
||||
BASE + 'static/update-triage-launcher.js',
|
||||
BASE + 'static/update-triage-gesture.js',
|
||||
BASE + 'static/update-decision-transaction.js',
|
||||
BASE + 'static/agenda-session-launcher.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/mention-composer.js',
|
||||
BASE + 'static/push-notifications.js',
|
||||
BASE + 'static/issue-filing-receipt.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
const OPTIONAL_FEATURES = [
|
||||
];
|
||||
|
||||
const SHARED_IMAGE_ID = 'shared-image';
|
||||
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
|
||||
|
||||
function boundedShareField(form, name, limit) {
|
||||
const value = form.get(name);
|
||||
return typeof value === 'string' ? value.trim().slice(0, limit) : '';
|
||||
}
|
||||
|
||||
async function acceptSharedContent(request) {
|
||||
const form = await request.formData();
|
||||
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
|
||||
let marker = '';
|
||||
await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {});
|
||||
if (images.length > 5) marker = 'multiple';
|
||||
else if (images.length > 0) {
|
||||
const supported = images.every(image =>
|
||||
SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES
|
||||
);
|
||||
if (!supported) {
|
||||
marker = 'unsupported';
|
||||
} else if (images.length === 1) {
|
||||
const image = images[0];
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
});
|
||||
marker = 'image';
|
||||
} else {
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
|
||||
attachments:images.map(image => ({
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
})),
|
||||
});
|
||||
marker = 'images';
|
||||
}
|
||||
}
|
||||
const target = new URL(BASE, self.location.origin);
|
||||
target.searchParams.set('launch', 'new');
|
||||
if (marker) target.searchParams.set('shared', marker);
|
||||
for (const [name, limit] of [['title', 255], ['text', 10000], ['url', 2048]]) {
|
||||
const value = boundedShareField(form, name, limit);
|
||||
if (value) target.searchParams.set(name, value);
|
||||
}
|
||||
return new Response(null, {status:303, headers:{Location:target.pathname + target.search}});
|
||||
}
|
||||
|
||||
async function fetchNavigation(request) {
|
||||
const controller = new AbortController();
|
||||
let timeout;
|
||||
@@ -256,7 +333,7 @@ async function flushAndNotify() {
|
||||
body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' :
|
||||
needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
||||
tag: 'stackchain-delivery-' + receipt.id,
|
||||
data: { route: receipt.route },
|
||||
data: receipt.url ? { url: receipt.url } : { route: receipt.route },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -306,9 +383,33 @@ self.addEventListener('push', event => {
|
||||
try { payload = event.data?.json?.() || {}; }
|
||||
catch (_error) { return; }
|
||||
const route = String(payload.route || '');
|
||||
const protectRoute = String(payload.protect_route || '');
|
||||
const tag = String(payload.tag || '');
|
||||
const notificationId = Number(payload.notification_id);
|
||||
const updateCount = Number(payload.update_count);
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
if (
|
||||
route === '#/my-work/agenda'
|
||||
&& protectRoute === '#/my-work/agenda/protect-today'
|
||||
&& /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag)
|
||||
&& Number.isSafeInteger(deadlineCount)
|
||||
&& deadlineCount > 0
|
||||
&& deadlineCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(
|
||||
deadlineCount + ' deadline' + (deadlineCount === 1 ? '' : 's') + ' need' + (deadlineCount === 1 ? 's' : '') + ' attention',
|
||||
{
|
||||
body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'),
|
||||
tag,
|
||||
actions: [
|
||||
{ action: 'protect-today', title: 'Protect Today' },
|
||||
{ action: 'open-agenda', title: 'Open Agenda' },
|
||||
],
|
||||
data: {route, protectRoute},
|
||||
}
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/updates'
|
||||
&& tag === 'stackchain-update-digest'
|
||||
@@ -352,8 +453,45 @@ async function openWorkRoute(route) {
|
||||
return client.focus();
|
||||
}
|
||||
|
||||
async function openCanonicalIssueUrl(rawUrl) {
|
||||
let target;
|
||||
try { target = new URL(rawUrl); } catch (_error) { return; }
|
||||
if (
|
||||
target.origin !== self.location.origin
|
||||
|| !/^\/git\/[^/]+\/[^/]+\/issues\/\d+$/.test(target.pathname)
|
||||
|| target.search || target.hash
|
||||
) return;
|
||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (!client) return self.clients.openWindow(target.href);
|
||||
if (client.navigate) await client.navigate(target.href);
|
||||
return client.focus();
|
||||
}
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
const route = String(event.notification.data?.route || '');
|
||||
const issueUrl = String(event.notification.data?.url || '');
|
||||
if (issueUrl) {
|
||||
event.notification.close();
|
||||
event.waitUntil(openCanonicalIssueUrl(issueUrl));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'protect-today') {
|
||||
const protectRoute = String(event.notification.data?.protectRoute || route);
|
||||
if (
|
||||
protectRoute !== '#/my-work/agenda/protect-today'
|
||||
|| !['#/my-work/agenda', protectRoute].includes(route)
|
||||
) return;
|
||||
event.notification.close();
|
||||
event.waitUntil(openWorkRoute(protectRoute));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'open-agenda') {
|
||||
if (route !== '#/my-work/agenda') return;
|
||||
event.notification.close();
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'tomorrow') {
|
||||
const notificationId = Number(event.notification.data?.notificationId);
|
||||
if (
|
||||
@@ -414,6 +552,14 @@ self.addEventListener('notificationclick', event => {
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request;
|
||||
const requestUrl = new URL(request.url);
|
||||
if (
|
||||
request.method === 'POST' && request.mode === 'navigate'
|
||||
&& requestUrl.origin === self.location.origin && requestUrl.pathname === BASE
|
||||
) {
|
||||
event.respondWith(acceptSharedContent(request));
|
||||
return;
|
||||
}
|
||||
if (request.method !== 'GET' || request.url.includes('/api/')) return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.sharedImageCapture = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const RECORD_ID = 'shared-image';
|
||||
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
async function consume({marker, store, restore, status = () => {}}) {
|
||||
if (!marker) return false;
|
||||
if (!['image', 'images'].includes(marker)) {
|
||||
status('Share one PNG, JPEG, or WebP screenshot.');
|
||||
return false;
|
||||
}
|
||||
const value = await store?.get(RECORD_ID);
|
||||
const attachments = Array.isArray(value?.attachments) ? value.attachments : null;
|
||||
if (marker === 'images') {
|
||||
if (!attachments?.length || attachments.length > 5 || attachments.some(attachment =>
|
||||
!attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')))) {
|
||||
status('The shared screenshots are unavailable. Share them again.');
|
||||
return false;
|
||||
}
|
||||
restore(attachments);
|
||||
await store.delete(RECORD_ID);
|
||||
status(attachments.length + ' shared screenshots ready to file with this issue.');
|
||||
return true;
|
||||
}
|
||||
if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
|
||||
status('The shared screenshot is unavailable. Share it again.');
|
||||
return false;
|
||||
}
|
||||
restore(value);
|
||||
await store.delete(RECORD_ID);
|
||||
status('Shared screenshot ready to file with this issue.');
|
||||
return true;
|
||||
}
|
||||
|
||||
return {consume, RECORD_ID};
|
||||
});
|
||||
@@ -6,21 +6,132 @@
|
||||
'use strict';
|
||||
|
||||
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
|
||||
const searchKinds = new Set(['search', 'search-preview']);
|
||||
const maxQueryLength = 200;
|
||||
|
||||
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
||||
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
||||
function cleanQuery(value) {
|
||||
const query = typeof value === 'string' ? value.trim() : '';
|
||||
return query.length <= maxQueryLength ? query : '';
|
||||
}
|
||||
|
||||
function cleanScope(value) {
|
||||
const repository = typeof value?.repository === 'string' &&
|
||||
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
|
||||
? value.repository.trim() : '';
|
||||
return {
|
||||
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
|
||||
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
|
||||
repository,
|
||||
};
|
||||
}
|
||||
|
||||
function cleanPreview(value) {
|
||||
if (!value || !['issue', 'pull'].includes(value.kind)) return null;
|
||||
const repository = typeof value.repository === 'string' ? value.repository : '';
|
||||
const parts = repository.split('/');
|
||||
const validPart = part => /^[A-Za-z0-9_.-]+$/.test(part);
|
||||
const number = Number(value.number);
|
||||
if (parts.length !== 2 || !parts.every(validPart) || !Number.isInteger(number) || number < 1) return null;
|
||||
return { kind:value.kind, repository, number };
|
||||
}
|
||||
|
||||
function previewToken(preview) {
|
||||
return preview ? `${preview.kind}:${preview.repository}:${preview.number}` : '';
|
||||
}
|
||||
|
||||
function parsePreview(value) {
|
||||
const match = /^(issue|pull):([^/:]+\/[^/:]+):(\d+)$/.exec(value || '');
|
||||
return match ? cleanPreview({ kind:match[1], repository:match[2], number:Number(match[3]) }) : null;
|
||||
}
|
||||
|
||||
return function createTaskOverlayHistory({ history, location, eventTarget, onChange }) {
|
||||
let started = false;
|
||||
let restoredFromUrl = false;
|
||||
|
||||
function stateKind(state = history.state) {
|
||||
return allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||
function stateDetail(state = history.state) {
|
||||
const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||
if (!searchKinds.has(kind)) return { kind };
|
||||
const query = cleanQuery(state.searchQuery);
|
||||
const scope = state.searchScope === undefined ? null : cleanScope(state.searchScope);
|
||||
const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null;
|
||||
if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) {
|
||||
return { kind:'search', ...(query ? { query } : {}) };
|
||||
}
|
||||
return { kind, ...(query ? { query } : {}), ...(scope ? { scope } : {}), ...(preview ? { preview } : {}) };
|
||||
}
|
||||
|
||||
function urlDetail() {
|
||||
if (!location || typeof URLSearchParams === 'undefined') return { kind:null };
|
||||
const params = new URLSearchParams(location.search || '');
|
||||
const rawQuery = params.get('search');
|
||||
if (rawQuery === null) return { kind:null };
|
||||
const query = cleanQuery(rawQuery);
|
||||
if (rawQuery.trim() && !query) return { kind:null };
|
||||
const preview = parsePreview(params.get('preview'));
|
||||
const hasScope = params.has('search_kind') || params.has('search_state') || params.has('search_repository');
|
||||
const scope = hasScope ? cleanScope({
|
||||
kind:params.get('search_kind'), state:params.get('search_state'),
|
||||
repository:params.get('search_repository'),
|
||||
}) : null;
|
||||
return {
|
||||
kind:preview ? 'search-preview' : 'search',
|
||||
...(query ? { query } : {}),
|
||||
...(scope ? { scope } : {}),
|
||||
...(preview ? { preview } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toState(detail, base = history.state) {
|
||||
const state = { ...(base || {}), taskOverlay:detail.kind };
|
||||
delete state.searchQuery;
|
||||
delete state.searchPreview;
|
||||
delete state.searchScope;
|
||||
if (searchKinds.has(detail.kind)) {
|
||||
if (detail.query) state.searchQuery = detail.query;
|
||||
if (detail.scope) state.searchScope = cleanScope(detail.scope);
|
||||
if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function urlFor(detail) {
|
||||
if (!location || typeof URLSearchParams === 'undefined') return undefined;
|
||||
const params = new URLSearchParams(location.search || '');
|
||||
params.delete('search');
|
||||
params.delete('preview');
|
||||
params.delete('search_kind');
|
||||
params.delete('search_state');
|
||||
params.delete('search_repository');
|
||||
if (searchKinds.has(detail.kind)) {
|
||||
params.set('search', detail.query || '');
|
||||
if (detail.scope) {
|
||||
params.set('search_kind', detail.scope.kind);
|
||||
params.set('search_state', detail.scope.state);
|
||||
if (detail.scope.repository) params.set('search_repository', detail.scope.repository);
|
||||
}
|
||||
if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
|
||||
}
|
||||
const query = params.toString();
|
||||
return `${location.pathname || ''}${query ? `?${query}` : ''}${location.hash || ''}`;
|
||||
}
|
||||
|
||||
let initial = stateDetail();
|
||||
if (!initial.kind) {
|
||||
const fromUrl = urlDetail();
|
||||
if (fromUrl.kind) {
|
||||
initial = fromUrl;
|
||||
restoredFromUrl = true;
|
||||
history.replaceState(toState(fromUrl), '', urlFor(fromUrl));
|
||||
}
|
||||
}
|
||||
let active = initial;
|
||||
|
||||
function apply(state) {
|
||||
const next = stateKind(state);
|
||||
if (next === active) return;
|
||||
const previous = active;
|
||||
const next = stateDetail(state);
|
||||
if (JSON.stringify(next) === JSON.stringify(active)) return;
|
||||
const previous = active.kind;
|
||||
active = next;
|
||||
onChange(next, previous);
|
||||
onChange(next.kind, previous, next);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -28,33 +139,54 @@
|
||||
if (started) return;
|
||||
started = true;
|
||||
eventTarget.addEventListener('popstate', event => apply(event.state));
|
||||
if (restoredFromUrl) onChange(active.kind, null, active);
|
||||
},
|
||||
open(kind) {
|
||||
open(kind, detail = {}) {
|
||||
if (!allowed.has(kind)) return false;
|
||||
if (active === kind) return true;
|
||||
const previous = active;
|
||||
const state = { ...(history.state || {}), taskOverlay: kind };
|
||||
history.pushState(state, '');
|
||||
active = kind;
|
||||
onChange(kind, previous);
|
||||
const next = stateDetail(toState({
|
||||
kind, query:cleanQuery(detail.query),
|
||||
scope:detail.scope === undefined ? undefined : cleanScope(detail.scope),
|
||||
preview:cleanPreview(detail.preview),
|
||||
}));
|
||||
if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true;
|
||||
const previous = active.kind;
|
||||
history.pushState(toState(next), '', urlFor(next));
|
||||
active = next;
|
||||
onChange(next.kind, previous, next);
|
||||
return true;
|
||||
},
|
||||
update(detail = {}) {
|
||||
if (!searchKinds.has(active.kind)) return false;
|
||||
const next = stateDetail(toState({
|
||||
kind:active.kind,
|
||||
query:detail.query === undefined ? active.query : cleanQuery(detail.query),
|
||||
scope:detail.scope === undefined ? active.scope : cleanScope(detail.scope),
|
||||
preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview),
|
||||
}));
|
||||
history.replaceState(toState(next), '', urlFor(next));
|
||||
active = next;
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (!active) return false;
|
||||
if (!active.kind) return false;
|
||||
history.back();
|
||||
return true;
|
||||
},
|
||||
leave() {
|
||||
if (!active) return false;
|
||||
const previous = active;
|
||||
if (!active.kind) return false;
|
||||
const previous = active.kind;
|
||||
const state = { ...(history.state || {}) };
|
||||
delete state.taskOverlay;
|
||||
history.replaceState(state, '');
|
||||
active = null;
|
||||
onChange(null, previous);
|
||||
delete state.searchQuery;
|
||||
delete state.searchPreview;
|
||||
delete state.searchScope;
|
||||
active = { kind:null };
|
||||
history.replaceState(state, '', urlFor(active));
|
||||
onChange(null, previous, active);
|
||||
return true;
|
||||
},
|
||||
current() { return active; },
|
||||
current() { return active.kind; },
|
||||
currentState() { return { ...active, ...(active.preview ? { preview:{ ...active.preview } } : {}) }; },
|
||||
};
|
||||
};
|
||||
});
|
||||
+16
-1
@@ -91,6 +91,21 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
return write(ids) ? 'added' : 'unavailable';
|
||||
}
|
||||
|
||||
function addMany(items) {
|
||||
if (!storageKey()) return { status: 'unavailable', ids: [] };
|
||||
const current = read();
|
||||
const additions = [];
|
||||
for (const item of items || []) {
|
||||
const id = identity(item);
|
||||
if (!id) return { status: 'unavailable', ids: [] };
|
||||
if (!current.includes(id) && !additions.includes(id)) additions.push(id);
|
||||
}
|
||||
if (!additions.length) return { status: 'exists', ids: [] };
|
||||
if (current.length + additions.length > limit) return { status: 'full', ids: [] };
|
||||
return write(current.concat(additions)) ?
|
||||
{ status: 'added', ids: additions } : { status: 'unavailable', ids: [] };
|
||||
}
|
||||
|
||||
function replace(ids) {
|
||||
const unique = [];
|
||||
for (const id of ids || []) {
|
||||
@@ -162,7 +177,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, remove, move, reconcile, contains, position, limit };
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
@@ -1,3 +1,35 @@
|
||||
function withFilingEstimate(draft, value) {
|
||||
draft.estimateMinutes = Number(value) || null;
|
||||
return draft;
|
||||
}
|
||||
|
||||
function unfiledDraftSummary(item) {
|
||||
const plan = item.filingPlan || {};
|
||||
const ready = Boolean(plan.repository);
|
||||
const startsToday = plan.completionIntent === 'create-and-start';
|
||||
const evidenceCount = Number(item.attachmentCount || (item.hasAttachment ? 1 : 0));
|
||||
const evidence = evidenceCount ? `${evidenceCount} screenshot${evidenceCount === 1 ? '' : 's'}` : 'No screenshots';
|
||||
return {
|
||||
label:ready ? 'Ready to file' : 'Needs filing',
|
||||
repository:plan.repository || '', ready,
|
||||
action:ready ? (startsToday ? 'Review & start' : 'Review & file') : 'Choose repository',
|
||||
details:[startsToday ? 'Create & start' : 'Create issue',
|
||||
plan.estimateMinutes ? `${plan.estimateMinutes} min` : '', evidence].filter(Boolean).join(' · '),
|
||||
};
|
||||
}
|
||||
|
||||
function unfiledSavedMessage(item) {
|
||||
return item.repository ? 'Saved planned Draft. Resume on any signed-in device.' :
|
||||
'Saved to Drafts. Choose a repository when you’re ready to file it.';
|
||||
}
|
||||
|
||||
function applyIssueFilingMode(qs, enabled) {
|
||||
qs('#create-issue-filing').hidden = !enabled;
|
||||
qs('.create-issue-capture-actions').hidden = false;
|
||||
qs('#file-new-issue').hidden = enabled;
|
||||
qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work';
|
||||
}
|
||||
|
||||
function createUnfiledCaptures({
|
||||
storage,
|
||||
attachmentStore = null,
|
||||
@@ -8,6 +40,8 @@ function createUnfiledCaptures({
|
||||
maxItems = 20,
|
||||
}) {
|
||||
const storageKey = 'stackchain.unfiled-issues.v1';
|
||||
const listeners = new Set();
|
||||
const onChange = (id, removed) => listeners.forEach(listener => listener(id, removed));
|
||||
|
||||
function read() {
|
||||
try {
|
||||
@@ -35,6 +69,56 @@ function createUnfiledCaptures({
|
||||
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
|
||||
}
|
||||
|
||||
function filingPlan(value = {}, remote = false) {
|
||||
const get = (local, wire) => value?.[remote ? wire : local];
|
||||
const repository = String(get('repository', 'repository') || '').trim();
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return null;
|
||||
const labelIds = Array.from(new Set((Array.isArray(get('labelIds', 'label_ids')) ?
|
||||
get('labelIds', 'label_ids') : []).filter(id => Number.isInteger(id) && id > 0))).slice(0, 20);
|
||||
const plan = {repository, labelIds};
|
||||
const milestoneId = Number(get('milestoneId', 'milestone_id'));
|
||||
if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId;
|
||||
const dueDate = String(get('dueDate', 'due_date') || '');
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) plan.dueDate = dueDate;
|
||||
const bounded = (local, wire, limit) => {
|
||||
const text = String(get(local, wire) || '').trim();
|
||||
if (text) plan[local] = text.slice(0, limit);
|
||||
};
|
||||
bounded('templateName', 'template_name', 80);
|
||||
bounded('templateId', 'template_id', 80);
|
||||
bounded('capturedBody', 'captured_body', 10000);
|
||||
if (get('unassigned', 'unassigned') === true) plan.unassigned = true;
|
||||
const assignee = String(get('assignee', 'assignee') || '').trim();
|
||||
if (!plan.unassigned && /^[A-Za-z0-9_.-]+$/.test(assignee)) {
|
||||
plan.assignee = assignee;
|
||||
bounded('assigneeName', 'assignee_name', 255);
|
||||
}
|
||||
const estimateMinutes = Number(get('estimateMinutes', 'estimate_minutes'));
|
||||
if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) {
|
||||
plan.estimateMinutes = estimateMinutes;
|
||||
}
|
||||
const completionIntent = String(get('completionIntent', 'completion_intent') || '');
|
||||
if (['create', 'create-and-start'].includes(completionIntent)) plan.completionIntent = completionIntent;
|
||||
return plan;
|
||||
}
|
||||
|
||||
function exportFilingPlan(plan) {
|
||||
if (!plan) return null;
|
||||
return {
|
||||
repository:plan.repository, label_ids:plan.labelIds,
|
||||
...(plan.milestoneId ? {milestone_id:plan.milestoneId} : {}),
|
||||
...(plan.dueDate ? {due_date:plan.dueDate} : {}),
|
||||
...(plan.templateName ? {template_name:plan.templateName} : {}),
|
||||
...(plan.templateId ? {template_id:plan.templateId} : {}),
|
||||
...(plan.capturedBody ? {captured_body:plan.capturedBody} : {}),
|
||||
...(plan.unassigned ? {unassigned:true} : {}),
|
||||
...(plan.assignee ? {assignee:plan.assignee} : {}),
|
||||
...(plan.assigneeName ? {assignee_name:plan.assigneeName} : {}),
|
||||
...(plan.estimateMinutes ? {estimate_minutes:plan.estimateMinutes} : {}),
|
||||
...(plan.completionIntent ? {completion_intent:plan.completionIntent} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
@@ -42,19 +126,51 @@ function createUnfiledCaptures({
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
const attachment = note?.attachment;
|
||||
const attachments = note?.attachments;
|
||||
if (Array.isArray(attachments) && attachments.length > 5) {
|
||||
throw new Error('You can attach up to 5 screenshots.');
|
||||
}
|
||||
const validAttachments = Array.isArray(attachments) && attachments.length > 0 &&
|
||||
attachments.every(value => value?.blob && value?.filename &&
|
||||
['image/png', 'image/jpeg', 'image/webp'].includes(String(value?.contentType || '')));
|
||||
if (attachments && !validAttachments) {
|
||||
throw new Error('One or more screenshots are unavailable. Choose them again before saving.');
|
||||
}
|
||||
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
|
||||
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
|
||||
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
|
||||
if (hasAttachment && !attachmentStore) {
|
||||
if ((hasAttachment || validAttachments) && !attachmentStore) {
|
||||
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
||||
}
|
||||
return {title, body, ownerLogin, attachment, hasAttachment};
|
||||
const seenBlockers = new Set();
|
||||
const blockers = (Array.isArray(note?.blockers) ? note.blockers : []).reduce((items, blocker) => {
|
||||
const repository = String(blocker?.repository || '').trim();
|
||||
const number = Number(blocker?.number);
|
||||
const key = repository + '#' + number;
|
||||
if (items.length >= 5 || seenBlockers.has(key) ||
|
||||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
||||
!Number.isInteger(number) || number < 1) return items;
|
||||
seenBlockers.add(key);
|
||||
items.push({repository, number,
|
||||
title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)});
|
||||
return items;
|
||||
}, []);
|
||||
return {
|
||||
title, body, ownerLogin, attachment, attachments, blockers,
|
||||
filingPlan:filingPlan(note),
|
||||
hasAttachment:hasAttachment || validAttachments,
|
||||
attachmentCount:validAttachments ? attachments.length : (hasAttachment ? 1 : 0),
|
||||
};
|
||||
}
|
||||
|
||||
function persist(prepared, existing, removed = null) {
|
||||
const item = {
|
||||
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
|
||||
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
|
||||
savedAt:Number(now()), ...(prepared.hasAttachment ? {
|
||||
hasAttachment:true, attachmentCount:prepared.attachmentCount,
|
||||
} : {}), ...(prepared.blockers.length ? {
|
||||
blockers:prepared.blockers, blockerCount:prepared.blockers.length,
|
||||
} : {}), ...(prepared.filingPlan ? {filingPlan:prepared.filingPlan} : {}),
|
||||
};
|
||||
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
|
||||
const writeItems = () => {
|
||||
@@ -63,10 +179,17 @@ function createUnfiledCaptures({
|
||||
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
onChange(item.id, false);
|
||||
return item;
|
||||
};
|
||||
const stage = prepared.hasAttachment
|
||||
? Promise.resolve(attachmentStore.put(item.id, {
|
||||
? Promise.resolve(attachmentStore.put(item.id, prepared.attachments ? {
|
||||
attachments:prepared.attachments.map(value => ({
|
||||
filename:String(value.filename).slice(0, 255),
|
||||
contentType:String(value.contentType), blob:value.blob,
|
||||
...(String(value.note || '').trim() ? {note:String(value.note).trim().slice(0, 240)} : {}),
|
||||
})),
|
||||
} : {
|
||||
filename:String(prepared.attachment.filename).slice(0, 255),
|
||||
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
|
||||
}))
|
||||
@@ -113,6 +236,7 @@ function createUnfiledCaptures({
|
||||
const removed = items.find(item => item.id === id);
|
||||
if (!removed?.hasAttachment) {
|
||||
write(remaining);
|
||||
onChange(id, true);
|
||||
return true;
|
||||
}
|
||||
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
|
||||
@@ -122,6 +246,7 @@ function createUnfiledCaptures({
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
onChange(id, true);
|
||||
return true;
|
||||
})
|
||||
);
|
||||
@@ -133,10 +258,18 @@ function createUnfiledCaptures({
|
||||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||
throw new Error('Reconnect with the account that saved this capture.');
|
||||
}
|
||||
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
const draft = {...(item.filingPlan || {repository:'', labelIds:[]}), title:item.title, body:item.body,
|
||||
...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {})};
|
||||
if (!item.hasAttachment) return draft;
|
||||
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
|
||||
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
|
||||
if (Array.isArray(attachment?.attachments) && attachment.attachments.length) {
|
||||
if (attachment.attachments.length > 5 || attachment.attachments.some(value =>
|
||||
!value?.blob || !value?.filename || !value?.contentType)) {
|
||||
throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
return {...draft, attachments:attachment.attachments};
|
||||
}
|
||||
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
|
||||
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
@@ -146,7 +279,147 @@ function createUnfiledCaptures({
|
||||
|
||||
function completeResume(id) { return discard(id); }
|
||||
|
||||
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
|
||||
function encodeBytes(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function decodeBytes(value, contentType) {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return new Blob([bytes], {type:contentType});
|
||||
}
|
||||
|
||||
async function exportOwned(login) {
|
||||
const owner = String(login || '').trim();
|
||||
const exported = [];
|
||||
for (const item of read().filter(candidate => candidate.ownerLogin === owner)) {
|
||||
let evidence = [];
|
||||
if (item.hasAttachment) {
|
||||
const stored = await attachmentStore?.get(item.id);
|
||||
const attachments = Array.isArray(stored?.attachments) ? stored.attachments :
|
||||
(stored?.blob ? [stored] : []);
|
||||
if (attachments.length !== Number(item.attachmentCount || 1)) {
|
||||
throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
evidence = await Promise.all(attachments.map(async attachment => ({
|
||||
filename:attachment.filename,
|
||||
content_type:attachment.contentType,
|
||||
...(attachment.note ? {note:attachment.note} : {}),
|
||||
data:encodeBytes(await attachment.blob.arrayBuffer()),
|
||||
})));
|
||||
}
|
||||
exported.push({
|
||||
id:item.id, title:item.title, body:item.body, saved_at:Number(item.savedAt),
|
||||
...(item.filingPlan ? {filing_plan:exportFilingPlan(item.filingPlan)} : {}),
|
||||
...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {}),
|
||||
...(evidence.length ? {evidence} : {}),
|
||||
});
|
||||
}
|
||||
return exported;
|
||||
}
|
||||
|
||||
async function mergeRemote(drafts, login) {
|
||||
const ownerLogin = String(login || '').trim();
|
||||
if (!ownerLogin || !Array.isArray(drafts)) return 0;
|
||||
const existing = read();
|
||||
const known = new Set(existing.map(item => item.id));
|
||||
const imported = [];
|
||||
for (const remote of drafts) {
|
||||
if (!remote || known.has(remote.id) || imported.length + existing.length >= maxItems) continue;
|
||||
const evidence = Array.isArray(remote.evidence) ? remote.evidence : [];
|
||||
if (evidence.length && !attachmentStore) continue;
|
||||
const plan = filingPlan(remote.filing_plan, true);
|
||||
const item = {
|
||||
id:String(remote.id), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''),
|
||||
savedAt:Number(remote.saved_at),
|
||||
...(plan ? {filingPlan:plan} : {}),
|
||||
...(Array.isArray(remote.blockers) && remote.blockers.length ? {
|
||||
blockers:remote.blockers, blockerCount:remote.blockers.length,
|
||||
} : {}),
|
||||
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
|
||||
};
|
||||
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
|
||||
if (evidence.length) {
|
||||
await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({
|
||||
filename:String(entry.filename), contentType:String(entry.content_type),
|
||||
blob:decodeBytes(String(entry.data), String(entry.content_type)),
|
||||
...(entry.note ? {note:String(entry.note)} : {}),
|
||||
}))});
|
||||
}
|
||||
known.add(item.id);
|
||||
imported.push(item);
|
||||
}
|
||||
if (imported.length) write(imported.concat(existing));
|
||||
return imported.length;
|
||||
}
|
||||
|
||||
async function reconcileRemote(drafts, login) {
|
||||
const ownerLogin = String(login || '').trim();
|
||||
if (!ownerLogin || !Array.isArray(drafts)) return 0;
|
||||
const existing = read();
|
||||
const retained = existing.filter(item => item.ownerLogin !== ownerLogin);
|
||||
const reconciled = [];
|
||||
const stagedEvidence = new Map();
|
||||
for (const remote of drafts) {
|
||||
if (!remote || reconciled.length + retained.length >= maxItems) continue;
|
||||
const evidence = Array.isArray(remote.evidence) ? remote.evidence : [];
|
||||
if (evidence.length && !attachmentStore) {
|
||||
throw new Error('The synchronized screenshots cannot be stored on this device.');
|
||||
}
|
||||
const plan = filingPlan(remote.filing_plan, true);
|
||||
const item = {
|
||||
id:String(remote.id || ''), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''),
|
||||
savedAt:Number(remote.saved_at),
|
||||
...(plan ? {filingPlan:plan} : {}),
|
||||
...(Array.isArray(remote.blockers) && remote.blockers.length ? {
|
||||
blockers:remote.blockers, blockerCount:remote.blockers.length,
|
||||
} : {}),
|
||||
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
|
||||
};
|
||||
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
|
||||
if (evidence.length) {
|
||||
stagedEvidence.set(item.id, {attachments:evidence.map(entry => ({
|
||||
filename:String(entry.filename), contentType:String(entry.content_type),
|
||||
blob:decodeBytes(String(entry.data), String(entry.content_type)),
|
||||
...(entry.note ? {note:String(entry.note)} : {}),
|
||||
}))});
|
||||
}
|
||||
reconciled.push(item);
|
||||
}
|
||||
const evidenceIds = new Set(reconciled.filter(item => item.hasAttachment).map(item => item.id));
|
||||
const affectedIds = new Set(stagedEvidence.keys());
|
||||
for (const item of existing) if (item.ownerLogin === ownerLogin && item.hasAttachment) affectedIds.add(item.id);
|
||||
const previousEvidence = new Map();
|
||||
for (const id of affectedIds) previousEvidence.set(id, await attachmentStore?.get(id));
|
||||
try {
|
||||
for (const [id, value] of stagedEvidence) await attachmentStore.put(id, value);
|
||||
for (const item of existing) {
|
||||
if (item.ownerLogin === ownerLogin && item.hasAttachment && !evidenceIds.has(item.id)) {
|
||||
await attachmentStore?.delete(item.id);
|
||||
}
|
||||
}
|
||||
write(reconciled.concat(retained));
|
||||
} catch (error) {
|
||||
for (const [id, value] of previousEvidence) {
|
||||
await Promise.resolve(value ? attachmentStore?.put(id, value) : attachmentStore?.delete(id)).catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return reconciled.length;
|
||||
}
|
||||
|
||||
return {list, capacity, save, replaceOldest, discard, resume, completeResume,
|
||||
exportOwned, mergeRemote, reconcileRemote, currentLogin:getCurrentLogin,
|
||||
subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createUnfiledCaptures;
|
||||
module.exports.summary = unfiledDraftSummary;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
(function (root, factory) {
|
||||
const createUnfiledDraftSync = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createUnfiledDraftSync;
|
||||
if (root) root.createUnfiledDraftSync = createUnfiledDraftSync;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function createUnfiledDraftSync(options, mountedFetch) {
|
||||
if (mountedFetch) return createUnfiledDraftSync.mount(options, mountedFetch);
|
||||
const {
|
||||
captures, fetchJson, storage, getLogin = () => '', onState = () => {}, maxConflictRetries = 2,
|
||||
createConflictId = () => 'conflict-' + (globalThis.crypto?.randomUUID?.() || Date.now().toString(36)),
|
||||
} = options;
|
||||
const key = 'stackchain.unfiled-draft-sync.v1';
|
||||
let inFlight = null;
|
||||
let rerun = false;
|
||||
let queuedOwner = '';
|
||||
|
||||
function read(login) {
|
||||
try {
|
||||
const all = JSON.parse(storage?.getItem(key) || '{}');
|
||||
const state = all?.[login];
|
||||
return {
|
||||
all:all && typeof all === 'object' ? all : {},
|
||||
known:new Set(Array.isArray(state?.known) ? state.known : []),
|
||||
deleted:new Set(Array.isArray(state?.deleted) ? state.deleted : []),
|
||||
base:state?.base && typeof state.base === 'object' ? state.base : {},
|
||||
};
|
||||
} catch (_error) {
|
||||
return {all:{}, known:new Set(), deleted:new Set(), base:{}};
|
||||
}
|
||||
}
|
||||
|
||||
function write(login, state) {
|
||||
state.all[login] = {known:[...state.known], deleted:[...state.deleted], base:state.base};
|
||||
storage?.setItem(key, JSON.stringify(state.all));
|
||||
}
|
||||
|
||||
function publish(status, message, details = {}) {
|
||||
onState({status, ...(message ? {message} : {}), ...details});
|
||||
const visibleStatus = globalThis.document?.querySelector?.('#my-work-action-status');
|
||||
if (message && visibleStatus) visibleStatus.textContent = message;
|
||||
}
|
||||
|
||||
function sameDrafts(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
async function fingerprint(draft) {
|
||||
if (!draft) return null;
|
||||
const text = JSON.stringify(draft);
|
||||
if (globalThis.crypto?.subtle && globalThis.TextEncoder) {
|
||||
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
||||
return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
let first = 2166136261, second = 3335557771;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
first = Math.imul(first ^ text.charCodeAt(index), 16777619);
|
||||
second = Math.imul(second ^ text.charCodeAt(index), 2246822519);
|
||||
}
|
||||
return text.length + ':' + (first >>> 0).toString(16) + (second >>> 0).toString(16);
|
||||
}
|
||||
|
||||
async function reconcile(captures, drafts, owner) {
|
||||
if (typeof captures.reconcileRemote === 'function') {
|
||||
await captures.reconcileRemote(drafts, owner);
|
||||
return;
|
||||
}
|
||||
const finalIds = new Set(drafts.map(item => item.id));
|
||||
for (const item of await captures.exportOwned(owner)) {
|
||||
if (!finalIds.has(item.id)) await captures.discard(item.id);
|
||||
}
|
||||
await captures.mergeRemote(drafts, owner);
|
||||
}
|
||||
|
||||
async function runSync(login) {
|
||||
const owner = String(login || '').trim();
|
||||
if (!owner) return false;
|
||||
publish('syncing', 'Syncing Drafts…');
|
||||
const state = read(owner);
|
||||
try {
|
||||
const remote = await fetchJson('api/v1/unfiled-drafts');
|
||||
const remoteDrafts = Array.isArray(remote?.drafts) ? remote.drafts : [];
|
||||
const local = await captures.exportOwned(owner);
|
||||
const merged = [];
|
||||
const localById = new Map(local.map(item => [item.id, item]));
|
||||
const remoteById = new Map(remoteDrafts.map(item => [item.id, item]));
|
||||
const ids = new Set([...state.known, ...localById.keys(), ...remoteById.keys()]);
|
||||
let preservedConflict = false;
|
||||
const usedIds = new Set(ids);
|
||||
for (const id of ids) {
|
||||
const localDraft = state.deleted.has(id) ? null : localById.get(id);
|
||||
const remoteDraft = remoteById.get(id);
|
||||
const localHash = await fingerprint(localDraft);
|
||||
const remoteHash = await fingerprint(remoteDraft);
|
||||
const baseHash = state.base[id] || null;
|
||||
const localChanged = localHash !== baseHash;
|
||||
const remoteChanged = remoteHash !== baseHash;
|
||||
if (localChanged && remoteChanged && localHash !== remoteHash) {
|
||||
preservedConflict = true;
|
||||
if (remoteDraft) merged.push(remoteDraft);
|
||||
if (localDraft) {
|
||||
let conflictId = String(createConflictId()).slice(0, 100);
|
||||
for (let suffix = 2; usedIds.has(conflictId); suffix += 1) {
|
||||
conflictId = (String(createConflictId()).slice(0, 95) + '-' + suffix).slice(0, 100);
|
||||
}
|
||||
usedIds.add(conflictId);
|
||||
const suffix = ' (conflict copy)';
|
||||
merged.push({...localDraft, id:conflictId,
|
||||
title:(String(localDraft.title).slice(0, 255 - suffix.length) + suffix)});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const selected = localChanged ? localDraft : (remoteChanged ? remoteDraft : (localDraft || remoteDraft));
|
||||
if (selected) merged.push(selected);
|
||||
}
|
||||
merged.sort((left, right) => Number(right.saved_at) - Number(left.saved_at));
|
||||
|
||||
let finalSnapshot = remote;
|
||||
if (!sameDrafts(merged, remoteDrafts)) {
|
||||
finalSnapshot = await fetchJson('api/v1/unfiled-drafts', {
|
||||
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({revision:Number(remote.revision || 0), drafts:merged}),
|
||||
});
|
||||
}
|
||||
await reconcile(captures, finalSnapshot.drafts || [], owner);
|
||||
state.known = new Set((finalSnapshot.drafts || []).map(item => item.id));
|
||||
state.deleted.clear();
|
||||
state.base = {};
|
||||
for (const item of finalSnapshot.drafts || []) state.base[item.id] = await fingerprint(item);
|
||||
write(owner, state);
|
||||
publish(preservedConflict ? 'conflict-preserved' : 'ready', preservedConflict
|
||||
? 'Draft changed on two devices · both versions were saved.'
|
||||
: 'Drafts synced across devices.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.status !== 409) {
|
||||
publish('pending', 'Saved on this device · sync pending');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function sync(login) {
|
||||
const owner = String(login || '').trim();
|
||||
if (!owner) return Promise.resolve(false);
|
||||
if (inFlight) {
|
||||
rerun = true;
|
||||
queuedOwner = owner;
|
||||
return inFlight;
|
||||
}
|
||||
inFlight = (async () => {
|
||||
let result = false;
|
||||
let conflictRetries = 0;
|
||||
let activeOwner = owner;
|
||||
do {
|
||||
rerun = false;
|
||||
try {
|
||||
result = await runSync(activeOwner);
|
||||
} catch (error) {
|
||||
if (error?.status !== 409) throw error;
|
||||
if (conflictRetries >= maxConflictRetries) {
|
||||
publish('conflict', 'Draft sync needs attention. Retry when your devices finish saving.', {retryable:true});
|
||||
throw error;
|
||||
}
|
||||
conflictRetries += 1;
|
||||
rerun = true;
|
||||
}
|
||||
if (queuedOwner) {
|
||||
if (queuedOwner !== activeOwner) conflictRetries = 0;
|
||||
activeOwner = queuedOwner;
|
||||
queuedOwner = '';
|
||||
rerun = true;
|
||||
}
|
||||
} while (rerun);
|
||||
return result;
|
||||
})().finally(() => { inFlight = null; });
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
async function remove(id, login) {
|
||||
const owner = String(login || '').trim();
|
||||
if (!owner) return false;
|
||||
const state = read(owner);
|
||||
state.deleted.add(String(id));
|
||||
write(owner, state);
|
||||
await captures.discard(String(id));
|
||||
return sync(owner);
|
||||
}
|
||||
|
||||
function retry() {
|
||||
return sync(getLogin());
|
||||
}
|
||||
|
||||
captures.subscribe?.((id, removed) => {
|
||||
const login = getLogin();
|
||||
if (login) void (removed ? remove(id, login) : sync(login)).catch(() => {});
|
||||
});
|
||||
|
||||
return {sync, remove, retry};
|
||||
}
|
||||
createUnfiledDraftSync.mount = (captures, fetchJson) => {
|
||||
const getLogin = captures.currentLogin;
|
||||
const retryButton = globalThis.document?.querySelector?.('#retry-unfiled-draft-sync');
|
||||
const controller = createUnfiledDraftSync({
|
||||
captures, fetchJson, storage:globalThis.localStorage, getLogin,
|
||||
onState:state => { if (retryButton) retryButton.hidden = !state.retryable; },
|
||||
});
|
||||
retryButton?.addEventListener?.('click', async () => {
|
||||
retryButton.disabled = true;
|
||||
try { await controller.retry(); } catch (_error) { /* state remains actionable */ }
|
||||
finally { retryButton.disabled = false; }
|
||||
});
|
||||
const connect = () => {
|
||||
const login = getLogin();
|
||||
if (login) void controller.sync(login).catch(() => {});
|
||||
else globalThis.setTimeout(connect, 250);
|
||||
};
|
||||
globalThis.setTimeout(connect, 0);
|
||||
globalThis.addEventListener?.('online', connect);
|
||||
return controller;
|
||||
};
|
||||
return createUnfiledDraftSync;
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
(function(r){
|
||||
function transaction({controls=[],status}) {
|
||||
let busy=false, disabled=[];
|
||||
async function run(message, action, {origin,failure}={}) {
|
||||
if (busy) return false;
|
||||
busy=true;
|
||||
disabled=controls.map(control=>control.disabled);
|
||||
controls.forEach(control=>{ control.disabled=true; });
|
||||
status.textContent=message;
|
||||
let completed=false;
|
||||
try { completed=Boolean(await action()); }
|
||||
catch (error) { failure=failure || error.message || 'Could not complete decision. Retry.'; }
|
||||
if (!completed && failure) status.textContent=failure;
|
||||
busy=false;
|
||||
controls.forEach((control,index)=>{ control.disabled=disabled[index]; });
|
||||
if (!completed) origin?.focus?.();
|
||||
return completed;
|
||||
}
|
||||
return {run,busy:()=>busy};
|
||||
}
|
||||
function actions(o) {
|
||||
return {
|
||||
keepUnread:()=>o.transaction.run('Keeping unread…',()=>{
|
||||
if(o.triage.active()) o.triage.keepUnreadAndNext(); else o.close();
|
||||
return true;
|
||||
},{origin:o.keepControl,failure:'Could not keep this update unread. Retry.'}),
|
||||
markRead:()=>o.transaction.run('Marking read…',async()=>{
|
||||
const result=await o.reader.markReadAndNext(o.items());
|
||||
if(!result) return false;
|
||||
o.undo.offer(result.item,result.items);
|
||||
if(o.triage.active()) o.triage.acceptCompleted();
|
||||
return true;
|
||||
},{origin:o.readControl,failure:'Could not mark update read. Retry.'}),
|
||||
};
|
||||
}
|
||||
if(typeof module==='object'&&module.exports){module.exports=transaction;module.exports.createActions=actions;}
|
||||
else {r.createUpdateDecisionTransaction=transaction;r.createUpdateDecisionActions=actions;}
|
||||
})(typeof self!=='undefined'?self:this);
|
||||
@@ -0,0 +1,87 @@
|
||||
function createUpdateFollowUp({ storage = null, getLogin = () => '' } = {}) {
|
||||
const sourceKey = 'stackchain.update-follow-up-source.v1';
|
||||
const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
|
||||
const safeRepository = value => {
|
||||
const candidate = String(value || '');
|
||||
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(candidate) &&
|
||||
candidate.split('/').every(part => part !== '.' && part !== '..') ? candidate : '';
|
||||
};
|
||||
const safeUrl = value => {
|
||||
try {
|
||||
const parsed = new URL(String(value || ''));
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : '';
|
||||
} catch (_error) { return ''; }
|
||||
};
|
||||
|
||||
function draft(detail = {}) {
|
||||
const source = safeUrl(detail.url || detail.latest_comment?.url);
|
||||
const context = clean(detail.latest_comment?.body || detail.subject_body, 9000);
|
||||
const title = clean(detail.title, 228);
|
||||
const sections = [];
|
||||
if (source) sections.push('Source: ' + source);
|
||||
if (context) sections.push('Latest context:\n> ' + context.replace(/\n/g, '\n> '));
|
||||
return {
|
||||
repository: safeRepository(detail.repository),
|
||||
title: ('Follow up: ' + (title || 'Unread update')).slice(0, 240),
|
||||
body: sections.join('\n\n').slice(0, 9500),
|
||||
};
|
||||
}
|
||||
|
||||
function readSource() {
|
||||
const login = String(getLogin() || '').trim();
|
||||
if (!login) return null;
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(sourceKey) || 'null');
|
||||
return record?.version === 1 && record.ownerLogin === login &&
|
||||
Number.isInteger(record.source?.notification_id) && record.source.notification_id > 0 ? record : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function writeSource(record) {
|
||||
storage?.setItem(sourceKey, JSON.stringify(record));
|
||||
}
|
||||
|
||||
function stageSource(item = {}) {
|
||||
const ownerLogin = String(getLogin() || '').trim();
|
||||
const notificationId = Number(item.notification_id);
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before continuing this follow-up.');
|
||||
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid source update.');
|
||||
const source = {
|
||||
notification_id: notificationId,
|
||||
repository: safeRepository(item.repository),
|
||||
number: Number.isInteger(item.number) && item.number > 0 ? item.number : null,
|
||||
title: clean(item.title, 240), kind: 'update', has_update: true,
|
||||
};
|
||||
writeSource({ version:1, ownerLogin, source, admission:null });
|
||||
return { ...source };
|
||||
}
|
||||
|
||||
function source() {
|
||||
const record = readSource();
|
||||
return record ? { ...record.source } : null;
|
||||
}
|
||||
|
||||
function discardSource() {
|
||||
if (!readSource()) return false;
|
||||
storage?.removeItem(sourceKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function complete({ admit, queueRead, advance }) {
|
||||
const record = readSource();
|
||||
if (!record) throw new Error('Reopen the source update before continuing.');
|
||||
let admission = record.admission;
|
||||
if (!admission) {
|
||||
admission = await admit();
|
||||
writeSource({ ...record, admission });
|
||||
}
|
||||
await queueRead(record.source.notification_id);
|
||||
const advanced = await advance({ ...record.source });
|
||||
storage?.removeItem(sourceKey);
|
||||
return { admission, advanced:Boolean(advanced) };
|
||||
}
|
||||
|
||||
return { draft, stageSource, source, discardSource, complete };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp;
|
||||
@@ -0,0 +1,49 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateReadPosition = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateReadPosition(options) {
|
||||
const panel = options.panel;
|
||||
const jump = options.jump;
|
||||
let identity = '';
|
||||
let openingTop = 0;
|
||||
let movedByUser = false;
|
||||
let positioned = false;
|
||||
let target = null;
|
||||
|
||||
function moveToTarget() {
|
||||
if (!target) return false;
|
||||
target.classList?.add('update-new-activity');
|
||||
target.scrollIntoView({ block:'end' });
|
||||
positioned = true;
|
||||
jump.hidden = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
panel.addEventListener('scroll', () => {
|
||||
if (identity && !positioned && panel.scrollTop !== openingTop) movedByUser = true;
|
||||
}, { passive:true });
|
||||
|
||||
return {
|
||||
open(nextIdentity) {
|
||||
identity = String(nextIdentity || '');
|
||||
openingTop = panel.scrollTop;
|
||||
movedByUser = false;
|
||||
positioned = false;
|
||||
target = null;
|
||||
jump.hidden = true;
|
||||
},
|
||||
ready(readyIdentity, newest) {
|
||||
if (!identity || String(readyIdentity || '') !== identity || positioned || !newest) return false;
|
||||
target = newest;
|
||||
target.classList?.add('update-new-activity');
|
||||
if (movedByUser) {
|
||||
jump.hidden = false;
|
||||
return false;
|
||||
}
|
||||
return moveToTarget();
|
||||
},
|
||||
jump() {
|
||||
return moveToTarget();
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateReviewHandoff = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateReviewHandoff(options) {
|
||||
let source = null;
|
||||
let completing = null;
|
||||
|
||||
function eligible(detail, item = detail) {
|
||||
const merged = { ...(item || {}), ...(detail || {}) };
|
||||
return Boolean(
|
||||
merged.is_review && merged.update_reason === 'Review requested' &&
|
||||
merged.subject_type === 'Pull' && /^[^/]+\/[^/]+$/.test(String(merged.repository || '')) &&
|
||||
Number.isInteger(Number(merged.number)) && Number(merged.number) > 0 &&
|
||||
Number.isInteger(Number(merged.notification_id))
|
||||
);
|
||||
}
|
||||
|
||||
function reviewItem(item) {
|
||||
return {
|
||||
...item,
|
||||
kind: 'review',
|
||||
is_review: true,
|
||||
key: item.key || item.repository + '#' + item.number,
|
||||
};
|
||||
}
|
||||
|
||||
function begin(item) {
|
||||
if (!eligible(item) || source) return false;
|
||||
source = item;
|
||||
options.openReview(reviewItem(item));
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (!source || completing) return false;
|
||||
const item = source;
|
||||
source = null;
|
||||
options.restoreUpdate?.(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
function complete() {
|
||||
if (!source) return Promise.resolve(false);
|
||||
if (completing) return completing;
|
||||
const item = source;
|
||||
completing = Promise.resolve(options.admitRead(item.notification_id)).then(() => {
|
||||
options.advance(item);
|
||||
source = null;
|
||||
return true;
|
||||
}).finally(() => { completing = null; });
|
||||
return completing;
|
||||
}
|
||||
|
||||
return {
|
||||
eligible,
|
||||
begin,
|
||||
cancel,
|
||||
complete,
|
||||
active: () => Boolean(source),
|
||||
source: () => source,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageGesture = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageGesture(options) {
|
||||
const viewportWidth = options.viewportWidth || (() => root.innerWidth);
|
||||
const surface = options.surface;
|
||||
const threshold = options.threshold || 72;
|
||||
const edgeInset = options.edgeInset || 24;
|
||||
const interactive = 'a,button,input,textarea,select,label,summary,[contenteditable="true"],[role="button"]';
|
||||
let active = null;
|
||||
let busy = false;
|
||||
|
||||
function horizontalScroller(target) {
|
||||
for (let node = target; node && node !== surface; node = node.parentElement) {
|
||||
if (Number(node.scrollWidth) > Number(node.clientWidth) + 1) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function ignored(event) {
|
||||
return event.pointerType === 'mouse' || event.clientX <= edgeInset ||
|
||||
event.clientX >= viewportWidth() - edgeInset ||
|
||||
Boolean(event.target?.closest?.(interactive)) || horizontalScroller(event.target);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
active = null;
|
||||
delete surface.dataset.triageGesture;
|
||||
}
|
||||
|
||||
function start(event) {
|
||||
if (busy || !options.enabled() || viewportWidth() > 700 || ignored(event) || active) return;
|
||||
active = {id:event.pointerId, x:event.clientX, y:event.clientY, cancelled:false};
|
||||
surface.setPointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
function move(event) {
|
||||
if (!active || active.id !== event.pointerId) return;
|
||||
const dx = event.clientX - active.x;
|
||||
const dy = event.clientY - active.y;
|
||||
if (Math.abs(dy) > Math.abs(dx) || Math.abs(dy) > 36) {
|
||||
active.cancelled = true;
|
||||
delete surface.dataset.triageGesture;
|
||||
return;
|
||||
}
|
||||
if (Math.abs(dx) < 12) return;
|
||||
event.preventDefault?.();
|
||||
surface.dataset.triageGesture = dx > 0 ? 'keep' : 'read';
|
||||
}
|
||||
|
||||
function decide(callback) {
|
||||
busy = true;
|
||||
let outcome;
|
||||
try {
|
||||
outcome = callback();
|
||||
} catch (error) {
|
||||
busy = false;
|
||||
throw error;
|
||||
}
|
||||
if (outcome && typeof outcome.then === 'function') {
|
||||
Promise.resolve(outcome).finally(() => { busy = false; });
|
||||
} else {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function finish(event) {
|
||||
if (!active || active.id !== event.pointerId) return;
|
||||
const gesture = active;
|
||||
const dx = event.clientX - gesture.x;
|
||||
const dy = event.clientY - gesture.y;
|
||||
surface.releasePointerCapture?.(event.pointerId);
|
||||
reset();
|
||||
if (gesture.cancelled || Math.abs(dx) < threshold || Math.abs(dx) <= Math.abs(dy)) return;
|
||||
decide(dx > 0 ? options.keepUnread : options.markRead);
|
||||
}
|
||||
|
||||
surface.addEventListener('pointerdown', start);
|
||||
surface.addEventListener('pointermove', move);
|
||||
surface.addEventListener('pointerup', finish);
|
||||
surface.addEventListener('pointercancel', reset);
|
||||
|
||||
function destroy() {
|
||||
surface.removeEventListener('pointerdown', start);
|
||||
surface.removeEventListener('pointermove', move);
|
||||
surface.removeEventListener('pointerup', finish);
|
||||
surface.removeEventListener('pointercancel', reset);
|
||||
reset();
|
||||
}
|
||||
|
||||
return {destroy};
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageLauncher(options) {
|
||||
let pending = null;
|
||||
|
||||
function open() {
|
||||
if (pending) return pending;
|
||||
options.selectUpdates();
|
||||
options.announce('Checking all unread updates…');
|
||||
pending = Promise.resolve()
|
||||
.then(() => options.discover())
|
||||
.then(discovery => {
|
||||
if (!options.isUpdatesSelected()) return 'cancelled';
|
||||
if (discovery === false || discovery?.complete === false) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
return 'incomplete';
|
||||
}
|
||||
if (discovery?.complete) options.applySnapshot?.(discovery);
|
||||
if (options.hasMore()) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
return 'incomplete';
|
||||
}
|
||||
const opened = options.hasCheckpoint() ? options.resume() : options.start();
|
||||
if (!opened) options.announce('No unread updates are ready.');
|
||||
return opened ? 'opened' : 'empty';
|
||||
})
|
||||
.catch(() => {
|
||||
if (options.isUpdatesSelected()) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
}
|
||||
return 'incomplete';
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {open};
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageSession = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageSession(options) {
|
||||
const key = options.key || 'stackchain.update-triage.v1';
|
||||
const identity = item => Number.isInteger(item?.notification_id) ? String(item.notification_id) : '';
|
||||
const login = () => String(options.getLogin() || '').trim();
|
||||
let state = null;
|
||||
let running = false;
|
||||
let lastOutcome = null;
|
||||
|
||||
function read() {
|
||||
const owner = login();
|
||||
if (!owner) return null;
|
||||
try {
|
||||
const value = JSON.parse(options.storage.getItem(key) || 'null');
|
||||
if (![1, 2].includes(value?.version) || value.login !== owner || !Array.isArray(value.identities) ||
|
||||
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
|
||||
const identities = value.identities.filter(value => typeof value === 'string' && value);
|
||||
const completed = value.completed.filter(value => identities.includes(value));
|
||||
const kept = Array.isArray(value.kept) ? value.kept.filter(value => identities.includes(value)) : [];
|
||||
if (!identities.length || !identities.includes(value.current)) return null;
|
||||
return { version:2, login:owner, identities, current:value.current, completed, kept };
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
if (!state) return;
|
||||
options.storage.setItem(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function available() {
|
||||
const byIdentity = new Map((options.getItems() || []).map(item => [identity(item), item]));
|
||||
return state.identities.filter(id => !state.completed.includes(id) && byIdentity.has(id))
|
||||
.map(id => byIdentity.get(id));
|
||||
}
|
||||
|
||||
function nextAvailable() {
|
||||
if (!running || !state) return null;
|
||||
const candidates = available().filter(item => identity(item) !== state.current);
|
||||
return candidates.find(item => state.identities.indexOf(identity(item)) > state.identities.indexOf(state.current)) || candidates[0] || null;
|
||||
}
|
||||
|
||||
function finish() {
|
||||
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
|
||||
const keptIdentities = state ? state.kept.filter(id => unread.has(id)) : [];
|
||||
const reviewed = state ? state.completed.length : 0;
|
||||
const outcome = { reviewed, resolved:reviewed - keptIdentities.length, kept:keptIdentities.length, keptIdentities };
|
||||
lastOutcome = outcome;
|
||||
running = false;
|
||||
state = null;
|
||||
options.storage.removeItem(key);
|
||||
options.onFinish(outcome);
|
||||
return false;
|
||||
}
|
||||
|
||||
function openCurrent(preferredIndex = null) {
|
||||
const items = available();
|
||||
if (!items.length) return finish();
|
||||
let index = items.findIndex(item => identity(item) === state.current);
|
||||
if (index < 0) index = Math.min(preferredIndex ?? 0, items.length - 1);
|
||||
state.current = identity(items[index]);
|
||||
persist();
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
const progress = {
|
||||
index:originalIndex + 1,
|
||||
total:state.identities.length,
|
||||
};
|
||||
if (items[index]?.update_reason) progress.reason = String(items[index].update_reason);
|
||||
options.onProgress(progress);
|
||||
options.onOpen(items[index]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function advance(openNext = true) {
|
||||
if (!running || !state) return false;
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
if (!state.completed.includes(state.current)) state.completed.push(state.current);
|
||||
const candidates = available();
|
||||
const next = candidates.find(item => state.identities.indexOf(identity(item)) > originalIndex) || candidates[0];
|
||||
if (!next) return finish();
|
||||
state.current = identity(next);
|
||||
if (openNext) return openCurrent();
|
||||
persist();
|
||||
const progress = {
|
||||
index:state.identities.indexOf(state.current) + 1,
|
||||
total:state.identities.length,
|
||||
};
|
||||
if (next?.update_reason) progress.reason = String(next.update_reason);
|
||||
options.onProgress(progress);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
resumable: () => Boolean(read()),
|
||||
start() {
|
||||
const identities = (options.getItems() || []).map(identity).filter(Boolean);
|
||||
if (!identities.length) return finish();
|
||||
state = { version:2, login:login(), identities, current:identities[0], completed:[], kept:[] };
|
||||
if (!state.login) return false;
|
||||
running = true;
|
||||
return openCurrent();
|
||||
},
|
||||
resume() {
|
||||
state = read();
|
||||
if (!state) return false;
|
||||
running = true;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
reconcile() {
|
||||
if (!running || !state) return false;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
completeAndNext: advance,
|
||||
acceptCompleted: () => advance(false),
|
||||
keepUnreadAndNext() {
|
||||
if (!running || !state) return false;
|
||||
if (!state.kept.includes(state.current)) state.kept.push(state.current);
|
||||
return advance();
|
||||
},
|
||||
reviewKept(identities = lastOutcome?.keptIdentities || []) {
|
||||
const unread = new Set((options.getItems() || []).map(identity).filter(Boolean));
|
||||
const kept = (identities || []).filter(id => unread.has(id));
|
||||
if (!kept.length) return false;
|
||||
state = { version:2, login:login(), identities:kept, current:kept[0], completed:[], kept:[] };
|
||||
if (!state.login) return false;
|
||||
running = true;
|
||||
return openCurrent();
|
||||
},
|
||||
next: nextAvailable,
|
||||
items: () => state ? available().slice() : [],
|
||||
end: finish,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
(function(r,f){if(typeof module==='object'&&module.exports)module.exports=f;else r.createWorkDetailPosition=f})(typeof self!=='undefined'?self:this,function(o){let i='';return{open(n){n=String(n||'');if(n&&n===i)return false;i=n;o.panel.scrollTop=0;return true}}});
|
||||
+35
-4
@@ -6,6 +6,7 @@
|
||||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
@@ -15,11 +16,18 @@
|
||||
function parse(fragment) {
|
||||
const parts = String(fragment || '').split('/');
|
||||
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
|
||||
const queue = parts[2]?.replace(/s$/, '');
|
||||
if (queueFilters.includes(queue) && parts.length === 3) {
|
||||
return { kind: 'queue', filter: queue };
|
||||
}
|
||||
if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) {
|
||||
return { kind: 'queue', filter: 'agenda', action: 'protect-today' };
|
||||
}
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
}
|
||||
if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
|
||||
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
|
||||
const number = positiveInteger(parts[5]);
|
||||
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
|
||||
@@ -30,24 +38,28 @@
|
||||
const notificationId = positiveInteger(item.notification_id);
|
||||
return notificationId ? '#/my-work/update/' + notificationId : '';
|
||||
}
|
||||
if (!['issue', 'pull', 'review'].includes(item?.kind)) return '';
|
||||
const kind = item?.is_filed && !item?.is_assigned ? 'filed' : item?.kind;
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(kind)) return '';
|
||||
const repository = String(item.repository || '').split('/');
|
||||
const number = positiveInteger(item.number);
|
||||
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
|
||||
return '#/my-work/' + item.kind + '/' + repository.join('/') + '/' + number;
|
||||
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number;
|
||||
}
|
||||
|
||||
function sameRoute(item, route) {
|
||||
if (route.kind === 'queue') return false;
|
||||
if (route.kind === 'update') {
|
||||
return Number(item.notification_id) === route.notification_id;
|
||||
}
|
||||
const itemKind = item.is_review ? 'review' : item.kind;
|
||||
const itemKind = item.is_review ? 'review' :
|
||||
(item.is_filed && !item.is_assigned ? 'filed' : item.kind);
|
||||
return itemKind === route.kind && item.repository === route.repository &&
|
||||
Number(item.number) === route.number;
|
||||
}
|
||||
|
||||
function createController({
|
||||
location, history, eventTarget, onOpen, onClose, onInvalid,
|
||||
onQueue = function () {},
|
||||
resolve, onResolving = function () {}, onError = function () {},
|
||||
}) {
|
||||
let items = [];
|
||||
@@ -100,6 +112,16 @@
|
||||
active = '';
|
||||
return;
|
||||
}
|
||||
if (route.kind === 'queue') {
|
||||
if (!ready) return;
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
if (active && active !== fragment) onClose();
|
||||
if (active === fragment) return;
|
||||
active = fragment;
|
||||
onQueue(route.filter, route.action || null);
|
||||
return;
|
||||
}
|
||||
const item = items.find(candidate => sameRoute(candidate, route));
|
||||
if (!item) {
|
||||
if (ready) resolveMissing(fragment, route);
|
||||
@@ -134,6 +156,15 @@
|
||||
onOpen(item);
|
||||
return true;
|
||||
},
|
||||
queue(filter) {
|
||||
if (!queueFilters.includes(filter)) return false;
|
||||
const name = filter + (['update', 'draft'].includes(filter) ? 's' : '');
|
||||
const fragment = '#/my-work/' + name;
|
||||
if (location.hash !== fragment) history.pushState(null, '', fragment);
|
||||
active = fragment;
|
||||
onQueue(filter);
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (parse(location.hash)) history.back();
|
||||
else {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
let active = false;
|
||||
const selected = new Set();
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function changed() {
|
||||
const state = snapshot();
|
||||
onChange(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function start() {
|
||||
active = true;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function select(item) {
|
||||
if (!active) return 'inactive';
|
||||
const id = identity(item);
|
||||
if (!id) return 'invalid';
|
||||
if (selected.has(id)) return 'selected';
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.add(id);
|
||||
changed();
|
||||
return 'selected';
|
||||
}
|
||||
|
||||
function toggle(item) {
|
||||
if (!active) return 'inactive';
|
||||
const id = identity(item);
|
||||
if (!id) return 'invalid';
|
||||
if (selected.has(id)) {
|
||||
selected.delete(id);
|
||||
changed();
|
||||
return 'removed';
|
||||
}
|
||||
return select(item);
|
||||
}
|
||||
|
||||
function selectMany(items) {
|
||||
if (!active) return { status: 'inactive', added: 0, count: selected.size, limit: maximum };
|
||||
let added = 0;
|
||||
let capped = false;
|
||||
for (const item of items || []) {
|
||||
const id = identity(item);
|
||||
if (!id || selected.has(id)) continue;
|
||||
if (selected.size >= maximum) {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
selected.add(id);
|
||||
added += 1;
|
||||
}
|
||||
changed();
|
||||
return { status: capped ? 'limit' : 'selected', added, count: selected.size, limit: maximum };
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!active) return snapshot();
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function retain(items) {
|
||||
const retained = new Set((items || []).map(identity).filter(Boolean));
|
||||
Array.from(selected).forEach(id => { if (!retained.has(id)) selected.delete(id); });
|
||||
return changed();
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { active, count: selected.size, ids: Array.from(selected) };
|
||||
}
|
||||
|
||||
return { identity, start, cancel, select, selectMany, clear, toggle, retain, snapshot, limit: maximum };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createWorkSelection;
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
playwright==1.55.0
|
||||
@@ -244,6 +244,14 @@ async def managed_session_active(management_id: str) -> bool:
|
||||
return status == "active"
|
||||
|
||||
|
||||
async def managed_session_statuses(management_ids) -> dict[str, str]:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().managed_statuses,
|
||||
management_ids,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
|
||||
|
||||
async def touch_session(session: Session) -> bool:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().touch,
|
||||
|
||||
+10
-4
@@ -22,15 +22,21 @@ COMMONJS_BROWSER_BRANCH = re.compile(
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||
"issue-capture": ("static/create-issue-sheet.js",),
|
||||
"issue-capture": (
|
||||
"static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js",
|
||||
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/issue-attachment.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
|
||||
+309
-28
@@ -4,7 +4,7 @@ import re
|
||||
import shlex
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
from urllib.parse import quote, urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -367,6 +367,7 @@ async def repository_access(repository: str) -> dict | None:
|
||||
|
||||
WORK_SEARCHES = {
|
||||
"issue": ("assigned=true", "issues", None),
|
||||
"filed": ("created=true", "issues", "created_by_me"),
|
||||
"pull": ("assigned=true", "pulls", "assigned_to_me"),
|
||||
"review": ("review_requested=true", "pulls", "review_requested"),
|
||||
}
|
||||
@@ -403,6 +404,41 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def assigned_issue_snapshot(
|
||||
*,
|
||||
limit: int = 50,
|
||||
max_pages: int = 20,
|
||||
max_concurrency: int = 4,
|
||||
deadline_seconds: float = 5.0,
|
||||
) -> dict:
|
||||
"""Load a complete, bounded assigned-issue snapshot for deadline dispatch."""
|
||||
async with asyncio.timeout(deadline_seconds):
|
||||
first = await work_page("issue", 1, limit)
|
||||
page_count = max(1, (first["total"] + limit - 1) // limit)
|
||||
if page_count > max_pages:
|
||||
raise ValueError("Assigned issue snapshot exceeds the scan limit")
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def load(page: int) -> dict:
|
||||
async with semaphore:
|
||||
return await work_page("issue", page, limit)
|
||||
|
||||
remaining = await asyncio.gather(
|
||||
*(load(page) for page in range(2, page_count + 1))
|
||||
)
|
||||
pages = [first, *remaining]
|
||||
if any(page.get("total") != first["total"] for page in pages[1:]):
|
||||
raise ValueError("Assigned issue pagination changed during the scan")
|
||||
items = [item for page in pages for item in page["items"]]
|
||||
issue_ids = [
|
||||
item.get("id") for item in items
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), int) and item["id"] > 0
|
||||
]
|
||||
if len(items) != first["total"] or len(set(issue_ids)) != first["total"]:
|
||||
raise ValueError("Assigned issue snapshot has an incomplete issue set")
|
||||
return {"items": items, "complete": True}
|
||||
|
||||
|
||||
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
@@ -428,13 +464,46 @@ def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
async def global_search(query: str, limit: int = 10) -> dict:
|
||||
"""Search accessible issues and pulls concurrently with a bounded result set."""
|
||||
async def global_search(
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
page: int = 1,
|
||||
kind: str = "all",
|
||||
state: str = "all",
|
||||
repository: str | None = None,
|
||||
continuation: dict[str, int | None] | None = None,
|
||||
) -> dict:
|
||||
"""Search accessible work with lossless, independently retryable streams."""
|
||||
item_types = ("issues", "pulls") if kind == "all" else (
|
||||
"issues" if kind == "issue" else "pulls",
|
||||
)
|
||||
stream_limits = {
|
||||
item_type: (
|
||||
(limit + 1) // 2 if item_type == "issues" else limit // 2
|
||||
) if kind == "all" else limit
|
||||
for item_type in item_types
|
||||
}
|
||||
stream_pages = {
|
||||
item_type: continuation.get(item_type) if continuation is not None else page
|
||||
for item_type in item_types
|
||||
}
|
||||
active_types = tuple(
|
||||
item_type for item_type in item_types
|
||||
if stream_pages[item_type] is not None and stream_limits[item_type] > 0
|
||||
)
|
||||
|
||||
async def load(item_type: str) -> Any:
|
||||
params = {
|
||||
"q": query, "type": item_type, "state": state,
|
||||
"limit": stream_limits[item_type], "page": stream_pages[item_type],
|
||||
}
|
||||
if repository:
|
||||
owner, repo = repository.split("/", 1)
|
||||
params.update({"owner": owner, "repo": repo})
|
||||
response = await _get_client().get(
|
||||
"/api/v1/repos/issues/search",
|
||||
headers=_auth(),
|
||||
params={"q": query, "type": item_type, "limit": limit, "page": 1},
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
@@ -443,30 +512,58 @@ async def global_search(query: str, limit: int = 10) -> dict:
|
||||
return payload
|
||||
|
||||
outcomes = await asyncio.gather(
|
||||
load("issues"), load("pulls"), return_exceptions=True
|
||||
*(load(item_type) for item_type in active_types), return_exceptions=True
|
||||
)
|
||||
for outcome in outcomes:
|
||||
if isinstance(outcome, asyncio.CancelledError):
|
||||
raise outcome
|
||||
if all(isinstance(outcome, BaseException) for outcome in outcomes):
|
||||
if outcomes and all(isinstance(outcome, BaseException) for outcome in outcomes):
|
||||
raise outcomes[0]
|
||||
|
||||
results = []
|
||||
streams: list[list[dict]] = []
|
||||
seen: set[tuple[str, str, int]] = set()
|
||||
for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True):
|
||||
result_kinds = tuple(
|
||||
"issue" if item_type == "issues" else "pull" for item_type in active_types
|
||||
)
|
||||
for outcome, result_kind in zip(outcomes, result_kinds, strict=True):
|
||||
normalized_stream = []
|
||||
if isinstance(outcome, BaseException):
|
||||
streams.append(normalized_stream)
|
||||
continue
|
||||
for item in outcome:
|
||||
normalized = _normalize_global_search_item(item, kind)
|
||||
normalized = _normalize_global_search_item(item, result_kind)
|
||||
if normalized is not None:
|
||||
identity = (kind, normalized["repository"], normalized["number"])
|
||||
identity = (result_kind, normalized["repository"], normalized["number"])
|
||||
if identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
results.append(normalized)
|
||||
normalized_stream.append(normalized)
|
||||
streams.append(normalized_stream)
|
||||
results = []
|
||||
for index in range(max((len(stream) for stream in streams), default=0)):
|
||||
for stream in streams:
|
||||
if index < len(stream):
|
||||
results.append(stream[index])
|
||||
|
||||
failed_streams = [
|
||||
"issue" if item_type == "issues" else "pull"
|
||||
for item_type, outcome in zip(active_types, outcomes, strict=True)
|
||||
if isinstance(outcome, BaseException)
|
||||
]
|
||||
next_continuation = {item_type: None for item_type in item_types}
|
||||
for item_type, outcome in zip(active_types, outcomes, strict=True):
|
||||
if isinstance(outcome, BaseException):
|
||||
next_continuation[item_type] = stream_pages[item_type]
|
||||
elif len(outcome) >= stream_limits[item_type]:
|
||||
next_continuation[item_type] = int(stream_pages[item_type]) + 1
|
||||
has_more = any(value is not None for value in next_continuation.values())
|
||||
return {
|
||||
"items": results[:limit],
|
||||
"partial": any(isinstance(outcome, BaseException) for outcome in outcomes),
|
||||
"partial": bool(failed_streams),
|
||||
"has_more": has_more,
|
||||
"next_page": page + 1,
|
||||
"continuation": next_continuation,
|
||||
"failed_streams": failed_streams,
|
||||
}
|
||||
|
||||
|
||||
@@ -490,6 +587,19 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
login = user.get("login") if isinstance(user, dict) else ""
|
||||
state = issue.get("state") if isinstance(issue.get("state"), str) else ""
|
||||
actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue"
|
||||
reviewable = False
|
||||
if actual_kind == "pull" and state == "open" and login:
|
||||
try:
|
||||
pull = await fetch(f"repos/{repository}/pulls/{number}")
|
||||
except Exception:
|
||||
pull = None
|
||||
requested_reviewers = pull.get("requested_reviewers") if isinstance(pull, dict) else []
|
||||
if not isinstance(requested_reviewers, list):
|
||||
requested_reviewers = []
|
||||
reviewable = any(
|
||||
isinstance(reviewer, dict) and reviewer.get("login") == login
|
||||
for reviewer in requested_reviewers
|
||||
)
|
||||
return {
|
||||
"kind": actual_kind,
|
||||
"repository": repository,
|
||||
@@ -507,6 +617,8 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
"claimable": actual_kind == "issue" and state == "open" and not assignee_names,
|
||||
"reopenable": actual_kind == "issue" and state == "closed",
|
||||
"assigned_to_me": bool(login and login in assignee_names),
|
||||
"reviewable": reviewable,
|
||||
"commentable": bool(login),
|
||||
}
|
||||
|
||||
|
||||
@@ -627,8 +739,20 @@ def _page_metadata(result: dict) -> dict:
|
||||
|
||||
|
||||
async def issues() -> WorkItems:
|
||||
result = await work_page("issue")
|
||||
return WorkItems(result["items"], {"issue": _page_metadata(result)})
|
||||
assigned, filed = await asyncio.gather(work_page("issue"), work_page("filed"))
|
||||
merged: dict[int, dict] = {}
|
||||
for result in (assigned, filed):
|
||||
for issue in result["items"]:
|
||||
identity = issue.get("id")
|
||||
if identity not in merged:
|
||||
merged[identity] = {**issue, "work_reasons": []}
|
||||
for reason in issue.get("work_reasons", []):
|
||||
if reason not in merged[identity]["work_reasons"]:
|
||||
merged[identity]["work_reasons"].append(reason)
|
||||
return WorkItems(
|
||||
list(merged.values()),
|
||||
{"issue": _page_metadata(assigned), "filed": _page_metadata(filed)},
|
||||
)
|
||||
|
||||
|
||||
def _safe_web_url(value: Any) -> str:
|
||||
@@ -804,6 +928,14 @@ async def mark_notification_read(thread_id: int) -> None:
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def mark_notification_unread(thread_id: int) -> None:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/notifications/threads/{thread_id}?to-status=unread",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def acknowledge_notification(thread_id: int) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -873,6 +1005,14 @@ def _gitea_api_path(value: Any) -> str:
|
||||
return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "")
|
||||
|
||||
|
||||
async def _notification_subscription(repository: str, number: str) -> dict:
|
||||
try:
|
||||
value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
|
||||
except Exception:
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
async def notification_detail(thread_id: int) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -897,18 +1037,16 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
)
|
||||
if supported_conversation:
|
||||
assert conversation_match is not None
|
||||
subject_detail, comment, conversation = await asyncio.gather(
|
||||
subject_detail, comment, subscription = await asyncio.gather(
|
||||
fetch(subject_path),
|
||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||
issue_conversation_page(
|
||||
conversation_match.group(1), int(conversation_match.group(3))
|
||||
),
|
||||
_notification_subscription(conversation_match.group(1), conversation_match.group(3)),
|
||||
)
|
||||
else:
|
||||
subject_detail, comment, conversation = await asyncio.gather(
|
||||
subject_detail, comment, subscription = await asyncio.gather(
|
||||
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
|
||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
|
||||
asyncio.sleep(0, result={}),
|
||||
)
|
||||
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
|
||||
comment = comment if isinstance(comment, dict) else {}
|
||||
@@ -969,12 +1107,18 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
and comment_match
|
||||
and comment_match.group(1) == repository_name
|
||||
),
|
||||
"conversation": conversation,
|
||||
"mute_supported": bool(
|
||||
supported_conversation
|
||||
and isinstance(subscription, dict)
|
||||
and subscription.get("subscribed") is True
|
||||
and subscription.get("ignored") is not True
|
||||
),
|
||||
"conversation_available": bool(supported_conversation),
|
||||
}
|
||||
|
||||
|
||||
async def notification_conversation_page(
|
||||
thread_id: int, page: int, limit: int = 20
|
||||
thread_id: int, page: int | None, limit: int = 20
|
||||
) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -1015,6 +1159,21 @@ async def notification_conversation_target(thread_id: int) -> tuple[str, int]:
|
||||
return match.group(1), int(match.group(3))
|
||||
|
||||
|
||||
async def mute_notification(thread_id: int) -> dict:
|
||||
"""Unsubscribe the current operator from the trusted notification conversation."""
|
||||
repository, number = await notification_conversation_target(thread_id)
|
||||
user = await fetch("user")
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("Gitea did not identify the current operator")
|
||||
response = await _get_client().delete(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/subscriptions/{quote(login, safe='')}",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {"id": thread_id, "repository": repository, "number": number, "muted": True}
|
||||
|
||||
|
||||
async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -1254,6 +1413,51 @@ async def repo_labels(repository: str) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
async def repo_issue_templates(repository: str) -> list[dict]:
|
||||
"""Return a small allowlisted view of repository issue templates."""
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/issue_templates", headers=_auth()
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if payload is None:
|
||||
return []
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea issue templates response was not a list")
|
||||
templates = []
|
||||
for item in payload:
|
||||
if len(templates) >= 20:
|
||||
break
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
name = name.strip()[:80]
|
||||
identifier = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:80]
|
||||
labels = []
|
||||
for label in item.get("labels", []) if isinstance(item.get("labels"), list) else []:
|
||||
if not isinstance(label, str):
|
||||
continue
|
||||
clean = label.strip()[:80]
|
||||
if clean and clean not in labels:
|
||||
labels.append(clean)
|
||||
if len(labels) >= 20:
|
||||
break
|
||||
scalar = lambda key, limit: (
|
||||
item.get(key, "")[:limit] if isinstance(item.get(key), str) else ""
|
||||
)
|
||||
templates.append({
|
||||
"id": identifier or f"template-{len(templates) + 1}",
|
||||
"name": name,
|
||||
"about": scalar("about", 240).strip(),
|
||||
"title": scalar("title", 255),
|
||||
"body": scalar("content", 9000).strip(),
|
||||
"labels": labels,
|
||||
})
|
||||
return templates
|
||||
|
||||
|
||||
async def repo_milestones(repository: str) -> list[dict]:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/milestones",
|
||||
@@ -1278,12 +1482,14 @@ async def create_issue(
|
||||
repository: str,
|
||||
title: str,
|
||||
body: str,
|
||||
assignee: str,
|
||||
assignee: str | None,
|
||||
label_ids: list[int] | None = None,
|
||||
milestone_id: int | None = None,
|
||||
due_date: str | None = None,
|
||||
) -> dict:
|
||||
payload: dict = {"title": title, "body": body, "assignee": assignee}
|
||||
payload: dict = {"title": title, "body": body}
|
||||
if assignee is not None:
|
||||
payload["assignee"] = assignee
|
||||
if label_ids:
|
||||
payload["labels"] = label_ids
|
||||
if milestone_id is not None:
|
||||
@@ -1306,8 +1512,11 @@ async def create_issue(
|
||||
for item in assignees
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
]
|
||||
if assignee not in confirmed_assignees:
|
||||
raise ValueError("Gitea did not confirm issue self-assignment")
|
||||
expected_assignees = [assignee] if assignee is not None else []
|
||||
if confirmed_assignees != expected_assignees:
|
||||
raise ValueError(
|
||||
"Gitea did not confirm self-assignment or exact issue assignment"
|
||||
)
|
||||
labels_value = issue.get("labels")
|
||||
labels = labels_value if isinstance(labels_value, list) else []
|
||||
milestone_value = issue.get("milestone")
|
||||
@@ -1823,6 +2032,58 @@ async def update_assigned_issue_milestone(
|
||||
}
|
||||
|
||||
|
||||
async def update_assigned_issue_release_plan(
|
||||
repository: str, number: int, milestone_id: int, due_date: str | None
|
||||
) -> dict:
|
||||
path = f"repos/{repository}/issues/{number}"
|
||||
login, issue = await _current_login_and_target(path)
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or isinstance(issue.get("pull_request"), dict)
|
||||
or not _login_in_users(login, issue.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
|
||||
selected = next(
|
||||
(item for item in await repo_milestones(repository) if item["id"] == milestone_id),
|
||||
None,
|
||||
)
|
||||
if selected is None:
|
||||
raise ValueError("Unknown open repository milestone")
|
||||
|
||||
payload: dict = {"milestone": milestone_id}
|
||||
if due_date is not None:
|
||||
payload["due_date"] = due_date
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/{path}", headers=_auth(), json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
milestone_value = confirmed.get("milestone") if isinstance(confirmed, dict) else None
|
||||
normalized = (
|
||||
{"id": milestone_value["id"], "title": milestone_value["title"]}
|
||||
if isinstance(milestone_value, dict)
|
||||
and isinstance(milestone_value.get("id"), int)
|
||||
and isinstance(milestone_value.get("title"), str)
|
||||
else None
|
||||
)
|
||||
expected_due_date = due_date if due_date is not None else issue.get("due_date")
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or normalized != selected
|
||||
or confirmed.get("due_date") != expected_due_date
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the issue release plan")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"milestone": normalized,
|
||||
"due_date": confirmed.get("due_date"),
|
||||
}
|
||||
|
||||
|
||||
async def issue_conversation_page(
|
||||
repository: str,
|
||||
number: int,
|
||||
@@ -2099,19 +2360,22 @@ async def resolve_work_route(
|
||||
"title": detail.get("title", ""),
|
||||
"url": detail.get("url", ""),
|
||||
}
|
||||
if repository is None or number is None or kind not in {"issue", "pull", "review"}:
|
||||
if repository is None or number is None or kind not in {"issue", "filed", "pull", "review"}:
|
||||
raise WorkRouteUnavailableError("Work identity is missing")
|
||||
|
||||
target_path = "issues" if kind == "issue" else "pulls"
|
||||
target_path = "issues" if kind in {"issue", "filed"} else "pulls"
|
||||
login, target = await _current_login_and_target(
|
||||
f"repos/{repository}/{target_path}/{number}"
|
||||
)
|
||||
assigned = _login_in_users(login, target.get("assignees"))
|
||||
requested = _login_in_users(login, target.get("requested_reviewers"))
|
||||
author = target.get("user") if isinstance(target.get("user"), dict) else {}
|
||||
authored = author.get("login") == login
|
||||
eligible = (
|
||||
target.get("state") == "open"
|
||||
and (
|
||||
(kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned)
|
||||
or (kind == "filed" and not isinstance(target.get("pull_request"), dict) and authored)
|
||||
or (kind == "pull" and assigned)
|
||||
or (kind == "review" and requested)
|
||||
)
|
||||
@@ -2126,6 +2390,11 @@ async def resolve_work_route(
|
||||
"state": "open",
|
||||
"url": _safe_web_url(target.get("html_url")),
|
||||
**({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}),
|
||||
**({
|
||||
"is_filed": True,
|
||||
"is_assigned": assigned,
|
||||
"work_reasons": ["created_by_me"],
|
||||
} if kind == "filed" else {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -2140,6 +2409,18 @@ async def is_assigned_issue(repository: str, number: int) -> bool:
|
||||
)
|
||||
|
||||
|
||||
async def is_authored_issue(repository: str, number: int) -> bool:
|
||||
login, issue = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
author = issue.get("user") if isinstance(issue.get("user"), dict) else {}
|
||||
return (
|
||||
issue.get("state") == "open"
|
||||
and not isinstance(issue.get("pull_request"), dict)
|
||||
and author.get("login") == login
|
||||
)
|
||||
|
||||
|
||||
async def pull_requests() -> WorkItems:
|
||||
assigned, review_requested = await asyncio.gather(
|
||||
work_page("pull"),
|
||||
|
||||
+10
-2
@@ -22,10 +22,14 @@ class LaterStore:
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
|
||||
connection.close()
|
||||
return
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
@@ -63,8 +67,12 @@ class LaterStore:
|
||||
"UPDATE later_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(self.clock(),),
|
||||
)
|
||||
connection.execute("PRAGMA user_version = 1")
|
||||
connection.commit()
|
||||
return connection
|
||||
connection.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
|
||||
+904
-61
File diff suppressed because it is too large.
Load diff
@@ -29,6 +29,7 @@ class Issue(BaseModel):
|
||||
state: str
|
||||
labels: list[str] = []
|
||||
assignees: list[str] = []
|
||||
work_reasons: list[str] = []
|
||||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
due_date: str | None = None
|
||||
|
||||
+17
-4
@@ -225,13 +225,26 @@ class PasskeyStore:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return StoredPasskey(*row) if row else None
|
||||
|
||||
def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
|
||||
def advance_counter(
|
||||
self, credential_id: bytes, *, expected: int, new: int
|
||||
) -> bool:
|
||||
if expected == 0 and new == 0:
|
||||
comparison = "sign_count = 0"
|
||||
elif new > expected:
|
||||
comparison = "sign_count = ?"
|
||||
else:
|
||||
return False
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
parameters = (
|
||||
(new, credential_id)
|
||||
if expected == 0 and new == 0
|
||||
else (new, credential_id, expected)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"UPDATE passkey_credentials SET sign_count = ? "
|
||||
"WHERE credential_id = ? AND sign_count <= ?",
|
||||
(new_sign_count, credential_id, new_sign_count),
|
||||
f"UPDATE passkey_credentials SET sign_count = ? "
|
||||
f"WHERE credential_id = ? AND {comparison}",
|
||||
parameters,
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from collections.abc import Awaitable, Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
class UnsafePushEndpoint(ValueError):
|
||||
"""Raised when a Web Push endpoint could reach a non-public service."""
|
||||
|
||||
|
||||
Resolver = Callable[[str, int], Awaitable[list[str]]]
|
||||
|
||||
|
||||
async def _resolve(host: str, port: int) -> list[str]:
|
||||
loop = asyncio.get_running_loop()
|
||||
records = await loop.getaddrinfo(
|
||||
host,
|
||||
port,
|
||||
family=socket.AF_UNSPEC,
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
return sorted({record[4][0] for record in records})
|
||||
|
||||
|
||||
async def validate_public_push_endpoint(
|
||||
endpoint: str,
|
||||
*,
|
||||
resolver: Resolver = _resolve,
|
||||
timeout_seconds: float = 2.0,
|
||||
) -> str:
|
||||
"""Return a canonical public HTTPS push endpoint or fail closed."""
|
||||
parsed = urlsplit(endpoint)
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as error:
|
||||
raise UnsafePushEndpoint(
|
||||
"Endpoint must be a canonical public Web Push service URL"
|
||||
) from error
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
or port not in {None, 443}
|
||||
):
|
||||
raise UnsafePushEndpoint("Endpoint must be a canonical public Web Push service URL")
|
||||
try:
|
||||
addresses = await asyncio.wait_for(
|
||||
resolver(parsed.hostname, port or 443),
|
||||
timeout=max(0.01, timeout_seconds),
|
||||
)
|
||||
except (OSError, asyncio.TimeoutError) as error:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
|
||||
try:
|
||||
public = addresses and all(ipaddress.ip_address(address).is_global for address in addresses)
|
||||
except ValueError as error:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
|
||||
if not public:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
|
||||
return endpoint
|
||||
+200
-9
@@ -3,9 +3,12 @@ import json
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Awaitable, Callable
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -22,8 +25,12 @@ class PushConfiguration:
|
||||
async def send_web_push(
|
||||
subscription: dict, payload: str, configuration: PushConfiguration
|
||||
) -> None:
|
||||
import requests
|
||||
from pywebpush import webpush
|
||||
|
||||
session = requests.Session()
|
||||
session.max_redirects = 0
|
||||
|
||||
await asyncio.to_thread(
|
||||
webpush,
|
||||
subscription_info=subscription,
|
||||
@@ -31,6 +38,8 @@ async def send_web_push(
|
||||
vapid_private_key=configuration.private_key,
|
||||
vapid_claims={"sub": configuration.subject},
|
||||
ttl=300,
|
||||
timeout=10,
|
||||
requests_session=session,
|
||||
)
|
||||
|
||||
|
||||
@@ -40,11 +49,12 @@ async def dispatch_unread_updates(
|
||||
unread: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
session_active: Callable[[str], Awaitable[bool]] | None = None,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
lease_seconds: float = 60.0,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
max_individual_notifications: int = 3,
|
||||
endpoint_validator: Callable[[str], Awaitable[str]] | None = None,
|
||||
) -> int:
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
@@ -52,6 +62,7 @@ async def dispatch_unread_updates(
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
@@ -68,20 +79,22 @@ async def dispatch_unread_updates(
|
||||
}
|
||||
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
|
||||
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
|
||||
if session_active is not None:
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
authorized = [await session_active(item.session_id) for item in deliveries]
|
||||
statuses = await session_statuses(
|
||||
[item.session_id for item in deliveries]
|
||||
)
|
||||
except Exception:
|
||||
# Authorization state is mandatory for delivery. Preserve subscriptions
|
||||
# so a temporary registry failure can be retried safely.
|
||||
return 0
|
||||
for delivery, active in zip(deliveries, authorized):
|
||||
if not active:
|
||||
for delivery in deliveries:
|
||||
if statuses.get(delivery.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, delivery.session_id)
|
||||
deliveries = [
|
||||
delivery
|
||||
for delivery, active in zip(deliveries, authorized)
|
||||
if active
|
||||
for delivery in deliveries
|
||||
if statuses.get(delivery.session_id) == "active"
|
||||
]
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
ownership_lost = asyncio.Event()
|
||||
@@ -90,6 +103,15 @@ async def dispatch_unread_updates(
|
||||
async with semaphore:
|
||||
if ownership_lost.is_set():
|
||||
return 0
|
||||
validate_endpoint = endpoint_validator
|
||||
if validate_endpoint is None and send is None:
|
||||
validate_endpoint = validate_public_push_endpoint
|
||||
try:
|
||||
if validate_endpoint is not None:
|
||||
await validate_endpoint(delivery.subscription["endpoint"])
|
||||
except UnsafePushEndpoint:
|
||||
await asyncio.to_thread(store.delete_session, delivery.session_id)
|
||||
return 0
|
||||
count = 0
|
||||
digest_pending = set(delivery.digest_revisions)
|
||||
new_revisions = tuple(
|
||||
@@ -109,6 +131,7 @@ async def dispatch_unread_updates(
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
@@ -155,6 +178,7 @@ async def dispatch_unread_updates(
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
@@ -202,9 +226,176 @@ async def dispatch_unread_updates(
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def dispatch_device_safely(delivery) -> int:
|
||||
try:
|
||||
return await dispatch_device(delivery)
|
||||
except Exception:
|
||||
# Device-specific validation, persistence, or provider failures
|
||||
# must not cancel healthy siblings. Leave any uncheckpointed
|
||||
# revisions unseen so a later poll can retry them.
|
||||
return 0
|
||||
|
||||
counts = await asyncio.gather(
|
||||
*(dispatch_device(delivery) for delivery in deliveries)
|
||||
*(dispatch_device_safely(delivery) for delivery in deliveries)
|
||||
)
|
||||
return sum(counts)
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner)
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="unread")
|
||||
|
||||
|
||||
async def dispatch_deadline_reminders(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
assigned: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
**kwargs,
|
||||
) -> int:
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="deadline",
|
||||
now=time.time(),
|
||||
lease_seconds=max(
|
||||
15.0,
|
||||
float(kwargs.get("lease_seconds", 60.0)),
|
||||
float(kwargs.get("send_timeout_seconds", 10.0)) + 5.0,
|
||||
),
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
return await _dispatch_deadline_reminders_unlocked(
|
||||
store, configuration, assigned, send, owner=owner, **kwargs
|
||||
)
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="deadline")
|
||||
|
||||
|
||||
async def _dispatch_deadline_reminders_unlocked(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
assigned: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
lease_seconds: float = 60.0,
|
||||
max_concurrency: int = 8,
|
||||
owner: str,
|
||||
) -> int:
|
||||
"""Send one privacy-safe Agenda digest per eligible device and local day."""
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
devices = await asyncio.to_thread(store.deadline_reminder_devices)
|
||||
if not devices:
|
||||
return 0
|
||||
current = now or datetime.now(timezone.utc)
|
||||
eligible_devices = []
|
||||
for device in devices:
|
||||
try:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
except ZoneInfoNotFoundError:
|
||||
continue
|
||||
if (
|
||||
local_now.hour >= device.reminder_hour
|
||||
and device.delivered_local_day != local_now.date().isoformat()
|
||||
):
|
||||
eligible_devices.append(device)
|
||||
if not eligible_devices:
|
||||
return 0
|
||||
snapshot = await assigned()
|
||||
if snapshot.get("complete") is False:
|
||||
return 0
|
||||
due_days = []
|
||||
for item in snapshot.get("items", []):
|
||||
if not isinstance(item, dict) or not item.get("due_date"):
|
||||
continue
|
||||
raw_due = str(item["due_date"])
|
||||
try:
|
||||
due_day = datetime.strptime(raw_due[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
continue
|
||||
due_days.append(due_day)
|
||||
if not due_days:
|
||||
return 0
|
||||
due_counts = {}
|
||||
for device in eligible_devices:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
local_cutoff = local_now.date() + timedelta(days=device.reminder_days)
|
||||
due_count = sum(due_day <= local_cutoff for due_day in due_days)
|
||||
if due_count:
|
||||
due_counts[device.session_id] = due_count
|
||||
eligible_devices = [
|
||||
device for device in eligible_devices if device.session_id in due_counts
|
||||
]
|
||||
if not eligible_devices:
|
||||
return 0
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses(
|
||||
[device.session_id for device in eligible_devices]
|
||||
)
|
||||
except Exception:
|
||||
return 0
|
||||
for device in eligible_devices:
|
||||
if statuses.get(device.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
eligible_devices = [
|
||||
device
|
||||
for device in eligible_devices
|
||||
if statuses.get(device.session_id) == "active"
|
||||
]
|
||||
if not eligible_devices:
|
||||
return 0
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def dispatch_device(device) -> int:
|
||||
async with semaphore:
|
||||
try:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
except ZoneInfoNotFoundError:
|
||||
return 0
|
||||
local_day = local_now.date().isoformat()
|
||||
if (
|
||||
local_now.hour < device.reminder_hour
|
||||
or device.delivered_local_day == local_day
|
||||
):
|
||||
return 0
|
||||
due_count = due_counts[device.session_id]
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="deadline",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not still_owner:
|
||||
return 0
|
||||
payload = json.dumps({
|
||||
"title": f"{due_count} deadline{'s' if due_count != 1 else ''} need{'s' if due_count == 1 else ''} attention",
|
||||
"body": f"Open Agenda to review or replan {'it' if due_count == 1 else 'them'}.",
|
||||
"route": "#/my-work/agenda",
|
||||
"protect_route": "#/my-work/agenda/protect-today",
|
||||
"tag": f"stackchain-deadline-digest-{local_day}",
|
||||
"deadline_count": due_count,
|
||||
}, separators=(",", ":"))
|
||||
try:
|
||||
operation = (
|
||||
send(device.subscription, payload)
|
||||
if send is not None
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception:
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_deadline_reminder_delivered, device.session_id, local_day
|
||||
)
|
||||
return 1
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(dispatch_device(device) for device in eligible_devices), return_exceptions=True
|
||||
)
|
||||
return sum(result for result in results if isinstance(result, int))
|
||||
+110
-10
@@ -22,6 +22,16 @@ class PushDelivery:
|
||||
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeadlineReminderDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
timezone: str
|
||||
reminder_hour: int
|
||||
reminder_days: int
|
||||
delivered_local_day: str | None
|
||||
|
||||
|
||||
def _revisions(
|
||||
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
) -> tuple[tuple[int, str], ...]:
|
||||
@@ -66,7 +76,7 @@ class PushSubscriptionStore:
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_dispatch_lease (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
channel TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
@@ -78,6 +88,16 @@ class PushSubscriptionStore:
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_deadline_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
reminder_days INTEGER NOT NULL DEFAULT 2,
|
||||
delivered_local_day TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
delivery_columns = {
|
||||
@@ -95,6 +115,32 @@ class PushSubscriptionStore:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_digest_pending ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
|
||||
)
|
||||
lease_columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(push_dispatch_lease)")
|
||||
}
|
||||
if "singleton" in lease_columns:
|
||||
connection.executescript(
|
||||
"""
|
||||
ALTER TABLE push_dispatch_lease RENAME TO push_dispatch_lease_legacy;
|
||||
CREATE TABLE push_dispatch_lease (
|
||||
channel TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
INSERT INTO push_dispatch_lease(channel, owner, expires_at)
|
||||
SELECT 'unread', owner, expires_at
|
||||
FROM push_dispatch_lease_legacy WHERE singleton = 1;
|
||||
DROP TABLE push_dispatch_lease_legacy;
|
||||
"""
|
||||
)
|
||||
preference_columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(push_deadline_preferences)")
|
||||
}
|
||||
if "reminder_days" not in preference_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
|
||||
)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def _connect(self):
|
||||
@@ -103,29 +149,30 @@ class PushSubscriptionStore:
|
||||
return connection
|
||||
|
||||
def acquire_dispatch_lease(
|
||||
self, owner: str, *, now: float, lease_seconds: float
|
||||
self, owner: str, *, channel: str = "unread", now: float, lease_seconds: float
|
||||
) -> bool:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current = connection.execute(
|
||||
"SELECT owner, expires_at FROM push_dispatch_lease WHERE singleton = 1"
|
||||
"SELECT owner, expires_at FROM push_dispatch_lease WHERE channel = ?",
|
||||
(channel,),
|
||||
).fetchone()
|
||||
if current is not None and current[0] != owner and current[1] > now:
|
||||
return False
|
||||
connection.execute(
|
||||
"""INSERT INTO push_dispatch_lease(singleton, owner, expires_at)
|
||||
VALUES (1, ?, ?)
|
||||
ON CONFLICT(singleton) DO UPDATE SET
|
||||
"""INSERT INTO push_dispatch_lease(channel, owner, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(channel) DO UPDATE SET
|
||||
owner = excluded.owner, expires_at = excluded.expires_at""",
|
||||
(owner, now + lease_seconds),
|
||||
(channel, owner, now + lease_seconds),
|
||||
)
|
||||
return True
|
||||
|
||||
def release_dispatch_lease(self, owner: str) -> bool:
|
||||
def release_dispatch_lease(self, owner: str, *, channel: str = "unread") -> bool:
|
||||
with self._connect() as connection:
|
||||
result = connection.execute(
|
||||
"DELETE FROM push_dispatch_lease WHERE singleton = 1 AND owner = ?",
|
||||
(owner,),
|
||||
"DELETE FROM push_dispatch_lease WHERE channel = ? AND owner = ?",
|
||||
(channel, owner),
|
||||
)
|
||||
return result.rowcount == 1
|
||||
|
||||
@@ -154,6 +201,59 @@ class PushSubscriptionStore:
|
||||
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
||||
).fetchone() is not None
|
||||
|
||||
def set_deadline_preferences(
|
||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int,
|
||||
reminder_days: int = 2,
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_deadline_preferences(
|
||||
session_id, enabled, timezone, reminder_hour, reminder_days
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
timezone = excluded.timezone,
|
||||
reminder_hour = excluded.reminder_hour,
|
||||
reminder_days = excluded.reminder_days""",
|
||||
(session_id, int(enabled), timezone, reminder_hour, reminder_days),
|
||||
)
|
||||
|
||||
def deadline_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT enabled, timezone, reminder_hour, reminder_days
|
||||
FROM push_deadline_preferences WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"enabled": bool(row[0]) if row else False,
|
||||
"timezone": row[1] if row else "UTC",
|
||||
"reminder_hour": row[2] if row else 9,
|
||||
"reminder_days": row[3] if row else 2,
|
||||
}
|
||||
|
||||
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
||||
p.reminder_hour, p.reminder_days, p.delivered_local_day
|
||||
FROM push_subscriptions s
|
||||
JOIN push_deadline_preferences p ON p.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
return [
|
||||
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4], row[5])
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def mark_deadline_reminder_delivered(self, session_id: str, local_day: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_deadline_preferences SET delivered_local_day = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(local_day, session_id),
|
||||
)
|
||||
|
||||
def claim_unseen(
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||
) -> list[PushDelivery]:
|
||||
|
||||
@@ -7,6 +7,7 @@ SESSION_BODY_LIMIT = 16 * 1024
|
||||
API_MUTATION_BODY_LIMIT = 64 * 1024
|
||||
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
|
||||
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
|
||||
UNFILED_DRAFT_SYNC_BODY_LIMIT = 17 * 1024 * 1024
|
||||
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
||||
|
||||
|
||||
@@ -15,6 +16,8 @@ def request_body_limit(method: str, path: str) -> int | None:
|
||||
normalized_method = method.upper()
|
||||
if normalized_method == "POST" and path == "/api/v1/session":
|
||||
return SESSION_BODY_LIMIT
|
||||
if normalized_method == "PUT" and path == "/api/v1/unfiled-drafts":
|
||||
return UNFILED_DRAFT_SYNC_BODY_LIMIT
|
||||
if (
|
||||
normalized_method == "POST"
|
||||
and (
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Durable, account-scoped saved Search views."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_VIEW_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
|
||||
|
||||
class SavedSearchConflict(ValueError):
|
||||
"""Raised when a client attempts to replace a stale collection."""
|
||||
|
||||
def __init__(self, snapshot: dict):
|
||||
super().__init__("saved searches changed on another device")
|
||||
self.snapshot = snapshot
|
||||
|
||||
|
||||
class SavedSearchStore:
|
||||
def __init__(self, path: str | Path, *, limit: int = 20, timeout: float = 1.0):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(self.path, timeout=self.timeout) as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS saved_searches (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
views TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
return {"revision": 0, "views": []} if row is None else {
|
||||
"revision": int(row[0]), "views": json.loads(row[1])
|
||||
}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, views FROM saved_searches WHERE login = ?",
|
||||
(self._login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
def _normalize(self, views: list[dict]) -> list[dict]:
|
||||
if not isinstance(views, list):
|
||||
raise ValueError("views must be a list")
|
||||
if len(views) > self.limit:
|
||||
raise ValueError(f"saved searches are limited to {self.limit}")
|
||||
normalized = []
|
||||
seen = set()
|
||||
for raw in views:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("saved search must be an object")
|
||||
view_id = raw.get("id")
|
||||
if not isinstance(view_id, str) or not _VIEW_ID.fullmatch(view_id):
|
||||
raise ValueError("id is invalid")
|
||||
if view_id in seen:
|
||||
raise ValueError("saved search ids must be unique")
|
||||
name = raw.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("name is required")
|
||||
name = name.strip()
|
||||
if len(name) > 60:
|
||||
raise ValueError("name must be at most 60 characters")
|
||||
query = raw.get("query")
|
||||
if not isinstance(query, str) or not 2 <= len(query.strip()) <= 200:
|
||||
raise ValueError("query must be between 2 and 200 characters")
|
||||
kind = raw.get("kind", "all")
|
||||
state = raw.get("state", "all")
|
||||
if kind not in {"all", "issue", "pull"}:
|
||||
raise ValueError("kind is invalid")
|
||||
if state not in {"all", "open", "closed"}:
|
||||
raise ValueError("state is invalid")
|
||||
repository = raw.get("repository", "")
|
||||
if not isinstance(repository, str) or (repository and not _REPOSITORY.fullmatch(repository)):
|
||||
raise ValueError("repository is invalid")
|
||||
seen.add(view_id)
|
||||
normalized.append({
|
||||
"id": view_id,
|
||||
"name": name,
|
||||
"query": query.strip(),
|
||||
"kind": kind,
|
||||
"state": state,
|
||||
"repository": repository,
|
||||
})
|
||||
return normalized
|
||||
|
||||
def replace(self, login: str, expected_revision: int, views: list[dict]) -> dict:
|
||||
login = self._login(login)
|
||||
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||
raise ValueError("revision is invalid")
|
||||
normalized = self._normalize(views)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, views FROM saved_searches WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current = self._snapshot(row)
|
||||
if current["revision"] != expected_revision:
|
||||
raise SavedSearchConflict(current)
|
||||
revision = expected_revision + 1
|
||||
connection.execute(
|
||||
"INSERT INTO saved_searches(login, revision, views) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, views=excluded.views",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
return {"revision": revision, "views": normalized}
|
||||
+29
-10
@@ -273,21 +273,40 @@ class SessionStore:
|
||||
|
||||
def managed_status(self, management_id: str, *, idle_timeout_seconds: int) -> str:
|
||||
"""Return the authorization state for a durable device identifier."""
|
||||
return self.managed_statuses(
|
||||
[management_id], idle_timeout_seconds=idle_timeout_seconds
|
||||
)[management_id]
|
||||
|
||||
def managed_statuses(
|
||||
self, management_ids, *, idle_timeout_seconds: int
|
||||
) -> dict[str, str]:
|
||||
"""Return authorization states for durable device identifiers in one read."""
|
||||
requested = set(management_ids)
|
||||
if not requested:
|
||||
return {}
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT expires_at, last_active_at FROM active_sessions "
|
||||
"WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
placeholders = ",".join("?" for _ in requested)
|
||||
rows = connection.execute(
|
||||
"SELECT management_id, expires_at, last_active_at "
|
||||
f"FROM active_sessions WHERE management_id IN ({placeholders})",
|
||||
tuple(requested),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
if row is None or row[0] <= now:
|
||||
return "revoked"
|
||||
if row[1] + max(1, idle_timeout_seconds) <= now:
|
||||
return "idle"
|
||||
return "active"
|
||||
known = {row[0]: row[1:] for row in rows if row[0] in requested}
|
||||
idle_timeout = max(1, idle_timeout_seconds)
|
||||
statuses = {}
|
||||
for management_id in requested:
|
||||
row = known.get(management_id)
|
||||
if row is None or row[0] <= now:
|
||||
statuses[management_id] = "revoked"
|
||||
elif row[1] + idle_timeout <= now:
|
||||
statuses[management_id] = "idle"
|
||||
else:
|
||||
statuses[management_id] = "active"
|
||||
return statuses
|
||||
|
||||
def revoke_managed(self, management_id: str) -> bool:
|
||||
try:
|
||||
|
||||
+10
-2
@@ -30,10 +30,14 @@ class TodayStore:
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.recap_limit = recap_limit
|
||||
self.clock = clock
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
|
||||
connection.close()
|
||||
return
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
@@ -101,8 +105,12 @@ class TodayStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute("PRAGMA user_version = 1")
|
||||
connection.commit()
|
||||
return connection
|
||||
connection.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Durable, account-scoped unfiled issue drafts and ordered evidence."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
|
||||
|
||||
class UnfiledDraftConflict(ValueError):
|
||||
"""Raised when a client attempts to replace a stale draft collection."""
|
||||
|
||||
def __init__(self, snapshot: dict):
|
||||
super().__init__("unfiled drafts changed on another device")
|
||||
self.snapshot = snapshot
|
||||
|
||||
|
||||
class UnfiledDraftStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 20,
|
||||
max_total_bytes: int = 12 * 1024 * 1024,
|
||||
timeout: float = 1.0,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.max_total_bytes = max_total_bytes
|
||||
self.timeout = timeout
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(self.path, timeout=self.timeout) as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS unfiled_drafts (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
drafts TEXT NOT NULL
|
||||
)"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
return {"revision": 0, "drafts": []} if row is None else {
|
||||
"revision": int(row[0]), "drafts": json.loads(row[1])
|
||||
}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?",
|
||||
(self._login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
@staticmethod
|
||||
def _filing_plan(raw: object) -> dict | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("filing plan is invalid")
|
||||
repository = raw.get("repository")
|
||||
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
||||
raise ValueError("filing repository is invalid")
|
||||
label_ids = raw.get("label_ids", [])
|
||||
if not isinstance(label_ids, list) or len(label_ids) > 20:
|
||||
raise ValueError("filing labels are invalid")
|
||||
if any(not isinstance(item, int) or isinstance(item, bool) or item < 1 for item in label_ids):
|
||||
raise ValueError("filing labels are invalid")
|
||||
if len(set(label_ids)) != len(label_ids):
|
||||
raise ValueError("filing labels are invalid")
|
||||
plan = {"repository": repository, "label_ids": label_ids}
|
||||
for key in ("milestone_id", "estimate_minutes"):
|
||||
value = raw.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
upper = 1440 if key == "estimate_minutes" else None
|
||||
lower = 5 if key == "estimate_minutes" else 1
|
||||
if (
|
||||
not isinstance(value, int)
|
||||
or isinstance(value, bool)
|
||||
or value < lower
|
||||
or (upper is not None and value > upper)
|
||||
):
|
||||
raise ValueError(f"filing {key.replace('_', ' ')} is invalid")
|
||||
plan[key] = value
|
||||
due_date = raw.get("due_date")
|
||||
if due_date is not None:
|
||||
try:
|
||||
datetime.strptime(due_date, "%Y-%m-%d")
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("filing due date is invalid") from error
|
||||
plan["due_date"] = due_date
|
||||
for key, limit in {
|
||||
"template_name": 80,
|
||||
"template_id": 80,
|
||||
"captured_body": 10_000,
|
||||
"assignee_name": 255,
|
||||
}.items():
|
||||
value = raw.get(key)
|
||||
if value is not None:
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
raise ValueError(f"filing {key.replace('_', ' ')} is invalid")
|
||||
plan[key] = value
|
||||
unassigned = raw.get("unassigned", False)
|
||||
if not isinstance(unassigned, bool):
|
||||
raise ValueError("filing owner intent is invalid")
|
||||
assignee = raw.get("assignee")
|
||||
if unassigned and assignee is not None:
|
||||
raise ValueError("filing owner intent is invalid")
|
||||
if unassigned:
|
||||
plan["unassigned"] = True
|
||||
elif assignee is not None:
|
||||
if not isinstance(assignee, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+", assignee):
|
||||
raise ValueError("filing assignee is invalid")
|
||||
plan["assignee"] = assignee
|
||||
completion_intent = raw.get("completion_intent")
|
||||
if completion_intent is not None:
|
||||
if completion_intent not in {"create", "create-and-start"}:
|
||||
raise ValueError("filing completion intent is invalid")
|
||||
plan["completion_intent"] = completion_intent
|
||||
return plan
|
||||
|
||||
def _normalize(self, drafts: list[dict]) -> list[dict]:
|
||||
if not isinstance(drafts, list):
|
||||
raise ValueError("drafts must be a list")
|
||||
if len(drafts) > self.limit:
|
||||
raise ValueError(f"unfiled drafts are limited to {self.limit}")
|
||||
normalized = []
|
||||
seen = set()
|
||||
decoded_total = 0
|
||||
for raw in drafts:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("draft must be an object")
|
||||
draft_id = raw.get("id")
|
||||
if not isinstance(draft_id, str) or not _DRAFT_ID.fullmatch(draft_id):
|
||||
raise ValueError("draft id is invalid")
|
||||
if draft_id in seen:
|
||||
raise ValueError("draft ids must be unique")
|
||||
title = raw.get("title")
|
||||
body = raw.get("body", "")
|
||||
saved_at = raw.get("saved_at")
|
||||
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 255:
|
||||
raise ValueError("title is invalid")
|
||||
if not isinstance(body, str) or len(body) > 10_000:
|
||||
raise ValueError("body is invalid")
|
||||
if not isinstance(saved_at, int) or isinstance(saved_at, bool) or saved_at < 0:
|
||||
raise ValueError("saved_at is invalid")
|
||||
filing_plan = self._filing_plan(raw.get("filing_plan"))
|
||||
blockers = raw.get("blockers", [])
|
||||
if not isinstance(blockers, list) or len(blockers) > 5:
|
||||
raise ValueError("blockers are invalid")
|
||||
clean_blockers = []
|
||||
for blocker in blockers:
|
||||
repository = blocker.get("repository") if isinstance(blocker, dict) else None
|
||||
number = blocker.get("number") if isinstance(blocker, dict) else None
|
||||
blocker_title = blocker.get("title", "") if isinstance(blocker, dict) else ""
|
||||
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
||||
raise ValueError("blocker repository is invalid")
|
||||
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||
raise ValueError("blocker number is invalid")
|
||||
if not isinstance(blocker_title, str) or len(blocker_title) > 255:
|
||||
raise ValueError("blocker title is invalid")
|
||||
clean_blockers.append({"repository": repository, "number": number, "title": blocker_title})
|
||||
evidence = raw.get("evidence", [])
|
||||
if not isinstance(evidence, list) or len(evidence) > 5:
|
||||
raise ValueError("evidence is invalid")
|
||||
clean_evidence = []
|
||||
for item in evidence:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("evidence is invalid")
|
||||
filename = item.get("filename")
|
||||
content_type = item.get("content_type")
|
||||
note = item.get("note", "")
|
||||
data = item.get("data")
|
||||
if not isinstance(filename, str) or not filename or len(filename) > 255:
|
||||
raise ValueError("evidence filename is invalid")
|
||||
if content_type not in _CONTENT_TYPES:
|
||||
raise ValueError("evidence content type is invalid")
|
||||
if not isinstance(note, str) or len(note) > 240:
|
||||
raise ValueError("evidence note is invalid")
|
||||
if not isinstance(data, str):
|
||||
raise ValueError("evidence data is invalid")
|
||||
try:
|
||||
decoded_total += len(base64.b64decode(data, validate=True))
|
||||
except (binascii.Error, ValueError) as error:
|
||||
raise ValueError("evidence data must be valid base64") from error
|
||||
if decoded_total > self.max_total_bytes:
|
||||
raise ValueError("synchronized evidence is too large")
|
||||
clean_evidence.append({
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
**({"note": note} if note else {}),
|
||||
"data": data,
|
||||
})
|
||||
seen.add(draft_id)
|
||||
normalized.append({
|
||||
"id": draft_id,
|
||||
"title": title.strip(),
|
||||
"body": body,
|
||||
"saved_at": saved_at,
|
||||
**({"filing_plan": filing_plan} if filing_plan else {}),
|
||||
**({"blockers": clean_blockers} if clean_blockers else {}),
|
||||
**({"evidence": clean_evidence} if clean_evidence else {}),
|
||||
})
|
||||
return normalized
|
||||
|
||||
def replace(self, login: str, expected_revision: int, drafts: list[dict]) -> dict:
|
||||
login = self._login(login)
|
||||
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||
raise ValueError("revision is invalid")
|
||||
normalized = self._normalize(drafts)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current = self._snapshot(row)
|
||||
if current["revision"] != expected_revision:
|
||||
raise UnfiledDraftConflict(current)
|
||||
revision = expected_revision + 1
|
||||
connection.execute(
|
||||
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
return {"revision": revision, "drafts": normalized}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Deterministic Gitea double used by the release-artifact browser journey."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
REPOSITORY = {
|
||||
"id": 7,
|
||||
"name": "mobile",
|
||||
"full_name": "acme/mobile",
|
||||
"description": "Mobile filing acceptance repository",
|
||||
"html_url": "http://127.0.0.1/acme/mobile",
|
||||
"owner": {"login": "acme"},
|
||||
}
|
||||
USER = {"id": 1, "login": "timmy", "full_name": "Timmy"}
|
||||
|
||||
|
||||
class FakeGiteaServer(ThreadingHTTPServer):
|
||||
def __init__(self, address: tuple[str, int]):
|
||||
super().__init__(address, FakeGiteaHandler)
|
||||
self.created_issues: list[dict] = []
|
||||
|
||||
|
||||
class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
server: FakeGiteaServer
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
def _json(self, status: int, payload: object, **headers: str) -> None:
|
||||
body = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
for name, value in headers.items():
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/api/v1/user":
|
||||
self._json(200, USER)
|
||||
elif path == "/api/v1/user/repos":
|
||||
self._json(200, [REPOSITORY], **{"X-Total-Count": "1"})
|
||||
elif path in {"/api/v1/repos/acme/mobile", "/api/v1/repos/acme/mobile/"}:
|
||||
self._json(200, REPOSITORY)
|
||||
elif path == "/api/v1/repos/search":
|
||||
self._json(200, {"data": [REPOSITORY], "ok": True})
|
||||
elif path.startswith("/api/v1/"):
|
||||
self._json(200, [], **{"X-Total-Count": "0"})
|
||||
else:
|
||||
self._json(404, {"message": "not found"})
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = urlsplit(self.path).path
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||
if path != "/api/v1/repos/acme/mobile/issues":
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
self.server.created_issues.append(payload)
|
||||
assignee = payload.get("assignee")
|
||||
self._json(
|
||||
201,
|
||||
{
|
||||
"id": 101,
|
||||
"number": 41,
|
||||
"title": payload.get("title", ""),
|
||||
"body": payload.get("body", ""),
|
||||
"state": "open",
|
||||
"html_url": "http://127.0.0.1/acme/mobile/issues/41",
|
||||
"assignees": [{"login": assignee}] if assignee else [],
|
||||
"labels": [],
|
||||
"milestone": None,
|
||||
"due_date": payload.get("due_date"),
|
||||
"updated_at": "2026-08-15T12:00:00Z",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("release artifact browser journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import Page, expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TITLE = "Offline artifact journey 863"
|
||||
BODY = "Captured on a phone, retained offline, delivered exactly once."
|
||||
ACCESS_TOKEN = "artifact-browser-access-token-863"
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def wait_for_health(url: str, process: subprocess.Popen[str]) -> None:
|
||||
deadline = time.monotonic() + 20
|
||||
tls = ssl._create_unverified_context()
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
stdout, stderr = process.communicate()
|
||||
raise AssertionError(f"release server exited early\nstdout:\n{stdout}\nstderr:\n{stderr}")
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=0.5, context=tls) as response:
|
||||
if response.status == 200:
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
raise AssertionError("release server did not become healthy")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def release_server(archive: Path, tmp_path: Path, gitea_url: str):
|
||||
release_root = tmp_path / "release"
|
||||
release_root.mkdir()
|
||||
with tarfile.open(archive, "r:gz") as bundle:
|
||||
bundle.extractall(release_root, filter="data")
|
||||
assert (release_root / "release-manifest.json").is_file()
|
||||
assert not (release_root / "tests").exists(), "journey must run the packaged artifact, not checkout code"
|
||||
|
||||
port = free_port()
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir()
|
||||
key_file = tmp_path / "localhost.key"
|
||||
certificate_file = tmp_path / "localhost.crt"
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", str(key_file), "-out", str(certificate_file), "-days", "1",
|
||||
"-subj", "/CN=127.0.0.1", "-addext", "subjectAltName=IP:127.0.0.1",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
origin = f"https://127.0.0.1:{port}"
|
||||
env = {
|
||||
**os.environ,
|
||||
"PYTHONPATH": str(release_root),
|
||||
"GITEA_URL": gitea_url,
|
||||
"GITEA_TOKEN": "deterministic-e2e-token",
|
||||
"STACKCHAIN_DASHBOARD_AUTH_MODE": "operator",
|
||||
"STACKCHAIN_DASHBOARD_ACCESS_TOKEN": ACCESS_TOKEN,
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET": "artifact-browser-independent-session-secret-863",
|
||||
"STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN": origin,
|
||||
"STACKCHAIN_STATE_DIR": str(tmp_path / "state"),
|
||||
}
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable, "-m", "uvicorn", "src.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(port),
|
||||
"--ssl-keyfile", str(key_file), "--ssl-certfile", str(certificate_file),
|
||||
],
|
||||
cwd=release_root,
|
||||
env=env,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
wait_for_health(origin + "/healthz", process)
|
||||
yield origin
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.communicate()
|
||||
|
||||
|
||||
def indexed_issue_records(page: Page) -> list[dict]:
|
||||
return page.evaluate(
|
||||
"""async () => {
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('stackchain-background-outbox-v1', 1);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return await new Promise((resolve, reject) => {
|
||||
const request = db.transaction('issues', 'readonly').objectStore('issues').getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_reload(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": 390, "height": 844},
|
||||
service_workers="allow",
|
||||
ignore_https_errors=True,
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
|
||||
page.on("response", lambda response: failed_responses.append(
|
||||
f"{response.status} {response.url}"
|
||||
) if response.status >= 400 else None)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
expect(page.locator("#sign-in")).to_be_visible()
|
||||
page.locator('input[name="device_label"]').fill("Release journey phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
new_action = page.locator('[data-mobile-task="new"]')
|
||||
expect(new_action).to_be_visible()
|
||||
service_worker_ready = page.evaluate(
|
||||
"""() => Promise.race([
|
||||
navigator.serviceWorker.ready.then(() => true),
|
||||
new Promise(resolve => setTimeout(() => resolve(false), 10000)),
|
||||
])"""
|
||||
)
|
||||
assert service_worker_ready, (
|
||||
"release artifact did not register its service worker; browser errors=" + repr(browser_errors)
|
||||
+ "; failed responses=" + repr(failed_responses)
|
||||
)
|
||||
if not page.evaluate("() => navigator.serviceWorker.controller !== null"):
|
||||
page.reload(wait_until="networkidle")
|
||||
new_action = page.locator('[data-mobile-task="new"]')
|
||||
assert page.evaluate("() => navigator.serviceWorker.controller !== null")
|
||||
|
||||
new_action.click()
|
||||
page.locator("#create-issue-title").fill(TITLE)
|
||||
page.locator("#create-issue-body").fill(BODY)
|
||||
page.locator("#file-new-issue").click()
|
||||
expect(page.locator("#create-issue-filing")).to_be_visible()
|
||||
page.locator("#create-issue-repository").select_option("acme/mobile")
|
||||
expect(page.locator("#submit-new-issue")).to_be_enabled()
|
||||
|
||||
context.set_offline(True)
|
||||
page.locator("#submit-new-issue").click()
|
||||
expect(page.locator("#issue-filing-review")).to_be_visible()
|
||||
page.locator("#confirm-issue-filing").click()
|
||||
page.wait_for_timeout(1_000)
|
||||
filing_statuses = page.locator(
|
||||
"#my-work-action-status, #create-issue-status, #issue-filing-review-status"
|
||||
).all_inner_texts()
|
||||
assert any(
|
||||
marker in " ".join(filing_statuses).lower()
|
||||
for marker in ("queued", "saved for", "background delivery")
|
||||
), (filing_statuses, browser_errors, failed_responses)
|
||||
|
||||
local_record = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
|
||||
assert [(item["title"], item["body"], item["repository"]) for item in local_record["items"]] == [
|
||||
(TITLE, BODY, "acme/mobile")
|
||||
]
|
||||
durable = indexed_issue_records(page)
|
||||
assert [(item["title"], item["body"], item["repository"]) for item in durable] == [
|
||||
(TITLE, BODY, "acme/mobile")
|
||||
]
|
||||
operation_id = durable[0]["operationId"]
|
||||
assert operation_id and operation_id == local_record["items"][0]["operationId"]
|
||||
assert fake.created_issues == []
|
||||
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
|
||||
durable_after_reload = indexed_issue_records(page)
|
||||
assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)]
|
||||
assert durable_after_reload[0]["operationId"] == operation_id
|
||||
assert fake.created_issues == []
|
||||
|
||||
browser_errors.clear() # Chromium reports expected network errors while the context is offline.
|
||||
failed_responses.clear()
|
||||
context.set_offline(False)
|
||||
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||
for _ in range(80):
|
||||
if fake.created_issues:
|
||||
break
|
||||
page.wait_for_timeout(250)
|
||||
assert fake.created_issues == [{"title": TITLE, "body": BODY, "assignee": "timmy"}]
|
||||
|
||||
for _ in range(40):
|
||||
durable_completion = indexed_issue_records(page)
|
||||
if durable_completion and all(item.get("status") == "sent" for item in durable_completion):
|
||||
break
|
||||
page.wait_for_timeout(100)
|
||||
assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
|
||||
page.reload(wait_until="networkidle")
|
||||
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||
for _ in range(40):
|
||||
completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
|
||||
if completed_local["items"] == []:
|
||||
break
|
||||
page.wait_for_timeout(100)
|
||||
assert completed_local["items"] == []
|
||||
page.wait_for_timeout(500)
|
||||
assert len(fake.created_issues) == 1
|
||||
assert browser_errors == []
|
||||
assert failed_responses == []
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -8,6 +8,8 @@ from tests.dashboard_bundle import dashboard_bundle_text
|
||||
def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
html = dashboard_bundle_text()
|
||||
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
|
||||
commands = (Path(__file__).parents[1] / "frontend" / "commands.js").read_text()
|
||||
api_paths += re.findall(r"return ['\"]([^'\"]*api/v1/[^'\"]*)['\"]", commands)
|
||||
|
||||
assert api_paths
|
||||
assert {
|
||||
@@ -18,6 +20,7 @@ def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/repositories/search?q=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
|
||||
}
|
||||
@@ -64,6 +64,38 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_preserves_explicit_no_owner():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'unowned-1',operationId:'unowned-1',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Backlog capture',body:'',labelIds:[],unassigned:true}};
|
||||
const calls=[];const store={{claimNext:async()=>item,complete:async()=>{{item=null;}},release:async()=>{{}},fail:async()=>{{}}}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push({{url,body:options.body||''}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:19,assignees:[]}};}};
|
||||
(async()=>{{await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(calls));}})();
|
||||
"""
|
||||
calls = run_node(script)
|
||||
assert json.loads(calls[1]["body"]) == {
|
||||
"title": "Backlog capture", "body": "", "label_ids": [], "unassigned": True
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_issue_receipt_uses_the_confirmed_canonical_gitea_url():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'unowned-link',operationId:'unowned-link',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Backlog capture',body:'',labelIds:[],unassigned:true}};
|
||||
const store={{claimNext:async()=>item,complete:async()=>{{item=null;}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:19,title:'Backlog capture',assignees:[],url:'https://forge.example/git/o/r/issues/19'}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(result.receipts[0]));}})();
|
||||
"""
|
||||
receipt = run_node(script)
|
||||
|
||||
assert receipt == {
|
||||
"id": "unowned-link",
|
||||
"status": "confirmed",
|
||||
"kind": "issue",
|
||||
"url": "https://forge.example/git/o/r/issues/19",
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -164,6 +196,71 @@ const fetchJson=async(url,options={{}})=>{{
|
||||
assert output["second"]["confirmed"][0]["number"] == 469
|
||||
|
||||
|
||||
def test_closed_app_capture_retries_only_unfinished_blockers_after_creation():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'blocked',operationId:'blocked',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Blocked',body:'',labelIds:[],blockers:[
|
||||
{{repository:'o/api',number:7,title:'API'}},{{repository:'o/web',number:8,title:'Web'}}
|
||||
]}};
|
||||
const calls=[];let secondAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
const body=options.body?JSON.parse(options.body):null;calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||
if(url.endsWith('/issues'))return{{number:42,repository:'o/r'}};
|
||||
if(body.number===8 && secondAttempts++===0){{const e=new Error('offline');e.status=503;throw e;}}
|
||||
return{{number:42,dependencies_available:true,dependencies:[body]}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';try{{await sync.flush();}}catch(e){{first=e.message;}}const checkpoint={{...item}};const result=await sync.flush();process.stdout.write(JSON.stringify({{first,checkpoint,calls,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["first"] == "offline"
|
||||
assert output["checkpoint"]["deliveredIssue"]["number"] == 42
|
||||
assert output["checkpoint"]["deliveredBlockers"] == 1
|
||||
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
|
||||
blockers = [call for call in output["calls"] if call["url"].endswith("/blockers")]
|
||||
assert [call["body"]["number"] for call in blockers] == [7, 8, 8]
|
||||
assert output["result"]["confirmed"][0]["number"] == 42
|
||||
|
||||
|
||||
def test_evidence_bundle_retry_resumes_at_failed_image_and_posts_one_ordered_comment():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Journey',body:'',labelIds:[],attachments:[
|
||||
{{filename:'one.png',contentType:'image/png',data:'b25l',note:'First state'}},
|
||||
{{filename:'two.png',contentType:'image/png',data:'dHdv',note:'Tap [Submit](unsafe)'}},
|
||||
{{filename:'three.png',contentType:'image/png',data:'dGhyZWU='}},
|
||||
]}};
|
||||
const calls=[];let twoAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
let filename=null;if(options.body instanceof FormData)filename=options.body.get('file').name;
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename,body:options.body instanceof FormData?null:JSON.parse(options.body)}});
|
||||
if(url.endsWith('/issues'))return{{number:823}};
|
||||
if(url.endsWith('/attachments')){{if(filename==='two.png' && twoAttempts++===0){{const e=new Error('offline');e.status=503;throw e;}}return{{markdown:''}};}}
|
||||
return{{id:9}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';try{{await sync.flush();}}catch(e){{first=e.message;}}const checkpoint={{...item}};await sync.flush();process.stdout.write(JSON.stringify({{first,checkpoint,calls}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["first"] == "offline"
|
||||
uploads = [call for call in output["calls"] if call["filename"]]
|
||||
assert [call["filename"] for call in uploads] == ["one.png", "two.png", "two.png", "three.png"]
|
||||
assert [call["key"] for call in uploads] == [
|
||||
"bundle:attachment-0", "bundle:attachment-1", "bundle:attachment-1", "bundle:attachment-2"
|
||||
]
|
||||
assert output["checkpoint"]["attachmentMarkdowns"] == [""]
|
||||
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
|
||||
assert len(comments) == 1
|
||||
assert comments[0]["body"]["body"].split("\n\n") == [
|
||||
"**Screenshot 1 — First state**", "",
|
||||
"**Screenshot 2 — Tap \\[Submit\\]\\(unsafe\\)**", "",
|
||||
""
|
||||
]
|
||||
|
||||
|
||||
|
||||
def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
@@ -633,22 +730,32 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
await store.reconcile([{{
|
||||
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',blob:new Blob(['first-image-bytes'],{{type:'image/png'}})}},
|
||||
}}]);
|
||||
}},{{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
|
||||
{{filename:'a.png',contentType:'image/png',blob:new Blob(['a-bytes'],{{type:'image/png'}})}},
|
||||
{{filename:'b.png',contentType:'image/png',blob:new Blob(['b-bytes'],{{type:'image/png'}})}},
|
||||
]}}]);
|
||||
await store.reconcile([
|
||||
{{id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',stored:true}}}},
|
||||
{{id:'second',operationId:'second',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'two.png',contentType:'image/png',data:'c2Vjb25kLWltYWdlLWJ5dGVz'}}}},
|
||||
{{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
|
||||
{{filename:'a.png',contentType:'image/png',stored:true}},
|
||||
{{filename:'b.png',contentType:'image/png',stored:true}},
|
||||
]}},
|
||||
]);
|
||||
const snapshot=await store.snapshot();
|
||||
process.stdout.write(JSON.stringify({{first:{{isBlob:snapshot[0].attachment.blob instanceof Blob,
|
||||
text:await snapshot[0].attachment.blob?.text()}},second:snapshot[1].attachment.data}}));
|
||||
const byId=Object.fromEntries(snapshot.map(value=>[value.id,value]));
|
||||
process.stdout.write(JSON.stringify({{first:{{isBlob:byId.first.attachment.blob instanceof Blob,
|
||||
text:await byId.first.attachment.blob?.text()}},second:byId.second.attachment.data,
|
||||
bundle:await Promise.all(byId.bundle.attachments.map(value=>value.blob?.text()))}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == {"isBlob": True, "text": "first-image-bytes"}
|
||||
assert output["second"] == "c2Vjb25kLWltYWdlLWJ5dGVz"
|
||||
assert output["bundle"] == ["a-bytes", "b-bytes"]
|
||||
|
||||
|
||||
def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():
|
||||
@@ -1420,5 +1527,6 @@ async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in()
|
||||
assert "await Notification.requestPermission()" in html
|
||||
assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html
|
||||
assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html
|
||||
assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html
|
||||
assert "if (window.location.hash !== '#/my-work/drafts') return;" in html
|
||||
assert "onQueue: openWorkQueueRoute" in html
|
||||
assert "workRoute.queue(filter)" in html
|
||||
assert "openDeliveryReceiptRoute" not in html
|
||||
@@ -0,0 +1,572 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BATCH_FIND_WORK = Path(__file__).parents[1] / "frontend" / "batch-find-work.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_batch_preflights_today_capacity_before_claiming_any_issue():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1,
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
onProgress:progress=>calls.push('progress:'+progress.status),
|
||||
}});
|
||||
flow.run([
|
||||
{{repository:'stackchain/dashboard',number:671}},
|
||||
{{repository:'stackchain/dashboard',number:672}},
|
||||
]).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "full",
|
||||
"selected": 2,
|
||||
"available": 1,
|
||||
"queued": [],
|
||||
"failed": [],
|
||||
},
|
||||
"calls": ["progress:full"],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_claims_in_order_continues_after_conflict_and_reports_truthfully():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
claim:item=>{{
|
||||
calls.push('claim:'+item.number);
|
||||
return item.number===672 ? Promise.reject(new Error('already claimed')) :
|
||||
Promise.resolve({{...item,assignees:['timmy']}});
|
||||
}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
onProgress:progress=>calls.push('progress:'+progress.status+':'+progress.processed),
|
||||
}});
|
||||
flow.run([671,672,673].map(number=>({{repository:'stackchain/dashboard',number}})))
|
||||
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete",
|
||||
"selected": 3,
|
||||
"available": 3,
|
||||
"queued": ["stackchain/dashboard#671", "stackchain/dashboard#673"],
|
||||
"failed": [{"key": "stackchain/dashboard#672", "reason": "already claimed"}],
|
||||
},
|
||||
"calls": [
|
||||
"claim:671", "queue:671", "progress:running:1",
|
||||
"claim:672", "progress:running:2",
|
||||
"claim:673", "queue:673", "progress:running:3",
|
||||
"progress:complete:3",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_does_not_reclaim_confirmed_issue_when_today_sync_needs_recovery():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1,
|
||||
claim:item=>{{calls.push('claim');return Promise.resolve({{...item,assignees:['timmy']}});}},
|
||||
queue:item=>{{calls.push('queue');return 'sync-unavailable';}},
|
||||
}});
|
||||
flow.run([{{repository:'stackchain/dashboard',number:671}}])
|
||||
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete",
|
||||
"selected": 1,
|
||||
"available": 1,
|
||||
"queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#671",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"calls": ["claim", "queue"],
|
||||
}
|
||||
|
||||
|
||||
def test_interrupted_batch_resumes_confirmed_assignment_without_claiming_twice():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const values={{}};
|
||||
const storage={{
|
||||
getItem:key=>values[key] || null,
|
||||
setItem:(key,value)=>{{values[key]=value;}},
|
||||
removeItem:key=>{{delete values[key];}},
|
||||
}};
|
||||
const calls=[];
|
||||
const item={{repository:'stackchain/dashboard',number:679,title:'Recover me'}};
|
||||
const first=createBatchFindWork({{
|
||||
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||
claim:value=>{{calls.push('claim');return Promise.resolve({{...value,assignees:['timmy']}});}},
|
||||
queue:()=>{{calls.push('queue-failed');return 'sync-unavailable';}},
|
||||
}});
|
||||
first.run([item]).then(firstResult=>{{
|
||||
const second=createBatchFindWork({{
|
||||
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||
claim:()=>{{calls.push('duplicate-claim');return Promise.resolve(item);}},
|
||||
queue:value=>{{calls.push('queue-resumed:'+value.assignees[0]);return 'queued';}},
|
||||
}});
|
||||
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
|
||||
firstResult, resumed, calls, keys:Object.keys(values),
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"firstResult": {
|
||||
"status": "complete", "selected": 1, "available": 1, "queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#679",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"resumed": {
|
||||
"status": "complete", "selected": 1, "available": 1,
|
||||
"queued": ["stackchain/dashboard#679"], "failed": [],
|
||||
},
|
||||
"calls": ["claim", "queue-failed", "queue-resumed:timmy"],
|
||||
"keys": [],
|
||||
}
|
||||
|
||||
|
||||
def test_interrupted_batch_restores_operation_context_for_idempotent_recovery():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const values={{}};
|
||||
const storage={{
|
||||
getItem:key=>values[key] || null,
|
||||
setItem:(key,value)=>{{values[key]=value;}},
|
||||
removeItem:key=>{{delete values[key];}},
|
||||
}};
|
||||
const calls=[];
|
||||
const options={{
|
||||
capacity:()=>2, storage, owner:()=> 'timmy', journalName:'search-later-batch',
|
||||
queueFailureReason:'assigned but Later could not be saved',
|
||||
claim:item=>Promise.resolve({{...item,assigned_to_me:true}}),
|
||||
}};
|
||||
const first=createBatchFindWork({{...options, queue:(item,context)=>{{
|
||||
calls.push(['first',item.number,context?.until]);
|
||||
return item.number===813 ? 'queued' : 'unavailable';
|
||||
}}}});
|
||||
const items=[813,814].map(number=>({{repository:'stackchain/dashboard',number}}));
|
||||
first.run(items, {{}}, {{until:'2026-08-15T09:00:00.000Z'}}).then(firstResult=>{{
|
||||
const second=createBatchFindWork({{...options, queue:(item,context)=>{{
|
||||
calls.push(['resume',item.number,context?.until]);
|
||||
return 'queued';
|
||||
}}}});
|
||||
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
|
||||
firstResult,resumed,calls,keys:Object.keys(values),
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["firstResult"]["queued"] == ["stackchain/dashboard#813"]
|
||||
assert result["firstResult"]["failed"] == [{
|
||||
"key": "stackchain/dashboard#814",
|
||||
"reason": "assigned but Later could not be saved",
|
||||
"assigned": True,
|
||||
}]
|
||||
assert result["resumed"]["queued"] == [
|
||||
"stackchain/dashboard#813", "stackchain/dashboard#814",
|
||||
]
|
||||
assert result["calls"] == [
|
||||
["first", 813, "2026-08-15T09:00:00.000Z"],
|
||||
["first", 814, "2026-08-15T09:00:00.000Z"],
|
||||
["resume", 814, "2026-08-15T09:00:00.000Z"],
|
||||
]
|
||||
assert result["keys"] == []
|
||||
|
||||
|
||||
def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const key='stackchain.find-work-batch.v1.timmy';
|
||||
const values={{[key]:JSON.stringify({{
|
||||
owner:'timmy', available:2, items:[
|
||||
{{item:{{repository:'stackchain/dashboard',number:678}},state:'queued'}},
|
||||
{{item:{{repository:'stackchain/dashboard',number:679}},state:'pending'}},
|
||||
],
|
||||
}})}};
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>2, owner:()=> 'timmy',
|
||||
storage:{{getItem:key=>values[key]||null,setItem:(key,value)=>values[key]=value,removeItem:key=>delete values[key]}},
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
}});
|
||||
flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 2, "available": 2,
|
||||
"queued": ["stackchain/dashboard#678", "stackchain/dashboard#679"],
|
||||
"failed": [],
|
||||
},
|
||||
"calls": ["claim:679", "queue:679"],
|
||||
}
|
||||
|
||||
|
||||
def test_search_batch_uses_an_isolated_owner_journal_and_can_resume_without_dom_mounting():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const values={{}};
|
||||
const calls=[];
|
||||
const storage={{
|
||||
getItem:key=>values[key] || null,
|
||||
setItem:(key,value)=>{{values[key]=value;}},
|
||||
removeItem:key=>{{delete values[key];}},
|
||||
}};
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1, owner:()=> 'timmy', storage,
|
||||
journalName:'search-today-batch', autoMount:false,
|
||||
claim:item=>Promise.resolve({{...item,assigned_to_me:true}}),
|
||||
queue:()=>{{calls.push('queue');return 'sync-unavailable';}},
|
||||
}});
|
||||
flow.run([{{repository:'stackchain/dashboard',number:809}}]).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,keys:Object.keys(values),calls,pending:flow.pending()}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 1, "available": 1, "queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#809",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"keys": ["stackchain.search-today-batch.v1.timmy"],
|
||||
"calls": ["queue"],
|
||||
"pending": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_time_budget_blocks_claims_until_every_estimate_fits():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
timeBudget:()=>({{capacity_minutes:90,planned_minutes:30}}),
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
}});
|
||||
const items=[701,702].map(number=>({{repository:'stackchain/dashboard',number}}));
|
||||
Promise.all([
|
||||
flow.run(items, {{'stackchain/dashboard#701':30}}),
|
||||
flow.run(items, {{'stackchain/dashboard#701':30,'stackchain/dashboard#702':45}}),
|
||||
]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"results": [
|
||||
{
|
||||
"status": "estimates-required", "selected": 2, "available": 3,
|
||||
"queued": [], "failed": [], "remaining_minutes": 60,
|
||||
"requested_minutes": 30,
|
||||
"invalid": ["stackchain/dashboard#702"],
|
||||
},
|
||||
{
|
||||
"status": "over-budget", "selected": 2, "available": 3,
|
||||
"queued": [], "failed": [], "remaining_minutes": 60,
|
||||
"requested_minutes": 75, "over_minutes": 15,
|
||||
},
|
||||
],
|
||||
"calls": [],
|
||||
}
|
||||
|
||||
|
||||
def test_time_budget_persists_estimates_only_for_successfully_queued_claims():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
timeBudget:()=>({{capacity_minutes:120,planned_minutes:30}}),
|
||||
claim:item=>item.number===702 ? Promise.reject(new Error('already claimed')) : Promise.resolve(item),
|
||||
queue:item=>'queued',
|
||||
persistEstimate:(item,minutes)=>calls.push(item.number+':'+minutes),
|
||||
}});
|
||||
flow.run([701,702].map(number=>({{repository:'stackchain/dashboard',number}})),{{
|
||||
'stackchain/dashboard#701':30,
|
||||
'stackchain/dashboard#702':45,
|
||||
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 2, "available": 3,
|
||||
"queued": ["stackchain/dashboard#701"],
|
||||
"failed": [{"key": "stackchain/dashboard#702", "reason": "already claimed"}],
|
||||
},
|
||||
"calls": ["701:30"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_has_preclaim_time_review_controls():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-estimate-review"' in html
|
||||
assert 'id="find-work-estimate-list"' in html
|
||||
assert 'id="confirm-find-work-estimates"' in html
|
||||
assert 'inputmode="numeric"' in dashboard
|
||||
assert "timeBudget:" in dashboard
|
||||
assert "persistEstimate:" in dashboard
|
||||
assert ".find-work-estimate-review" in css
|
||||
assert ".find-work-estimate-review input" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_selection_survives_loaded_pages_and_removes_confirmed_claims():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const pages={{
|
||||
1:{{items:[{{id:1,repository:'stackchain/dashboard',number:671}}],page:1,total:2,has_more:true}},
|
||||
2:{{items:[{{id:2,repository:'stackchain/dashboard',number:672}}],page:2,total:2,has_more:false}},
|
||||
}};
|
||||
const controller=createFindWork({{
|
||||
fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) :
|
||||
Promise.resolve(pages[path.includes('page=2') ? 2 : 1]),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
return controller.loadMore();
|
||||
}}).then(()=>{{
|
||||
controller.toggleSelection(controller.items()[1]);
|
||||
return controller.claim(controller.items()[0]);
|
||||
}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
selecting:controller.selection().active,
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"selected": [672], "selecting": True}
|
||||
|
||||
|
||||
def test_fill_today_selects_ranked_matches_up_to_remaining_capacity():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const states=[];
|
||||
const controller=createFindWork({{
|
||||
fetchJson:()=>Promise.resolve({{items:[
|
||||
{{id:1,repository:'stackchain/dashboard',number:701}},
|
||||
{{id:2,repository:'stackchain/dashboard',number:702}},
|
||||
{{id:3,repository:'stackchain/dashboard',number:703}},
|
||||
{{id:4,repository:'stackchain/dashboard',number:704}},
|
||||
],page:1,total:4,has_more:false}}),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}},
|
||||
onSelection:state=>states.push(state),
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[1]);
|
||||
const outcome=controller.fillSelection(3);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
outcome,
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
lastState:states.at(-1),
|
||||
}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"outcome": {"selected": 3, "added": 2, "limit": 3},
|
||||
"selected": [702, 701, 703],
|
||||
"lastState": {
|
||||
"active": True,
|
||||
"count": 3,
|
||||
"ids": [
|
||||
"stackchain/dashboard#702",
|
||||
"stackchain/dashboard#701",
|
||||
"stackchain/dashboard#703",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_fill_today_with_no_capacity_does_not_change_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const controller=createFindWork({{
|
||||
fetchJson:()=>Promise.resolve({{items:[{{id:1,repository:'stackchain/dashboard',number:701}}]}}),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
const outcome=controller.fillSelection(0);
|
||||
process.stdout.write(JSON.stringify({{outcome,selection:controller.selection()}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"outcome": {"selected": 0, "added": 0, "limit": 0},
|
||||
"selection": {"active": False, "count": 0, "ids": []},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert 'id="select-find-work"' in html
|
||||
assert 'id="batch-find-work-actions"' in html
|
||||
assert 'id="claim-selected-work"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "createBatchFindWork({" in dashboard
|
||||
assert "findWorkController.startSelection()" in dashboard
|
||||
assert "findWorkController.toggleSelection(item)" in dashboard
|
||||
assert ".find-work-batch-actions" in css
|
||||
assert "position:sticky" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert "BASE + 'static/batch-find-work.js'" in worker
|
||||
assert '<script src="static/batch-find-work.js"></script>' in html
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_touch_accessible_batch_recovery():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="resume-find-work-batch"' in html
|
||||
assert 'aria-describedby="find-work-status"' in html
|
||||
assert "mountRecovery(" in BATCH_FIND_WORK.read_text()
|
||||
assert "typeof localStorage === 'undefined'" in BATCH_FIND_WORK.read_text()
|
||||
assert ".find-work-batch-recovery" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="fill-find-work-today"' in html
|
||||
assert 'aria-describedby="find-work-selection-status"' in html
|
||||
assert "findWorkController.fillSelection(remainingSlots)" in dashboard
|
||||
assert "openFindWorkEstimateReview(findWorkController.selectedItems())" in dashboard
|
||||
assert ".fill-find-work-today" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_search_ignores_stale_response_and_preserves_cross_query_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const pending=[];
|
||||
const rendered=[];
|
||||
const fetchJson=path=>new Promise(resolve=>pending.push({{path,resolve}}));
|
||||
const controller=createFindWork({{
|
||||
fetchJson,
|
||||
onItems:items=>rendered.push(items.map(item=>item.number)),
|
||||
onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
const first=controller.search('api');
|
||||
const second=controller.search('dashboard');
|
||||
pending[1].resolve({{items:[{{id:2,repository:'stackchain/dashboard',number:673}}],page:1,total:1,has_more:false}});
|
||||
second.then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
const third=controller.search('worker');
|
||||
pending[2].resolve({{items:[{{id:3,repository:'stackchain/worker',number:674}}],page:1,total:1,has_more:false}});
|
||||
return third;
|
||||
}}).then(()=>{{
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
pending[0].resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false}});
|
||||
return first;
|
||||
}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
calls:pending.map(entry=>entry.path),
|
||||
rendered,
|
||||
items:controller.items().map(item=>item.number),
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": [
|
||||
"api/v1/available-issues?page=1&facets=true&q=api",
|
||||
"api/v1/available-issues?page=1&facets=true&q=dashboard",
|
||||
"api/v1/available-issues?page=1&facets=true&q=worker",
|
||||
],
|
||||
"rendered": [[673], [674]],
|
||||
"items": [674],
|
||||
"selected": [673, 674],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_search_has_clear_live_results_and_touch_targets():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-search"' in html
|
||||
assert 'id="clear-find-work-search"' in html
|
||||
assert 'id="find-work-match-status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "findWorkController.search(" in dashboard
|
||||
assert "#find-work-search" in dashboard
|
||||
assert ".find-work-search" in css
|
||||
assert ".find-work-search button" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_facets_serialize_filters_and_preserve_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const calls=[];
|
||||
const controller=createFindWork({{
|
||||
fetchJson:path=>{{ calls.push(path); return Promise.resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false,facets:{{repositories:['stackchain/api'],labels:['critical']}}}}); }},
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}}, onFacets:()=>{{}},
|
||||
}});
|
||||
controller.reset({{items:[{{id:9,repository:'stackchain/old',number:9}}],page:1,total:1,has_more:false}});
|
||||
controller.startSelection(); controller.toggleSelection(controller.items()[0]);
|
||||
controller.setFilters({{repositories:['stackchain/api'],labels:['critical']}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
calls, filters:controller.filters(), selected:controller.selectedItems().map(item=>item.number)
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": ["api/v1/available-issues?page=1&facets=true&repository=stackchain%2Fapi&label=critical"],
|
||||
"filters": {"repositories": ["stackchain/api"], "labels": ["critical"]},
|
||||
"selected": [9],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_accessible_touch_sized_facets():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-filters"' in html
|
||||
assert 'id="find-work-repository-filters"' in html
|
||||
assert 'id="find-work-label-filters"' in html
|
||||
assert 'id="clear-find-work-filters"' in html
|
||||
assert 'aria-label="Filter available work"' in html
|
||||
assert "setFilters(next)" in PICK_WORK.read_text()
|
||||
assert ".find-work-filters" in css
|
||||
assert ".find-work-filter-option" in css and "min-height:44px" in css
|
||||
@@ -21,8 +21,22 @@ def test_release_promotion_waits_for_tests_and_bundle():
|
||||
text = WORKFLOW.read_text()
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
|
||||
assert "needs: [lint, build-release]" in release
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
assert "github.event_name == 'push'" in release
|
||||
assert "actions/download-artifact@v3" in release
|
||||
assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release
|
||||
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
|
||||
|
||||
|
||||
def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
|
||||
assert "needs: build-release" in browser
|
||||
assert "actions/download-artifact@v3" in browser
|
||||
assert "pip install -r requirements-e2e.txt" in browser
|
||||
assert "python3 -m playwright install --with-deps chromium" in browser
|
||||
assert 'STACKCHAIN_RUN_RELEASE_E2E: "1"' in browser
|
||||
assert "python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q" in browser
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
@@ -11,6 +11,7 @@ FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
COMMANDS = FRONTEND / "commands.js"
|
||||
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
||||
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
|
||||
SEARCH_DEFER = FRONTEND / "search-defer.js"
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
@@ -52,6 +53,80 @@ def test_command_script_resolves_inside_dashboard_subpath():
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
|
||||
|
||||
|
||||
def test_mobile_search_renders_touch_sized_type_and_status_scope_controls():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
commands = COMMANDS.read_text()
|
||||
|
||||
assert '<fieldset class="cmd-search-scope"' in html
|
||||
assert '<legend>Filter search results</legend>' in html
|
||||
assert '<label for="cmd-search-kind">Type</label>' in html
|
||||
assert '<select id="cmd-search-kind"' in html
|
||||
assert '<option value="pull">Pull requests</option>' in html
|
||||
assert '<label for="cmd-search-state">Status</label>' in html
|
||||
assert '<select id="cmd-search-state"' in html
|
||||
assert '.cmd-search-scope' in css
|
||||
assert '#cmd-search-kind, #cmd-search-state { min-height:44px;' in css
|
||||
assert "commandSearch.setScope(scope)" in html
|
||||
assert "taskOverlayHistory.update({ scope })" in html
|
||||
assert "'&kind=' + encodeURIComponent(scope.kind)" in commands
|
||||
assert "'&state=' + encodeURIComponent(scope.state)" in commands
|
||||
|
||||
|
||||
def test_mobile_search_exposes_accessible_repository_scope_and_forwards_it():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
commands = COMMANDS.read_text()
|
||||
|
||||
assert '<label for="cmd-search-repository">Repository</label>' in html
|
||||
assert '<input id="cmd-search-repository"' in html
|
||||
assert 'list="cmd-search-repositories"' in html
|
||||
assert '<datalist id="cmd-search-repositories">' in html
|
||||
assert "api/v1/repositories/search?q=" in html
|
||||
assert "repository:qs('#cmd-search-repository').value" in html
|
||||
assert "'&repository=' + encodeURIComponent(scope.repository)" in commands
|
||||
assert '#cmd-search-repository' in css
|
||||
|
||||
|
||||
def test_mobile_search_preview_exposes_a_keyboard_safe_reply_and_continue_composer():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
preview = SEARCH_PREVIEW.read_text()
|
||||
|
||||
assert '<section class="search-preview-reply"' in html
|
||||
assert '<textarea id="search-preview-reply"' in html
|
||||
assert '<button id="send-search-preview-reply"' in html
|
||||
assert '<button id="send-search-preview-reply-next"' in html
|
||||
assert "searchPreviewReplyOptions(fetchReviewJson" in html
|
||||
assert "root.searchPreviewReplyPath(item)" in preview
|
||||
assert "api.saveReplyDraft" in preview
|
||||
assert "api.reply({ advance:true })" in preview
|
||||
assert ".search-preview-reply textarea" in css
|
||||
assert "scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom))" in css
|
||||
|
||||
|
||||
def test_remote_command_search_repository_change_aborts_and_restarts_scoped_search():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const pending = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{signal, scope, resolve}})),
|
||||
onState() {{}},
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setScope({{kind:'all', state:'all', repository:'stackchain/api'}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (!pending[0].signal.aborted) throw new Error('repository change did not abort old request');
|
||||
if (pending[1].scope.repository !== 'stackchain/api') throw new Error('repository was not forwarded');
|
||||
pending[1].resolve({{items:[],has_more:false,next_page:2}});
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_ignores_stale_responses():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
@@ -85,6 +160,37 @@ if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result'
|
||||
)
|
||||
|
||||
|
||||
def test_remote_command_search_scope_change_aborts_and_restarts_without_stale_results():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const pending = [];
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{query, signal, page, scope, resolve}})),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setScope({{kind:'pull', state:'open'}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (!pending[0].signal.aborted) throw new Error('scope change did not abort old request');
|
||||
if (JSON.stringify(pending[1].scope) !== JSON.stringify({{kind:'pull', state:'open'}})) {{
|
||||
throw new Error('new request did not receive scope');
|
||||
}}
|
||||
pending[1].resolve({{items:[{{kind:'pull',repository:'a/b',number:2}}],has_more:false,next_page:2}});
|
||||
pending[0].resolve({{items:[{{kind:'issue',repository:'a/b',number:1}}],has_more:false,next_page:2}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.filter(state => state.status === 'ready').at(-1);
|
||||
if (ready.items.length !== 1 || ready.items[0].kind !== 'pull') throw new Error('stale scope result entered list');
|
||||
if (ready.scope.kind !== 'pull' || ready.scope.state !== 'open') throw new Error('scope missing from state');
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_aborts_superseded_and_cleared_queries():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
@@ -142,9 +248,83 @@ if (!ready || ready.items.length !== 1 || ready.partial !== true) {{
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_retries_partial_continuation_without_losing_results():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const requests = [];
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal, page, scope, continuation) => {{
|
||||
requests.push({{page, continuation}});
|
||||
if (requests.length === 1) return Promise.resolve({{
|
||||
items:[{{kind:'pull',repository:'a/b',number:2}}], partial:true,
|
||||
has_more:true, next_page:2, continuation:{{issues:1,pulls:null}}, failed_streams:['issue']
|
||||
}});
|
||||
return Promise.resolve({{
|
||||
items:[{{kind:'issue',repository:'a/b',number:1}}], partial:false,
|
||||
has_more:false, next_page:2, continuation:{{issues:null,pulls:null}}, failed_streams:[]
|
||||
}});
|
||||
}},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await controller.loadMore();
|
||||
const ready = states.filter(state => state.status === 'ready').at(-1);
|
||||
if (JSON.stringify(requests[1].continuation) !== JSON.stringify({{issues:1,pulls:null}})) {{
|
||||
throw new Error('retry did not preserve failed stream page: ' + JSON.stringify(requests));
|
||||
}}
|
||||
if (ready.partial || ready.items.length !== 2 || ready.items[0].kind !== 'pull' || ready.items[1].kind !== 'issue') {{
|
||||
throw new Error('recovered results were not appended: ' + JSON.stringify(ready));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_appends_deduplicated_pages_and_ignores_stale_load_more():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const pending = new Map();
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal, page) => new Promise(resolve => pending.set(query + ':' + page, resolve)),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get('mobile:1')({{ items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2 }});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.loadMore();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setQuery('release');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get('mobile:2')({{ items:[{{kind:'issue',repository:'a/b',number:1}},{{kind:'pull',repository:'a/b',number:2}}], has_more:false, next_page:3 }});
|
||||
pending.get('release:1')({{ items:[{{kind:'issue',repository:'a/b',number:3}}], has_more:false, next_page:2 }});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.filter(state => state.status === 'ready').at(-1);
|
||||
if (ready.query !== 'release' || ready.items.length !== 1 || ready.items[0].number !== 3) {{
|
||||
throw new Error('stale page entered current query: ' + JSON.stringify(states));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_command_selection_wraps_for_arrow_keys():
|
||||
script = f"""
|
||||
const commands = require({json.dumps(str(COMMANDS))});
|
||||
if (commands.searchUrl('mobile', 2, {{kind:'all',state:'open'}}, {{issues:1,pulls:3}}) !==
|
||||
'api/v1/search?q=mobile&limit=10&page=2&kind=all&state=open&issues_page=1&pulls_page=3') {{
|
||||
throw new Error('independent continuation query was not encoded');
|
||||
}}
|
||||
if (commands.nextSelection(-1, 'ArrowDown', 3) !== 0) throw new Error('should select first');
|
||||
if (commands.nextSelection(2, 'ArrowDown', 3) !== 0) throw new Error('should wrap down');
|
||||
if (commands.nextSelection(0, 'ArrowUp', 3) !== 2) throw new Error('should wrap up');
|
||||
@@ -160,16 +340,287 @@ def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath()
|
||||
assert 'role="combobox"' in html
|
||||
assert 'aria-controls="cmd-results"' in html
|
||||
assert 'role="listbox"' in html
|
||||
assert "fetch('api/v1/search?q='" in html
|
||||
assert "async function searchGlobalWork(query, signal)" in html
|
||||
assert "fetch(filterCommands.searchUrl(" in html
|
||||
assert "async function searchGlobalWork(query, signal, page = 1, scope" in html
|
||||
assert "signal," in html
|
||||
assert "Some results are temporarily unavailable." in html
|
||||
assert "Retry missing results" in html
|
||||
assert 'id="cmd-load-more"' in html
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/",
|
||||
"api/v1/search?q=mobile",
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile"
|
||||
|
||||
|
||||
def test_search_preview_navigates_a_loaded_result_session_and_reports_position():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const items = [1, 2, 3].map(number => ({{repository:'stackchain/api', number, kind:'issue'}}));
|
||||
const states = [];
|
||||
const navigated = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
|
||||
mutate: () => Promise.resolve(),
|
||||
getSession: () => ({{items, more:false}}),
|
||||
loadMore: () => Promise.resolve(),
|
||||
onNavigate: item => navigated.push(item.number),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open(items[1]);
|
||||
await preview.previous();
|
||||
await preview.next();
|
||||
const ready = states.filter(state => state.status === 'ready');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
navigated,
|
||||
positions:ready.map(state => state.navigation && [state.navigation.position, state.navigation.total,
|
||||
state.navigation.hasPrevious, state.navigation.hasNext]),
|
||||
current:ready.at(-1).detail.number,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"navigated": [1, 2],
|
||||
"positions": [[2, 3, True, True], [1, 3, False, True], [2, 3, True, True]],
|
||||
"current": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const item = {{repository:'stackchain/api', number:42, kind:'pull'}};
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const calls = [];
|
||||
const states = [];
|
||||
let attempt = 0;
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: candidate => Promise.resolve({{...candidate, title:'Review reply'}}),
|
||||
mutate: () => Promise.resolve(),
|
||||
postReply: (candidate, body, operationId) => {{
|
||||
calls.push([candidate.number, body, operationId]);
|
||||
attempt += 1;
|
||||
return attempt === 1 ? Promise.reject(new Error('offline')) : Promise.resolve({{
|
||||
id:91, author:'timmy', body, created_at:'2026-08-14T12:30:00Z'
|
||||
}});
|
||||
}},
|
||||
storage,
|
||||
createOperationId: () => 'reply-operation-42',
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open(item);
|
||||
preview.saveReplyDraft('Ready to merge.');
|
||||
try {{ await preview.reply(); }} catch (_) {{}}
|
||||
const draftAfterFailure = preview.replyDraft();
|
||||
await preview.reply();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
draftAfterFailure,
|
||||
draftAfterSuccess:preview.replyDraft(),
|
||||
comments:states.filter(state => state.conversation).at(-1).conversation.comments,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": [
|
||||
[42, "Ready to merge.", "reply-operation-42"],
|
||||
[42, "Ready to merge.", "reply-operation-42"],
|
||||
],
|
||||
"draftAfterFailure": "Ready to merge.",
|
||||
"draftAfterSuccess": "",
|
||||
"comments": [{
|
||||
"id": 91,
|
||||
"author": "timmy",
|
||||
"body": "Ready to merge.",
|
||||
"created_at": "2026-08-14T12:30:00Z",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_reply_and_next_advances_only_after_server_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const items = [1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}}));
|
||||
const values = new Map();
|
||||
let confirm;
|
||||
const navigated = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson:item => Promise.resolve(item), mutate:() => Promise.resolve(),
|
||||
postReply:() => new Promise(resolve => {{ confirm = resolve; }}),
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
createOperationId:() => 'reply-next-1',
|
||||
getSession:() => ({{items,more:false}}), loadMore:() => Promise.resolve(),
|
||||
onNavigate:item => navigated.push(item.number), onState:() => {{}},
|
||||
}});
|
||||
await preview.open(items[0]);
|
||||
preview.saveReplyDraft('Done.');
|
||||
const pending = preview.reply({{advance:true}});
|
||||
await Promise.resolve();
|
||||
const before = [...navigated];
|
||||
confirm({{id:7,author:'timmy',body:'Done.'}});
|
||||
await pending;
|
||||
process.stdout.write(JSON.stringify({{before,after:navigated,draft:preview.replyDraft()}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": [],
|
||||
"after": [2],
|
||||
"draft": "",
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_keeps_confirmed_reply_when_earlier_conversation_load_finishes_late():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const values = new Map();
|
||||
let finishConversation;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson:item => Promise.resolve({{...item,title:'Race'}}),
|
||||
fetchConversation:() => new Promise(resolve => {{ finishConversation = resolve; }}),
|
||||
mutate:() => Promise.resolve(),
|
||||
postReply:(_item,body) => Promise.resolve({{id:9,author:'timmy',body}}),
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
createOperationId:() => 'reply-race-9', onState:state => states.push(state),
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api',number:9,kind:'issue'}});
|
||||
preview.saveReplyDraft('Confirmed reply');
|
||||
await preview.reply();
|
||||
finishConversation({{comments:[{{id:3,author:'alex',body:'Earlier'}}],older_page:null}});
|
||||
await Promise.resolve();
|
||||
const comments = states.filter(state => state.conversation?.status === 'ready').at(-1).conversation.comments;
|
||||
process.stdout.write(JSON.stringify(comments.map(comment => comment.id)));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [3, 9]
|
||||
|
||||
|
||||
def test_search_preview_next_loads_one_page_at_boundary_then_announces_completion():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let session = {{items:[1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}})), more:true}};
|
||||
let loads = 0;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(),
|
||||
getSession: () => session,
|
||||
loadMore: async () => {{
|
||||
loads += 1;
|
||||
session = {{items:[...session.items, {{repository:'stackchain/api', number:3, kind:'issue'}}], more:false}};
|
||||
}},
|
||||
onNavigate() {{}}, onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open(session.items[1]);
|
||||
await preview.next();
|
||||
await preview.next();
|
||||
const final = states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{loads, current:final.detail.number, complete:final.complete,
|
||||
navigation:final.navigation}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"loads": 1,
|
||||
"current": 3,
|
||||
"complete": True,
|
||||
"navigation": {"position": 3, "total": 3, "hasPrevious": True, "hasNext": False},
|
||||
}
|
||||
|
||||
|
||||
def test_remote_search_load_more_is_awaitable_for_preview_boundary_navigation():
|
||||
script = f"""
|
||||
const commands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const states = [];
|
||||
const controller = commands.createGlobalSearchController({{
|
||||
delay:0,
|
||||
search: async (_query, _signal, page) => page === 1
|
||||
? {{items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2}}
|
||||
: {{items:[{{kind:'issue',repository:'a/b',number:2}}], has_more:false, next_page:3}},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
const loaded = await controller.loadMore();
|
||||
process.stdout.write(JSON.stringify({{numbers:loaded.items.map(item => item.number), more:loaded.more}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"numbers": [1, 2], "more": False}
|
||||
|
||||
|
||||
def test_mobile_search_preview_progressively_renders_a_retryable_paged_conversation():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
preview_source = SEARCH_PREVIEW.read_text()
|
||||
|
||||
assert 'id="search-preview-conversation"' in html
|
||||
assert 'id="search-preview-comments"' in html
|
||||
assert 'id="search-preview-conversation-status"' in html
|
||||
assert 'id="retry-search-preview-conversation"' in html
|
||||
assert 'id="load-older-search-preview-comments"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "fetchConversation:" in html
|
||||
assert "api.retryConversation()" in preview_source
|
||||
assert "api.loadOlderConversation()" in preview_source
|
||||
assert "renderMarkdown(comment.body || '')" in preview_source
|
||||
assert ".search-preview-conversation" in css
|
||||
assert ".search-preview-comment" in css
|
||||
assert "min-height:44px" in css
|
||||
|
||||
|
||||
def test_mobile_search_preview_renders_session_navigation_and_advances_after_planning():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="previous-search-result"' in html
|
||||
assert 'id="search-preview-position"' in html
|
||||
assert 'id="next-search-result"' in html
|
||||
assert 'aria-label="Search result navigation"' in html
|
||||
assert ".search-preview-navigation" in css
|
||||
assert ".search-preview-header button, .search-preview-actions button" in css
|
||||
assert "session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})]" in html
|
||||
preview_source = SEARCH_PREVIEW.read_text()
|
||||
assert "api.previous()" in preview_source
|
||||
assert "api.next()" in preview_source
|
||||
|
||||
defer_flow = html.split("if (context === 'search')", 1)[1].split(
|
||||
"if (context === 'detail')", 1
|
||||
)[0]
|
||||
queue_flow = html.split(
|
||||
"qs('#queue-search-result').addEventListener('click'", 1
|
||||
)[1].split("qs('#start-search-result')", 1)[0]
|
||||
assert "next()" in defer_flow
|
||||
assert "next()" in queue_flow
|
||||
assert "closeSearchPreview()" not in defer_flow
|
||||
assert "if (outcome === 'queued') await next()" in queue_flow
|
||||
|
||||
|
||||
def test_search_preview_ignores_stale_result_details():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
@@ -197,6 +648,85 @@ if (ready.length !== 1 || ready[0].detail.number !== 2) {{
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_publishes_detail_before_progressive_conversation_and_ignores_stale_comments():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const pending = new Map();
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
|
||||
fetchConversation: item => new Promise(resolve => pending.set(item.number, resolve)),
|
||||
mutate: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api', number:1, kind:'issue'}});
|
||||
const firstReady = states.find(state => state.status === 'ready' && state.detail?.number === 1);
|
||||
if (!firstReady || firstReady.conversation?.status !== 'loading') throw new Error('detail waited for conversation');
|
||||
await preview.open({{repository:'stackchain/api', number:2, kind:'issue'}});
|
||||
pending.get(1)({{comments:[{{id:1,body:'stale'}}], page:1, older_page:null}});
|
||||
pending.get(2)({{comments:[{{id:2,body:'current'}}], page:1, older_page:null}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const final = states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
number:final.detail.number,
|
||||
status:final.conversation.status,
|
||||
comments:final.conversation.comments.map(comment => comment.body),
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"number": 2,
|
||||
"status": "ready",
|
||||
"comments": ["current"],
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_retries_failed_conversation_and_prepends_older_messages_without_duplicates():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let calls = 0;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve({{...item, title:'Issue'}}),
|
||||
fetchConversation: (_item, page) => {{
|
||||
calls += 1;
|
||||
if (calls === 1) return Promise.reject(new Error('offline'));
|
||||
if (page === 1) return Promise.resolve({{comments:[{{id:1,body:'old'}},{{id:3,body:'duplicate'}}], older_page:null}});
|
||||
return Promise.resolve({{comments:[{{id:3,body:'newer'}},{{id:4,body:'newest'}}], older_page:1}});
|
||||
}},
|
||||
mutate: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api', number:42, kind:'issue'}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (states.at(-1).conversation.status !== 'error') throw new Error('failure unavailable');
|
||||
await preview.retryConversation();
|
||||
await preview.loadOlderConversation();
|
||||
const final = states.at(-1).conversation;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
status:final.status,
|
||||
ids:final.comments.map(comment => comment.id),
|
||||
olderPage:final.olderPage,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": 3,
|
||||
"status": "ready",
|
||||
"ids": [1, 3, 4],
|
||||
"olderPage": None,
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_claim_is_single_flight_and_reports_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
@@ -249,6 +779,71 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_shares_canonical_url_without_closing_the_preview():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const calls = [];
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item),
|
||||
mutate: () => Promise.resolve(),
|
||||
share: async url => {{ calls.push(url); return 'shared'; }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
const detail = {{ repository:'stackchain/api', number:42, kind:'issue' }};
|
||||
await preview.open(detail);
|
||||
const result = await preview.share('https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42');
|
||||
process.stdout.write(JSON.stringify({{ result, calls, states }}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["result"] == "shared"
|
||||
assert payload["calls"] == [
|
||||
"https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42"
|
||||
]
|
||||
assert payload["states"][-1]["status"] == "shared"
|
||||
assert payload["states"][-1]["detail"]["number"] == 42
|
||||
|
||||
|
||||
def test_search_preview_share_is_single_flight_and_cancellation_restores_ready_context():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let calls = 0;
|
||||
let rejectShare;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(),
|
||||
share: () => {{ calls += 1; return new Promise((_resolve, reject) => rejectShare = reject); }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
|
||||
const first = preview.share('https://forge.example/dashboard/?search=x');
|
||||
const second = preview.share('https://forge.example/dashboard/?search=x');
|
||||
const canceled = new Error('canceled'); canceled.name = 'AbortError'; rejectShare(canceled);
|
||||
await Promise.allSettled([first, second]);
|
||||
const state = states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{ calls, same:first === second, status:state.status,
|
||||
item:state.item, detail:state.detail, errorName:state.error?.name }}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": 1,
|
||||
"same": True,
|
||||
"status": "share-canceled",
|
||||
"item": {"repository": "stackchain/api", "number": 42, "kind": "issue"},
|
||||
"detail": {"repository": "stackchain/api", "number": 42, "kind": "issue"},
|
||||
"errorName": "AbortError",
|
||||
}
|
||||
|
||||
|
||||
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
@@ -261,12 +856,60 @@ def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
assert "qs('#cmd-input').value = ''" not in remote_branch
|
||||
|
||||
|
||||
def test_search_preview_share_action_uses_canonical_stackchain_url_and_keeps_context():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "share: url => createWorkRoute.share(url, navigator, navigator.clipboard)" in html
|
||||
handler = html.split("qs('#share-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#claim-search-result')", 1
|
||||
)[0]
|
||||
assert "canonicalSearchPreviewUrl()" in handler
|
||||
assert "searchPreview.share" in handler
|
||||
assert "closeSearchPreview" not in handler
|
||||
assert "taskOverlayHistory.close" not in handler
|
||||
|
||||
|
||||
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
|
||||
assert "claimButton.hidden = !(detail.claimable || detail.assigned_to_me)" in html
|
||||
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
|
||||
assert "openPreviewIssueInMyWork" in html
|
||||
assert "openPreviewWorkInMyWork" in html
|
||||
|
||||
|
||||
def test_assigned_pull_preview_hands_off_to_existing_my_work_sheet_without_claiming():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "claimButton.hidden = !(detail.claimable || detail.assigned_to_me)" in html
|
||||
assert "async function openPreviewWorkInMyWork(detail)" in html
|
||||
handoff = html.split("async function openPreviewWorkInMyWork(detail)", 1)[1].split(
|
||||
"function runCommandItem", 1
|
||||
)[0]
|
||||
assert "candidate.kind === detail.kind" in handoff
|
||||
assert "candidate.key === detail.repository + '#' + detail.number" in handoff
|
||||
|
||||
click_handler = html.split(
|
||||
"qs('#claim-search-result').addEventListener('click'", 1
|
||||
)[1].split("qs('#start-search-result')", 1)[0]
|
||||
assert "if (claimed.claimable) await searchPreview.claim(claimed)" in click_handler
|
||||
assert "await openPreviewWorkInMyWork(claimed)" in click_handler
|
||||
|
||||
|
||||
def test_requested_pull_preview_opens_native_review_without_assignment_mutation():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert "startButton.hidden = !(detail.reviewable" in html
|
||||
assert "detail.reviewable ? 'Review now'" in html
|
||||
handler = html.split(
|
||||
"qs('#start-search-result').addEventListener('click'", 1
|
||||
)[1].split("qs('#close-whiteboard')", 1)[0]
|
||||
assert "detail.kind = 'review'" in handler
|
||||
assert "openRoutedWork" in handler
|
||||
assert "{ replace:true }" in handler
|
||||
assert "searchPreview.claim" not in handler
|
||||
assert "fetchReviewJson" not in handler
|
||||
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
|
||||
|
||||
|
||||
def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
@@ -281,10 +924,144 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
|
||||
|
||||
def test_search_preview_queues_eligible_issue_and_continues_preserved_search():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="queue-search-result"' in html
|
||||
assert "queueButton.hidden = !(detail.kind === 'issue'" in html
|
||||
assert "detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today'" in html
|
||||
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
|
||||
"const searchAssignAndStart", 1
|
||||
)[0]
|
||||
assert "queue: confirmed => queueToday(acceptClaimedIssue(confirmed))" in orchestrator
|
||||
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#start-search-result')", 1
|
||||
)[0]
|
||||
assert "destination: 'queue'" in handler
|
||||
assert "alreadyOwned: detail.assigned_to_me" in handler
|
||||
assert "next()" in handler
|
||||
assert "closeSearchPreview()" not in handler
|
||||
assert "mobileSearchViewport.restoreScroll()" in html
|
||||
assert "@media (max-width:420px)" in css
|
||||
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
|
||||
|
||||
|
||||
def test_search_queue_confirmation_is_accessible_and_existing_today_item_is_not_readded():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'id="cmd-search-action-status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
|
||||
"const searchAssignAndStart", 1
|
||||
)[0]
|
||||
assert "qs('#cmd-search-action-status').textContent = message" in orchestrator
|
||||
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#start-search-result')", 1
|
||||
)[0]
|
||||
assert "todayWork.contains(detail)" in handler
|
||||
assert "Already in Today." in handler
|
||||
assert handler.index("todayWork.contains(detail)") < handler.index("searchAssignAndStart.run")
|
||||
|
||||
|
||||
def test_search_defer_claims_once_then_schedules_the_canonical_issue_without_touching_today():
|
||||
script = f"""
|
||||
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
|
||||
(async () => {{
|
||||
const calls = [];
|
||||
let releaseClaim;
|
||||
const flow = createSearchDefer({{
|
||||
claim: detail => {{
|
||||
calls.push(['claim', detail.number]);
|
||||
return new Promise(resolve => releaseClaim = resolve);
|
||||
}},
|
||||
accept: confirmed => {{
|
||||
calls.push(['accept', confirmed.key]);
|
||||
return {{kind:'issue', repository:confirmed.repository, number:confirmed.number, key:confirmed.key}};
|
||||
}},
|
||||
defer: (item, until) => {{ calls.push(['defer', item.key, until.toISOString()]); return 'deferred'; }},
|
||||
refresh: () => calls.push(['refresh']),
|
||||
announce: message => calls.push(['announce', message]),
|
||||
formatTime: value => value.toISOString(),
|
||||
}});
|
||||
const detail = {{kind:'issue', repository:'stackchain/api', number:42, claimable:true}};
|
||||
const until = new Date('2026-08-15T09:00:00.000Z');
|
||||
const first = flow.run(detail, until);
|
||||
const second = flow.run(detail, until);
|
||||
if (first !== second) throw new Error('confirmation was not single-flight');
|
||||
releaseClaim({{kind:'issue', repository:'stackchain/api', number:42, key:'stackchain/api#42'}});
|
||||
const outcome = await first;
|
||||
process.stdout.write(JSON.stringify({{outcome, calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"outcome": "deferred",
|
||||
"calls": [
|
||||
["claim", 42],
|
||||
["accept", "stackchain/api#42"],
|
||||
["defer", "stackchain/api#42", "2026-08-15T09:00:00.000Z"],
|
||||
["refresh"],
|
||||
["announce", "Assigned and deferred until 2026-08-15T09:00:00.000Z."],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_opens_later_picker_before_claiming_an_eligible_issue():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="defer-search-result"' in html
|
||||
assert "const deferButton = qs('#defer-search-result')" in html
|
||||
assert "deferButton.hidden = !(detail.kind === 'issue'" in html
|
||||
assert "detail.assigned_to_me ? 'Defer' : 'Assign & defer'" in html
|
||||
handler = html.split("qs('#defer-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#queue-search-result')", 1
|
||||
)[0]
|
||||
assert "laterPicker.open(detail, event.currentTarget, 'search')" in handler
|
||||
assert "searchPreview.claim" not in handler
|
||||
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
|
||||
assert "@media (max-width:420px)" in css
|
||||
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
|
||||
|
||||
|
||||
def test_search_defer_reports_recoverable_partial_outcome_when_later_storage_fails_after_claim():
|
||||
script = f"""
|
||||
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
|
||||
(async () => {{
|
||||
const messages = [];
|
||||
let refreshes = 0;
|
||||
const flow = createSearchDefer({{
|
||||
claim: () => Promise.resolve({{kind:'issue', repository:'stackchain/api', number:42}}),
|
||||
accept: issue => issue,
|
||||
defer: () => 'unavailable',
|
||||
refresh: () => refreshes += 1,
|
||||
announce: message => messages.push(message),
|
||||
formatTime: value => value.toISOString(),
|
||||
}});
|
||||
const outcome = await flow.run({{kind:'issue', repository:'stackchain/api', number:42, claimable:true}},
|
||||
new Date('2026-08-15T09:00:00.000Z'));
|
||||
process.stdout.write(JSON.stringify({{outcome, refreshes, messages}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"outcome": "unavailable",
|
||||
"refreshes": 1,
|
||||
"messages": [
|
||||
"Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry."
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "startButton.textContent = detail.reopenable ? 'Reopen & resume'" in html
|
||||
assert "detail.reopenable ? 'Reopen & resume'" in html
|
||||
assert "mutate: (detail, action) => fetchReviewJson(" in html
|
||||
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-whiteboard')", 1
|
||||
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v94" in worker
|
||||
assert "stackchain-dashboard-shell-v102" in worker
|
||||
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
HYDRATOR = FRONTEND / "conversation-action-hydrator.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_conversation_paints_before_optional_actions_load_and_hydrates_once():
|
||||
script = f"""
|
||||
const createConversationActionHydrator = require({json.dumps(str(HYDRATOR))});
|
||||
const events = [];
|
||||
let finishLoad;
|
||||
const hydrator = createConversationActionHydrator({{
|
||||
load: () => {{
|
||||
events.push('load');
|
||||
return new Promise(resolve => {{ finishLoad = resolve; }});
|
||||
}},
|
||||
activate: () => ({{name:'actions'}}),
|
||||
}});
|
||||
const root = {{}};
|
||||
const retry = {{hidden:false}};
|
||||
const status = {{textContent:'2 messages loaded.'}};
|
||||
const state = {{comments:[1,2]}};
|
||||
const pending = hydrator.show({{
|
||||
root, state, retry, status,
|
||||
paint: (_state, actions) => events.push(actions ? 'paint-actions' : 'paint-core'),
|
||||
wire: () => events.push('wire'),
|
||||
}});
|
||||
events.push('returned');
|
||||
finishLoad();
|
||||
pending.then(() => {{
|
||||
hydrator.show({{
|
||||
root, state, retry, status,
|
||||
paint: (_state, actions) => events.push(actions ? 'paint-actions-again' : 'paint-core-again'),
|
||||
wire: () => events.push('wire-again'),
|
||||
}}).then(() => process.stdout.write(JSON.stringify({{events,retry,status:status.textContent}})));
|
||||
}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"events": [
|
||||
"paint-core", "load", "returned", "wire", "paint-actions",
|
||||
"paint-actions-again",
|
||||
],
|
||||
"retry": {"hidden": True},
|
||||
"status": "2 messages loaded.",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_comment_actions_keep_conversation_usable_and_retry_in_place():
|
||||
script = f"""
|
||||
const createConversationActionHydrator = require({json.dumps(str(HYDRATOR))});
|
||||
const events = [];
|
||||
let attempt = 0;
|
||||
const hydrator = createConversationActionHydrator({{
|
||||
load: async () => {{
|
||||
attempt += 1;
|
||||
if (attempt === 1) throw new Error('chunk unavailable');
|
||||
}},
|
||||
activate: () => ({{name:'actions'}}),
|
||||
}});
|
||||
const root = {{}};
|
||||
const retry = {{hidden:true}};
|
||||
const status = {{textContent:'2 messages loaded.'}};
|
||||
const options = {{
|
||||
root, retry, status, state:{{comments:[1,2]}},
|
||||
paint: (_state, actions) => events.push(actions ? 'actions' : 'core'),
|
||||
wire: () => events.push('wire'),
|
||||
}};
|
||||
(async () => {{
|
||||
const failed = await hydrator.show(options);
|
||||
events.push('reply-still-usable');
|
||||
const recovered = await hydrator.show(options);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
failed, recovered, attempt, events, retry, status:status.textContent,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"failed": False,
|
||||
"recovered": True,
|
||||
"attempt": 2,
|
||||
"events": ["core", "reply-still-usable", "core", "wire", "actions"],
|
||||
"retry": {"hidden": True},
|
||||
"status": "2 messages loaded.",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_pull_and_update_conversations_trigger_optional_actions_not_startup():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
javascript = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert '<script src="static/conversation-action-hydrator.js"></script>' in html
|
||||
assert html.index("static/conversation-action-hydrator.js") < html.index("static/dashboard.js")
|
||||
assert "await commentActionFeatures.run('comment-actions'" not in javascript
|
||||
assert "load: () => commentActionFeatures.load('comment-actions')" in javascript
|
||||
assert "createConversationActionHydrator" in javascript
|
||||
for kind in ("issue", "pull", "update"):
|
||||
assert f"retry-{kind}-comment-actions" in html
|
||||
assert f"showConversationWithActions('{kind}'" in javascript
|
||||
assert f"qs('#retry-{kind}-comment-actions').addEventListener" in javascript
|
||||
|
||||
|
||||
def test_comment_action_retry_is_a_phone_sized_inline_control():
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert ".conversation-actions-retry" in css
|
||||
rule = css.split(".conversation-actions-retry", 1)[1].split("}", 1)[0]
|
||||
assert "min-height:44px" in rule
|
||||
assert "max-width:100%" in rule
|
||||
@@ -16,7 +16,7 @@ def run_node(script):
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_confirmed_issue_is_planned_synced_warmed_and_started_once():
|
||||
def test_confirmed_issue_persists_and_syncs_estimate_before_starting_once():
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
|
||||
@@ -26,13 +26,22 @@ if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
|
||||
const calls = [];
|
||||
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:387}};
|
||||
const flow = createCreateAndStart({{
|
||||
todayWork: {{limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':', add:()=>{{calls.push('add');return 'added';}}}},
|
||||
todaySync: {{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}}, flush:()=>calls.push('flush')}},
|
||||
todayWork: {{
|
||||
limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':',
|
||||
add:()=>{{calls.push('add');return 'added';}},
|
||||
planning:()=>({{capacity_minutes:120,estimates:{{}}}}),
|
||||
replacePlanning:plan=>{{calls.push('plan:'+JSON.stringify(plan));return true;}},
|
||||
}},
|
||||
todaySync: {{
|
||||
enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}},
|
||||
enqueueConfiguration:(capacity,estimates)=>{{calls.push('configure:'+capacity+':'+JSON.stringify(estimates));return true;}},
|
||||
flush:()=>calls.push('flush')
|
||||
}},
|
||||
refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:item=>calls.push('start:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
const result = flow.complete(issue);
|
||||
flow.complete(issue);
|
||||
const result = flow.complete(issue, 45);
|
||||
flow.complete(issue, 45);
|
||||
process.stdout.write(JSON.stringify({{available:true,result,calls}}));
|
||||
}}
|
||||
"""
|
||||
@@ -42,7 +51,9 @@ if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"add",
|
||||
'plan:{"capacity_minutes":120,"estimates":{"issue:stackchain/dashboard:387:":45}}',
|
||||
"add:issue:stackchain/dashboard:387:",
|
||||
'configure:120:{"issue:stackchain/dashboard:387:":45}',
|
||||
"refresh",
|
||||
"flush",
|
||||
"warm",
|
||||
@@ -61,8 +72,14 @@ def test_mobile_capture_wires_distinct_create_and_start_intent_into_the_offline_
|
||||
assert '<button id="create-and-start-issue" type="submit">Create & start</button>' in html
|
||||
assert '<script src="static/create-and-start.js"></script>' in html
|
||||
assert "const createAndStart = createCreateAndStart({" in dashboard
|
||||
assert "event.submitter?.id === 'create-and-start-issue'" in dashboard
|
||||
assert "createAndStart.complete(created)" in dashboard
|
||||
assert "event.submitter.id === 'create-and-start-issue'" in dashboard
|
||||
assert "review.intent === 'create-and-start'" in dashboard
|
||||
assert 'id="create-issue-estimate"' in html
|
||||
assert 'min="5" max="1440" step="5"' in html
|
||||
assert "createAndStart.capacity(qs('#create-issue-estimate').value)" in dashboard
|
||||
assert "estimateMinutes: startCapacity.minutes" in dashboard
|
||||
assert "createAndStart.complete(created, completion.estimateMinutes)" in dashboard
|
||||
assert ".create-issue-estimate" in css
|
||||
assert ".create-issue-actions" in css and "grid-template-columns" in css
|
||||
assert "BASE + 'static/create-and-start.js'" in worker
|
||||
|
||||
@@ -125,3 +142,61 @@ process.stdout.write(JSON.stringify({{first,second,calls}}));
|
||||
"refresh", "flush", "warm", "start",
|
||||
"Created, added to Today, and ready to work.",
|
||||
]
|
||||
|
||||
|
||||
def test_create_and_start_reports_validation_and_remaining_today_capacity():
|
||||
script = f"""
|
||||
const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});
|
||||
const flow=createCreateAndStart({{
|
||||
todayWork:{{limit:5,read:()=>['first','second'],planning:()=>({{
|
||||
capacity_minutes:120,estimates:{{first:30,second:40}}
|
||||
}})}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
empty:flow.capacity(''),tooSmall:flow.capacity('4'),fits:flow.capacity('45'),over:flow.capacity('55')
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"empty": {"valid": False, "reason": "required"},
|
||||
"tooSmall": {"valid": False, "reason": "range"},
|
||||
"fits": {"valid": True, "minutes": 45, "capacityMinutes": 120, "plannedMinutes": 70,
|
||||
"remainingMinutes": 50, "projectedMinutes": 5, "fits": True},
|
||||
"over": {"valid": True, "minutes": 55, "capacityMinutes": 120, "plannedMinutes": 70,
|
||||
"remainingMinutes": 50, "projectedMinutes": -5, "fits": False},
|
||||
}
|
||||
|
||||
|
||||
def test_create_and_start_does_not_start_when_estimate_cannot_be_saved():
|
||||
script = f"""
|
||||
const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});const calls=[];
|
||||
const flow=createCreateAndStart({{
|
||||
todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:o/r:849:',add:()=> 'added',
|
||||
planning:()=>({{capacity_minutes:60,estimates:{{}}}}),replacePlanning:()=>false}},
|
||||
todaySync:{{enqueue:()=>true,enqueueConfiguration:()=>true,flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'),
|
||||
announce:message=>calls.push(message),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{result:flow.complete({{repository:'o/r',number:849}},30),calls}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": "estimate-unavailable",
|
||||
"calls": ["Created and added to Today, but its estimate could not be saved on this device."],
|
||||
}
|
||||
|
||||
|
||||
def test_create_and_start_does_not_start_when_estimate_sync_cannot_be_queued():
|
||||
script = f"""
|
||||
const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});const calls=[];
|
||||
const flow=createCreateAndStart({{
|
||||
todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:o/r:849:',add:()=> 'added',
|
||||
planning:()=>({{capacity_minutes:60,estimates:{{}}}}),replacePlanning:()=>true}},
|
||||
todaySync:{{enqueue:()=>true,enqueueConfiguration:()=>false,flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'),
|
||||
announce:message=>calls.push(message),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{result:flow.complete({{repository:'o/r',number:849}},30),calls}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": "sync-unavailable",
|
||||
"calls": ["Created and estimated on this device, but Today plan sync is unavailable."],
|
||||
}
|
||||
@@ -170,6 +170,102 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
|
||||
assert "correct horse battery staple" not in signed_in.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class StaleAuthentication:
|
||||
new_sign_count = 4
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys,
|
||||
"verify_authentication",
|
||||
lambda **_kwargs: StaleAuthentication(),
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
options = await client.post("/api/v1/passkeys/authentication/options")
|
||||
denied = await client.post(
|
||||
"/api/v1/passkeys/authentication/verify",
|
||||
json={
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
"device_label": "Phone",
|
||||
"action": "sign_in",
|
||||
"target": "dashboard",
|
||||
},
|
||||
)
|
||||
|
||||
events = main._security_event_store().list(limit=10).events
|
||||
assert denied.status_code == 401
|
||||
assert "stackchain_session=" not in denied.headers.get("set-cookie", "")
|
||||
assert [event.kind for event in events] == ["passkey_counter_anomaly"]
|
||||
assert events[0].device_label == "Phone"
|
||||
assert events[0].target == "sign_in:dashboard"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class StaleAuthentication:
|
||||
new_sign_count = 4
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys,
|
||||
"verify_authentication",
|
||||
lambda **_kwargs: StaleAuthentication(),
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
options = await client.post(
|
||||
"/api/v1/passkeys/authorization/options",
|
||||
json={"action": "merge_pull", "target": "stackchain/dashboard#42"},
|
||||
headers=headers,
|
||||
)
|
||||
denied = await client.post(
|
||||
"/api/v1/passkeys/authorization/verify",
|
||||
json={
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
"action": "merge_pull",
|
||||
"target": "stackchain/dashboard#42",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
events = main._security_event_store().list(limit=10).events
|
||||
assert options.status_code == 200
|
||||
assert denied.status_code == 401
|
||||
assert "grant" not in denied.json()
|
||||
assert events[0].kind == "passkey_counter_anomaly"
|
||||
assert events[0].target == "merge_pull:stackchain/dashboard#42"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_options_are_source_limited_before_challenge_generation(
|
||||
access_control, monkeypatch
|
||||
@@ -780,6 +876,107 @@ async def test_anonymous_share_target_redirect_preserves_only_bounded_capture_fi
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_app_shortcut_preserves_only_a_known_launch_action(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
valid = await client.get("/", params={"launch": "agenda", "next": "https://evil.example"})
|
||||
invalid = await client.get("/", params={"launch": "delete-account"})
|
||||
|
||||
assert valid.status_code == 303
|
||||
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
|
||||
"continue": ["./?launch=agenda"]
|
||||
}
|
||||
assert invalid.status_code == 303
|
||||
assert invalid.headers["location"] == "login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_search_preview_preserves_only_bounded_canonical_continuation(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
valid = await client.get(
|
||||
"/", params={"search": "release blocker", "preview": "issue:stackchain/api:42"}
|
||||
)
|
||||
malformed = await client.get(
|
||||
"/", params={"search": "release blocker", "preview": "issue:stackchain/api:0"}
|
||||
)
|
||||
duplicate = await client.get(
|
||||
"/?search=release&search=other&preview=issue%3Astackchain%2Fapi%3A42"
|
||||
)
|
||||
oversized = await client.get(
|
||||
"/", params={"search": "release", "preview": f"issue:{'x' * 201}/api:42"}
|
||||
)
|
||||
|
||||
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
|
||||
"continue": [
|
||||
"./?search=release+blocker&preview=issue%3Astackchain%2Fapi%3A42"
|
||||
]
|
||||
}
|
||||
assert malformed.headers["location"] == "login"
|
||||
assert duplicate.headers["location"] == "login"
|
||||
assert oversized.headers["location"] == "login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_search_preview_preserves_only_valid_search_scope(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
valid = await client.get("/", params={
|
||||
"search": "release blocker", "preview": "pull:stackchain/api:42",
|
||||
"search_kind": "pull", "search_state": "open",
|
||||
"search_repository": "stackchain/api",
|
||||
})
|
||||
invalid = await client.get("/", params={
|
||||
"search": "release blocker", "preview": "pull:stackchain/api:42",
|
||||
"search_kind": "script", "search_state": "secret",
|
||||
})
|
||||
|
||||
valid_continue = parse_qs(urlsplit(valid.headers["location"]).query)["continue"][0]
|
||||
invalid_continue = parse_qs(urlsplit(invalid.headers["location"]).query)["continue"][0]
|
||||
assert valid_continue.endswith(
|
||||
"search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42&"
|
||||
"search_kind=pull&search_state=open&search_repository=stackchain%2Fapi"
|
||||
)
|
||||
assert invalid_continue == (
|
||||
"./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
valid = await client.get(
|
||||
"/",
|
||||
params={
|
||||
"launch": "new",
|
||||
"shared": "image",
|
||||
"title": "Broken checkout",
|
||||
"text": "Steps to reproduce",
|
||||
},
|
||||
)
|
||||
wrong_launch = await client.get(
|
||||
"/", params={"launch": "agenda", "shared": "image"}
|
||||
)
|
||||
wrong_marker = await client.get(
|
||||
"/", params={"launch": "new", "shared": "document"}
|
||||
)
|
||||
duplicate_marker = await client.get(
|
||||
"/?launch=new&shared=image&shared=image"
|
||||
)
|
||||
|
||||
assert valid.status_code == 303
|
||||
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
|
||||
"continue": [
|
||||
"./?title=Broken+checkout&text=Steps+to+reproduce&launch=new&shared=image"
|
||||
]
|
||||
}
|
||||
assert wrong_launch.headers["location"] == "login"
|
||||
assert wrong_marker.headers["location"] == "login"
|
||||
assert duplicate_marker.headers["location"] == "login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_oversized_share_target_is_not_carried_through_login(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -1950,6 +2147,7 @@ async def test_public_routes_remain_available_and_readiness_hides_identity(acces
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
await main._check_readiness()
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
health = await client.get("/healthz")
|
||||
|
||||
@@ -777,6 +777,12 @@ def test_security_activity_explains_pending_outcome_confirmation():
|
||||
assert "event.status === 'pending'" in source
|
||||
|
||||
|
||||
def test_security_activity_labels_permanent_comment_deletion():
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "comment_deleted: 'Comment deleted'" in source
|
||||
|
||||
|
||||
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
from src.push_notifications import PushConfiguration, dispatch_deadline_reminders
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplicates(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="America/New_York", reminder_hour=9
|
||||
)
|
||||
sent = []
|
||||
|
||||
async def assigned():
|
||||
return {
|
||||
"complete": True,
|
||||
"items": [
|
||||
{
|
||||
"id": 42,
|
||||
"title": "Private launch plan",
|
||||
"repository": {"full_name": "private/repo"},
|
||||
"due_date": "2026-08-14T12:00:00Z",
|
||||
},
|
||||
{"id": 43, "due_date": "2026-08-20T12:00:00Z"},
|
||||
],
|
||||
}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload))
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
now = datetime(2026, 8, 13, 13, 5, tzinfo=timezone.utc)
|
||||
|
||||
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 1
|
||||
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 0
|
||||
assert sent == [{
|
||||
"title": "1 deadline needs attention",
|
||||
"body": "Open Agenda to review or replan it.",
|
||||
"route": "#/my-work/agenda",
|
||||
"protect_route": "#/my-work/agenda/protect-today",
|
||||
"tag": "stackchain-deadline-digest-2026-08-13",
|
||||
"deadline_count": 1,
|
||||
}]
|
||||
assert "Private launch plan" not in json.dumps(sent)
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for device, timezone_name in (("tokyo", "Asia/Tokyo"), ("la", "America/Los_Angeles")):
|
||||
store.upsert(device, {
|
||||
"endpoint": f"https://push.example/{device}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(device, enabled=True, timezone=timezone_name, reminder_hour=0)
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [
|
||||
{"id": 1, "due_date": "2026-08-15T23:59:59Z"},
|
||||
{"id": 2, "due_date": "2026-08-16T23:59:59Z"},
|
||||
]}
|
||||
|
||||
sent = {}
|
||||
async def send(subscription, payload):
|
||||
device = subscription["endpoint"].rsplit("/", 1)[-1]
|
||||
sent[device] = json.loads(payload)["deadline_count"]
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store, PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned, send, now=datetime(2026, 8, 13, 23, 30, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert delivered == 2
|
||||
assert sent == {"tokyo": 2, "la": 1}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_uses_each_devices_confirmed_horizon(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for device, reminder_days in (("today", 0), ("week", 7)):
|
||||
store.upsert(device, {
|
||||
"endpoint": f"https://push.example/{device}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
device,
|
||||
enabled=True,
|
||||
timezone="UTC",
|
||||
reminder_hour=9,
|
||||
reminder_days=reminder_days,
|
||||
)
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [
|
||||
{"id": 1, "due_date": "2026-08-13"},
|
||||
{"id": 2, "due_date": "2026-08-15"},
|
||||
{"id": 3, "due_date": "2026-08-20"},
|
||||
{"id": 4, "due_date": "2026-08-21"},
|
||||
]}
|
||||
|
||||
sent = {}
|
||||
|
||||
async def send(subscription, payload):
|
||||
device = subscription["endpoint"].rsplit("/", 1)[-1]
|
||||
sent[device] = json.loads(payload)["deadline_count"]
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
send,
|
||||
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert delivered == 2
|
||||
assert sent == {"today": 1, "week": 3}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9
|
||||
)
|
||||
sent = []
|
||||
|
||||
async def incomplete():
|
||||
return {"complete": False, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
|
||||
|
||||
async def complete():
|
||||
return {"complete": True, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(payload)
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_deadline_reminders(
|
||||
store, config, incomplete, send,
|
||||
now=datetime(2026, 8, 13, 18, 0, tzinfo=timezone.utc),
|
||||
) == 0
|
||||
assert await dispatch_deadline_reminders(
|
||||
store, config, complete, send,
|
||||
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
|
||||
) == 0
|
||||
assert sent == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_skips_snapshot_until_a_device_reaches_its_local_hour(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9
|
||||
)
|
||||
snapshot_calls = 0
|
||||
|
||||
async def assigned():
|
||||
nonlocal snapshot_calls
|
||||
snapshot_calls += 1
|
||||
return {"complete": True, "items": []}
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert delivered == 0
|
||||
assert snapshot_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_skips_snapshot_after_every_device_was_delivered_today(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id, timezone_name in (
|
||||
("device-a", "UTC"),
|
||||
("device-b", "America/New_York"),
|
||||
):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
session_id, enabled=True, timezone=timezone_name, reminder_hour=9
|
||||
)
|
||||
store.mark_deadline_reminder_delivered(session_id, "2026-08-13")
|
||||
snapshot_calls = 0
|
||||
|
||||
async def assigned():
|
||||
nonlocal snapshot_calls
|
||||
snapshot_calls += 1
|
||||
return {"complete": True, "items": []}
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert delivered == 0
|
||||
assert snapshot_calls == 0
|
||||
|
||||
|
||||
def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="Europe/London", reminder_hour=8
|
||||
)
|
||||
|
||||
assert store.deadline_preferences("device-a") == {
|
||||
"enabled": True,
|
||||
"timezone": "Europe/London",
|
||||
"reminder_hour": 8,
|
||||
"reminder_days": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
|
||||
database = tmp_path / "push.sqlite3"
|
||||
import sqlite3
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.executescript("""
|
||||
CREATE TABLE push_subscriptions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
subscription_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE push_deadline_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
delivered_local_day TEXT
|
||||
);
|
||||
INSERT INTO push_subscriptions VALUES ('device-a', 'https://push.example/a', '{}');
|
||||
INSERT INTO push_deadline_preferences VALUES ('device-a', 1, 'UTC', 8, NULL);
|
||||
""")
|
||||
|
||||
store = PushSubscriptionStore(database)
|
||||
|
||||
assert store.deadline_preferences("device-a")["reminder_days"] == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch):
|
||||
pages = {
|
||||
1: {"items": [{"id": 1}], "total": 2, "has_more": True},
|
||||
2: {"items": [{"id": 2}], "total": 2, "has_more": False},
|
||||
}
|
||||
|
||||
async def work_page(stream, page, limit):
|
||||
assert stream == "issue"
|
||||
assert limit == 1
|
||||
return pages[page]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
assert await gitea_proxy.assigned_issue_snapshot(limit=1) == {
|
||||
"items": [{"id": 1}, {"id": 2}],
|
||||
"complete": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_deadline_snapshot_fetches_remaining_pages_concurrently(monkeypatch):
|
||||
active = 0
|
||||
peak = 0
|
||||
remaining_started = asyncio.Event()
|
||||
|
||||
async def work_page(stream, page, limit):
|
||||
nonlocal active, peak
|
||||
assert stream == "issue"
|
||||
assert limit == 1
|
||||
if page == 1:
|
||||
return {"items": [{"id": 1}], "total": 5, "has_more": True}
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
if active == 2:
|
||||
remaining_started.set()
|
||||
await asyncio.wait_for(remaining_started.wait(), timeout=0.2)
|
||||
await asyncio.sleep(0)
|
||||
active -= 1
|
||||
return {"items": [{"id": page}], "total": 5, "has_more": page < 5}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
assert await gitea_proxy.assigned_issue_snapshot(limit=1, max_concurrency=2) == {
|
||||
"items": [{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5}],
|
||||
"complete": True,
|
||||
}
|
||||
assert peak == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"remaining",
|
||||
[
|
||||
{"items": [{"id": 2}], "total": 3, "has_more": True},
|
||||
{"items": [{"id": 1}], "total": 2, "has_more": False},
|
||||
],
|
||||
)
|
||||
async def test_assigned_deadline_snapshot_rejects_changed_or_duplicate_pages(
|
||||
monkeypatch, remaining
|
||||
):
|
||||
async def work_page(_stream, page, _limit):
|
||||
if page == 1:
|
||||
return {"items": [{"id": 1}], "total": 2, "has_more": True}
|
||||
return remaining
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
with pytest.raises(ValueError, match="incomplete|changed"):
|
||||
await gitea_proxy.assigned_issue_snapshot(limit=1)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_deadline_snapshot_enforces_aggregate_deadline(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def work_page(_stream, _page, _limit):
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
await gitea_proxy.assigned_issue_snapshot(deadline_seconds=0.01)
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_competing_workers_send_one_deadline_digest(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
first = PushSubscriptionStore(path)
|
||||
second = PushSubscriptionStore(path)
|
||||
first.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
first.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
sending = __import__("asyncio").Event()
|
||||
release = __import__("asyncio").Event()
|
||||
sent = []
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(payload)
|
||||
sending.set()
|
||||
await release.wait()
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
now = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc)
|
||||
active = __import__("asyncio").create_task(
|
||||
dispatch_deadline_reminders(first, config, assigned, send, now=now)
|
||||
)
|
||||
await sending.wait()
|
||||
competing = await dispatch_deadline_reminders(second, config, assigned, send, now=now)
|
||||
release.set()
|
||||
|
||||
assert competing == 0
|
||||
assert await active == 1
|
||||
assert len(sent) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminders_bound_fanout_and_isolate_failed_devices(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for index in range(4):
|
||||
session_id = f"device-{index}"
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
active = 0
|
||||
peak = 0
|
||||
two_active = asyncio.Event()
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
nonlocal active, peak
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
if active == 2:
|
||||
two_active.set()
|
||||
await asyncio.wait_for(two_active.wait(), timeout=0.5)
|
||||
active -= 1
|
||||
if subscription["endpoint"].endswith("device-1"):
|
||||
raise RuntimeError("provider failed")
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
send,
|
||||
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
|
||||
max_concurrency=2,
|
||||
)
|
||||
|
||||
assert delivered == 3
|
||||
assert peak == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminders_bulk_authorize_due_devices_and_prune_inactive(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("active-device", "revoked-device"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
authorization_calls = []
|
||||
sent = []
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
|
||||
|
||||
async def session_statuses(management_ids):
|
||||
authorization_calls.append(list(management_ids))
|
||||
return {
|
||||
management_id: "active" if management_id == "active-device" else "revoked"
|
||||
for management_id in management_ids
|
||||
}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
sent.append(subscription["endpoint"])
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
send,
|
||||
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
|
||||
session_statuses=session_statuses,
|
||||
)
|
||||
|
||||
assert delivered == 1
|
||||
assert authorization_calls == [["active-device", "revoked-device"]]
|
||||
assert sent == ["https://push.example/active-device"]
|
||||
assert store.is_subscribed("revoked-device") is False
|
||||
+10
-5
@@ -270,13 +270,17 @@ const values = new Map([
|
||||
]);
|
||||
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const inbox = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
|
||||
process.stdout.write(JSON.stringify(inbox.partition()));
|
||||
const partition=inbox.partition();
|
||||
process.stdout.write(JSON.stringify({{...partition,labels:partition.deliveries.map(inbox.deliveryLabel)}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert len(output["drafts"]) == 1
|
||||
assert len(output["deliveries"]) == 5
|
||||
assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2, "authorization": 0}
|
||||
assert output["labels"] == [
|
||||
"Needs attention", "Identity protected", "Needs attention", "Sending", "Queued for sync"
|
||||
]
|
||||
assert [item["outbox_id"] for item in output["retryable"]] == ["waiting"]
|
||||
waiting = next(item for item in output["deliveries"] if item["outbox_id"] == "waiting")
|
||||
assert waiting["last_attempt_at"] == 90
|
||||
@@ -293,7 +297,7 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
assert 'class="draft-resume"' in html
|
||||
assert 'class="draft-discard"' in html
|
||||
assert 'Discard this unfinished draft?' in html
|
||||
assert '.draft-actions button { min-height:44px;' in html
|
||||
assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px;' in html
|
||||
assert 'createDraftInbox({ storage: localStorage' in html
|
||||
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
|
||||
assert "Verified not posted — retry" in html
|
||||
@@ -313,10 +317,11 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
|
||||
assert 'Sending <strong data-delivery-count="sending">' in html
|
||||
assert 'Needs attention <strong data-delivery-count="attention">' in html
|
||||
assert 'Authorize <strong data-delivery-count="authorization">' in html
|
||||
assert "const deliveryCenter = draftInbox.partition(lastDrafts);" in html
|
||||
assert "const deliveryCenter = draftInbox.partition(displayedDrafts);" in html
|
||||
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
|
||||
assert "deliveryCenter.retryable.length" in html
|
||||
assert "item.status === 'sending' ? 'Sending'" in html
|
||||
assert "draftInbox.deliveryLabel(item)" in html
|
||||
assert "if (item.status === 'sending') return 'Sending'" in DRAFTS.read_text()
|
||||
assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html
|
||||
assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html
|
||||
assert '.delivery-center button { min-height:44px;' in html
|
||||
@@ -330,7 +335,7 @@ async def test_mobile_dashboard_requires_an_explicit_authorize_and_close_gesture
|
||||
assert '>Authorize & close</button>' in html
|
||||
assert "list.querySelectorAll('.draft-authorize')" in html
|
||||
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
|
||||
assert "item.status === 'authorization' ? 'Awaiting authorization'" in html
|
||||
assert "if (item.status === 'authorization') return 'Awaiting authorization'" in DRAFTS.read_text()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -66,10 +66,16 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
security_center = first.feature_bundles["security-center"]
|
||||
assert b"function attachSecurityCenter" not in first.runtime_bytes
|
||||
assert b"function attachSecurityCenter" in security_center.runtime_bytes
|
||||
assert b"function createGlobalSearchController" not in first.runtime_bytes
|
||||
assert b"function createGlobalSearchController" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createAssignAndStart" not in first.runtime_bytes
|
||||
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"gitea_time_logged" not in first.runtime_bytes
|
||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
|
||||
assert len(first.runtime_gzip_bytes) <= 95 * 1024
|
||||
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
|
||||
assert len(first.runtime_gzip_bytes) <= 99 * 1024
|
||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||
@@ -187,7 +193,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v94';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v100';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -254,6 +254,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"number": 7, "state": "open", "assignees": [],
|
||||
"body": "Deploy fails after **three** retries.",
|
||||
})
|
||||
if request.url.path.endswith("/issues/7/subscriptions/check"):
|
||||
return httpx.Response(200, json={"subscribed": True, "ignored": False})
|
||||
if request.url.path.endswith("/issues/comments/9"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 9,
|
||||
@@ -282,7 +284,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/subscriptions/check",
|
||||
]
|
||||
assert result == {
|
||||
"id": 42,
|
||||
@@ -300,18 +302,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
},
|
||||
"issue": {"number": 7, "assignees": [], "claimable": True},
|
||||
"acknowledge_supported": True,
|
||||
"conversation": {
|
||||
"comments": [{
|
||||
"id": 9,
|
||||
"author": "alexander",
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
}],
|
||||
"page": 1,
|
||||
"older_page": None,
|
||||
"total": 1,
|
||||
},
|
||||
"mute_supported": True,
|
||||
"conversation_available": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -398,3 +390,37 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
|
||||
"created_at": "2026-08-07T19:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mute_notification_unsubscribes_current_user_from_trusted_thread_target():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path.endswith("/notifications/threads/42"):
|
||||
return httpx.Response(200, json={
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Pull",
|
||||
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/pulls/7",
|
||||
},
|
||||
})
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "DELETE":
|
||||
return httpx.Response(204)
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.mute_notification(42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {"id": 42, "repository": "stackchain/api", "number": 7, "muted": True}
|
||||
assert requests == [
|
||||
("GET", "/api/v1/notifications/threads/42"),
|
||||
("GET", "/api/v1/user"),
|
||||
("DELETE", "/api/v1/repos/stackchain/api/issues/7/subscriptions/timmy"),
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -245,6 +246,7 @@ async def test_application_lifespan_opens_and_closes_gitea_transport(monkeypatch
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
@@ -270,6 +272,7 @@ async def test_application_shutdown_finishes_snapshot_before_closing_transport(m
|
||||
calls.append("snapshot cancelled")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
assert main._live_snapshot_task is not None
|
||||
@@ -306,6 +309,7 @@ async def test_application_shutdown_cancels_available_work_scan_before_transport
|
||||
calls.append("available scan cancelled")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
task = main._available_issue_snapshot_task
|
||||
@@ -341,6 +345,7 @@ async def test_application_shutdown_drains_authored_mutation_before_transport(mo
|
||||
return {"id": 461}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
@@ -382,6 +387,7 @@ async def test_application_shutdown_bounds_and_settles_stalled_authored_mutation
|
||||
|
||||
monkeypatch.setattr(main, "AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS", 0.01)
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
|
||||
@@ -66,6 +66,64 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages(
|
||||
assert result["items"][0]["work_reasons"] == ["review_requested"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_filed_work_page_loads_open_authored_issues_with_a_distinct_reason():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(200, headers={"X-Total-Count": "61"}, json=[{
|
||||
"id": 870, "number": 870, "title": "Delegated filing", "state": "open",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "https://forge.example/stackchain/dashboard/issues/870",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.work_page("filed", page=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&created=true&type=issues&limit=50&page=2"
|
||||
]
|
||||
assert result == {
|
||||
"stream": "filed", "page": 2, "total": 61, "has_more": False,
|
||||
"items": [{
|
||||
"id": 870, "number": 870, "title": "Delegated filing", "state": "open",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "https://forge.example/stackchain/dashboard/issues/870",
|
||||
"work_reasons": ["created_by_me"],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_context_merges_self_assigned_and_filed_streams_without_duplicates(monkeypatch):
|
||||
async def page(stream, page=1, limit=50):
|
||||
del page, limit
|
||||
common = {
|
||||
"id": 7, "number": 7, "title": "My own filing", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"}, "html_url": "https://forge.example/issues/7",
|
||||
}
|
||||
if stream == "issue":
|
||||
return {"items": [{**common, "assignees": [{"login": "timmy"}]}], "page": 1, "total": 1, "has_more": False}
|
||||
return {"items": [{**common, "work_reasons": ["created_by_me"]}, {
|
||||
**common, "id": 8, "number": 8, "title": "Delegated",
|
||||
"work_reasons": ["created_by_me"],
|
||||
}], "page": 1, "total": 2, "has_more": False}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", page)
|
||||
result = await gitea_proxy.issues()
|
||||
|
||||
assert [item["number"] for item in result] == [7, 8]
|
||||
assert result[0]["work_reasons"] == ["created_by_me"]
|
||||
assert result.pagination == {
|
||||
"issue": {"page": 1, "total": 1, "has_more": False},
|
||||
"filed": {"page": 1, "total": 2, "has_more": False},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_priority():
|
||||
requests = []
|
||||
@@ -215,6 +273,120 @@ async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkey
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_search_filters_full_catalog_before_pagination(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{
|
||||
"repository": "stackchain/api",
|
||||
"number": number,
|
||||
"title": "Routine API maintenance",
|
||||
}
|
||||
for number in range(1, 52)
|
||||
] + [{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 673,
|
||||
"title": "Search the full Find Work catalog",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
by_repo = await client.get("/api/v1/available-issues?page=1&q=DASHBOARD")
|
||||
by_number = await client.get("/api/v1/available-issues?page=1&q=%23673")
|
||||
by_title = await client.get("/api/v1/available-issues?page=1&q=full%20find")
|
||||
|
||||
expected = [{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 673,
|
||||
"title": "Search the full Find Work catalog",
|
||||
}]
|
||||
assert by_repo.json() == {"items": expected, "page": 1, "total": 1, "has_more": False}
|
||||
assert by_number.json() == by_repo.json()
|
||||
assert by_title.json() == by_repo.json()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_filter_before_pagination_and_describe_catalog(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]},
|
||||
{"repository": "stackchain/api", "number": 2, "title": "API docs", "labels": ["docs"]},
|
||||
{"repository": "stackchain/dashboard", "number": 3, "title": "Critical UI", "labels": ["critical", "frontend"]},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("repository", "stackchain/api"), ("label", "critical")],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]}],
|
||||
"page": 1,
|
||||
"total": 1,
|
||||
"has_more": False,
|
||||
"facets": {
|
||||
"repositories": ["stackchain/api", "stackchain/dashboard"],
|
||||
"labels": ["backend", "critical", "docs", "frontend"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_or_within_each_facet_and_compose_with_search(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{"repository": "stackchain/api", "number": 1, "title": "Repair worker", "labels": ["backend"]},
|
||||
{"repository": "stackchain/web", "number": 2, "title": "Repair mobile", "labels": ["frontend"]},
|
||||
{"repository": "stackchain/docs", "number": 3, "title": "Repair guide", "labels": ["docs"]},
|
||||
{"repository": "stackchain/api", "number": 4, "title": "Routine task", "labels": ["backend"]},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("q", "repair"), ("repository", "stackchain/api"), ("repository", "stackchain/web"),
|
||||
("label", "backend"), ("label", "frontend")],
|
||||
)
|
||||
|
||||
assert [item["number"] for item in response.json()["items"]] == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_reject_excess_values_before_scanning(monkeypatch):
|
||||
async def must_not_scan():
|
||||
raise AssertionError("invalid facets must be rejected before catalog scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("label", f"label-{index}") for index in range(11)],
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch):
|
||||
async def must_not_scan():
|
||||
raise AssertionError("invalid query must be rejected before catalog scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues", params={"q": "x" * 101})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_reuses_catalog_published_by_another_worker(
|
||||
monkeypatch, tmp_path
|
||||
@@ -240,6 +412,82 @@ async def test_available_issue_endpoint_reuses_catalog_published_by_another_work
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_waits_for_slow_cold_catalog_from_another_worker(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
now = 100.0
|
||||
path = tmp_path / "shared-available.sqlite3"
|
||||
owner_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
|
||||
worker_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
|
||||
owner = owner_store.try_acquire_refresh(lease_seconds=6)
|
||||
monkeypatch.setattr(main, "_available_issue_snapshot_store", worker_store)
|
||||
|
||||
sleep_calls = 0
|
||||
|
||||
async def publish_after_existing_one_second_window(_delay):
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls == 60:
|
||||
owner_store.publish(
|
||||
owner,
|
||||
items=[{"repository": "stackchain/api", "number": 8}],
|
||||
)
|
||||
|
||||
async def must_not_scan():
|
||||
raise AssertionError("a valid cross-worker lease must prevent a duplicate scan")
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", publish_after_existing_one_second_window)
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert sleep_calls > 50
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"] == [
|
||||
{"repository": "stackchain/api", "number": 8}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_takes_over_released_cold_refresh(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
now = 100.0
|
||||
path = tmp_path / "shared-available.sqlite3"
|
||||
owner_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
|
||||
worker_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
|
||||
owner = owner_store.try_acquire_refresh(lease_seconds=6)
|
||||
monkeypatch.setattr(main, "_available_issue_snapshot_store", worker_store)
|
||||
|
||||
sleep_calls = 0
|
||||
scan_calls = 0
|
||||
|
||||
async def release_failed_owner(_delay):
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls == 3:
|
||||
owner_store.release_refresh(owner)
|
||||
|
||||
async def available():
|
||||
nonlocal scan_calls
|
||||
scan_calls += 1
|
||||
return [{"repository": "stackchain/web", "number": 9}]
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", release_failed_owner)
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"] == [
|
||||
{"repository": "stackchain/web", "number": 9}
|
||||
]
|
||||
assert scan_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_coalesces_cold_scan_and_reuses_it_for_pages(monkeypatch):
|
||||
calls = 0
|
||||
@@ -378,7 +626,7 @@ async def test_confirmed_claim_is_removed_from_retained_available_snapshot(monke
|
||||
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
assert page == 1
|
||||
totals = {"issue": 84, "pull": 61, "review": 73}
|
||||
totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73}
|
||||
return {
|
||||
"items": [], "page": 1, "total": totals[stream],
|
||||
"has_more": True, "stream": stream,
|
||||
@@ -390,7 +638,8 @@ async def test_initial_work_collections_expose_independent_pagination(monkeypatc
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
assert assigned_issues.pagination == {
|
||||
"issue": {"page": 1, "total": 84, "has_more": True}
|
||||
"issue": {"page": 1, "total": 84, "has_more": True},
|
||||
"filed": {"page": 1, "total": 62, "has_more": True},
|
||||
}
|
||||
assert pulls.pagination == {
|
||||
"pull": {"page": 1, "total": 61, "has_more": True},
|
||||
@@ -412,6 +661,7 @@ async def test_work_collections_include_supported_review_request_search(monkeypa
|
||||
assert await gitea_proxy.pull_requests() == []
|
||||
assert requested_streams == [
|
||||
("issue", 1, 50),
|
||||
("filed", 1, 50),
|
||||
("pull", 1, 50),
|
||||
("review", 1, 50),
|
||||
]
|
||||
|
||||
+413
-6
@@ -6,12 +6,146 @@ import pytest
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_endpoint_scopes_every_page_to_an_exact_repository(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def search(query, limit, page, kind, state, repository):
|
||||
requested.append((query, limit, page, kind, state, repository))
|
||||
return {"items": [], "partial": False, "has_more": False, "next_page": 4}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "global_search", search, 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/search?q=mobile&limit=7&page=3&kind=pull&state=open&repository=stackchain%2Fapi"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert requested == [("mobile", 7, 3, "pull", "open", "stackchain/api")]
|
||||
assert response.json()["scope"] == {
|
||||
"kind": "pull", "state": "open", "repository": "stackchain/api"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_endpoint_forwards_independent_stream_continuation(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def search(query, limit, page, kind, state, repository, continuation):
|
||||
requested.append((query, limit, page, kind, state, repository, continuation))
|
||||
return {
|
||||
"items": [], "partial": False, "has_more": False, "next_page": 3,
|
||||
"continuation": {"issues": None, "pulls": None}, "failed_streams": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "global_search", search, 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/search?q=mobile&limit=7&page=2&issues_page=1&pulls_page=3"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert requested == [
|
||||
("mobile", 7, 2, "all", "all", None, {"issues": 1, "pulls": 3})
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_rejects_malformed_repository_scope_before_upstream(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def search(*args):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "global_search", search, 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/search?q=mobile&repository=stackchain%2Fapi%2Fsecrets"
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_repository_scope_reaches_issue_and_pull_streams():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(dict(request.url.params))
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
await gitea_proxy.global_search(
|
||||
"mobile", limit=6, page=2, repository="stackchain/api"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 2
|
||||
assert all(request["owner"] == "stackchain" and request["repo"] == "api" for request in requests)
|
||||
assert all(request["page"] == "2" for request in requests)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_endpoint_forwards_valid_type_and_status_scope(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def search(query, limit, page, kind, state):
|
||||
requested.append((query, limit, page, kind, state))
|
||||
return {"items": [], "partial": False, "has_more": False, "next_page": 2}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "global_search", search, 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/search?q=mobile&limit=7&page=1&kind=pull&state=open"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert requested == [("mobile", 7, 1, "pull", "open")]
|
||||
assert response.json()["scope"] == {"kind": "pull", "state": "open"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_specific_type_uses_one_full_width_scoped_stream():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(dict(request.url.params))
|
||||
return httpx.Response(200, json=[{
|
||||
"number": 9,
|
||||
"title": "Review mobile search",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.global_search(
|
||||
"mobile", limit=7, page=2, kind="pull", state="open"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [{
|
||||
"q": "mobile", "type": "pulls", "state": "open", "limit": "7", "page": "2"
|
||||
}]
|
||||
assert [item["kind"] for item in result["items"]] == ["pull"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def search(query, limit):
|
||||
requested.append((query, limit))
|
||||
async def search(query, limit, page, kind, state):
|
||||
requested.append((query, limit, page, kind, state))
|
||||
return {
|
||||
"items": [{
|
||||
"kind": "issue",
|
||||
@@ -27,12 +161,12 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
|
||||
monkeypatch.setattr(main.gitea_proxy, "global_search", search, 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/search?q=mobile&limit=7")
|
||||
response = await client.get("/api/v1/search?q=mobile&limit=7&page=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert requested == [("mobile", 7)]
|
||||
assert response.json() == {"query": "mobile", "items": [{
|
||||
assert requested == [("mobile", 7, 2, "all", "all")]
|
||||
assert response.json() == {"query": "mobile", "scope": {"kind": "all", "state": "all"}, "items": [{
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
@@ -76,6 +210,121 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
|
||||
assert response.json()["claimable"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("kind", ["issue", "pull"])
|
||||
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(
|
||||
monkeypatch, kind
|
||||
):
|
||||
requested = []
|
||||
|
||||
async def conversation(repository, number, page, limit):
|
||||
requested.append((repository, number, page, limit))
|
||||
return {
|
||||
"comments": [{
|
||||
"id": 7,
|
||||
"author": "alexander",
|
||||
"body": "Current **decision**",
|
||||
"created_at": "2026-08-14T12:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42#issuecomment-7",
|
||||
}],
|
||||
"page": 3,
|
||||
"older_page": 2,
|
||||
"total": 41,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "issue_conversation_page", conversation, raising=False
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/api/v1/repos/stackchain/api/issues/42/preview/conversation?kind={kind}&limit=20"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["comments"][0]["body"] == "Current **decision**"
|
||||
assert requested == [("stackchain/api", 42, None, 20)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_reply_is_idempotent_for_an_authorized_pull(monkeypatch):
|
||||
main._idempotency_ledger.clear()
|
||||
previews = []
|
||||
comments = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
previews.append((repository, kind, number))
|
||||
return {
|
||||
"repository": repository,
|
||||
"kind": "pull",
|
||||
"number": number,
|
||||
"title": "Review mobile reply",
|
||||
"state": "open",
|
||||
}
|
||||
|
||||
async def comment(repository, number, body):
|
||||
comments.append((repository, number, body))
|
||||
return {
|
||||
"id": 91,
|
||||
"author": "timmy",
|
||||
"body": body,
|
||||
"created_at": "2026-08-14T12:30:00Z",
|
||||
"url": "https://forge.example/stackchain/api/pulls/42#issuecomment-91",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "search-reply-pull-42"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
|
||||
json={"body": "Ready to merge."},
|
||||
headers=headers,
|
||||
)
|
||||
replay = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
|
||||
json={"body": "Ready to merge."},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert first.status_code == replay.status_code == 201
|
||||
assert replay.json() == first.json()
|
||||
assert previews == [("stackchain/api", "pull", 42)]
|
||||
assert comments == [("stackchain/api", 42, "Ready to merge.")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_reply_rejects_a_mismatched_target_before_commenting(monkeypatch):
|
||||
main._idempotency_ledger.clear()
|
||||
comments = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
return {
|
||||
"repository": repository,
|
||||
"kind": "issue",
|
||||
"number": number,
|
||||
"title": "Different target",
|
||||
}
|
||||
|
||||
async def comment(*args):
|
||||
comments.append(args)
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
|
||||
json={"body": "Do not misroute this."},
|
||||
headers={"Idempotency-Key": "search-reply-mismatch-42"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert comments == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
|
||||
requests = []
|
||||
@@ -110,7 +359,11 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert {request["type"] for request in requests} == {"issues", "pulls"}
|
||||
assert all(request["q"] == "mobile queue" and request["limit"] == "7" for request in requests)
|
||||
assert {request["type"]: request["limit"] for request in requests} == {
|
||||
"issues": "4", "pulls": "3"
|
||||
}
|
||||
assert all(request["q"] == "mobile queue" and request["state"] == "all"
|
||||
for request in requests)
|
||||
assert results == {
|
||||
"items": [{
|
||||
"kind": "issue", "repository": "stackchain/api", "number": 42,
|
||||
@@ -122,6 +375,10 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
|
||||
"url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}],
|
||||
"partial": False,
|
||||
"has_more": False,
|
||||
"next_page": 2,
|
||||
"continuation": {"issues": None, "pulls": None},
|
||||
"failed_streams": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -154,9 +411,93 @@ async def test_global_search_returns_healthy_stream_when_other_stream_fails():
|
||||
"url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}],
|
||||
"partial": True,
|
||||
"has_more": True,
|
||||
"next_page": 2,
|
||||
"continuation": {"issues": 1, "pulls": None},
|
||||
"failed_streams": ["issue"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_retries_a_failed_stream_without_advancing_its_page():
|
||||
requests = []
|
||||
issue_attempts = 0
|
||||
|
||||
async def handler(request):
|
||||
nonlocal issue_attempts
|
||||
params = dict(request.url.params)
|
||||
requests.append((params["type"], params["page"], params["limit"]))
|
||||
if params["type"] == "issues":
|
||||
issue_attempts += 1
|
||||
if issue_attempts == 1:
|
||||
return httpx.Response(503)
|
||||
number = int(params["page"])
|
||||
return httpx.Response(200, json=[{
|
||||
"number": number,
|
||||
"title": f'{params["type"]} {number}',
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}',
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
partial = await gitea_proxy.global_search("search", limit=4)
|
||||
recovered = await gitea_proxy.global_search(
|
||||
"search", limit=4, continuation=partial["continuation"]
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert partial["partial"] is True
|
||||
assert partial["has_more"] is True
|
||||
assert partial["continuation"] == {"issues": 1, "pulls": None}
|
||||
assert recovered["partial"] is False
|
||||
assert [item["kind"] for item in recovered["items"]] == ["issue"]
|
||||
assert requests == [
|
||||
("issues", "1", "2"), ("pulls", "1", "2"), ("issues", "1", "2")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_odd_limit_keeps_both_streams_contiguous_across_pages():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
params = dict(request.url.params)
|
||||
stream_limit = int(params["limit"])
|
||||
page = int(params["page"])
|
||||
requests.append((params["type"], page, stream_limit))
|
||||
start = (page - 1) * stream_limit + 1
|
||||
return httpx.Response(200, json=[{
|
||||
"number": number,
|
||||
"title": f'{params["type"]} {number}',
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": f'https://forge.example/stackchain/web/{params["type"]}/{number}',
|
||||
} for number in range(start, start + stream_limit)])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
first = await gitea_proxy.global_search("search", limit=7)
|
||||
second = await gitea_proxy.global_search(
|
||||
"search", limit=7, continuation=first["continuation"]
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
pulls = [item["number"] for result in (first, second)
|
||||
for item in result["items"] if item["kind"] == "pull"]
|
||||
issues = [item["number"] for result in (first, second)
|
||||
for item in result["items"] if item["kind"] == "issue"]
|
||||
assert pulls == [1, 2, 3, 4, 5, 6]
|
||||
assert issues == [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
assert requests == [
|
||||
("issues", 1, 4), ("pulls", 1, 3),
|
||||
("issues", 2, 4), ("pulls", 2, 3),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_caps_combined_results_to_requested_limit():
|
||||
async def handler(request):
|
||||
@@ -180,6 +521,38 @@ async def test_global_search_caps_combined_results_to_requested_limit():
|
||||
assert result["partial"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_pages_each_stream_and_balances_results():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
params = dict(request.url.params)
|
||||
requests.append(params)
|
||||
kind = params["type"]
|
||||
page = int(params["page"])
|
||||
items = [{
|
||||
"number": page * 10 + number,
|
||||
"title": f"{kind} page {page} result {number}",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": f"https://forge.example/stackchain/web/{kind}/{page * 10 + number}",
|
||||
} for number in (1, 2, 3, 4)]
|
||||
return httpx.Response(200, json=items)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.global_search("search", limit=4, page=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert {(request["type"], request["page"]) for request in requests} == {
|
||||
("issues", "2"), ("pulls", "2")
|
||||
}
|
||||
assert [item["kind"] for item in result["items"]] == ["issue", "pull", "issue", "pull"]
|
||||
assert result["next_page"] == 3
|
||||
assert result["has_more"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_propagates_cancellation_to_stop_obsolete_work():
|
||||
async def handler(request):
|
||||
@@ -231,6 +604,8 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
|
||||
"claimable": True,
|
||||
"reopenable": False,
|
||||
"assigned_to_me": False,
|
||||
"reviewable": False,
|
||||
"commentable": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +633,38 @@ async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
|
||||
assert preview["claimable"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_marks_authoritative_requested_pull_review_actionable():
|
||||
requested_paths = []
|
||||
|
||||
async def handler(request):
|
||||
requested_paths.append(request.url.path)
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/pulls/9"):
|
||||
return httpx.Response(200, json={
|
||||
"state": "open",
|
||||
"requested_reviewers": [{"login": "timmy"}, None],
|
||||
})
|
||||
return httpx.Response(200, json={
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
"pull_request": {"merged": False},
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/web", "issue", 9)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview["reviewable"] is True
|
||||
assert "/api/v1/repos/stackchain/web/pulls/9" in requested_paths
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_offers_reopen_only_for_closed_issues():
|
||||
async def handler(request):
|
||||
|
||||
@@ -11,6 +11,49 @@ def test_health_endpoint_reports_service_liveness_without_gitea_access():
|
||||
assert main.health() == {"status": "ok", "service": "stackchain-dashboard"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_never_contacts_gitea_for_a_request(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def unexpected_user_lookup():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise AssertionError("request-time readiness must not contact Gitea")
|
||||
|
||||
monkeypatch.setattr(main, "_readiness_state", None, raising=False)
|
||||
monkeypatch.setattr(main, "current_user", unexpected_user_lookup)
|
||||
|
||||
first = await main.readiness()
|
||||
second = await main.readiness()
|
||||
|
||||
assert first.status_code == 503
|
||||
assert second.status_code == 503
|
||||
assert calls == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_monitor_refreshes_on_an_interval_and_cancels(monkeypatch):
|
||||
calls = 0
|
||||
refreshed_twice = asyncio.Event()
|
||||
|
||||
async def check():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
refreshed_twice.set()
|
||||
|
||||
monkeypatch.setattr(main, "_check_readiness", check)
|
||||
monkeypatch.setattr(main, "READINESS_INTERVAL_SECONDS", 0.01, raising=False)
|
||||
|
||||
task = asyncio.create_task(main._readiness_monitor())
|
||||
await asyncio.wait_for(refreshed_twice.wait(), timeout=0.2)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert calls == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
|
||||
async def connected_user():
|
||||
@@ -18,6 +61,8 @@ async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(main, "current_user", connected_user)
|
||||
|
||||
await main._check_readiness()
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert any(getattr(route, "path", None) == "/readyz" for route in main.app.routes)
|
||||
@@ -35,6 +80,8 @@ async def test_readiness_endpoint_hides_unexpected_upstream_error_details(monkey
|
||||
|
||||
monkeypatch.setattr(main, "current_user", unavailable_user)
|
||||
|
||||
await main._check_readiness()
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
@@ -53,6 +100,8 @@ async def test_readiness_endpoint_returns_503_for_null_current_user_payload(monk
|
||||
|
||||
monkeypatch.setattr(main, "current_user", null_user)
|
||||
|
||||
await main._check_readiness()
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
@@ -77,6 +126,8 @@ async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monk
|
||||
monkeypatch.setattr(main, "READINESS_TIMEOUT_SECONDS", 0.01, raising=False)
|
||||
monkeypatch.setattr(main, "current_user", hanging_user)
|
||||
|
||||
await main._check_readiness()
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
@@ -87,3 +138,18 @@ async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monk
|
||||
"error": "Gitea readiness check timed out after 0.01s",
|
||||
}
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_expires_a_stale_healthy_snapshot(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_readiness_state",
|
||||
main.ReadinessState(checked_at=10.0, ready=True, login="timmy"),
|
||||
)
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: 76.0)
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert json.loads(response.body)["error"] == "Gitea readiness status is not available"
|
||||
@@ -8,6 +8,121 @@ from src import gitea_proxy, main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_issue_templates_are_bounded_and_normalized():
|
||||
async def handler(request):
|
||||
assert request.url.path == "/api/v1/repos/stackchain/api/issue_templates"
|
||||
return httpx.Response(200, json=[
|
||||
{
|
||||
"name": " Bug report ", "about": " Reproduce a defect ",
|
||||
"title": "[Bug] ", "content": "## Steps\n1. ",
|
||||
"labels": ["bug", " needs-triage ", "bug", 42],
|
||||
},
|
||||
{"name": "", "content": "ignored"},
|
||||
"malformed",
|
||||
] + [{"name": f"Template {index}", "content": "x"} for index in range(30)])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
templates = await gitea_proxy.repo_issue_templates("stackchain/api")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert templates[0] == {
|
||||
"id": "bug-report",
|
||||
"name": "Bug report",
|
||||
"about": "Reproduce a defect",
|
||||
"title": "[Bug] ",
|
||||
"body": "## Steps\n1.",
|
||||
"labels": ["bug", "needs-triage"],
|
||||
}
|
||||
assert len(templates) == 20
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_issue_templates_route_checks_access_and_returns_blank_fallback(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def access(repository):
|
||||
calls.append(("access", repository))
|
||||
return {"full_name": repository}
|
||||
|
||||
async def templates(repository):
|
||||
calls.append(("templates", repository))
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_issue_templates", templates, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/repos/stackchain/api/issue-templates")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
assert calls == [("access", "stackchain/api"), ("templates", "stackchain/api")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_filing_metadata_authorizes_once_and_isolates_concurrent_section_failures(monkeypatch):
|
||||
calls = []
|
||||
started = set()
|
||||
all_started = asyncio.Event()
|
||||
|
||||
async def access(repository):
|
||||
calls.append(("access", repository))
|
||||
return {"full_name": repository}
|
||||
|
||||
async def section(name, result=None, error=None):
|
||||
calls.append((name, "stackchain/api"))
|
||||
started.add(name)
|
||||
if len(started) == 3:
|
||||
all_started.set()
|
||||
await asyncio.wait_for(all_started.wait(), timeout=0.2)
|
||||
if error:
|
||||
raise error
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repo_labels",
|
||||
lambda repository: section("labels", [{"id": 3, "name": "P0"}]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repo_milestones",
|
||||
lambda repository: section("milestones", [{"id": 9, "title": "August RC"}]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repo_issue_templates",
|
||||
lambda repository: section("templates", error=RuntimeError("templates unavailable")),
|
||||
)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issue-filing-metadata"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"labels": {"available": True, "items": [{"id": 3, "name": "P0"}]},
|
||||
"milestones": {
|
||||
"available": True,
|
||||
"items": [{"id": 9, "title": "August RC"}],
|
||||
},
|
||||
"templates": {
|
||||
"available": False,
|
||||
"items": [],
|
||||
"error": "Issue types could not be loaded.",
|
||||
},
|
||||
}
|
||||
assert calls.count(("access", "stackchain/api")) == 1
|
||||
assert set(calls[1:]) == {
|
||||
("labels", "stackchain/api"),
|
||||
("milestones", "stackchain/api"),
|
||||
("templates", "stackchain/api"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_owned_comment_mutations_verify_thread_and_author_before_writing():
|
||||
requests = []
|
||||
@@ -215,6 +330,81 @@ async def test_gitea_milestone_update_revalidates_assignment_and_open_repository
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("due_date", ["2026-08-31T23:59:59Z", None])
|
||||
async def test_release_plan_applies_milestone_and_optional_deadline_in_one_patch(due_date):
|
||||
requests = []
|
||||
existing_due_date = "2026-08-20T23:59:59Z"
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "GET" and request.url.path.endswith("/issues/17"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "pull_request": None,
|
||||
"assignees": [{"login": "timmy"}], "due_date": existing_due_date,
|
||||
})
|
||||
if request.method == "GET" and request.url.path.endswith("/milestones"):
|
||||
return httpx.Response(200, json=[
|
||||
{"id": 9, "title": "August RC", "state": "open"},
|
||||
])
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open",
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"due_date": due_date or existing_due_date,
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.update_assigned_issue_release_plan(
|
||||
"stackchain/api", 17, 9, due_date
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
patch_requests = [request for request in requests if request.method == "PATCH"]
|
||||
assert len(patch_requests) == 1
|
||||
expected_payload = {"milestone": 9}
|
||||
if due_date:
|
||||
expected_payload["due_date"] = due_date
|
||||
assert json.loads(patch_requests[0].content) == expected_payload
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "state": "open",
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"due_date": due_date or existing_due_date,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_release_plan_endpoint_is_bounded_and_returns_confirmed_plan(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def update(repository, number, milestone_id, due_date):
|
||||
calls.append((repository, number, milestone_id, due_date))
|
||||
return {
|
||||
"repository": repository, "number": number, "state": "open",
|
||||
"milestone": {"id": milestone_id, "title": "August RC"},
|
||||
"due_date": due_date,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "update_assigned_issue_release_plan", update, raising=False
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/release-plan",
|
||||
json={"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["milestone"] == {"id": 9, "title": "August RC"}
|
||||
assert response.json()["due_date"] == "2026-08-31T23:59:59Z"
|
||||
assert calls == [("stackchain/api", 17, 9, "2026-08-31T23:59:59Z")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch):
|
||||
calls = []
|
||||
@@ -545,6 +735,106 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_endpoint_validates_and_assigns_selected_initial_owner(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def access(repository):
|
||||
return {"full_name": repository}
|
||||
|
||||
async def candidates(repository):
|
||||
assert repository == "stackchain/api"
|
||||
return [{"login": "alex", "name": "Alex"}]
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
calls.append((repository, title, assignee))
|
||||
return {"number": 18, "repository": repository, "assignees": [assignee]}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues",
|
||||
json={"title": "Delegate at capture", "assignee": "alex"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["assignees"] == ["alex"]
|
||||
assert calls == [("stackchain/api", "Delegate at capture", "alex")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_endpoint_preserves_explicit_no_owner(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def access(repository):
|
||||
return {"full_name": repository}
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
calls.append((repository, title, assignee))
|
||||
return {"number": 19, "repository": repository, "assignees": []}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues",
|
||||
json={"title": "Backlog capture", "unassigned": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["assignees"] == []
|
||||
assert calls == [("stackchain/api", "Backlog capture", None)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_endpoint_rejects_named_and_no_owner_together():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues",
|
||||
json={"title": "Contradictory owner", "unassigned": True, "assignee": "alex"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "assignee" in response.text
|
||||
assert "unassigned" in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_initial_owner_candidates_require_repository_access_and_are_bounded(monkeypatch):
|
||||
async def access(repository):
|
||||
return {"full_name": repository} if repository == "stackchain/api" else None
|
||||
|
||||
async def candidates(repository):
|
||||
return ([{"login": "bad user", "name": "Unsafe"}] +
|
||||
[{"login": f"user-{index}", "name": f"User {index}"} for index in range(40)])
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
listed = await client.get("/api/v1/repos/stackchain/api/issue-assignees")
|
||||
missing = await client.get("/api/v1/repos/stackchain/private/issue-assignees")
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.headers["cache-control"] == "no-store"
|
||||
assert len(listed.json()) == 25
|
||||
assert all(item["login"] != "bad user" for item in listed.json())
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
|
||||
calls = []
|
||||
@@ -1043,6 +1333,49 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
|
||||
assert result["labels"] == ["P0"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_issue_omits_assignment_and_confirms_no_owner():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(201, json={
|
||||
"id": 83, "number": 19, "title": "Backlog capture", "state": "open",
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/19",
|
||||
"assignees": [], "labels": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.create_issue(
|
||||
"stackchain/api", "Backlog capture", "Context", None, []
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert json.loads(requests[0].content) == {
|
||||
"title": "Backlog capture", "body": "Context"
|
||||
}
|
||||
assert result["assignees"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_issue_requires_exact_selected_initial_owner():
|
||||
async def handler(_request):
|
||||
return httpx.Response(201, json={
|
||||
"id": 82, "number": 18, "title": "Delegate", "state": "open",
|
||||
"assignees": [{"login": "alex"}, {"login": "timmy"}], "labels": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="exact issue assignment"):
|
||||
await gitea_proxy.create_issue("stackchain/api", "Delegate", "", "alex", [])
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_issue_requires_confirmed_release_plan():
|
||||
async def handler(_request):
|
||||
@@ -1672,6 +2005,40 @@ async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkey
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_detail_endpoint_returns_authored_filing_in_read_only_mode(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def authored(repository, number):
|
||||
calls.append(("authored", repository, number))
|
||||
return (repository, number) == ("stackchain/api", 8)
|
||||
|
||||
async def detail(repository, number):
|
||||
calls.append(("detail", repository, number))
|
||||
return {"repository": repository, "number": number, "title": "Delegated filing"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_detail", detail)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/8/detail?access=filed"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"repository": "stackchain/api",
|
||||
"number": 8,
|
||||
"title": "Delegated filing",
|
||||
"read_only": True,
|
||||
}
|
||||
assert calls == [
|
||||
("authored", "stackchain/api", 8),
|
||||
("detail", "stackchain/api", 8),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no_store(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -58,6 +58,95 @@ controller.select(file);
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_evidence_bundle_keeps_order_limits_selection_and_uploads_each_image_once():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
let sequence=0; const calls=[];
|
||||
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||
const controller=attachment.create({{
|
||||
maxFiles:5,
|
||||
createOperationId:()=> 'evidence-' + (++sequence),
|
||||
upload:async payload=>{{calls.push([payload.filename,payload.operation_id]);return {{markdown:''}};}},
|
||||
}});
|
||||
for(const name of ['one.png','two.png','three.png','four.png','five.png']) controller.select(image(name));
|
||||
let limit='';try{{controller.select(image('six.png'));}}catch(error){{limit=error.message;}}
|
||||
controller.remove(1);
|
||||
controller.select(image('replacement.png'));
|
||||
(async()=>{{
|
||||
const serialized=await controller.serialize();
|
||||
const comment=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
|
||||
const second=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
|
||||
process.stdout.write(JSON.stringify({{limit,state:controller.state(),names:serialized.map(x=>x.filename),comment,second,calls}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["limit"] == "Up to 5 screenshots. Remove one before adding another."
|
||||
assert output["names"] == ["one.png", "three.png", "four.png", "five.png", "replacement.png"]
|
||||
assert [item["name"] for item in output["state"]] == output["names"]
|
||||
assert output["comment"] == output["second"]
|
||||
assert output["comment"].startswith("Evidence sequence\n\n![one.png]")
|
||||
assert len(output["calls"]) == 5
|
||||
assert len({key for _, key in output["calls"]}) == 5
|
||||
|
||||
|
||||
def test_mobile_evidence_bundle_reorders_selected_image_for_serialization_and_upload():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const uploaded=[];
|
||||
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||
const controller=attachment.create({{
|
||||
maxFiles:5,
|
||||
upload:async payload=>{{uploaded.push(payload.filename);return {{markdown:''}};}},
|
||||
}});
|
||||
['one.png','two.png','three.png'].forEach(name=>controller.select(image(name)));
|
||||
controller.move(2, 0);
|
||||
controller.move(0, 99);
|
||||
controller.move(0, 1);
|
||||
(async()=>{{
|
||||
const serialized=await controller.serialize();
|
||||
const comment=await controller.prepareComment({{repository:'o/r',number:825}},'Reviewed');
|
||||
process.stdout.write(JSON.stringify({{names:serialized.map(x=>x.filename),uploaded,comment}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["names"] == ["one.png", "three.png", "two.png"]
|
||||
assert output["uploaded"] == output["names"]
|
||||
assert output["comment"].endswith(
|
||||
"\n\n\n\n"
|
||||
)
|
||||
|
||||
|
||||
def test_mobile_evidence_notes_follow_images_and_render_as_safe_ordered_captions():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||
const controller=attachment.create({{
|
||||
maxFiles:5,
|
||||
upload:async payload=>({{markdown:''}}),
|
||||
}});
|
||||
['one.png','two.png','three.png'].forEach(name=>controller.select(image(name)));
|
||||
controller.setNote(0, ' Login *token* is visible ');
|
||||
controller.setNote(1, 'Keyboard hides [Submit](bad)');
|
||||
controller.setNote(2, '');
|
||||
controller.move(1, 0);
|
||||
controller.remove(1);
|
||||
(async()=>{{
|
||||
const serialized=await controller.serialize();
|
||||
const comment=await controller.prepareComment({{repository:'o/r',number:827}},'Evidence');
|
||||
process.stdout.write(JSON.stringify({{serialized:serialized.map(x=>({{filename:x.filename,note:x.note||''}})),comment}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["serialized"] == [
|
||||
{"filename": "two.png", "note": "Keyboard hides [Submit](bad)"},
|
||||
{"filename": "three.png", "note": ""},
|
||||
]
|
||||
assert output["comment"] == (
|
||||
"Evidence\n\n**Screenshot 1 — Keyboard hides \\[Submit\\]\\(bad\\)**\n\n"
|
||||
"\n\n"
|
||||
)
|
||||
|
||||
|
||||
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
@@ -191,6 +280,74 @@ const canvas = {{
|
||||
assert output["name"] == "camera.jpg"
|
||||
|
||||
|
||||
def test_browser_optimizer_downscales_small_file_when_decoded_pixels_exceed_budget():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const original = new Blob(['compressed'], {{type:'image/jpeg'}});
|
||||
original.name = 'panorama.jpg';
|
||||
let closed = false;
|
||||
const attempts = [];
|
||||
const canvas = {{
|
||||
width: 0, height: 0,
|
||||
getContext: () => ({{drawImage: () => {{}}}}),
|
||||
toBlob: callback => {{
|
||||
attempts.push([canvas.width, canvas.height]);
|
||||
callback(new Blob(['bounded'], {{type:'image/jpeg'}}));
|
||||
}},
|
||||
}};
|
||||
(async()=>{{
|
||||
const result = await attachment.optimizeImage(original, {{
|
||||
createImageBitmap: async () => ({{width:6000,height:4000,close:()=>{{closed=true;}}}}),
|
||||
createCanvas: () => canvas,
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
attempts,closed,size:result.size,type:result.type,name:result.name,
|
||||
maxPixels:attachment.MAX_PIXELS,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert len(output["attempts"]) == 1
|
||||
width, height = output["attempts"][0]
|
||||
assert width * height <= output["maxPixels"]
|
||||
assert width < 6000
|
||||
assert height < 4000
|
||||
assert output["closed"] is True
|
||||
assert output["size"] == 7
|
||||
assert output["type"] == "image/jpeg"
|
||||
assert output["name"] == "panorama.jpg"
|
||||
|
||||
|
||||
def test_mobile_preview_inspects_small_screenshot_before_rendering_it():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
class Element{{constructor(){{this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;}}addEventListener(t,f){{this.listeners[t]=f;}}dispatch(t){{return this.listeners[t]({{target:this}});}}}}
|
||||
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
|
||||
const original=new Blob(['compressed'],{{type:'image/jpeg'}}); original.name='panorama.jpg';
|
||||
const bounded=new Blob(['bounded'],{{type:'image/jpeg'}}); bounded.name='panorama.jpg';
|
||||
const inspected=[];
|
||||
attachment.mount({{
|
||||
input,preview,image,meta,remove,status,
|
||||
optimizeImage:async file=>{{inspected.push(file.name);return bounded;}},
|
||||
createObjectURL:blob=>blob===bounded?'blob:bounded':'blob:unsafe',revokeObjectURL:()=>{{}},upload:async()=>{{}},
|
||||
}});
|
||||
input.files=[original];
|
||||
(async()=>{{
|
||||
await input.dispatch('change');
|
||||
process.stdout.write(JSON.stringify({{inspected,src:image.src,hidden:preview.hidden,status:status.textContent}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"inspected": ["panorama.jpg"],
|
||||
"src": "blob:bounded",
|
||||
"hidden": False,
|
||||
"status": "Screenshot optimized and ready to upload.",
|
||||
}
|
||||
|
||||
|
||||
def test_binary_screenshot_builds_multipart_body_with_original_bytes():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
@@ -224,6 +381,122 @@ controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0K
|
||||
assert output["removed"] is None
|
||||
|
||||
|
||||
def test_attachment_view_restores_ordered_evidence_bundle_preview():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
class Element{{constructor(){{this.listeners={{}};this.hidden=true;this.value='';this.textContent='';this.src='';this.disabled=false;}}addEventListener(t,f){{this.listeners[t]=f;}}}}
|
||||
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();input.multiple=true;
|
||||
const controller=attachment.mount({{input,preview,image,meta,remove,status,createObjectURL:blob=>'blob:'+blob.size,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
|
||||
const value=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
|
||||
const restored=controller.restore([value('one.png'),value('two.png')]);
|
||||
process.stdout.write(JSON.stringify({{restored,src:image.src,meta:meta.textContent,hidden:preview.hidden}}));
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert [item["name"] for item in output["restored"]] == ["one.png", "two.png"]
|
||||
assert output["src"] == "blob:7"
|
||||
assert output["meta"] == "2 screenshots ready · latest: two.png"
|
||||
assert output["hidden"] is False
|
||||
|
||||
|
||||
|
||||
def test_evidence_review_tray_selects_reorders_and_removes_any_screenshot():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
class Element{{
|
||||
constructor(tag='div'){{this.tag=tag;this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}};this.dataset={{}};}}
|
||||
addEventListener(type,fn){{this.listeners[type]=fn;}}
|
||||
dispatch(type){{return this.listeners[type]({{target:this}});}}
|
||||
appendChild(child){{this.children.push(child);return child;}}
|
||||
replaceChildren(...children){{this.children=children;}}
|
||||
setAttribute(name,value){{this.attributes[name]=String(value);}}
|
||||
}}
|
||||
const document={{createElement:tag=>new Element(tag)}};
|
||||
const input=new Element('input'),preview=new Element(),image=new Element('img'),meta=new Element(),remove=new Element('button'),status=new Element(),tray=new Element(),earlier=new Element('button'),later=new Element('button');
|
||||
input.multiple=true;
|
||||
const urls=[];
|
||||
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document,
|
||||
removedMessage:'Screenshot removed. Your issue draft is unchanged.',
|
||||
optimizeImage:async file=>file,
|
||||
createObjectURL:blob=>{{const url='blob:'+blob.name;urls.push(url);return url;}},revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
|
||||
const file=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||
input.files=['one.png','two.png','three.png'].map(file);
|
||||
(async()=>{{
|
||||
await input.dispatch('change');
|
||||
await tray.children[1].dispatch('click');
|
||||
await earlier.dispatch('click');
|
||||
const reordered=(await controller.serialize()).map(item=>item.filename);
|
||||
await remove.dispatch('click');
|
||||
const remaining=(await controller.serialize()).map(item=>item.filename);
|
||||
process.stdout.write(JSON.stringify({{reordered,remaining,preview:image.src,meta:meta.textContent,
|
||||
selected:tray.children.map(button=>button.attributes['aria-pressed']),
|
||||
labels:tray.children.map(button=>button.attributes['aria-label']),
|
||||
earlierDisabled:earlier.disabled,laterDisabled:later.disabled,status:status.textContent}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["reordered"] == ["two.png", "one.png", "three.png"]
|
||||
assert output["remaining"] == ["one.png", "three.png"]
|
||||
assert output["preview"] == "blob:one.png"
|
||||
assert output["meta"] == "Screenshot 1 of 2 · one.png · 1 KB"
|
||||
assert output["selected"] == ["true", "false"]
|
||||
assert output["labels"] == [
|
||||
"Review screenshot 1 of 2: one.png", "Review screenshot 2 of 2: three.png"
|
||||
]
|
||||
assert output["earlierDisabled"] is True
|
||||
assert output["laterDisabled"] is False
|
||||
assert output["status"] == "Screenshot removed. Your issue draft is unchanged."
|
||||
|
||||
|
||||
def test_evidence_review_edits_the_active_note_and_keeps_it_visible_after_reorder():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
class Element{{
|
||||
constructor(tag='div'){{this.tag=tag;this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}};}}
|
||||
addEventListener(type,fn){{this.listeners[type]=fn;}}
|
||||
dispatch(type){{return this.listeners[type]({{target:this}});}}
|
||||
appendChild(child){{this.children.push(child);return child;}}
|
||||
replaceChildren(...children){{this.children=children;}}
|
||||
setAttribute(name,value){{this.attributes[name]=String(value);}}
|
||||
}}
|
||||
const document={{createElement:tag=>new Element(tag)}};
|
||||
const input=new Element('input'),preview=new Element(),image=new Element('img'),meta=new Element(),remove=new Element('button'),status=new Element(),tray=new Element(),earlier=new Element('button'),later=new Element('button'),note=new Element('textarea'),noteLabel=new Element('label');
|
||||
input.multiple=true;
|
||||
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,note,noteLabel,document,
|
||||
optimizeImage:async file=>file,
|
||||
createObjectURL:blob=>'blob:'+blob.name,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
|
||||
const file=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||
input.files=['one.png','two.png','three.png'].map(file);
|
||||
(async()=>{{
|
||||
await input.dispatch('change');
|
||||
await tray.children[1].dispatch('click');
|
||||
note.value='Keyboard hides Submit';
|
||||
await note.dispatch('input');
|
||||
await earlier.dispatch('click');
|
||||
const afterMove={{value:note.value,label:noteLabel.textContent,disabled:note.disabled}};
|
||||
await tray.children[1].dispatch('click');
|
||||
const other={{value:note.value,label:noteLabel.textContent}};
|
||||
process.stdout.write(JSON.stringify({{afterMove,other,serialized:(await controller.serialize()).map(x=>[x.filename,x.note||''])}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output == {
|
||||
"afterMove": {
|
||||
"value": "Keyboard hides Submit",
|
||||
"label": "Evidence note for screenshot 1 of 3 (optional)",
|
||||
"disabled": False,
|
||||
},
|
||||
"other": {
|
||||
"value": "",
|
||||
"label": "Evidence note for screenshot 2 of 3 (optional)",
|
||||
},
|
||||
"serialized": [
|
||||
["two.png", "Keyboard hides Submit"],
|
||||
["one.png", ""],
|
||||
["three.png", ""],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
@@ -264,13 +537,36 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
|
||||
|
||||
assert 'id="create-issue-attachment"' in html
|
||||
assert 'accept="image/png,image/jpeg,image/webp"' in html
|
||||
assert 'multiple' in html
|
||||
assert 'Up to 5' in html
|
||||
assert 'id="create-issue-attachment-preview"' in html
|
||||
assert 'id="remove-create-issue-attachment"' in html
|
||||
assert 'id="create-issue-evidence-tray"' in html
|
||||
assert 'role="toolbar"' in html
|
||||
assert 'aria-label="Evidence screenshots"' in html
|
||||
assert 'id="move-create-issue-attachment-earlier"' in html
|
||||
assert 'id="move-create-issue-attachment-later"' in html
|
||||
assert 'id="create-issue-evidence-note-label"' in html
|
||||
assert 'id="create-issue-evidence-note"' in html
|
||||
assert 'maxlength="240"' in html
|
||||
assert "tray: qs('#create-issue-evidence-tray')" in source
|
||||
assert "earlier: qs('#move-create-issue-attachment-earlier')" in source
|
||||
assert "later: qs('#move-create-issue-attachment-later')" in source
|
||||
assert "note: qs('#create-issue-evidence-note')" in source
|
||||
assert "noteLabel: qs('#create-issue-evidence-note-label')" in source
|
||||
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
|
||||
assert "attachment: await createIssueAttachmentController.serialize()" in source
|
||||
assert "createIssueAttachmentController.restore(hydrated.attachment);" in source
|
||||
assert "attachments:evidence" in source
|
||||
assert "hydrated.attachments || hydrated.attachment" in source
|
||||
assert "createIssueAttachmentController.clear();" in source
|
||||
assert ".create-issue-attachment" in css
|
||||
assert ".issue-evidence-tray" in css
|
||||
assert ".issue-evidence-review-actions" in css
|
||||
assert ".issue-evidence-note" in css
|
||||
assert "resize:vertical" in css
|
||||
assert "#create-issue-attachment-preview { grid-template-columns:1fr;" in css
|
||||
assert "#create-issue-attachment-image { grid-row:auto; width:100%;" in css
|
||||
assert "overflow-x:auto" in css
|
||||
assert "min-width:44px" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
|
||||
@@ -303,15 +599,18 @@ const input=new Element(), preview=new Element(), image=new Element(), meta=new
|
||||
const revoked=[];
|
||||
const controller=attachment.mount({{
|
||||
input,preview,image,meta,remove,status,
|
||||
optimizeImage:async file=>file,
|
||||
createObjectURL:()=> 'blob:preview', revokeObjectURL:url=>revoked.push(url),
|
||||
readDataUrl:async()=>'', upload:async()=>{{}},
|
||||
}});
|
||||
input.files=[{{name:'payload.svg',type:'image/svg+xml',size:20}}]; input.dispatch('change');
|
||||
const invalid={{message:status.textContent,hidden:preview.hidden}};
|
||||
input.files=[{{name:'screen.png',type:'image/png',size:2048}}]; input.dispatch('change');
|
||||
const selected={{src:image.src,meta:meta.textContent,hidden:preview.hidden,state:controller.state()}};
|
||||
remove.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({{invalid,selected,removed:{{hidden:preview.hidden,src:image.src,revoked,state:controller.state()}}}}));
|
||||
;(async()=>{{
|
||||
input.files=[{{name:'payload.svg',type:'image/svg+xml',size:20}}]; await input.dispatch('change');
|
||||
const invalid={{message:status.textContent,hidden:preview.hidden}};
|
||||
input.files=[{{name:'screen.png',type:'image/png',size:2048}}]; await input.dispatch('change');
|
||||
const selected={{src:image.src,meta:meta.textContent,hidden:preview.hidden,state:controller.state()}};
|
||||
await remove.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({{invalid,selected,removed:{{hidden:preview.hidden,src:image.src,revoked,state:controller.state()}}}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["invalid"] == {
|
||||
@@ -410,21 +709,38 @@ def test_pending_comment_admission_locks_attachment_replacement_and_remove_contr
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
class Element {{
|
||||
constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
|
||||
constructor() {{ this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}}; }}
|
||||
addEventListener(type,fn) {{ this.listeners[type]=fn; }}
|
||||
appendChild(child) {{ this.children.push(child); }}
|
||||
replaceChildren(...children) {{ this.children=children; }}
|
||||
setAttribute(name,value) {{ this.attributes[name]=String(value); }}
|
||||
}}
|
||||
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
|
||||
const controller=attachment.mount({{input,preview,image,meta,remove,status,
|
||||
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element(),tray=new Element(),earlier=new Element(),later=new Element();
|
||||
input.multiple=true;
|
||||
const document={{createElement:()=>new Element()}};
|
||||
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document,
|
||||
createObjectURL:()=> 'blob:preview',revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
|
||||
controller.restore([
|
||||
{{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}},
|
||||
{{filename:'two.png',contentType:'image/png',blob:new Blob(['two'],{{type:'image/png'}})}},
|
||||
]);
|
||||
controller.setBusy(true);
|
||||
const pending={{input:input.disabled,remove:remove.disabled}};
|
||||
const pending={{input:input.disabled,remove:remove.disabled,earlier:earlier.disabled,later:later.disabled,
|
||||
thumbnails:tray.children.map(button=>button.disabled)}};
|
||||
controller.setBusy(false);
|
||||
process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled}}}}));
|
||||
process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled,
|
||||
earlier:earlier.disabled,later:later.disabled,thumbnails:tray.children.map(button=>button.disabled)}}}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"pending": {"input": True, "remove": True},
|
||||
"released": {"input": False, "remove": False},
|
||||
"pending": {
|
||||
"input": True, "remove": True, "earlier": True, "later": True,
|
||||
"thumbnails": [True, True],
|
||||
},
|
||||
"released": {
|
||||
"input": False, "remove": False, "earlier": False, "later": True,
|
||||
"thumbnails": [False, False],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -462,6 +778,11 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
|
||||
assert "uploads before the comment is posted" in readme
|
||||
assert "New issue" in readme
|
||||
assert "retry resumes with the confirmed issue" in readme
|
||||
assert "thumbnail tray" in readme
|
||||
assert "Move earlier" in readme
|
||||
assert "Remove selected" in readme
|
||||
assert "Evidence note" in readme
|
||||
assert "caption" in readme
|
||||
|
||||
|
||||
def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_advancing():
|
||||
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user