Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
535658185a | ||
|
|
8b7c56161c | ||
|
|
2b2e2cceb4 | ||
|
|
de596f397f | ||
|
|
af86fc1e98 | ||
|
|
5bf491c725 | ||
|
|
11341ec5d9 | ||
|
|
eefb760169 | ||
|
|
3f9dd49766 | ||
|
|
72453b76db | ||
|
|
7639a42d7b | ||
|
|
0b992543f1 | ||
|
|
9904f17e3d | ||
|
|
0dd3932d14 | ||
|
|
8e6ee96ede | ||
|
|
161977cb67 | ||
|
|
e3c07c2dfb | ||
|
|
a610708e36 | ||
|
|
f8c5c2b058 | ||
|
|
72c470b652 | ||
|
|
b214a49686 | ||
|
|
a9508984e2 | ||
|
|
20d5176713 | ||
|
|
c96ed51b0d | ||
|
|
cf2e49300a | ||
|
|
5e49f27142 | ||
|
|
0280077e45 | ||
|
|
f781410e80 | ||
|
|
0311f85d96 | ||
|
|
5a6ef62c4c | ||
|
|
461c796227 | ||
|
|
6309ed43cd | ||
|
|
a0aa3c0a22 | ||
|
|
60bc7cd686 | ||
|
|
79c8f1626b | ||
|
|
cd9f40a1c4 | ||
|
|
0a3fb180ed | ||
|
|
74d1435ca7 | ||
|
|
8989e93c2c | ||
|
|
7b3b7dadb9 | ||
|
|
05f9e06a10 | ||
|
|
e0de45ea90 | ||
|
|
3224389441 | ||
|
|
63723e7fa3 | ||
|
|
740e91a615 | ||
|
|
0363f23cef | ||
|
|
2cbeba30fd | ||
|
|
43595de52f | ||
|
|
c451cd0bcb | ||
|
|
ad27db6846 | ||
|
|
791082bae2 | ||
|
|
d3ed0cca0c | ||
|
|
ccb595a081 | ||
|
|
7b1c9a608c | ||
|
|
854d040e88 | ||
|
|
95baa20eeb | ||
|
|
0cf88d7d7a | ||
|
|
969d5567d4 | ||
|
|
679639394f | ||
|
|
3b374cbb50 | ||
|
|
ad5e50eb48 | ||
|
|
4b8d0c4504 | ||
|
|
6e3b6804c1 | ||
|
|
c31129f1cc | ||
|
|
4afe4dd3b9 | ||
|
|
985472eb06 | ||
|
|
8b36455c21 | ||
|
|
3ba1fe76f0 | ||
|
|
321474e8cf | ||
|
|
166d14dd35 | ||
|
|
7a62f7ead7 | ||
|
|
461f90c8ff | ||
|
|
9ea590a65c | ||
|
|
33a6d1bb6d | ||
|
|
b9ae9c6c83 | ||
|
|
45ceedb679 | ||
|
|
2e2a7821ae | ||
|
|
33427d2407 | ||
|
|
f0b0b703ff | ||
|
|
055a9dbb2a | ||
|
|
1f7fd093fd | ||
|
|
ff0e47cbe7 | ||
|
|
c2da869c47 | ||
|
|
8ada75ec33 | ||
|
|
6dea5fd1ef | ||
|
|
bf4ce3adad | ||
|
|
34664edb8b | ||
|
|
f92794f571 | ||
|
|
edbe2d76dd | ||
|
|
cbdddcddbe | ||
|
|
de6c9c249a | ||
|
|
19122c69e6 | ||
|
|
2253c40577 | ||
|
|
5c2c0383df | ||
|
|
4e13f5482b | ||
|
|
51278bc86f | ||
|
|
a919242b8f | ||
|
|
56a9a0cba6 | ||
|
|
27fc74ced6 | ||
|
|
f686e2e71e |
No files matched your search
@@ -19,8 +19,22 @@ threads, create and self-assign issues, discover, claim, and release issue assig
|
||||
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
||||
inspect/comment on assigned pull
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
Assigned-issue comments can include one PNG, JPEG, or WebP screenshot up to 2 MB.
|
||||
The screenshot uploads before the comment is posted; validation or upload failures keep
|
||||
both the typed comment and removable preview available for retry. The mobile **New issue**
|
||||
sheet accepts the same image formats and stores the screenshot with its account-bound
|
||||
outbox capture. Durable admission writes the complete screenshot capture to IndexedDB
|
||||
before confirmation; localStorage keeps only bounded attachment metadata, avoiding base64
|
||||
quota pressure and synchronous multi-megabyte writes. Existing queued screenshot payloads
|
||||
migrate to the IndexedDB-backed form on the next durable admission or worker reconciliation.
|
||||
Delivery creates the issue exactly once, then uploads and comments with
|
||||
the image; after a partial failure, retry resumes with the confirmed issue instead of
|
||||
creating a duplicate.
|
||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||
issue-comment API; mobile issue capture requires issue
|
||||
issue-comment API. In issue, pull-request, and unread-update conversations, typing
|
||||
at least two characters after `@` offers repository-scoped teammate suggestions;
|
||||
touch or keyboard selection inserts the login without leaving the draft. Mention
|
||||
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
||||
creation and assignment permission. Once a repository and meaningful title are selected,
|
||||
the New issue sheet checks for similar open issues in that repository. Candidate links keep
|
||||
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
|
||||
@@ -37,10 +51,29 @@ the private content. Issue capture and authored mobile actions (issue
|
||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||
another worker replays a confirmed result instead of posting duplicate content. The ordered,
|
||||
five-item Today plan syncs across the operator's devices. Server revisions prevent delayed
|
||||
five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
|
||||
and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
|
||||
time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,
|
||||
and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through **Plan Today**
|
||||
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
|
||||
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
|
||||
as unknown rather than unblocked. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device. After a reload or installed-app
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
||||
**Comment & next** on that current issue or pull request posts the handoff online or admits it
|
||||
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||
one without closing or merging it. **Reply & next** provides the same one-action continuation
|
||||
for the current unread-update conversation. It deliberately leaves the notification unread;
|
||||
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
|
||||
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
||||
offline operations. After a healthy, fully paginated
|
||||
offline operations. Planning edits can remain offline for up to 30 days. After that, the
|
||||
expired edit is discarded visibly and the account plan is kept rather than replaying stale
|
||||
intent. The server retains no more than 4,096 operation receipts per account and removes
|
||||
receipts older than the same 30-day window; client base revisions keep a pruned replay from
|
||||
changing a newer Today or Later plan. After a healthy, fully paginated
|
||||
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
|
||||
queues an idempotent retirement before removing it locally; partial and degraded refreshes
|
||||
leave the plan unchanged, and offline retirements replay after reconnect. When
|
||||
@@ -53,10 +86,20 @@ triageable offline: **Queue read & next** writes an account-bound, notification-
|
||||
deduplicated acknowledgement to the durable background delivery system, removes the
|
||||
update from the local queue immediately, and opens the next saved conversation. A cold
|
||||
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
|
||||
authenticated notification-read endpoint and keeps transient failures queued. Installed-app
|
||||
authenticated notification-read endpoint and keeps transient failures queued. Every foreground
|
||||
same-origin dashboard API request also has a 15-second browser deadline, including fresh-
|
||||
authorization and step-up retries. Read timeouts settle with retry guidance even if the browser's
|
||||
fetch ignores abort; mutation timeouts instead tell the operator to refresh and verify the server
|
||||
outcome before retrying. Caller cancellation still takes precedence, and cross-origin fetches are
|
||||
not changed by this session boundary. Installed-app
|
||||
navigations are also deadline-bounded: after four seconds without a network response,
|
||||
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
|
||||
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
|
||||
Live dashboard refreshes use the server's per-section freshness windows: healthy sections
|
||||
refresh at their earliest deadline while a fully degraded snapshot waits for its reported
|
||||
cooldown. HTTP `Retry-After` delays are honored up to five minutes, and transport failures
|
||||
back off from eight seconds to a one-minute cap. Reconnect, foreground return, and explicit
|
||||
refresh still run immediately; a successful response resets transport backoff.
|
||||
Closed-app
|
||||
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
|
||||
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
|
||||
@@ -66,7 +109,12 @@ persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
||||
writes run outside the request event loop, and lock admission is bounded to 100 ms by
|
||||
default. Tune it with `STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS`; keep the value below
|
||||
route deadlines. Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
|
||||
route deadlines. During a controlled shutdown, the server gives in-flight authored mutations
|
||||
five seconds to finish and persist their ledger result before it closes the Gitea transport.
|
||||
Set `STACKCHAIN_AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS` to match the service manager's
|
||||
shutdown budget; when that deadline expires, remaining operations are cancelled and their
|
||||
pending reservations continue to fail closed rather than being retried automatically.
|
||||
Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
|
||||
If contention occurs after the upstream mutation, the dashboard fails closed with
|
||||
`Retry-After: 5` and asks the caller to verify the result before retrying. Direct API callers
|
||||
should preserve the `Idempotency-Key` header with the unchanged route and payload until a
|
||||
@@ -75,7 +123,12 @@ Approve, and Request changes reviews, and assigned-PR merge require repository
|
||||
write permission. Native Comment, Approve, and Request changes reviews support
|
||||
head-scoped draft comments anchored to changed lines; the dashboard validates each
|
||||
comment path and submits the summary, decision, and inline comments in one review
|
||||
request. The dashboard rechecks the current pull-request head, CI success, draft
|
||||
request. On phone-width review sheets, changed lines wrap inside a stable old/new
|
||||
line-number gutter by default so long source lines remain readable and commentable
|
||||
without horizontal panning. **Lines wrapped** toggles back to whitespace-preserving
|
||||
horizontal inspection, and an explicit choice persists on the device across files,
|
||||
review-sheet reopen, and reload. Desktop review diffs remain horizontally scrollable
|
||||
until wrapping is explicitly enabled. The dashboard rechecks the current pull-request head, CI success, draft
|
||||
state, and mergeability immediately before every merge.
|
||||
Serve the dashboard only to trusted users on its own origin; cross-origin API
|
||||
access is intentionally disabled. Authentication defaults to fail-closed
|
||||
@@ -96,6 +149,9 @@ export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
|
||||
# Optional; defaults to eight hours.
|
||||
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
|
||||
# Optional; explicit pointer, keyboard, or touch activity renews this idle window.
|
||||
# Background polling and queued delivery do not. Defaults to 15 minutes.
|
||||
export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
|
||||
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
|
||||
export STACKCHAIN_LOGIN_MAX_FAILURES=5
|
||||
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
|
||||
@@ -129,8 +185,9 @@ storage shared by all dashboard workers. Operators name a device at sign-in and
|
||||
open **Active devices** to review creation/expiry times, identify the current device,
|
||||
and revoke one remote session without interrupting other trusted devices. The API
|
||||
exposes only independent management IDs and bounded labels—never cookie values,
|
||||
session hashes, CSRF proofs, or source addresses. Existing two-column registries are
|
||||
migrated in place and their live sessions remain valid.
|
||||
session hashes, CSRF proofs, or source addresses. The registry also stores each
|
||||
session's last explicit activity. Existing two-column registries are migrated in
|
||||
place, their live sessions remain valid, and their idle clock starts at migration.
|
||||
|
||||
High-impact actions—merging a pull request, closing an assigned issue, revoking a
|
||||
remote device, or signing out every device—require the operator access token again.
|
||||
@@ -140,7 +197,12 @@ first use. Expired, replayed, cross-session, and target-substituted grants fail
|
||||
Gitea or session state is changed. The browser preserves the pending request and
|
||||
retries it once after the built-in mobile/keyboard-accessible authorization prompt.
|
||||
|
||||
If an active session expires, the first authenticated API rejection replaces the
|
||||
After 15 minutes without pointer, keyboard, or touch activity (configurable through
|
||||
`STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS`), the server rejects the session even if
|
||||
polling or background delivery continued. The dashboard locks and background outbox
|
||||
delivery pauses, but drafts, Today/Later state, queued mutations, and caches remain on
|
||||
the device; signing in resumes the existing account-bound work. If an active session
|
||||
expires, the first authenticated API rejection replaces the
|
||||
dashboard with sign-in and explains that private drafts remain on the device; signing
|
||||
in again resumes account-bound queued delivery. Expiry recovery does not clear offline
|
||||
state. A selectively or globally revoked device instead receives a bounded revocation
|
||||
@@ -160,7 +222,10 @@ Every response also defines the browser execution boundary with a Content Securi
|
||||
Policy that allows scripts only from the dashboard origin, denies framing and
|
||||
plugins, and blocks unused browser capabilities. Keep these response headers when
|
||||
proxying; do not add inline scripts or broaden `script-src`. The dashboard bootstrap
|
||||
and stylesheet are same-origin static assets included in the offline PWA shell.
|
||||
is assembled in source order into one content-addressed JavaScript response. Dashboard
|
||||
HTML and the offline worker reference that exact fingerprint, while the runtime receives
|
||||
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
|
||||
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
|
||||
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
||||
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
|
||||
removing unrelated forge preferences. Rotate either dashboard secret by replacing
|
||||
@@ -202,10 +267,31 @@ below the route deadlines. Streaming diff reads share the same read capacity,
|
||||
while POST, PATCH, PUT, and DELETE requests are never coalesced.
|
||||
|
||||
Live snapshots expose independent `context`, `events`, and `notifications`
|
||||
revisions. The browser sends its known revisions on later polls, so `/api/v1/live`
|
||||
can omit unchanged section bodies while still returning current freshness and
|
||||
retry metadata. The client retains omitted data and only rebuilds or persists the
|
||||
sections that changed.
|
||||
revision tokens. Snapshot content, freshness/backoff metadata, and revisions are published
|
||||
atomically through a private SQLite store shared by all application workers. An expiring
|
||||
refresh lease ensures only one worker loads currently due sections; other workers serve the
|
||||
same stale snapshot while that refresh runs, and can recover an abandoned lease after expiry.
|
||||
Set `STACKCHAIN_LIVE_SNAPSHOT_DB` to override the default
|
||||
`STACKCHAIN_STATE_DIR/live-snapshot.sqlite3`; keep the containing directory on private,
|
||||
worker-shared writable storage. The database and directory are restricted to the service
|
||||
account and never contain the Gitea token. Find Work uses the same worker-shared pattern:
|
||||
`STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB` overrides
|
||||
`STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3`. One expiring lease bounds each
|
||||
catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry
|
||||
bursts, and confirmed claims are removed from every worker's retained catalog. Releasing
|
||||
an assignment invalidates the shared catalog so the newly available issue can be discovered
|
||||
by the next authoritative scan. Each bounded opaque revision token includes the
|
||||
store generation, so a token from a different deployment or before replacement of the store
|
||||
cannot suppress different content. The browser sends its known tokens on later polls, so
|
||||
`/api/v1/live` can omit unchanged section bodies while still returning current freshness and
|
||||
retry metadata. The client retains omitted data and only rebuilds or persists the sections
|
||||
that changed. Malformed or oversized tokens are rejected before any upstream work.
|
||||
Confirmed notification acknowledgements update this worker's live view immediately, then
|
||||
maintain the shared snapshot outside the async request loop. Bulk acknowledgements remove
|
||||
all successful notification IDs in one SQLite transaction and advance the notification
|
||||
revision once. If shared-store lock admission fails after Gitea confirms the mutation, the
|
||||
API keeps the confirmed result and process-local filtering rather than falsely reporting the
|
||||
upstream write as failed; a later shared refresh reconciles the cache.
|
||||
|
||||
## Offline mobile shell
|
||||
|
||||
@@ -221,11 +307,17 @@ accepts a valid future local date and time and returns the item at that exact in
|
||||
shows the device timezone and rejects empty, normalized, invalid, or past values before saving.
|
||||
Deferred items leave normal and Attention queues without marking notifications read
|
||||
or changing any Gitea issue or pull request. They automatically return to their
|
||||
existing priority position at the wake time, and **Bring back now** restores them
|
||||
early. Later wake times are scoped to the confirmed Gitea login and synchronize
|
||||
existing priority position at the wake time. **Start now** atomically admits a deferred
|
||||
item to Today, removes its Later record, and opens that exact item in a resumable Today
|
||||
session; full or unavailable Today storage leaves the deferral intact for retry. Items
|
||||
already in Today are opened without duplication. **Bring back now** still restores an
|
||||
item early without starting it. Later wake times are scoped to the confirmed Gitea login and synchronize
|
||||
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
|
||||
and replay after reconnect; `STACKCHAIN_LATER_DB` can override the default durable
|
||||
store at `.stackchain-state/later.sqlite3`.
|
||||
and replay after reconnect. Each queued edit carries the account revision it was based
|
||||
on; if another device has since changed the same item, Stackchain keeps the newer
|
||||
account plan and reports the conflict instead of silently overwriting it. Unrelated
|
||||
items continue syncing in the same batch. `STACKCHAIN_LATER_DB` can override the
|
||||
default durable store at `.stackchain-state/later.sqlite3`.
|
||||
|
||||
After one successful online load, the installed dashboard precaches a versioned,
|
||||
subpath-scoped application shell. During a network outage or a dashboard HTTP
|
||||
@@ -248,11 +340,13 @@ repository catalogs, events, raw patches, and complete API responses are exclude
|
||||
A cold offline launch labels the saved time. Cached Today details open in the existing
|
||||
phone sheet, where comments can enter the account-bound durable outbox. For issue and
|
||||
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
|
||||
until reconnection. Cached
|
||||
requested reviews open in the existing review sheet so file progress, notes, summary,
|
||||
decision, and inline-comment drafts remain usable under the saved head SHA. Review
|
||||
submission is never queued: reconnect reloads the current head before enabling submit;
|
||||
a changed head starts clean SHA-scoped progress while retaining the prior-head draft.
|
||||
until reconnection. Cached requested reviews open in the existing review sheet so file progress, notes,
|
||||
summary, decision, and inline-comment drafts remain usable under the saved head SHA.
|
||||
**Queue review for reconnect** durably stores that complete SHA-bound review with one
|
||||
idempotency key. Reconnect or Background Sync submits it exactly once; transient failures
|
||||
retain the queue entry, while stale-head and invalid-inline-comment responses move it to
|
||||
Drafts as **Needs attention** without deleting feedback. Confirmed delivery removes the
|
||||
queued operation and its matching SHA-scoped draft and progress.
|
||||
Cached unread updates use the same phone conversation sheet and replies enter the
|
||||
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
|
||||
Cards without a saved detail explain that reconnection is required. **Clear offline
|
||||
@@ -264,7 +358,8 @@ issue comments, pull-request comments, and unread-update replies use bounded loc
|
||||
outboxes when connectivity or a retryable server failure prevents delivery. Issue
|
||||
captures and authored messages are also mirrored into account-bound IndexedDB lanes
|
||||
and registered with Background Sync, so a supporting installed browser can deliver
|
||||
new issues, issue comments, pull-request comments, and unread-update replies after
|
||||
new issues, issue comments, pull-request comments, unread-update replies, and completed
|
||||
pull-request reviews after
|
||||
every dashboard client has closed. The worker verifies the current Gitea login, shares
|
||||
an atomic delivery claim with the foreground path, and preserves the original
|
||||
idempotency key. Installed browsers can explicitly enable **Notify me when queued
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
function createAssignAndStart({ available, claim, start, queue, recover, announce }) {
|
||||
let request = null;
|
||||
|
||||
function run(item, { alreadyOwned = false, destination = 'start' } = {}) {
|
||||
if (request) return request;
|
||||
if (!available()) {
|
||||
announce('Today is full—remove an item before assigning this issue.');
|
||||
return Promise.resolve('full');
|
||||
}
|
||||
request = Promise.resolve()
|
||||
.then(() => alreadyOwned ? item : claim(item))
|
||||
.then(confirmed => {
|
||||
const outcome = destination === 'queue' ? queue(confirmed) : start(confirmed);
|
||||
if (outcome === 'queued') {
|
||||
announce(alreadyOwned ? 'Queued in Today. Keep finding work when ready.' :
|
||||
'Assigned and queued in Today. Keep finding work when ready.');
|
||||
return outcome;
|
||||
}
|
||||
if (outcome === 'started') {
|
||||
announce(alreadyOwned ? 'Added to Today and ready to work.' :
|
||||
'Assigned, added to Today, and ready to work.');
|
||||
return outcome;
|
||||
}
|
||||
if (destination === 'queue') {
|
||||
if (outcome === 'sync-unavailable') {
|
||||
announce(alreadyOwned ?
|
||||
'Saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.' :
|
||||
'Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.');
|
||||
} else {
|
||||
announce(alreadyOwned ?
|
||||
'Today could not be queued. The issue is open so you can recover.' :
|
||||
'Assigned to you, but Today could not be queued. The issue is open so you can recover.');
|
||||
}
|
||||
} else {
|
||||
announce(alreadyOwned ?
|
||||
'Today could not start. The issue is open so you can recover.' :
|
||||
'Assigned to you, but Today could not start. The issue is open so you can recover.');
|
||||
}
|
||||
recover(confirmed);
|
||||
return 'recovery';
|
||||
})
|
||||
.finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { run };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAssignAndStart;
|
||||
+73
-11
@@ -4,7 +4,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review', 'issue-close']);
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
body: String(message.body || ''),
|
||||
decision: String(message.decision || 'comment'),
|
||||
expectedHeadSha: String(message.expectedHeadSha || ''),
|
||||
comments: Array.isArray(message.comments) ? message.comments : [],
|
||||
});
|
||||
}
|
||||
|
||||
function clearConfirmedReviewState(item) {
|
||||
if (item.kind !== 'pull-review') return;
|
||||
try {
|
||||
if (item.draftKey && item.draftFingerprint &&
|
||||
storage?.getItem(item.draftKey) === item.draftFingerprint) storage.removeItem(item.draftKey);
|
||||
if (item.progressKey && item.progressFingerprint &&
|
||||
storage?.getItem(item.progressKey) === item.progressFingerprint) storage.removeItem(item.progressKey);
|
||||
} catch (_error) { /* Delivery is confirmed even when local cleanup is unavailable. */ }
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
@@ -32,6 +51,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
const requestedOperationId = String(message.operationId || '').slice(0, 128);
|
||||
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
|
||||
if (existing) return { ...existing };
|
||||
if (message.kind === 'pull-review') {
|
||||
const queuedReview = items.find(item => item.kind === 'pull-review' &&
|
||||
item.repository === String(message.repository || '') &&
|
||||
item.number === Number(message.number || 0) &&
|
||||
item.expectedHeadSha === String(message.expectedHeadSha || ''));
|
||||
if (queuedReview) {
|
||||
if (reviewFingerprint(queuedReview) === reviewFingerprint(message)) return { ...queuedReview };
|
||||
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
|
||||
}
|
||||
}
|
||||
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
|
||||
const id = String(requestedOperationId || makeId()).slice(0, 128);
|
||||
const item = {
|
||||
@@ -45,6 +74,15 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
...(message.kind === 'pull-review' ? {
|
||||
decision: String(message.decision || 'comment'),
|
||||
expectedHeadSha: String(message.expectedHeadSha || ''),
|
||||
comments: Array.isArray(message.comments) ? message.comments.map(comment => ({ ...comment })) : [],
|
||||
draftKey: String(message.draftKey || ''),
|
||||
progressKey: String(message.progressKey || ''),
|
||||
draftFingerprint: String(message.draftFingerprint || ''),
|
||||
progressFingerprint: String(message.progressFingerprint || ''),
|
||||
} : {}),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
@@ -95,6 +133,12 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
|
||||
}
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-close') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
|
||||
}
|
||||
@@ -114,17 +158,32 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
}
|
||||
result = delivery.message;
|
||||
} else {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
});
|
||||
if (item.kind === 'issue-close') {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
});
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
} else {
|
||||
const body = item.kind === 'pull-review' ? {
|
||||
body: item.body,
|
||||
decision: item.decision,
|
||||
expected_head_sha: item.expectedHeadSha,
|
||||
comments: item.comments,
|
||||
} : { body: item.body };
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!result) return { blocked: true };
|
||||
clearConfirmedReviewState(item);
|
||||
discard(item.id);
|
||||
return { result };
|
||||
} catch (error) {
|
||||
@@ -196,7 +255,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
const background = statuses.get(item.id);
|
||||
if (background?.status === 'sent') return [];
|
||||
if (background?.status === 'sent') {
|
||||
clearConfirmedReviewState(item);
|
||||
return [];
|
||||
}
|
||||
if (background?.status === 'attention') return [{
|
||||
...item,
|
||||
status: 'attention',
|
||||
|
||||
@@ -65,13 +65,18 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
if (current.recordType === 'receipt-preference') continue;
|
||||
const currentLane = current.outboxLane || 'issue';
|
||||
if (currentLane !== outboxLane) continue;
|
||||
const replacement = incoming.get(current.id);
|
||||
let replacement = incoming.get(current.id);
|
||||
if (!replacement) {
|
||||
if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) {
|
||||
await records.delete(current.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current.operationId === replacement.operationId && current.attachment?.data &&
|
||||
replacement.attachment?.stored && !replacement.attachment.data) {
|
||||
replacement = { ...replacement, attachment: current.attachment };
|
||||
incoming.set(current.id, replacement);
|
||||
}
|
||||
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && replacement.status === 'attention') ||
|
||||
current.status === 'sent') {
|
||||
@@ -141,8 +146,15 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && item.status === 'attention') ||
|
||||
current.status === 'sent')) return current;
|
||||
await records.put({ ...item });
|
||||
return item;
|
||||
const preservedAttachment = current?.attachment?.data && item?.attachment?.stored && !item.attachment.data
|
||||
? { attachment: current.attachment } : {};
|
||||
const next = current && current.operationId === item.operationId ? {
|
||||
...item, ...preservedAttachment,
|
||||
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
|
||||
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
|
||||
} : { ...item };
|
||||
await records.put(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -177,10 +189,14 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
return {
|
||||
reconcile,
|
||||
upsert,
|
||||
update,
|
||||
claim,
|
||||
claimNext,
|
||||
claimBatch,
|
||||
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
|
||||
complete: (id, deliveredIssue) => update(id, item => ({
|
||||
...item, status: 'sent', claimUntil: 0,
|
||||
...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}),
|
||||
})),
|
||||
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
||||
fail: (id, error, deliveryState) => update(id, item => ({
|
||||
...item, status: 'attention', claimUntil: 0, error,
|
||||
@@ -202,6 +218,7 @@ function createBackgroundIssueSync({
|
||||
batch = work => work(), requestTimeoutMs = 15000,
|
||||
}) {
|
||||
let purgeRequested = false;
|
||||
let activePurge = null;
|
||||
let activeFlush = null;
|
||||
const activeRequests = new Set();
|
||||
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
|
||||
@@ -239,6 +256,18 @@ function createBackgroundIssueSync({
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
id: item.id, status, kind: 'message',
|
||||
route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number),
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-close') {
|
||||
return {
|
||||
id: item.id, status, kind: 'message',
|
||||
route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(item.number),
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
||||
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
|
||||
@@ -257,6 +286,34 @@ function createBackgroundIssueSync({
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-close') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close',
|
||||
options: {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||
options: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: item.body,
|
||||
decision: item.decision,
|
||||
expected_head_sha: item.expectedHeadSha,
|
||||
comments: item.comments || [],
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
return authoredRequest(
|
||||
@@ -299,11 +356,69 @@ function createBackgroundIssueSync({
|
||||
};
|
||||
}
|
||||
|
||||
function stageOperationId(operationId, stage) {
|
||||
const suffix = ':' + stage;
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
async function deliverIssueCapture(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
let deliveredIssue = item.deliveredIssue;
|
||||
if (!deliveredIssue) {
|
||||
const request = deliveryRequest(item);
|
||||
deliveredIssue = await requestJson(request.url, request.options);
|
||||
await store.update?.(item.id, current => ({ ...current, deliveredIssue }));
|
||||
}
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestJson(
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: item.attachment.filename,
|
||||
content_type: item.attachment.contentType,
|
||||
data: item.attachment.data,
|
||||
}),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await store.update?.(item.id, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
|
||||
}
|
||||
await requestJson(
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
|
||||
},
|
||||
body: JSON.stringify({ body: attachmentMarkdown }),
|
||||
},
|
||||
);
|
||||
return deliveredIssue;
|
||||
}
|
||||
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = await requestJson(request.url, request.options);
|
||||
await store.complete(item.id);
|
||||
const delivered = item.attachment && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestJson(request.url, request.options);
|
||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||
const error = new Error('Issue closure was not confirmed.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await store.complete(item.id, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
} catch (error) {
|
||||
@@ -394,15 +509,26 @@ function createBackgroundIssueSync({
|
||||
return activeFlush;
|
||||
}
|
||||
|
||||
async function purge() {
|
||||
function purge() {
|
||||
if (activePurge) return activePurge;
|
||||
purgeRequested = true;
|
||||
activeRequests.forEach(controller => controller.abort());
|
||||
if (activeFlush) await activeFlush.catch(() => {});
|
||||
await store.close?.();
|
||||
activePurge = (async () => {
|
||||
if (activeFlush) await activeFlush.catch(() => {});
|
||||
await store.close?.();
|
||||
})();
|
||||
return activePurge;
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
const pendingPurge = activePurge;
|
||||
if (pendingPurge) await pendingPurge;
|
||||
if (activePurge === pendingPurge) activePurge = null;
|
||||
purgeRequested = false;
|
||||
}
|
||||
|
||||
return {
|
||||
flush, send, purge,
|
||||
flush, send, purge, resume,
|
||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||
snapshot: () => store.snapshot(),
|
||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.createCardPlanning = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createCardPlanning(root) {
|
||||
function sync(disclosure) {
|
||||
disclosure.querySelector('summary')?.setAttribute(
|
||||
'aria-expanded', String(disclosure.open)
|
||||
);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const disclosures = Array.from(root.querySelectorAll('[data-card-planning]'));
|
||||
disclosures.forEach(disclosure => {
|
||||
if (disclosure.dataset.cardPlanningBound === 'true') return;
|
||||
disclosure.dataset.cardPlanningBound = 'true';
|
||||
sync(disclosure);
|
||||
disclosure.addEventListener('toggle', () => {
|
||||
if (disclosure.open) {
|
||||
disclosures.forEach(other => {
|
||||
if (other === disclosure || !other.open) return;
|
||||
other.open = false;
|
||||
sync(other);
|
||||
});
|
||||
}
|
||||
sync(disclosure);
|
||||
});
|
||||
disclosure.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || !disclosure.open) return;
|
||||
disclosure.open = false;
|
||||
sync(disclosure);
|
||||
disclosure.querySelector('summary')?.focus();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { wire };
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
function createCommentNext({ post, queue, queueKind = '', canQueue, accept = () => undefined, complete }) {
|
||||
let inFlight = null;
|
||||
|
||||
function submit(item, body, operationId = '') {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
let comment;
|
||||
try {
|
||||
comment = await post(item, body, typeof operationId === 'function' ? operationId() : operationId);
|
||||
} catch (error) {
|
||||
if (!canQueue(error)) throw error;
|
||||
const identity = queueKind === 'update-reply' ? {
|
||||
kind: 'update-reply', notificationId: item.notification_id,
|
||||
} : {
|
||||
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
|
||||
repository: item.repository, number: item.number,
|
||||
};
|
||||
const admission = await queue({
|
||||
...identity, body,
|
||||
operationId: typeof operationId === 'function' ? operationId() : operationId,
|
||||
});
|
||||
if (!admission || (!admission.item && admission.durable !== true)) {
|
||||
throw new Error('Comment was not saved for delivery.');
|
||||
}
|
||||
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: admission.background ? 'queued' : 'saved',
|
||||
background: Boolean(admission.background),
|
||||
completed: Boolean(complete(item)),
|
||||
};
|
||||
}
|
||||
accept(item, { delivery: 'posted', comment });
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: 'posted',
|
||||
comment,
|
||||
completed: Boolean(complete(item)),
|
||||
};
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
return { submit, busy: () => Boolean(inFlight) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;
|
||||
@@ -1,3 +1,22 @@
|
||||
function buildLiveRevisionQuery(revisions = {}) {
|
||||
const params = new URLSearchParams();
|
||||
const tokenPattern = /^[0-9a-f]{16}\.[0-9]{1,20}$/;
|
||||
['context', 'events', 'notifications'].forEach((section) => {
|
||||
const revision = revisions[section];
|
||||
if (typeof revision === 'string' && tokenPattern.test(revision)) {
|
||||
params.set(section + '_revision', revision);
|
||||
}
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function retryAfterMs(value) {
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||
return Math.min(seconds * 1000, 300000);
|
||||
}
|
||||
|
||||
function createContextPoller({
|
||||
fetchContext,
|
||||
onSnapshot,
|
||||
@@ -9,25 +28,51 @@ function createContextPoller({
|
||||
clearDeadlineTimer = clearTimeout,
|
||||
intervalMs = 8000,
|
||||
timeoutMs = 12000,
|
||||
maxBackoffMs = 60000,
|
||||
}) {
|
||||
let activeRequest = null;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
let revisions = {};
|
||||
let retainedSnapshot = null;
|
||||
let nextDelayMs = intervalMs;
|
||||
let failureStreak = 0;
|
||||
|
||||
function snapshotDelay(snapshot) {
|
||||
const freshness = snapshot && snapshot.freshness;
|
||||
const sections = freshness && freshness.sections;
|
||||
const sectionValues = sections && Object.values(sections);
|
||||
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
|
||||
const retrySeconds = Number(freshness.retry_in_seconds);
|
||||
if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000;
|
||||
}
|
||||
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
|
||||
const healthyDeadlines = (sectionValues || [])
|
||||
.filter(section => !section.degraded)
|
||||
.map(section => freshForSeconds - Number(section.age_seconds))
|
||||
.filter(seconds => Number.isFinite(seconds));
|
||||
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
|
||||
const earliestDeadline = Math.min(...healthyDeadlines);
|
||||
if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) {
|
||||
return intervalMs;
|
||||
}
|
||||
return Math.max(0, earliestDeadline) * 1000;
|
||||
}
|
||||
return intervalMs;
|
||||
}
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
function schedule(delayMs = intervalMs) {
|
||||
cancelTimer();
|
||||
if (stopped || isHidden()) return;
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
refresh();
|
||||
}, intervalMs);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function abortError() {
|
||||
@@ -79,6 +124,8 @@ function createContextPoller({
|
||||
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
|
||||
.then((snapshot) => {
|
||||
if (activeRequest !== requestState) return retainedSnapshot;
|
||||
failureStreak = 0;
|
||||
nextDelayMs = snapshotDelay(snapshot);
|
||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||
);
|
||||
@@ -88,7 +135,14 @@ function createContextPoller({
|
||||
return retainedSnapshot;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (activeRequest === requestState && !stopped) onError(error);
|
||||
if (activeRequest === requestState && !stopped) {
|
||||
const retryAfterMs = Number(error && error.retryAfterMs);
|
||||
failureStreak += 1;
|
||||
nextDelayMs = Number.isFinite(retryAfterMs) && retryAfterMs > 0
|
||||
? retryAfterMs
|
||||
: Math.min(intervalMs * (2 ** (failureStreak - 1)), maxBackoffMs);
|
||||
onError(error);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -96,7 +150,7 @@ function createContextPoller({
|
||||
clearDeadlineTimer(requestState.deadline);
|
||||
if (activeRequest !== requestState) return;
|
||||
activeRequest = null;
|
||||
schedule();
|
||||
schedule(nextDelayMs);
|
||||
});
|
||||
return requestState.promise;
|
||||
}
|
||||
@@ -123,6 +177,9 @@ function createContextPoller({
|
||||
};
|
||||
}
|
||||
|
||||
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
|
||||
createContextPoller.retryAfterMs = retryAfterMs;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createContextPoller;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, announce }) {
|
||||
const completed = new Set();
|
||||
|
||||
function available() {
|
||||
return todayWork.read().length < todayWork.limit;
|
||||
}
|
||||
|
||||
function complete(issue) {
|
||||
const identity = todayWork.identity(issue);
|
||||
if (!identity || completed.has(identity)) return 'exists';
|
||||
const added = todayWork.add(issue);
|
||||
if (added === 'full') {
|
||||
announce('Created, but Today changed—remove an item, then add this issue.');
|
||||
return 'full';
|
||||
}
|
||||
if (added !== 'added' && added !== 'exists') {
|
||||
announce('Created, but Today could not be saved on this device.');
|
||||
return 'unavailable';
|
||||
}
|
||||
if (!todaySync.enqueue('add', identity)) {
|
||||
announce('Created and saved to Today on this device, but account sync is unavailable.');
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
completed.add(identity);
|
||||
refresh();
|
||||
todaySync.flush();
|
||||
warm();
|
||||
start(issue);
|
||||
announce('Created, added to Today, and ready to work.');
|
||||
return 'started';
|
||||
}
|
||||
|
||||
return { available, complete };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart;
|
||||
@@ -26,6 +26,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
let duplicateRequest = 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);
|
||||
@@ -143,6 +144,21 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
);
|
||||
}
|
||||
|
||||
function loadRepositoryPage(page) {
|
||||
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
||||
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
||||
const request = fetchJson('api/v1/repositories?page=' + safePage + '&limit=50')
|
||||
.then(payload => ({
|
||||
items: Array.isArray(payload?.items) ? payload.items : [],
|
||||
page: Number(payload?.page) || safePage,
|
||||
total: Math.max(0, Number(payload?.total) || 0),
|
||||
has_more: payload?.has_more === true,
|
||||
}))
|
||||
.finally(() => repositoryPageRequests.delete(safePage));
|
||||
repositoryPageRequests.set(safePage, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function duplicateKey(draft) {
|
||||
const repository = String(draft?.repository || '').trim();
|
||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||
@@ -214,7 +230,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, findDuplicates,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
};
|
||||
|
||||
+90
-5
@@ -82,6 +82,36 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||||
.event:last-child { border-bottom: 0; }
|
||||
.my-work { grid-column: 1 / -1; }
|
||||
.plan-today-sheet { position:fixed; inset:0; z-index:75; display:flex; justify-content:flex-end; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
|
||||
.plan-today-sheet[hidden] { display:none; }
|
||||
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
||||
.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; }
|
||||
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
|
||||
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
|
||||
.plan-today-available span { display:flex; align-items:center; gap:6px; }
|
||||
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
|
||||
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
|
||||
.plan-today-error { min-height:1.4em; color:#fca5a5; }
|
||||
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
|
||||
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
|
||||
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
|
||||
.plan-today-item-actions { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; }
|
||||
.plan-today-candidate-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px; }
|
||||
.plan-preview-actions { position:fixed; z-index:76; right:16px; bottom:calc(16px + env(safe-area-inset-bottom)); display:grid; grid-template-columns:1fr 1fr; gap:8px; width:min(420px,calc(100vw - 32px)); padding:10px; border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); box-shadow:0 12px 36px rgba(0,0,0,.45); }
|
||||
.plan-preview-actions[hidden] { display:none; }
|
||||
.plan-preview-actions button { min-height:44px; }
|
||||
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.plan-today-actions button { min-height:44px; width:100%; }
|
||||
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-readiness-sheet[hidden] { display:none; }
|
||||
.today-readiness-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-readiness-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-readiness-header h2 { margin:.2rem 0; }
|
||||
.today-readiness-header button, .today-readiness-actions button { min-height:44px; }
|
||||
.today-readiness-item { min-width:0; overflow-wrap:anywhere; margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.today-readiness-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.work-settings { width:100%; }
|
||||
.work-settings > summary { display:none; }
|
||||
@@ -96,7 +126,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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%; }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; }
|
||||
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
|
||||
.create-issue-actions #create-issue-status { grid-column:1/-1; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
@@ -105,6 +137,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.later-actions button { min-height:44px; width:100%; }
|
||||
.today-actions button { min-height:44px; width:100%; }
|
||||
.card-planning { min-width:0; }
|
||||
.card-planning > summary { display:none; }
|
||||
.card-planning:not([open]) > .card-planning-actions { display:grid; }
|
||||
.card-planning-actions { display:grid; gap:8px; min-width:0; }
|
||||
.detail-defer { grid-column:1/-1; max-width:100%; }
|
||||
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
@@ -155,9 +191,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.review-copy-fallback { min-height:140px; }
|
||||
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
|
||||
.review-progress-actions button { min-height:44px; max-width:100%; }
|
||||
.review-display-tools { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
|
||||
.review-display-tools button { min-height:44px; flex:0 0 auto; }
|
||||
.review-display-tools button[aria-pressed="true"] { color:#bfdbfe; background:#17365a; outline:1px solid #60a5fa; }
|
||||
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
|
||||
.review-diff-line { display:block; min-width:max-content; }
|
||||
.review-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; }
|
||||
.review-diff-line { display:grid; grid-template-columns:max-content minmax(max-content,1fr); align-items:stretch; min-width:max-content; }
|
||||
.review-line-numbers { position:sticky; left:0; z-index:1; display:grid; grid-template-columns:4ch 4ch; align-self:stretch; background:#0b1526; color:#7694b6; user-select:none; }
|
||||
.review-line-number { padding:8px 4px; text-align:right; border-right:1px solid #244363; }
|
||||
.review-line-code { min-width:0; padding:8px; white-space:pre; overflow-wrap:anywhere; }
|
||||
.review-inline-target { min-height:44px; width:100%; padding:0; border:0; border-radius:0; text-align:left; font:inherit; white-space:normal; }
|
||||
.wrap-lines .review-diff { overflow-x:hidden; white-space:normal; }
|
||||
.wrap-lines .review-diff-line { grid-template-columns:max-content minmax(0,1fr); min-width:0; }
|
||||
.wrap-lines .review-line-code { white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
|
||||
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
|
||||
.review-inline-composer[hidden] { display:none; }
|
||||
@@ -179,6 +224,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-reply { display:grid; gap:8px; margin-top:16px; }
|
||||
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
|
||||
.update-reply button { min-height:44px; width:100%; }
|
||||
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.update-reply-actions button { min-height:44px; width:100%; }
|
||||
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
@@ -189,6 +236,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.issue-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; }
|
||||
.issue-blockers h2 { margin-top:0; }
|
||||
.issue-blocker-list { display:grid; gap:8px; }
|
||||
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
|
||||
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
|
||||
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
|
||||
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
|
||||
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
.issue-planning-retry { min-height:44px; width:100%; }
|
||||
@@ -202,6 +255,22 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
.issue-attachment-controls { display:flex; max-width:100%; }
|
||||
.issue-attachment-trigger { min-height:44px; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; border:1px solid #60a5fa; border-radius:10px; color:#dbeafe; font-weight:700; cursor:pointer; }
|
||||
.issue-attachment-preview { display:grid; grid-template-columns:64px minmax(0,1fr); gap:8px 12px; align-items:center; max-width:100%; overflow:hidden; padding:10px; border:1px solid #315781; border-radius:10px; background:#101f34; }
|
||||
.issue-attachment-preview[hidden] { display:none; }
|
||||
.issue-attachment-preview img { grid-row:span 2; width:64px; height:64px; object-fit:cover; border-radius:8px; }
|
||||
.issue-attachment-preview .small { min-width:0; overflow-wrap:anywhere; }
|
||||
.issue-attachment-preview button { min-height:44px; width:auto; justify-self:start; }
|
||||
.mention-options { display:grid; max-width:100%; max-height:220px; overflow:auto; border:1px solid #315781; border-radius:10px; background:#101f34; box-shadow:0 10px 28px rgba(0,0,0,.35); }
|
||||
.mention-options[hidden] { display:none; }
|
||||
.mention-option { min-height:44px; max-width:100%; overflow:hidden; padding:9px 12px; border:0; border-bottom:1px solid #203a5c; border-radius:0; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:#101f34; color:#dbeafe; }
|
||||
.mention-option:last-child { border-bottom:0; }
|
||||
.mention-option[aria-selected="true"], .mention-option:hover { background:#17365a; color:#fff; }
|
||||
.mention-status:empty { display:none; }
|
||||
.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.comment-actions button { min-height:44px; width:100%; }
|
||||
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
|
||||
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
|
||||
@@ -226,7 +295,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.resume-today-session, .end-today-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav[hidden] { display:none; }
|
||||
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
||||
.work-session-nav button { min-height:44px; width:100%; }
|
||||
@@ -238,6 +308,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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 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; }
|
||||
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
|
||||
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
@@ -248,6 +320,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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-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-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.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; }
|
||||
@@ -264,10 +337,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.create-issue-sheet.open { display:flex; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
||||
.create-issue-repository-more { min-height:44px; width:100%; }
|
||||
.create-issue-form label { display:grid; gap:6px; }
|
||||
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||
@@ -335,6 +410,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.my-work-card { min-width:0; overflow-x:hidden; }
|
||||
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
|
||||
.card-planning > summary::-webkit-details-marker { display:none; }
|
||||
.card-planning:not([open]) > .card-planning-actions { display:none; }
|
||||
.card-planning[open] > .card-planning-actions { display:grid; margin-top:8px; }
|
||||
.plan-today-panel { width:100%; border-left:0; padding:14px; }
|
||||
.plan-today-item { grid-template-columns:1fr; }
|
||||
.plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; }
|
||||
.plan-today-candidate-actions { grid-template-columns:repeat(2,1fr); width:100%; }
|
||||
.plan-today-item-actions button { min-width:0; padding-inline:4px; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
|
||||
+1196
-61
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
function createDetailDefer({
|
||||
laterWork,
|
||||
session,
|
||||
continueSession = null,
|
||||
close,
|
||||
refresh,
|
||||
focus,
|
||||
@@ -16,6 +17,13 @@ function createDetailDefer({
|
||||
return false;
|
||||
}
|
||||
const inSession = session.active();
|
||||
if (continueSession && session.checkpointed?.(item)) {
|
||||
if (continueSession(item) === true) return true;
|
||||
laterWork.restore(item);
|
||||
refresh();
|
||||
announce('Could not remove this item from Today, so it was restored from Later.');
|
||||
return false;
|
||||
}
|
||||
if (!inSession && closeSheet) close();
|
||||
refresh();
|
||||
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
|
||||
|
||||
+20
-6
@@ -101,7 +101,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
function parseOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
|
||||
@@ -112,9 +112,11 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
id: 'stackchain.issue-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
kind: 'issue-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
status: item.status === 'completion' ? 'completion' : (item.status === 'attention' ? 'attention' : 'queued'),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued issue'),
|
||||
(item.status === 'completion' ? 'Created · ready to start' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
|
||||
continuation: item.status === 'completion' && item.completionIntent === 'create-and-start',
|
||||
delivery_state: item.deliveryState,
|
||||
repository: item.repository,
|
||||
title: textPreview(item.title) || 'Untitled queued issue',
|
||||
@@ -137,22 +139,34 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
|
||||
.map(item => {
|
||||
const isUpdate = item.kind === 'update-reply';
|
||||
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
const isReview = item.kind === 'pull-review';
|
||||
const isClosure = item.kind === 'issue-close';
|
||||
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
|
||||
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
|
||||
const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment =>
|
||||
String(comment.path || 'File') + ' · ' +
|
||||
(comment.new_position ? 'new line ' + comment.new_position : 'old line ' + comment.old_position) +
|
||||
': ' + String(comment.body || '')
|
||||
).filter(Boolean) : [];
|
||||
const copyText = [item.body, inlineFeedback.length ? 'Inline feedback\n' + inlineFeedback.join('\n') : '']
|
||||
.filter(Boolean).join('\n\n');
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
return {
|
||||
id: 'stackchain.authored-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
outbox_kind: item.kind,
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued message'),
|
||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||
(isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
|
||||
delivery_state: item.deliveryState,
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: item.body,
|
||||
copy_text: copyText,
|
||||
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
|
||||
+127
-5
@@ -48,7 +48,10 @@
|
||||
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
|
||||
</div>
|
||||
<div class="my-work-actions">
|
||||
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
|
||||
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
|
||||
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
|
||||
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
|
||||
<button class="find-work-action" id="find-work" type="button">Find work</button>
|
||||
<button class="new-issue" id="new-issue" type="button">New issue</button>
|
||||
</div>
|
||||
@@ -170,6 +173,55 @@
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<div class="plan-today-sheet" id="plan-today-sheet" role="dialog" aria-modal="true" aria-labelledby="plan-today-title" hidden>
|
||||
<section class="plan-today-panel">
|
||||
<div class="plan-today-header">
|
||||
<div><h2 id="plan-today-title">Plan Today</h2><p class="small muted">Choose and order the work you want to finish next.</p></div>
|
||||
<button id="cancel-plan-today" type="button">Cancel</button>
|
||||
</div>
|
||||
<div class="plan-today-capacity" id="plan-today-capacity" role="status" aria-live="polite">0 of 5 selected</div>
|
||||
<label class="plan-today-available" for="plan-today-available">Available today
|
||||
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
|
||||
</label>
|
||||
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
|
||||
<section aria-labelledby="today-plan-heading">
|
||||
<h3 id="today-plan-heading">Today, in order</h3>
|
||||
<div class="plan-today-list" id="plan-today-list"></div>
|
||||
</section>
|
||||
<section aria-labelledby="today-candidates-heading">
|
||||
<h3 id="today-candidates-heading">Available My Work</h3>
|
||||
<div class="plan-today-candidates" id="plan-today-candidates"></div>
|
||||
</section>
|
||||
<div class="plan-today-actions">
|
||||
<button id="save-today-plan" type="button">Save plan</button>
|
||||
<button id="save-and-start-today" type="button">Save & start</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
|
||||
<section class="today-readiness-panel">
|
||||
<div class="today-readiness-header">
|
||||
<div><div class="small">Today readiness</div><h2 id="today-readiness-title">This item is blocked</h2></div>
|
||||
<button id="today-readiness-close" type="button">Close</button>
|
||||
</div>
|
||||
<p id="today-readiness-summary" aria-live="polite"></p>
|
||||
<div class="today-readiness-item" id="today-readiness-item"></div>
|
||||
<div class="issue-blocker-list" id="today-readiness-blockers"></div>
|
||||
<div class="today-readiness-actions">
|
||||
<button id="today-readiness-next" type="button">Start next ready</button>
|
||||
<button id="today-readiness-anyway" type="button">Work this item anyway</button>
|
||||
<button id="today-readiness-retry" type="button">Retry status</button>
|
||||
<button id="today-readiness-plan" type="button">Review Today plan</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="plan-preview-actions" id="plan-preview-actions" hidden>
|
||||
<button id="back-to-plan" type="button">Back to plan</button>
|
||||
<button id="add-plan-preview" type="button">Add to Today & back</button>
|
||||
</div>
|
||||
|
||||
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
||||
<div class="cmd-palette-header">
|
||||
<strong>Search work</strong>
|
||||
@@ -192,7 +244,10 @@
|
||||
<div id="search-preview-meta" class="row"></div>
|
||||
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
|
||||
<div class="search-preview-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<div class="search-preview-primary-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
</div>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -228,6 +283,11 @@
|
||||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
|
||||
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
||||
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
|
||||
<section class="issue-blockers" id="issue-blockers" aria-labelledby="issue-blockers-title" hidden>
|
||||
<h2 id="issue-blockers-title">Blocked by</h2>
|
||||
<div class="issue-blocker-list" id="issue-blocker-list"></div>
|
||||
<div id="issue-blocker-status" class="small" aria-live="polite"></div>
|
||||
</section>
|
||||
<h2>Full conversation</h2>
|
||||
<div id="issue-comments"></div>
|
||||
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
|
||||
@@ -235,7 +295,21 @@
|
||||
<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>
|
||||
<button id="send-issue-comment" type="button">Post comment</button>
|
||||
<div class="mention-options" id="issue-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="issue-comment-mention-status" aria-live="polite"></div>
|
||||
<div class="issue-attachment-controls">
|
||||
<input class="visually-hidden" id="issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<label class="issue-attachment-trigger" for="issue-attachment">Attach screenshot</label>
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="issue-attachment-preview" hidden>
|
||||
<img id="issue-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="issue-attachment-meta"></span>
|
||||
<button id="remove-issue-attachment" type="button">Remove screenshot</button>
|
||||
</div>
|
||||
<div class="comment-actions">
|
||||
<button id="send-issue-comment" type="button">Post comment</button>
|
||||
<button id="send-issue-comment-next" type="button" hidden>Comment & next</button>
|
||||
</div>
|
||||
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<details class="issue-planning" id="issue-planning">
|
||||
@@ -299,6 +373,7 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-complete="issue" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -335,12 +410,27 @@
|
||||
<label for="create-issue-repository">Repository
|
||||
<select id="create-issue-repository" required></select>
|
||||
</label>
|
||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<label for="create-issue-title">Title
|
||||
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
|
||||
</label>
|
||||
<label for="create-issue-body">Description <span class="small">Optional</span>
|
||||
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
|
||||
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
|
||||
<div class="issue-attachment-controls">
|
||||
<label class="issue-attachment-trigger" for="create-issue-attachment">Attach screenshot</label>
|
||||
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
|
||||
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="create-issue-attachment-meta"></span>
|
||||
<button id="remove-create-issue-attachment" type="button">Remove</button>
|
||||
</div>
|
||||
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||
</section>
|
||||
<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>
|
||||
@@ -365,6 +455,7 @@
|
||||
<div class="create-issue-actions">
|
||||
<button id="save-unfiled-issue" type="button">Save for filing</button>
|
||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||
<button id="create-and-start-issue" type="submit">Create & start</button>
|
||||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -398,7 +489,12 @@
|
||||
<h2 id="update-reply-title">Reply</h2>
|
||||
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
|
||||
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
<button id="send-update-reply" type="button">Send reply</button>
|
||||
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
|
||||
<div class="update-reply-actions">
|
||||
<button id="send-update-reply" type="button">Send reply</button>
|
||||
<button id="send-update-reply-next" type="button" hidden>Reply & next</button>
|
||||
</div>
|
||||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="update-sheet-actions">
|
||||
@@ -411,6 +507,7 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-complete="update" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -431,7 +528,12 @@
|
||||
<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>
|
||||
<button id="send-pull-comment" type="button">Post comment</button>
|
||||
<div class="mention-options" id="pull-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="pull-comment-mention-status" aria-live="polite"></div>
|
||||
<div class="comment-actions">
|
||||
<button id="send-pull-comment" type="button">Post comment</button>
|
||||
<button id="send-pull-comment-next" type="button" hidden>Comment & next</button>
|
||||
</div>
|
||||
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<details class="pull-review" id="pull-review">
|
||||
@@ -451,6 +553,7 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-complete="pull" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -470,6 +573,10 @@
|
||||
<div class="review-sheet-body markdown-content" id="review-sheet-body"></div>
|
||||
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><button class="share-work-route" type="button">Share</button><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
|
||||
<h2>Changed files</h2>
|
||||
<div class="review-display-tools">
|
||||
<button id="review-wrap-lines" type="button" aria-pressed="false" aria-controls="review-files">Wrap lines</button>
|
||||
<span class="small">Keep long diffs inside the review sheet.</span>
|
||||
</div>
|
||||
<div id="review-files" class="muted"></div>
|
||||
<section class="review-inline-composer" id="review-inline-composer" aria-labelledby="review-inline-title" hidden>
|
||||
<strong id="review-inline-title">Comment on changed line</strong>
|
||||
@@ -515,6 +622,7 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-complete="review" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -533,7 +641,7 @@
|
||||
</dialog>
|
||||
|
||||
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page">Work <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span> <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
|
||||
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
||||
@@ -553,31 +661,45 @@
|
||||
<script src="static/background-issue-sync.js"></script>
|
||||
<script src="static/issue-outbox.js"></script>
|
||||
<script src="static/authored-outbox.js"></script>
|
||||
<script src="static/offline-issue-close.js"></script>
|
||||
<script src="static/notification-read-outbox.js"></script>
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
<script src="static/comment-next.js"></script>
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/update-ownership.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/conversation.js"></script>
|
||||
<script src="static/issue-attachment.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
<script src="static/assign-and-start.js"></script>
|
||||
<script src="static/queue-today.js"></script>
|
||||
<script src="static/pull-sheet.js"></script>
|
||||
<script src="static/review-sheet.js"></script>
|
||||
<script src="static/work-route.js"></script>
|
||||
<script src="static/task-overlay-history.js"></script>
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/install-app.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/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,155 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.issueAttachment = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
function create(options) {
|
||||
const readDataUrl = options.readDataUrl;
|
||||
const upload = options.upload;
|
||||
const createOperationId = options.createOperationId || (() => {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
});
|
||||
let selected = null;
|
||||
let confirmed = null;
|
||||
let serialized = null;
|
||||
let operationId = null;
|
||||
|
||||
function select(file) {
|
||||
if (!file || !IMAGE_TYPES.has(file.type)) {
|
||||
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
|
||||
}
|
||||
if (!Number.isFinite(file.size) || file.size <= 0 || file.size > MAX_BYTES) {
|
||||
throw new Error('Choose a screenshot that is 2 MB or smaller.');
|
||||
}
|
||||
selected = file;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
operationId = createOperationId();
|
||||
return state();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selected = null;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
operationId = null;
|
||||
}
|
||||
|
||||
function restore(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '');
|
||||
const data = String(value?.data || '');
|
||||
const padding = (data.match(/=*$/) || [''])[0].length;
|
||||
const size = Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
||||
select({ name: filename, type: contentType, size });
|
||||
serialized = { filename, contentType, data };
|
||||
return state();
|
||||
}
|
||||
|
||||
function state() {
|
||||
return selected ? {
|
||||
name: selected.name,
|
||||
size: selected.size,
|
||||
uploaded: Boolean(confirmed),
|
||||
} : null;
|
||||
}
|
||||
|
||||
async function serialize() {
|
||||
if (!selected) return null;
|
||||
if (!serialized) {
|
||||
const dataUrl = await readDataUrl(selected);
|
||||
const marker = ';base64,';
|
||||
const markerAt = String(dataUrl).indexOf(marker);
|
||||
if (markerAt < 0) throw new Error('The screenshot could not be read. Choose it again.');
|
||||
serialized = {
|
||||
filename: selected.name,
|
||||
contentType: selected.type,
|
||||
data: String(dataUrl).slice(markerAt + marker.length),
|
||||
};
|
||||
}
|
||||
return { ...serialized };
|
||||
}
|
||||
|
||||
async function prepareComment(item, body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!selected) return text;
|
||||
if (!confirmed) {
|
||||
const attachment = await serialize();
|
||||
confirmed = await upload({
|
||||
repository: item.repository,
|
||||
number: item.number,
|
||||
filename: attachment.filename,
|
||||
content_type: attachment.contentType,
|
||||
data: attachment.data,
|
||||
operation_id: operationId,
|
||||
});
|
||||
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
|
||||
confirmed = null;
|
||||
throw new Error('The server did not confirm the screenshot upload.');
|
||||
}
|
||||
}
|
||||
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
|
||||
}
|
||||
|
||||
return { select, restore, clear, state, serialize, prepareComment };
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
const controller = create(options);
|
||||
const clearSelection = controller.clear;
|
||||
let previewUrl = '';
|
||||
|
||||
function clearPreview() {
|
||||
if (previewUrl) options.revokeObjectURL(previewUrl);
|
||||
previewUrl = '';
|
||||
options.image.src = '';
|
||||
options.preview.hidden = true;
|
||||
options.input.value = '';
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
options.input.addEventListener('change', event => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
try {
|
||||
controller.select(file);
|
||||
} catch (error) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
return;
|
||||
}
|
||||
if (previewUrl) options.revokeObjectURL(previewUrl);
|
||||
previewUrl = options.createObjectURL(file);
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
||||
});
|
||||
options.remove.addEventListener('click', () => {
|
||||
clearPreview();
|
||||
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
|
||||
});
|
||||
|
||||
function restorePreview(value) {
|
||||
clearPreview();
|
||||
const restored = controller.restore(value);
|
||||
previewUrl = 'data:' + value.contentType + ';base64,' + value.data;
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
||||
return restored;
|
||||
}
|
||||
|
||||
return Object.assign(controller, { clear: clearPreview, restore: restorePreview });
|
||||
}
|
||||
|
||||
return { create, mount, MAX_BYTES };
|
||||
});
|
||||
+203
-38
@@ -5,16 +5,26 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
);
|
||||
const pending = new Map();
|
||||
|
||||
function captureAttachment(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '').slice(0, 255);
|
||||
const data = String(value?.data || '');
|
||||
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
|
||||
if (!data && value?.stored === true) return { filename, contentType, stored: true };
|
||||
if (!data) return undefined;
|
||||
return { filename, contentType, data };
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && typeof item === 'object');
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items, mirror = true) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 3, items }));
|
||||
coordinator?.notify('issue');
|
||||
if (mirror && backgroundSync?.reconcile) {
|
||||
Promise.resolve(backgroundSync.reconcile(items))
|
||||
@@ -23,7 +33,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(draft, mirror = true) {
|
||||
function prepareItem(draft) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
|
||||
const items = read();
|
||||
@@ -39,33 +49,60 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
if (attachment) item.attachment = attachment;
|
||||
item.operationId = item.id;
|
||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||
item.milestoneId = Number(draft.milestoneId);
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
|
||||
return item;
|
||||
}
|
||||
|
||||
function localIndexItem(item) {
|
||||
if (!item?.attachment?.data) return item;
|
||||
return {
|
||||
...item,
|
||||
attachment: {
|
||||
filename: item.attachment.filename,
|
||||
contentType: item.attachment.contentType,
|
||||
stored: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function enqueue(draft, mirror = true) {
|
||||
const items = read();
|
||||
const item = prepareItem(draft);
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function enqueueDurably(draft) {
|
||||
const item = enqueue(draft, false);
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
const item = enqueue(draft, false);
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
const item = prepareItem(draft);
|
||||
const current = read();
|
||||
try {
|
||||
await backgroundSync.reconcile(read());
|
||||
await backgroundSync.reconcile([...current, item]);
|
||||
const localItem = localIndexItem(item);
|
||||
write([...current, item].map(localIndexItem), false);
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
return { item: localItem, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item, background: false, durability: 'foreground-only', error };
|
||||
const persisted = read().find(candidate => candidate.id === item.id);
|
||||
if (!persisted) throw error;
|
||||
return { item: persisted, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function update(id, draft, mirror = true) {
|
||||
function prepareUpdate(id, draft) {
|
||||
let updated = null;
|
||||
write(read().map(item => {
|
||||
const items = read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const nextRepository = String(draft?.repository || '');
|
||||
const nextTitle = String(draft?.title || '');
|
||||
@@ -75,37 +112,56 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
? Number(draft.milestoneId) : undefined;
|
||||
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||
? String(draft.dueDate) : undefined;
|
||||
const nextAttachment = captureAttachment(draft?.attachment);
|
||||
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
|
||||
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
||||
|| attachmentChanged;
|
||||
updated = {
|
||||
...item,
|
||||
repository: nextRepository, title: nextTitle,
|
||||
body: nextBody, labelIds: nextLabelIds,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
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;
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
}), mirror);
|
||||
});
|
||||
return { items, updated };
|
||||
}
|
||||
|
||||
function update(id, draft, mirror = true) {
|
||||
const { items, updated } = prepareUpdate(id, draft);
|
||||
write(items, mirror);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function updateDurably(id, draft) {
|
||||
const item = update(id, draft, false);
|
||||
if (!item || !backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
const item = update(id, draft, false);
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
const { items, updated: item } = prepareUpdate(id, draft);
|
||||
if (!item) return { item, background: false, durability: 'foreground-only' };
|
||||
try {
|
||||
await backgroundSync.reconcile(read());
|
||||
await backgroundSync.reconcile(items);
|
||||
const localItems = items.map(localIndexItem);
|
||||
write(localItems, false);
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
return { item: localIndexItem(item), background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item, background: false, durability: 'foreground-only', error };
|
||||
const persisted = read().find(candidate => candidate.id === id);
|
||||
if (!persisted || persisted.operationId !== item.operationId) throw error;
|
||||
return { item: persisted, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +172,72 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return true;
|
||||
}
|
||||
|
||||
function persistDeliveryStage(id, stage) {
|
||||
write(read().map(item => item.id === id ? { ...item, ...stage } : item));
|
||||
}
|
||||
|
||||
function stageOperationId(operationId, stage) {
|
||||
const suffix = ':' + stage;
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
async function sendDirect(item, repository) {
|
||||
let issue = item.deliveredIssue;
|
||||
if (!issue) {
|
||||
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
});
|
||||
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
|
||||
}
|
||||
if (!item.attachment) return issue;
|
||||
let markdown = item.attachmentMarkdown;
|
||||
if (!markdown) {
|
||||
const uploaded = await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: item.attachment.filename,
|
||||
content_type: item.attachment.contentType,
|
||||
data: item.attachment.data,
|
||||
}),
|
||||
},
|
||||
);
|
||||
markdown = String(uploaded?.markdown || '');
|
||||
if (!markdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
|
||||
}
|
||||
await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
|
||||
},
|
||||
body: JSON.stringify({ body: markdown }),
|
||||
},
|
||||
);
|
||||
return issue;
|
||||
}
|
||||
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
@@ -132,22 +254,11 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
issue = delivery.issue;
|
||||
} else {
|
||||
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
});
|
||||
issue = await sendDirect(item, repository);
|
||||
}
|
||||
if (!issue) return { blocked: true };
|
||||
discard(item.id);
|
||||
return { issue };
|
||||
return { issue, item };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status >= 400 && status < 500) {
|
||||
@@ -166,16 +277,26 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
const completions = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
if (item.status === 'attention' || item.status === 'completion') continue;
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const result = await sendItem(item, currentLogin);
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.issue) {
|
||||
confirmed.push(result.issue);
|
||||
if (result.item?.completionIntent) completions.push({
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
issue: result.issue,
|
||||
});
|
||||
}
|
||||
if (result.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
return { confirmed, completions, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
@@ -202,7 +323,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
write(read().map(candidate => candidate.id === id ? queued : candidate));
|
||||
}
|
||||
const result = await sendItem(queued, currentLogin);
|
||||
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
|
||||
return {
|
||||
confirmed: result.issue ? [result.issue] : [],
|
||||
completions: result.issue && result.item?.completionIntent ? [{
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
issue: result.issue,
|
||||
}] : [],
|
||||
remaining: read(), blocked: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function retry(id, currentLogin) {
|
||||
@@ -214,18 +345,52 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
const background = statuses.get(item.id);
|
||||
if (background?.status === 'sent') return [];
|
||||
const deliveryStage = {
|
||||
...(background?.deliveredIssue ? { deliveredIssue: background.deliveredIssue } : {}),
|
||||
...(background?.attachmentMarkdown ? { attachmentMarkdown: background.attachmentMarkdown } : {}),
|
||||
};
|
||||
if (background?.status === 'sent') {
|
||||
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
|
||||
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
|
||||
}];
|
||||
return [];
|
||||
}
|
||||
if (background?.status === 'attention') return [{
|
||||
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...localIndexItem(item), ...deliveryStage, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
return [item];
|
||||
return [{ ...localIndexItem(item), ...deliveryStage }];
|
||||
});
|
||||
write(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
function pendingCompletions(currentLogin) {
|
||||
const login = String(currentLogin || '').trim();
|
||||
if (!login) return [];
|
||||
return read().filter(item => item.status === 'completion' && item.ownerLogin === login &&
|
||||
item.completionIntent === 'create-and-start' && item.deliveredIssue).map(item => ({
|
||||
id: item.id,
|
||||
intent: item.completionIntent,
|
||||
ownerLogin: item.ownerLogin,
|
||||
operationId: item.operationId,
|
||||
issue: item.deliveredIssue,
|
||||
}));
|
||||
}
|
||||
|
||||
function completeIntent(id, currentLogin) {
|
||||
const login = String(currentLogin || '').trim();
|
||||
const items = read();
|
||||
const matched = items.some(item => item.id === id && item.status === 'completion' && item.ownerLogin === login);
|
||||
if (!matched) return false;
|
||||
write(items.filter(item => item.id !== id));
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground,
|
||||
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
|
||||
@@ -0,0 +1,51 @@
|
||||
function createLaterAndStart({ todayWork, todaySync, laterWork, refresh, warm, start, announce }) {
|
||||
const pending = new Map();
|
||||
|
||||
async function run(item, identity) {
|
||||
const added = todayWork.add(item);
|
||||
if (added === 'full') {
|
||||
announce('Today is limited to 5 items. Remove one, then try Start now again.');
|
||||
return 'full';
|
||||
}
|
||||
if (added !== 'added' && added !== 'exists') {
|
||||
announce('Could not save Today on this device. The item remains in Later; try again.');
|
||||
return added;
|
||||
}
|
||||
if (added === 'added' && !todaySync.enqueue('add', identity)) {
|
||||
todayWork.remove(item);
|
||||
announce('Today sync is unavailable. The item remains in Later; try again.');
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
if (!laterWork.restore(item)) {
|
||||
if (added === 'added') {
|
||||
todayWork.remove(item);
|
||||
todaySync.enqueue('remove', identity);
|
||||
todaySync.flush();
|
||||
}
|
||||
announce('Could not remove this item from Later. Nothing was started; try again.');
|
||||
return 'later-unavailable';
|
||||
}
|
||||
refresh();
|
||||
if (added === 'added') todaySync.flush();
|
||||
warm();
|
||||
const outcome = await start(item);
|
||||
if (outcome === 'gated') {
|
||||
announce('Moved to Today. Choose how to handle its blocker before starting.');
|
||||
return 'gated';
|
||||
}
|
||||
announce(added === 'exists' ? 'Opened the existing Today item.' : 'Moved to Today and opened.');
|
||||
return 'started';
|
||||
}
|
||||
|
||||
function startDeferred(item) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (pending.has(identity)) return pending.get(identity);
|
||||
const operation = run(item, identity).finally(() => pending.delete(identity));
|
||||
pending.set(identity, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
return { start: startDeferred };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterAndStart;
|
||||
+38
-12
@@ -1,5 +1,6 @@
|
||||
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
|
||||
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
|
||||
const prefix = 'stackchain.later-sync.v1.';
|
||||
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
|
||||
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
|
||||
@@ -8,6 +9,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
let expiredCount = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
@@ -105,17 +107,32 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
const records = [...keys].map(recordKey => {
|
||||
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||
return record ? { ...record, recordKey } : null;
|
||||
}).filter(record => record?.operation)
|
||||
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
||||
left.operation.operation_id.localeCompare(right.operation.operation_id));
|
||||
const latestByItem = new Map();
|
||||
records.forEach(record => latestByItem.set(record.operation.item_id, record));
|
||||
records.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
|
||||
}).filter(record => record?.operation);
|
||||
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
||||
now() - Number(record.queued_at) > maxOfflineMs);
|
||||
expired.forEach(record => {
|
||||
storage.removeItem(record.recordKey);
|
||||
knownOperationKeys.delete(record.recordKey);
|
||||
});
|
||||
return records.filter(record => latestByItem.get(record.operation.item_id) === record)
|
||||
.map(record => record.operation).filter(operation =>
|
||||
if (expired.length) {
|
||||
expiredCount += expired.length;
|
||||
onStatus?.('expired', { count: expiredCount });
|
||||
}
|
||||
const activeRecords = records.filter(record => !expired.includes(record))
|
||||
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
||||
left.operation.operation_id.localeCompare(right.operation.operation_id));
|
||||
const latestByItem = new Map();
|
||||
activeRecords.forEach(record => latestByItem.set(record.operation.item_id, record));
|
||||
activeRecords.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
|
||||
storage.removeItem(record.recordKey);
|
||||
knownOperationKeys.delete(record.recordKey);
|
||||
});
|
||||
return activeRecords.filter(record => latestByItem.get(record.operation.item_id) === record)
|
||||
.map(record => ({
|
||||
...record.operation,
|
||||
base_revision: Number.isInteger(record.operation.base_revision)
|
||||
? record.operation.base_revision : 0,
|
||||
})).filter(operation =>
|
||||
operation && typeof operation.operation_id === 'string' &&
|
||||
['defer', 'restore'].includes(operation.action) && typeof operation.item_id === 'string' &&
|
||||
(operation.action === 'restore' || typeof operation.wake_at === 'string'));
|
||||
@@ -148,13 +165,16 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
|
||||
pending().filter(operation => operation.item_id === itemId)
|
||||
.forEach(operation => removeOperation(operation.operation_id));
|
||||
const operation = { operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt };
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('later');
|
||||
@@ -183,9 +203,11 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
const ownerKey = key();
|
||||
if (!ownerKey) return false;
|
||||
ensureChannel();
|
||||
expiredCount = 0;
|
||||
try {
|
||||
let operations = pending();
|
||||
let plan;
|
||||
let conflictCount = 0;
|
||||
if (!operations.length) plan = await fetchJson('api/v1/later');
|
||||
while (operations.length) {
|
||||
if (key() !== ownerKey) return false;
|
||||
@@ -202,6 +224,8 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
...(plan.duplicate_operation_ids || []),
|
||||
...(plan.rejected_operations || []).map(item => item.operation_id),
|
||||
] : batch.map(item => item.operation_id);
|
||||
conflictCount += (plan.rejected_operations || [])
|
||||
.filter(item => item.reason === 'stale_intent').length;
|
||||
for (const operationId of received) {
|
||||
if (pending().some(candidate => candidate.operation_id === operationId) &&
|
||||
!removeOperation(operationId)) {
|
||||
@@ -211,7 +235,9 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' : 'saved');
|
||||
onStatus?.(pending().length ? 'pending' :
|
||||
(conflictCount ? 'conflict' : expiredCount ? 'expired' : 'saved'),
|
||||
conflictCount ? { count: conflictCount } : expiredCount ? { count: expiredCount } : {});
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return true;
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
|
||||
return;
|
||||
}
|
||||
if (reason === 'session-idle') {
|
||||
status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.';
|
||||
return;
|
||||
}
|
||||
if (reason !== 'session-revoked') return;
|
||||
button.disabled = true;
|
||||
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createMentionComposer = api.create;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function() {
|
||||
'use strict';
|
||||
|
||||
function activeMention(value, caret) {
|
||||
const before = String(value || '').slice(0, Math.max(0, caret));
|
||||
const match = before.match(/(^|\s)@([A-Za-z0-9_.-]{2,39})$/);
|
||||
if (!match) return null;
|
||||
const start = before.length - match[2].length - 1;
|
||||
return { start, end:before.length, query:match[2] };
|
||||
}
|
||||
|
||||
function insertMention(value, mention, login) {
|
||||
const text = String(value || '');
|
||||
const suffixStart = mention.end < text.length && /\s/.test(text[mention.end]) ? mention.end + 1 : mention.end;
|
||||
const inserted = '@' + login + ' ';
|
||||
return {
|
||||
value:text.slice(0, mention.start) + inserted + text.slice(suffixStart),
|
||||
caret:mention.start + inserted.length,
|
||||
};
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const textarea = options.textarea;
|
||||
const listbox = options.listbox;
|
||||
const status = options.status;
|
||||
const setTimer = options.setTimer || setTimeout;
|
||||
const clearTimer = options.clearTimer || clearTimeout;
|
||||
const createOption = options.createOption || (() => document.createElement('button'));
|
||||
let timer = null;
|
||||
let requestId = 0;
|
||||
let mention = null;
|
||||
let candidates = [];
|
||||
let activeIndex = -1;
|
||||
|
||||
function dismiss() {
|
||||
requestId += 1;
|
||||
candidates = [];
|
||||
activeIndex = -1;
|
||||
listbox.replaceChildren();
|
||||
listbox.hidden = true;
|
||||
textarea.setAttribute('aria-expanded', 'false');
|
||||
textarea.removeAttribute('aria-activedescendant');
|
||||
status.textContent = '';
|
||||
}
|
||||
|
||||
function activate(index) {
|
||||
if (!candidates.length) return;
|
||||
activeIndex = (index + candidates.length) % candidates.length;
|
||||
listbox.children.forEach((option, optionIndex) => {
|
||||
option.setAttribute('aria-selected', optionIndex === activeIndex ? 'true' : 'false');
|
||||
});
|
||||
textarea.setAttribute('aria-activedescendant', listbox.children[activeIndex].id);
|
||||
}
|
||||
|
||||
function select(index) {
|
||||
if (!mention || !candidates[index]) return;
|
||||
const result = insertMention(textarea.value, mention, candidates[index].login);
|
||||
textarea.value = result.value;
|
||||
textarea.setSelectionRange(result.caret, result.caret);
|
||||
dismiss();
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent?.(new Event('input', { bubbles:true }));
|
||||
}
|
||||
|
||||
function render(items) {
|
||||
candidates = Array.isArray(items) ? items : [];
|
||||
activeIndex = -1;
|
||||
listbox.replaceChildren();
|
||||
candidates.forEach((candidate, index) => {
|
||||
const option = createOption();
|
||||
option.type = 'button';
|
||||
option.id = listbox.id + '-option-' + index;
|
||||
option.className = 'mention-option';
|
||||
option.dataset.login = candidate.login;
|
||||
option.setAttribute('role', 'option');
|
||||
option.setAttribute('aria-selected', 'false');
|
||||
option.textContent = '@' + candidate.login + (candidate.name !== candidate.login ? ' · ' + candidate.name : '');
|
||||
option.addEventListener('pointerdown', event => {
|
||||
event.preventDefault();
|
||||
select(index);
|
||||
});
|
||||
listbox.appendChild(option);
|
||||
});
|
||||
listbox.hidden = !candidates.length;
|
||||
textarea.setAttribute('aria-expanded', candidates.length ? 'true' : 'false');
|
||||
status.textContent = candidates.length ? candidates.length + ' teammates found.' : 'No matching teammates.';
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
mention = activeMention(textarea.value, textarea.selectionStart);
|
||||
const repository = options.getRepository();
|
||||
if (!mention || !repository) {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
const currentRequest = ++requestId;
|
||||
timer = setTimer(async () => {
|
||||
try {
|
||||
const items = await options.loadCandidates(repository, mention.query);
|
||||
if (currentRequest === requestId && repository === options.getRepository()) render(items);
|
||||
} catch (_error) {
|
||||
if (currentRequest === requestId) {
|
||||
listbox.hidden = true;
|
||||
textarea.setAttribute('aria-expanded', 'false');
|
||||
status.textContent = 'Teammate suggestions unavailable; you can keep typing.';
|
||||
}
|
||||
}
|
||||
}, options.delayMs ?? 180);
|
||||
}
|
||||
|
||||
function keydown(event) {
|
||||
if (event.key === 'Escape' && !listbox.hidden) {
|
||||
event.preventDefault();
|
||||
dismiss();
|
||||
} else if (event.key === 'ArrowDown' && candidates.length) {
|
||||
event.preventDefault();
|
||||
activate(activeIndex + 1);
|
||||
} else if (event.key === 'ArrowUp' && candidates.length) {
|
||||
event.preventDefault();
|
||||
activate(activeIndex < 0 ? candidates.length - 1 : activeIndex - 1);
|
||||
} else if (event.key === 'Enter' && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
select(activeIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
textarea.setAttribute('autocomplete', 'off');
|
||||
textarea.setAttribute('aria-autocomplete', 'list');
|
||||
textarea.setAttribute('aria-controls', listbox.id);
|
||||
textarea.setAttribute('aria-expanded', 'false');
|
||||
textarea.addEventListener('input', schedule);
|
||||
textarea.addEventListener('click', schedule);
|
||||
textarea.addEventListener('keydown', keydown);
|
||||
},
|
||||
dismiss,
|
||||
};
|
||||
}
|
||||
|
||||
return { activeMention, insertMention, create };
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
const overlays = options.overlays || [];
|
||||
let launcher = null;
|
||||
let wasHidden = false;
|
||||
let workMode = 'work';
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
@@ -34,9 +35,17 @@
|
||||
refreshVisibility();
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
function updateWork(mode, count) {
|
||||
const labels = {
|
||||
continue: ['Continue', 'Continue Today'],
|
||||
resume: ['Resume', 'Resume Today'],
|
||||
start: ['Start', 'Start Today'],
|
||||
work: ['Work', 'Work'],
|
||||
};
|
||||
workMode = labels[mode] ? mode : 'work';
|
||||
const total = Math.max(0, Number(count) || 0);
|
||||
const badge = options.attentionBadge;
|
||||
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
|
||||
if (badge) {
|
||||
badge.textContent = String(total);
|
||||
badge.hidden = total === 0;
|
||||
@@ -44,10 +53,14 @@
|
||||
if (buttons.work) {
|
||||
buttons.work.setAttribute(
|
||||
'aria-label',
|
||||
total ? 'Work, ' + total + ' items need attention' : 'Work'
|
||||
labels[workMode][1] + (total ? ', ' + total + ' items need attention' : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention};
|
||||
function updateAttention(count) {
|
||||
updateWork(workMode, count);
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention, updateWork};
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileWorkEntry = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
return 'work';
|
||||
}
|
||||
|
||||
function open() {
|
||||
const current = mode();
|
||||
if (current === 'continue') options.continueToday();
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else options.openFallback();
|
||||
return current;
|
||||
}
|
||||
|
||||
return {mode, open};
|
||||
});
|
||||
+123
-5
@@ -472,10 +472,67 @@ function workIdentity(item) {
|
||||
return [kind, repository, number, notification].join(':');
|
||||
}
|
||||
|
||||
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
|
||||
function createWorkSessionCheckpoint({
|
||||
storage,
|
||||
getLogin,
|
||||
onError = () => {},
|
||||
key = 'stackchain.today-session.v1',
|
||||
}) {
|
||||
const login = () => String(getLogin() || '').trim();
|
||||
let errorReported = false;
|
||||
const reportError = error => {
|
||||
if (errorReported) return;
|
||||
errorReported = true;
|
||||
onError(error);
|
||||
};
|
||||
const read = () => {
|
||||
const owner = login();
|
||||
if (!owner) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (value?.version !== 1 || value.login !== owner || typeof value.identity !== 'string' ||
|
||||
!Number.isInteger(value.index) || value.index < 0) return null;
|
||||
return value;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return {
|
||||
read,
|
||||
save(identity, index) {
|
||||
const owner = login();
|
||||
if (!owner) return false;
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ version:1, login:owner, identity, index }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
if (!read()) return false;
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkSession({
|
||||
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
|
||||
checkpointEnabled = () => true,
|
||||
onOpen, onProgress, onFinish,
|
||||
}) {
|
||||
let currentIdentity = '';
|
||||
let currentIndex = -1;
|
||||
let running = false;
|
||||
let durable = false;
|
||||
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||
const report = (items, index) => onProgress({
|
||||
@@ -485,9 +542,12 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||
can_next: index < items.length - 1,
|
||||
});
|
||||
const finish = () => {
|
||||
const clearCheckpoint = durable;
|
||||
running = false;
|
||||
durable = false;
|
||||
currentIdentity = '';
|
||||
currentIndex = -1;
|
||||
if (clearCheckpoint) checkpoint?.clear();
|
||||
onFinish();
|
||||
return false;
|
||||
};
|
||||
@@ -495,6 +555,7 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||
if (!items.length || index < 0 || index >= items.length) return finish();
|
||||
currentIndex = index;
|
||||
currentIdentity = workIdentity(items[index]);
|
||||
if (durable) checkpoint?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
@@ -502,11 +563,40 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
start() {
|
||||
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
|
||||
end: () => finish(),
|
||||
resumable: () => Boolean(checkpoint?.read()),
|
||||
reopen(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const requestedIdentity = requested ? workIdentity(requested) : currentIdentity;
|
||||
const index = items.findIndex(item => workIdentity(item) === requestedIdentity);
|
||||
if (index < 0) return false;
|
||||
currentIdentity = requestedIdentity;
|
||||
currentIndex = index;
|
||||
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
},
|
||||
resume(requested = null) {
|
||||
const saved = checkpoint?.read();
|
||||
if (!saved) return false;
|
||||
durable = true;
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
return openAt(items, 0);
|
||||
const exact = items.findIndex(item => workIdentity(item) ===
|
||||
(requested ? workIdentity(requested) : saved.identity));
|
||||
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
|
||||
},
|
||||
start(item = null) {
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
durable = Boolean(checkpoint && checkpointEnabled());
|
||||
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
|
||||
return openAt(items, requested >= 0 ? requested : 0);
|
||||
},
|
||||
reconcile() {
|
||||
if (!running) return false;
|
||||
@@ -523,21 +613,48 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
return index > 0 ? openAt(items, index - 1) : false;
|
||||
},
|
||||
next() {
|
||||
next(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (requested) {
|
||||
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
|
||||
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
|
||||
}
|
||||
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
|
||||
},
|
||||
complete() {
|
||||
complete(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
if (requested) {
|
||||
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
|
||||
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
|
||||
}
|
||||
const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (stillPresent >= 0) {
|
||||
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
|
||||
}
|
||||
return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
|
||||
},
|
||||
items: () => queue().slice(),
|
||||
target(action) {
|
||||
const items = queue();
|
||||
if (!items.length) return null;
|
||||
if (action === 'start') return items[0];
|
||||
if (action === 'resume') {
|
||||
const saved = checkpoint?.read();
|
||||
if (!saved) return null;
|
||||
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
|
||||
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
|
||||
}
|
||||
const current = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (action === 'continue') return current >= 0 ? items[current] : null;
|
||||
if (action === 'next') return current >= 0 && current < items.length - 1 ? items[current + 1] : null;
|
||||
if (action === 'complete') {
|
||||
return items[current >= 0 ? Math.min(current + 1, items.length - 1) : Math.min(currentIndex, items.length - 1)];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -611,6 +728,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
|
||||
buildMyWork.workIdentity = workIdentity;
|
||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
function createOfflineIssueClose({ enqueueDurably, completeToday, createOperationId = () =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) }) {
|
||||
return async function closeOfflineIssue(item) {
|
||||
const admission = await enqueueDurably({
|
||||
kind: 'issue-close',
|
||||
repository: String(item.repository || ''),
|
||||
number: Number(item.number || 0),
|
||||
body: '',
|
||||
operationId: String(createOperationId()).slice(0, 128),
|
||||
});
|
||||
const advanced = Boolean(completeToday(item, {
|
||||
successMessage: 'Issue closure queued. Next Today item opened.',
|
||||
failureMessage: 'Issue closure queued, but Today still needs completion.',
|
||||
}));
|
||||
return { admission, advanced };
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueClose;
|
||||
@@ -0,0 +1,63 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createPlanTodayPreview = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
return function createPlanTodayPreview({
|
||||
planner,
|
||||
identity,
|
||||
getScroll = () => 0,
|
||||
setScroll = () => {},
|
||||
onOpen = () => {},
|
||||
onClose = () => {},
|
||||
}) {
|
||||
let current = null;
|
||||
|
||||
function open(item, trigger = null) {
|
||||
if (!item || !identity?.(item) || !planner?.snapshot().open) return false;
|
||||
current = { item, trigger, scroll:Number(getScroll()) || 0 };
|
||||
onOpen(item, trigger);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setDependencies({ available, dependencies } = {}) {
|
||||
if (!current) return false;
|
||||
current.dependencies_available = available === true;
|
||||
current.dependencies = Array.isArray(dependencies) ? dependencies.filter(item => item?.state === 'open') : [];
|
||||
current.requires_override = !current.dependencies_available || current.dependencies.length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function close({ add = false, override = false } = {}) {
|
||||
if (!current) return 'closed';
|
||||
if (add && current.requires_override && !override) {
|
||||
return current.dependencies_available ? 'blocked' : 'dependencies-unavailable';
|
||||
}
|
||||
const { item, trigger, scroll } = current;
|
||||
let result = 'returned';
|
||||
if (add) {
|
||||
const id = identity(item);
|
||||
result = planner.snapshot().ids.includes(id) ? 'already-added' : planner.toggle(item);
|
||||
}
|
||||
current = null;
|
||||
setScroll(scroll);
|
||||
onClose(item, trigger);
|
||||
return result;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
if (!current) return { open:false };
|
||||
const state = { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll };
|
||||
if (Object.prototype.hasOwnProperty.call(current, 'dependencies_available')) {
|
||||
state.dependencies_available = current.dependencies_available;
|
||||
state.dependencies = current.dependencies;
|
||||
state.requires_override = current.requires_override;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
return { open, close, setDependencies, snapshot };
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
let openState = false;
|
||||
let draftIds = [];
|
||||
let itemsById = new Map();
|
||||
let capacityMinutes = null;
|
||||
let estimates = {};
|
||||
let capacityAware = false;
|
||||
|
||||
function cleanItems(items) {
|
||||
const unique = new Map();
|
||||
for (const item of items || []) {
|
||||
const id = identity?.(item);
|
||||
if (id && !unique.has(id)) unique.set(id, item);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function open(selectedItems, candidates, planning = null) {
|
||||
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
|
||||
draftIds = [];
|
||||
for (const item of selectedItems || []) {
|
||||
const id = identity?.(item);
|
||||
if (id && itemsById.has(id) && !draftIds.includes(id) && draftIds.length < limit) draftIds.push(id);
|
||||
}
|
||||
capacityAware = Boolean(planning && ('capacity_minutes' in planning || 'estimates' in planning));
|
||||
capacityMinutes = Number.isInteger(planning?.capacity_minutes) && planning.capacity_minutes > 0
|
||||
? planning.capacity_minutes : null;
|
||||
estimates = {};
|
||||
for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
|
||||
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
|
||||
}
|
||||
openState = true;
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function toggle(item) {
|
||||
if (!openState) return 'closed';
|
||||
const id = identity?.(item);
|
||||
if (!id) return 'unavailable';
|
||||
itemsById.set(id, item);
|
||||
const index = draftIds.indexOf(id);
|
||||
if (index >= 0) {
|
||||
draftIds.splice(index, 1);
|
||||
return 'removed';
|
||||
}
|
||||
if (draftIds.length >= limit) return 'full';
|
||||
draftIds.push(id);
|
||||
return 'added';
|
||||
}
|
||||
|
||||
function move(id, direction) {
|
||||
const index = draftIds.indexOf(id);
|
||||
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
|
||||
if (!openState || index < 0 || target < 0 || target >= draftIds.length) return false;
|
||||
[draftIds[index], draftIds[target]] = [draftIds[target], draftIds[index]];
|
||||
return true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
openState = false;
|
||||
draftIds = [];
|
||||
itemsById = new Map();
|
||||
capacityMinutes = null;
|
||||
estimates = {};
|
||||
capacityAware = false;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
|
||||
function setCapacity(minutes) {
|
||||
capacityAware = true;
|
||||
capacityMinutes = Number.isInteger(minutes) && minutes > 0 ? minutes : null;
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setEstimate(id, minutes) {
|
||||
if (!openState || !draftIds.includes(id)) return false;
|
||||
capacityAware = true;
|
||||
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
|
||||
else delete estimates[id];
|
||||
return true;
|
||||
}
|
||||
|
||||
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
|
||||
if (!openState) return 'closed';
|
||||
const ids = [...draftIds];
|
||||
const state = snapshot();
|
||||
if (state.over_capacity && !confirmOverCapacity) return 'confirm-over-capacity';
|
||||
const payload = capacityAware ? {
|
||||
ids,
|
||||
capacity_minutes: capacityMinutes,
|
||||
estimates: Object.fromEntries(ids.filter(id => estimates[id]).map(id => [id, estimates[id]])),
|
||||
} : ids;
|
||||
if (save?.(payload) === false) return 'unavailable';
|
||||
const first = ids.length ? itemsById.get(ids[0]) : null;
|
||||
close();
|
||||
if (startAfterSave && first) start?.(first);
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const basic = { open: openState, ids: [...draftIds], count: draftIds.length, limit };
|
||||
if (!capacityAware) return basic;
|
||||
const selectedEstimates = Object.fromEntries(draftIds.filter(id => estimates[id]).map(id => [id, estimates[id]]));
|
||||
const plannedMinutes = Object.values(selectedEstimates).reduce((total, value) => total + value, 0);
|
||||
const remainingMinutes = capacityMinutes === null ? null : capacityMinutes - plannedMinutes;
|
||||
return {
|
||||
...basic,
|
||||
capacity_minutes: capacityMinutes,
|
||||
estimates: selectedEstimates,
|
||||
planned_minutes: plannedMinutes,
|
||||
remaining_minutes: remainingMinutes,
|
||||
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
|
||||
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
|
||||
};
|
||||
}
|
||||
|
||||
function item(id) {
|
||||
return itemsById.get(id) || null;
|
||||
}
|
||||
|
||||
function candidates() {
|
||||
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
|
||||
}
|
||||
|
||||
return { open, toggle, move, cancel, setCapacity, setEstimate, commit, snapshot, item, candidates };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
|
||||
@@ -0,0 +1,17 @@
|
||||
function createQueueToday({ todayWork, todaySync, refresh, warm }) {
|
||||
return function queueToday(issue) {
|
||||
const added = todayWork.add(issue);
|
||||
if (added !== 'added' && added !== 'exists') return added;
|
||||
if (added === 'added' && !todaySync.enqueue('add', todayWork.identity(issue))) {
|
||||
refresh();
|
||||
warm();
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
refresh();
|
||||
if (added === 'added') todaySync.flush();
|
||||
warm();
|
||||
return 'queued';
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createQueueToday;
|
||||
@@ -47,6 +47,28 @@ function createReviewController({
|
||||
return { load, submit };
|
||||
}
|
||||
|
||||
function createWrapPreference({ storage, mobile }) {
|
||||
const key = 'stackchain.review-wrap.v1';
|
||||
let explicit = false;
|
||||
let wrapped = Boolean(mobile);
|
||||
try {
|
||||
const saved = storage?.getItem(key);
|
||||
if (saved === 'true' || saved === 'false') {
|
||||
explicit = true;
|
||||
wrapped = saved === 'true';
|
||||
}
|
||||
} catch (_error) { /* keep the viewport default */ }
|
||||
|
||||
function snapshot() { return { wrapped, explicit }; }
|
||||
function setWrapped(value) {
|
||||
wrapped = Boolean(value);
|
||||
explicit = true;
|
||||
try { storage?.setItem(key, String(wrapped)); } catch (_error) { /* keep in memory */ }
|
||||
return snapshot();
|
||||
}
|
||||
return { snapshot, setWrapped, storageKey: key };
|
||||
}
|
||||
|
||||
function diffLineClass(line) {
|
||||
if (line.startsWith('@@')) return 'hunk';
|
||||
if (line.startsWith('+')) return 'added';
|
||||
@@ -70,16 +92,19 @@ function parseDiffLines(lines) {
|
||||
return { text, kind: diffLineClass(text), commentable: false };
|
||||
}
|
||||
if (text.startsWith('-')) {
|
||||
const row = { text, kind: 'removed', commentable: true, old_position: oldLine };
|
||||
const row = { text, kind: 'removed', commentable: true, old_line: oldLine, old_position: oldLine };
|
||||
oldLine += 1;
|
||||
return row;
|
||||
}
|
||||
if (text.startsWith('+')) {
|
||||
const row = { text, kind: 'added', commentable: true, new_position: newLine };
|
||||
const row = { text, kind: 'added', commentable: true, new_line: newLine, new_position: newLine };
|
||||
newLine += 1;
|
||||
return row;
|
||||
}
|
||||
const row = { text, kind: 'context', commentable: true, new_position: newLine };
|
||||
const row = {
|
||||
text, kind: 'context', commentable: true,
|
||||
old_line: oldLine, new_line: newLine, new_position: newLine,
|
||||
};
|
||||
oldLine += 1;
|
||||
newLine += 1;
|
||||
return row;
|
||||
@@ -91,8 +116,14 @@ function renderDiffFile(file, index, escapeHtml) {
|
||||
let preview;
|
||||
if (file.diff_available) {
|
||||
const lines = parseDiffLines(file.diff_lines).map(row => {
|
||||
const oldNumber = row.old_line || row.old_position || '';
|
||||
const newNumber = row.new_line || row.new_position || '';
|
||||
const content = '<span class="review-line-numbers" aria-hidden="true">' +
|
||||
'<span class="review-line-number old">' + oldNumber + '</span>' +
|
||||
'<span class="review-line-number new">' + newNumber + '</span></span>' +
|
||||
'<span class="review-line-code">' + escapeHtml(row.text) + '</span>';
|
||||
if (!row.commentable) {
|
||||
return '<span class="review-diff-line ' + row.kind + '">' + escapeHtml(row.text) + '</span>';
|
||||
return '<span class="review-diff-line ' + row.kind + '">' + content + '</span>';
|
||||
}
|
||||
const position = row.old_position
|
||||
? ' data-old-position="' + row.old_position + '"'
|
||||
@@ -100,7 +131,7 @@ function renderDiffFile(file, index, escapeHtml) {
|
||||
return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
|
||||
'" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
|
||||
' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
|
||||
(row.old_position || row.new_position) + '">' + escapeHtml(row.text) + '</button>';
|
||||
(row.old_position || row.new_position) + '">' + content + '</button>';
|
||||
}).join('');
|
||||
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
|
||||
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
|
||||
@@ -169,7 +200,7 @@ function createProgress({ storage, repository, number, headSha, files }) {
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, markReviewed, clear };
|
||||
return { snapshot, markReviewed, clear, storageKey: key };
|
||||
}
|
||||
|
||||
function createDraft({ storage, repository, number, headSha, files }) {
|
||||
@@ -267,7 +298,10 @@ function createDraft({ storage, repository, number, headSha, files }) {
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear };
|
||||
return {
|
||||
snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear,
|
||||
storageKey: key,
|
||||
};
|
||||
}
|
||||
|
||||
function formatFeedback(draft, files) {
|
||||
@@ -322,6 +356,7 @@ function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }
|
||||
}
|
||||
|
||||
createReviewController.renderDiffFile = renderDiffFile;
|
||||
createReviewController.createWrapPreference = createWrapPreference;
|
||||
createReviewController.parseDiffLines = parseDiffLines;
|
||||
createReviewController.toggleDiff = toggleDiff;
|
||||
createReviewController.createProgress = createProgress;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v54';
|
||||
const CACHE = 'stackchain-dashboard-shell-v81';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const SHELL = [
|
||||
@@ -20,31 +21,45 @@ const SHELL = [
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-issue-close.js',
|
||||
BASE + 'static/notification-read-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/update-ownership.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/conversation.js',
|
||||
BASE + 'static/issue-attachment.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
BASE + 'static/queue-today.js',
|
||||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/task-overlay-history.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/mention-composer.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
|
||||
@@ -101,6 +116,46 @@ async function purgeRevokedSessionData() {
|
||||
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
|
||||
}
|
||||
|
||||
async function notifyIdleSession() {
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' }));
|
||||
}
|
||||
|
||||
async function storeOfflineLease(expiresAt) {
|
||||
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
|
||||
const cache = await caches.open(CACHE);
|
||||
await cache.put(OFFLINE_LEASE_URL, new Response(
|
||||
JSON.stringify({ expires_at: expiresAt }),
|
||||
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
|
||||
));
|
||||
}
|
||||
|
||||
async function validOfflineLease(cache) {
|
||||
const response = await cache.match(OFFLINE_LEASE_URL);
|
||||
const payload = await response?.json?.().catch(() => ({})) || {};
|
||||
return Number.isInteger(payload.expires_at)
|
||||
&& payload.expires_at > Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
async function expiredOfflineResponse() {
|
||||
await issueSync.purge();
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
);
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-expired' }));
|
||||
return new Response(
|
||||
'Your Stackchain session expired. Reconnect and sign in.',
|
||||
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
|
||||
async function cachedShellWithValidLease(cache) {
|
||||
if (!await validOfflineLease(cache)) return expiredOfflineResponse();
|
||||
return cache.match(BASE);
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const requestOptions = { ...options };
|
||||
const method = String(options.method || 'GET').toUpperCase();
|
||||
@@ -128,6 +183,9 @@ async function fetchJson(url, options = {}) {
|
||||
if (response.status === 401 && code === 'session_revoked') {
|
||||
await purgeRevokedSessionData();
|
||||
}
|
||||
if (response.status === 401 && code === 'session_idle') {
|
||||
await notifyIdleSession();
|
||||
}
|
||||
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
|
||||
error.status = response.status;
|
||||
error.code = code;
|
||||
@@ -173,7 +231,13 @@ self.addEventListener('sync', event => {
|
||||
});
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
|
||||
await issueSync.resume();
|
||||
await flushAndNotify();
|
||||
})());
|
||||
if (event.data?.type === 'stackchain-session-lease') {
|
||||
event.waitUntil(storeOfflineLease(event.data.expiresAt));
|
||||
}
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
@@ -212,13 +276,13 @@ self.addEventListener('fetch', event => {
|
||||
if (response.ok && !response.redirected && isDashboardShell) {
|
||||
await cache.put(BASE, response.clone());
|
||||
} else if (OUTAGE_STATUSES.has(response.status)) {
|
||||
const cached = await cache.match(BASE);
|
||||
const cached = await cachedShellWithValidLease(cache);
|
||||
if (cached) return cached;
|
||||
}
|
||||
return response;
|
||||
}).catch(async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
const cached = await cache.match(BASE);
|
||||
const cached = await cachedShellWithValidLease(cache);
|
||||
return cached || new Response(
|
||||
'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.',
|
||||
{ status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
|
||||
|
||||
+150
-7
@@ -15,6 +15,7 @@
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
MessageChannel: root.MessageChannel,
|
||||
location: root.location,
|
||||
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
|
||||
confirmAction: message => root.confirm(message),
|
||||
promptAuthorization: details => root.prompt(
|
||||
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
|
||||
@@ -27,6 +28,7 @@
|
||||
});
|
||||
root.fetch = boundary.fetch;
|
||||
const attach = () => {
|
||||
boundary.startActivityHeartbeat();
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
@@ -81,7 +83,9 @@
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
});
|
||||
boundary.resumeQueuedWork();
|
||||
boundary.refreshOfflineLease().then(valid => {
|
||||
if (valid) boundary.resumeQueuedWork();
|
||||
});
|
||||
};
|
||||
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
|
||||
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
||||
@@ -89,13 +93,19 @@
|
||||
root.stackchainSession = boundary;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction, addActivityListener,
|
||||
promptAuthorization = () => null,
|
||||
onExpired = () => {},
|
||||
onClearError = () => {},
|
||||
requestTimeoutMs = 15000,
|
||||
activityHeartbeatIntervalMs = 60000,
|
||||
now = () => Date.now(),
|
||||
setTimer = (callback, delay) => setTimeout(callback, delay),
|
||||
}) {
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
let expirationStarted = false;
|
||||
let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY;
|
||||
let activityHeartbeat = null;
|
||||
|
||||
async function handleRemoteRevocation() {
|
||||
if (expirationStarted) return;
|
||||
@@ -104,10 +114,47 @@
|
||||
location.replace(base + 'login?reason=session-revoked');
|
||||
}
|
||||
|
||||
async function handleSessionExpiry() {
|
||||
if (expirationStarted) return;
|
||||
expirationStarted = true;
|
||||
await clearPrivateDeviceData();
|
||||
onExpired();
|
||||
location.replace(base + 'login?reason=session-expired');
|
||||
}
|
||||
|
||||
function handleSessionIdle() {
|
||||
if (expirationStarted) return;
|
||||
expirationStarted = true;
|
||||
location.replace(base + 'login?reason=session-idle');
|
||||
}
|
||||
|
||||
async function handleServiceWorkerMessage(event) {
|
||||
if (event?.data?.type === 'stackchain-session-revoked') {
|
||||
await handleRemoteRevocation();
|
||||
}
|
||||
if (event?.data?.type === 'stackchain-session-idle') {
|
||||
handleSessionIdle();
|
||||
}
|
||||
if (event?.data?.type === 'stackchain-session-expired') {
|
||||
await handleSessionExpiry();
|
||||
}
|
||||
}
|
||||
|
||||
function storedOfflineLease() {
|
||||
const value = Number(localStorage?.getItem?.('stackchain.session-expires-at'));
|
||||
return Number.isInteger(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function scheduleOfflineExpiry(expiresAt) {
|
||||
const delay = Math.max(0, expiresAt * 1000 - now());
|
||||
setTimer(() => handleSessionExpiry(), delay);
|
||||
}
|
||||
|
||||
async function publishOfflineLease(expiresAt) {
|
||||
localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt));
|
||||
scheduleOfflineExpiry(expiresAt);
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt });
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
@@ -122,6 +169,56 @@
|
||||
catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function isDashboardApi(input) {
|
||||
try {
|
||||
const url = new URL(String(input?.url || input), origin);
|
||||
return url.origin === origin && url.pathname.startsWith(base + 'api/v1/');
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
async function fetchWithDeadline(input, options, method, phase = 'request') {
|
||||
if (!isDashboardApi(input)) return fetchImpl(input, options);
|
||||
const controller = new AbortController();
|
||||
const callerSignal = options.signal || input?.signal;
|
||||
let timeout;
|
||||
let rejectCancellation;
|
||||
const cancellation = new Promise((_resolve, reject) => { rejectCancellation = reject; });
|
||||
const cancelFromCaller = () => {
|
||||
const error = callerSignal.reason || new DOMException('The request was aborted.', 'AbortError');
|
||||
error.source = 'caller';
|
||||
controller.abort(error);
|
||||
rejectCancellation(error);
|
||||
};
|
||||
if (callerSignal?.aborted) cancelFromCaller();
|
||||
else callerSignal?.addEventListener?.('abort', cancelFromCaller, { once: true });
|
||||
const deadline = new Promise((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
const error = new Error(
|
||||
SAFE_METHODS.has(method)
|
||||
? 'Request timed out. Try again.'
|
||||
: 'Request timed out. Refresh to verify the outcome before retrying.'
|
||||
);
|
||||
error.name = 'TimeoutError';
|
||||
error.method = method;
|
||||
error.outcome = 'unknown';
|
||||
error.safeToRetry = SAFE_METHODS.has(method);
|
||||
error.phase = phase;
|
||||
controller.abort(error);
|
||||
reject(error);
|
||||
}, requestTimeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => fetchImpl(input, { ...options, signal: controller.signal })),
|
||||
deadline,
|
||||
cancellation,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
callerSignal?.removeEventListener?.('abort', cancelFromCaller);
|
||||
}
|
||||
}
|
||||
|
||||
async function sessionFetch(input, options = {}, allowStepUp = true) {
|
||||
const method = String(options.method || input?.method || 'GET').toUpperCase();
|
||||
const requestOptions = { ...options };
|
||||
@@ -131,7 +228,7 @@
|
||||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||
requestOptions.headers = headers;
|
||||
}
|
||||
const response = await fetchImpl(input, requestOptions);
|
||||
const response = await fetchWithDeadline(input, requestOptions, method);
|
||||
if (response.status === 428 && isSameOrigin(input) && allowStepUp) {
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
const detail = payload?.detail || {};
|
||||
@@ -147,7 +244,7 @@
|
||||
});
|
||||
const csrf = csrfToken();
|
||||
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
|
||||
const authorization = await fetchImpl(base + 'api/v1/fresh-authorization', {
|
||||
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
|
||||
method: 'POST',
|
||||
headers: authorizationHeaders,
|
||||
body: JSON.stringify({
|
||||
@@ -155,7 +252,7 @@
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
}),
|
||||
});
|
||||
}, 'POST', 'fresh-authorization');
|
||||
if (!authorization.ok) return authorization;
|
||||
const grant = await authorization.json().catch(() => ({}));
|
||||
if (!grant.grant) return response;
|
||||
@@ -170,14 +267,57 @@
|
||||
if (payload.code === 'session_revoked') {
|
||||
expirationStarted = false;
|
||||
await handleRemoteRevocation();
|
||||
} else if (payload.code === 'session_idle') {
|
||||
expirationStarted = false;
|
||||
handleSessionIdle();
|
||||
} else {
|
||||
onExpired();
|
||||
location.replace(base + 'login?reason=session-expired');
|
||||
expirationStarted = false;
|
||||
await handleSessionExpiry();
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function recordActivity() {
|
||||
const current = now();
|
||||
if (
|
||||
expirationStarted
|
||||
|| activityHeartbeat
|
||||
|| current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs
|
||||
) return Promise.resolve(false);
|
||||
lastActivityHeartbeatAt = current;
|
||||
activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' })
|
||||
.then(response => response.ok)
|
||||
.catch(() => false)
|
||||
.finally(() => { activityHeartbeat = null; });
|
||||
return activityHeartbeat;
|
||||
}
|
||||
|
||||
function startActivityHeartbeat() {
|
||||
['pointerdown', 'keydown', 'touchstart'].forEach(type => {
|
||||
addActivityListener?.(type, recordActivity, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshOfflineLease() {
|
||||
try {
|
||||
const response = await sessionFetch(base + 'api/v1/session');
|
||||
if (!response.ok) return false;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!payload.authenticated || !Number.isInteger(payload.expires_at)) return false;
|
||||
await publishOfflineLease(payload.expires_at);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
const expiresAt = storedOfflineLease();
|
||||
if (expiresAt * 1000 > now()) {
|
||||
scheduleOfflineExpiry(expiresAt);
|
||||
return true;
|
||||
}
|
||||
if (expiresAt) await handleSessionExpiry();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeDashboardStorage(storage) {
|
||||
if (!storage) return;
|
||||
const keys = [];
|
||||
@@ -287,6 +427,9 @@
|
||||
revokeActiveDevice,
|
||||
clearPrivateDeviceData,
|
||||
handleServiceWorkerMessage,
|
||||
refreshOfflineLease,
|
||||
resumeQueuedWork,
|
||||
recordActivity,
|
||||
startActivityHeartbeat,
|
||||
};
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const allowed = new Set(['new', 'find', 'search', 'search-preview']);
|
||||
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
|
||||
|
||||
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
||||
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null }) {
|
||||
return function completeTodayItem(item, options = {}) {
|
||||
if (!item || !todayWork.remove(item)) {
|
||||
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
|
||||
return false;
|
||||
}
|
||||
todaySync.enqueue('remove', todayWork.identity(item));
|
||||
todaySync.flush();
|
||||
refresh();
|
||||
warm();
|
||||
(advance || (() => workSession.complete()))();
|
||||
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;
|
||||
@@ -0,0 +1,93 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createTodayReadiness = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
return function createTodayReadiness({ inspect, onOpen = () => {}, onGate = () => {}, onPreview = () => {} }) {
|
||||
let pending = null;
|
||||
let generation = 0;
|
||||
|
||||
async function classify(item) {
|
||||
if (item?.kind !== 'issue') return { status:'ready', dependencies:[] };
|
||||
try {
|
||||
const detail = await inspect(item);
|
||||
if (detail?.available !== true) return { status:'unknown', dependencies:[] };
|
||||
const dependencies = Array.isArray(detail.dependencies)
|
||||
? detail.dependencies.filter(dependency => dependency?.state === 'open')
|
||||
: [];
|
||||
return { status:dependencies.length ? 'blocked' : 'ready', dependencies };
|
||||
} catch (_error) {
|
||||
return { status:'unknown', dependencies:[] };
|
||||
}
|
||||
}
|
||||
|
||||
async function run(action, items, target = null) {
|
||||
const request = ++generation;
|
||||
const ordered = Array.isArray(items) ? items.slice() : [];
|
||||
const preferred = target || ordered[0] || null;
|
||||
if (!preferred) return 'empty';
|
||||
const preferredIndex = ordered.indexOf(preferred);
|
||||
const candidates = preferredIndex >= 0
|
||||
? ordered.slice(preferredIndex).concat(ordered.slice(0, preferredIndex))
|
||||
: [preferred].concat(ordered);
|
||||
const targetState = { item:preferred, ...(await classify(preferred)) };
|
||||
if (request !== generation) return 'superseded';
|
||||
if (targetState.status === 'ready') {
|
||||
pending = null;
|
||||
onOpen(action, preferred);
|
||||
return 'opened';
|
||||
}
|
||||
const fallbackStates = await Promise.all(candidates.slice(1).map(async item => ({
|
||||
item,
|
||||
...(await classify(item)),
|
||||
})));
|
||||
const states = [targetState, ...fallbackStates];
|
||||
if (request !== generation) return 'superseded';
|
||||
const nextReadyState = states.slice(1).find(state => state.status === 'ready');
|
||||
pending = {
|
||||
action,
|
||||
items:ordered,
|
||||
target:preferred,
|
||||
status:targetState.status,
|
||||
dependencies:targetState.dependencies,
|
||||
nextReady:nextReadyState?.item || null,
|
||||
};
|
||||
onGate({ ...pending, items:pending.items.slice() });
|
||||
return 'gated';
|
||||
}
|
||||
|
||||
function open(item) {
|
||||
if (!pending || !item) return false;
|
||||
const action = pending.action;
|
||||
pending = null;
|
||||
generation += 1;
|
||||
onOpen(action, item);
|
||||
return true;
|
||||
}
|
||||
|
||||
function previewBlocker(blocker) {
|
||||
if (!pending || !blocker) return false;
|
||||
const current = pending.dependencies.find(dependency =>
|
||||
dependency?.repository === blocker.repository && Number(dependency?.number) === Number(blocker.number)
|
||||
);
|
||||
if (!current) return false;
|
||||
onPreview(current);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
run,
|
||||
retry: () => pending ? run(pending.action, pending.items, pending.target) : Promise.resolve('closed'),
|
||||
previewBlocker,
|
||||
startNextReady: () => open(pending?.nextReady),
|
||||
workAnyway: () => open(pending?.target),
|
||||
cancel() {
|
||||
generation += 1;
|
||||
pending = null;
|
||||
},
|
||||
snapshot: () => pending ? { ...pending, items:pending.items.slice() } : { open:false },
|
||||
};
|
||||
};
|
||||
});
|
||||
+64
-12
@@ -1,5 +1,6 @@
|
||||
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
|
||||
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePlan, onStatus, createOperationId, createChannel, coordinator,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
|
||||
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
|
||||
const prefix = 'stackchain.today-sync.v1.';
|
||||
const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
|
||||
const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.';
|
||||
@@ -8,6 +9,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
let expiredCount = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
@@ -54,12 +56,19 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
|
||||
if (plan.revision < savedRevision()) return false;
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify({ revision: plan.revision, ids: plan.ids }));
|
||||
storage?.setItem(snapshotKey(), JSON.stringify({
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
}));
|
||||
} catch (_error) {
|
||||
// A storage quota failure must not prevent the current tab from using server truth.
|
||||
}
|
||||
onRemoteIds?.(plan.ids);
|
||||
if (broadcast) channel?.postMessage({ revision: plan.revision, ids: plan.ids });
|
||||
onRemotePlan?.(plan);
|
||||
if (broadcast) channel?.postMessage({
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -97,13 +106,30 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
const candidate = storage.key?.(index);
|
||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||
}
|
||||
return [...keys].map(recordKey => JSON.parse(storage.getItem(recordKey) || 'null'))
|
||||
.filter(record => record?.operation)
|
||||
const records = [...keys].map(recordKey => {
|
||||
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||
return record ? { ...record, recordKey } : null;
|
||||
}).filter(record => record?.operation);
|
||||
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
||||
now() - Number(record.queued_at) > maxOfflineMs);
|
||||
expired.forEach(record => {
|
||||
storage.removeItem(record.recordKey);
|
||||
knownOperationKeys.delete(record.recordKey);
|
||||
});
|
||||
if (expired.length) {
|
||||
expiredCount += expired.length;
|
||||
onStatus?.('expired', { count: expiredCount });
|
||||
}
|
||||
return records.filter(record => !expired.includes(record))
|
||||
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
||||
left.operation.operation_id.localeCompare(right.operation.operation_id))
|
||||
.map(record => record.operation)
|
||||
.map(record => ({
|
||||
...record.operation,
|
||||
base_revision: Number.isInteger(record.operation.base_revision)
|
||||
? record.operation.base_revision : Math.max(0, savedRevision()),
|
||||
}))
|
||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
['add', 'remove', 'move', 'configure'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
@@ -136,13 +162,16 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
}
|
||||
const operation = { operation_id: operationId(), action, item_id: itemId, direction };
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, direction,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('today');
|
||||
@@ -151,6 +180,27 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
return saved;
|
||||
}
|
||||
|
||||
function enqueueConfiguration(capacityMinutes, estimates) {
|
||||
const operation = {
|
||||
operation_id: operationId(), action: 'configure', item_id: 'plan', direction: null,
|
||||
capacity_minutes: capacityMinutes, estimates: estimates || {},
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
coordinator?.notify('today');
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function migrate(ids) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
@@ -169,6 +219,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
const ownerKey = key();
|
||||
if (!ownerKey) return false;
|
||||
ensureChannel();
|
||||
expiredCount = 0;
|
||||
try {
|
||||
let operations = pending();
|
||||
let plan;
|
||||
@@ -199,7 +250,8 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' : 'saved');
|
||||
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
|
||||
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return !hadConflict;
|
||||
@@ -229,7 +281,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, migrate, flush, pending, startLifecycle };
|
||||
return { enqueue, enqueueConfiguration, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
+66
-6
@@ -1,5 +1,6 @@
|
||||
function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
const prefix = 'stackchain.today-work.v1.';
|
||||
const planningPrefix = 'stackchain.today-planning.v1.';
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
@@ -9,9 +10,17 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
function ownerKey(keyPrefix) {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
return login ? keyPrefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
return ownerKey(prefix);
|
||||
}
|
||||
|
||||
function planningKey() {
|
||||
return ownerKey(planningPrefix);
|
||||
}
|
||||
|
||||
function read() {
|
||||
@@ -37,6 +46,40 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
}
|
||||
}
|
||||
|
||||
function planning() {
|
||||
const key = planningKey();
|
||||
if (!key || !storage) return { capacity_minutes: null, estimates: {} };
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || 'null');
|
||||
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
|
||||
? value.capacity_minutes : null;
|
||||
const ids = new Set(read());
|
||||
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
|
||||
ids.has(id) && Number.isInteger(minutes) && minutes > 0
|
||||
));
|
||||
return { capacity_minutes: capacity, estimates };
|
||||
} catch (_error) {
|
||||
return { capacity_minutes: null, estimates: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function replacePlanning(value) {
|
||||
const key = planningKey();
|
||||
if (!key || !storage) return false;
|
||||
const ids = new Set(read());
|
||||
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
|
||||
? value.capacity_minutes : null;
|
||||
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
|
||||
ids.has(id) && Number.isInteger(minutes) && minutes > 0
|
||||
));
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ capacity_minutes: capacity, estimates }));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function add(item) {
|
||||
if (!storageKey()) return 'unavailable';
|
||||
const id = identity(item);
|
||||
@@ -55,14 +98,18 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
unique.push(id);
|
||||
}
|
||||
}
|
||||
return write(unique);
|
||||
const saved = write(unique);
|
||||
if (saved && planningKey()) replacePlanning(planning());
|
||||
return saved;
|
||||
}
|
||||
|
||||
function remove(item) {
|
||||
const id = identity(item);
|
||||
const ids = read();
|
||||
const next = ids.filter(candidate => candidate !== id);
|
||||
return next.length !== ids.length && write(next);
|
||||
const saved = next.length !== ids.length && write(next);
|
||||
if (saved) replacePlanning(planning());
|
||||
return saved;
|
||||
}
|
||||
|
||||
function move(item, direction) {
|
||||
@@ -81,7 +128,10 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
const retired = ids.filter(id => !retained.includes(id));
|
||||
if (retired.length) {
|
||||
const reported = onPrune?.(retired);
|
||||
if (reported !== false) write(retained);
|
||||
if (reported !== false) {
|
||||
write(retained);
|
||||
replacePlanning(planning());
|
||||
}
|
||||
}
|
||||
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
|
||||
}
|
||||
@@ -99,7 +149,17 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, add, remove, move, reconcile, contains, position, limit };
|
||||
function runway(items, currentIndex = 0) {
|
||||
const estimates = planning().estimates;
|
||||
const remaining = (items || []).slice(Math.max(0, currentIndex));
|
||||
const minutes = remaining.map(item => estimates[identity(item)] || null);
|
||||
return {
|
||||
current_minutes: minutes[0] || null,
|
||||
remaining_minutes: minutes.some(value => value === null) ? null : minutes.reduce((total, value) => total + value, 0),
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, remove, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Worker-shared Find Work catalog state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class RefreshLeaseLost(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AvailableIssueSnapshotState:
|
||||
items: list[dict] | None
|
||||
created_at: float | None
|
||||
retry_at: float | None
|
||||
refreshing: bool
|
||||
lease_expires_at: float | None
|
||||
|
||||
|
||||
class AvailableIssueSnapshotStore:
|
||||
def __init__(self, path, *, clock=None):
|
||||
self.path = Path(path)
|
||||
self.clock = clock or time.time
|
||||
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(self.path.parent, stat.S_IRWXU)
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS available_issue_snapshot (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
items_json TEXT,
|
||||
created_at REAL,
|
||||
retry_at REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS available_issue_refresh_lease (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
owner TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS available_issue_claim (
|
||||
repository TEXT NOT NULL,
|
||||
number INTEGER NOT NULL,
|
||||
PRIMARY KEY (repository, number)
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO available_issue_snapshot VALUES (1, NULL, NULL, NULL)"
|
||||
)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA busy_timeout = 1000")
|
||||
return connection
|
||||
|
||||
def try_acquire_refresh(self, *, lease_seconds: float) -> str | None:
|
||||
if lease_seconds <= 0:
|
||||
raise ValueError("refresh lease duration must be positive")
|
||||
now = self.clock()
|
||||
owner = secrets.token_hex(16)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
active = connection.execute(
|
||||
"SELECT expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if active is not None and active["expires_at"] > now:
|
||||
connection.rollback()
|
||||
return None
|
||||
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
|
||||
connection.execute(
|
||||
"INSERT INTO available_issue_refresh_lease VALUES (1, ?, ?)",
|
||||
(owner, now + lease_seconds),
|
||||
)
|
||||
connection.commit()
|
||||
return owner
|
||||
|
||||
def load(self) -> AvailableIssueSnapshotState:
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM available_issue_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
lease = connection.execute(
|
||||
"SELECT expires_at FROM available_issue_refresh_lease "
|
||||
"WHERE singleton = 1 AND expires_at > ?", (now,)
|
||||
).fetchone()
|
||||
return AvailableIssueSnapshotState(
|
||||
items=json.loads(row["items_json"]) if row["items_json"] else None,
|
||||
created_at=row["created_at"],
|
||||
retry_at=row["retry_at"],
|
||||
refreshing=lease is not None,
|
||||
lease_expires_at=lease["expires_at"] if lease else None,
|
||||
)
|
||||
|
||||
def publish(self, owner: str | None, *, items: list[dict]) -> AvailableIssueSnapshotState:
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
lease = connection.execute(
|
||||
"SELECT owner, expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if (
|
||||
owner is None
|
||||
or lease is None
|
||||
or lease["owner"] != owner
|
||||
or lease["expires_at"] <= now
|
||||
):
|
||||
connection.rollback()
|
||||
raise RefreshLeaseLost("available issue refresh lease expired or changed owner")
|
||||
claimed = {
|
||||
(row["repository"], row["number"])
|
||||
for row in connection.execute(
|
||||
"SELECT repository, number FROM available_issue_claim"
|
||||
)
|
||||
}
|
||||
items = [
|
||||
item for item in items
|
||||
if (item.get("repository"), item.get("number")) not in claimed
|
||||
]
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, "
|
||||
"retry_at = NULL WHERE singleton = 1",
|
||||
(json.dumps(items, separators=(",", ":")), now),
|
||||
)
|
||||
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
def remove_claimed(self, repository: str, number: int) -> AvailableIssueSnapshotState:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO available_issue_claim VALUES (?, ?)",
|
||||
(repository, number),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
items = json.loads(row["items_json"]) if row["items_json"] else None
|
||||
if items is not None:
|
||||
items = [
|
||||
item for item in items
|
||||
if item.get("repository") != repository or item.get("number") != number
|
||||
]
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = ? WHERE singleton = 1",
|
||||
(json.dumps(items, separators=(",", ":")),),
|
||||
)
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
def invalidate(self) -> AvailableIssueSnapshotState:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = NULL, "
|
||||
"created_at = NULL, retry_at = NULL WHERE singleton = 1"
|
||||
)
|
||||
connection.execute("DELETE FROM available_issue_claim")
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
def release_refresh(self, owner: str | None) -> None:
|
||||
if owner is None:
|
||||
return
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM available_issue_refresh_lease "
|
||||
"WHERE singleton = 1 AND owner = ?",
|
||||
(owner,),
|
||||
)
|
||||
|
||||
def fail_refresh(self, owner: str | None, *, retry_at: float) -> AvailableIssueSnapshotState:
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
lease = connection.execute(
|
||||
"SELECT owner, expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if (
|
||||
owner is None
|
||||
or lease is None
|
||||
or lease["owner"] != owner
|
||||
or lease["expires_at"] <= now
|
||||
):
|
||||
connection.rollback()
|
||||
raise RefreshLeaseLost("available issue refresh lease expired or changed owner")
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET retry_at = ? WHERE singleton = 1",
|
||||
(retry_at,),
|
||||
)
|
||||
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
|
||||
connection.commit()
|
||||
return self.load()
|
||||
@@ -0,0 +1,47 @@
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.middleware.gzip import GZipResponder, IdentityResponder
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
|
||||
def _quality(accept_encoding: str, coding: str) -> float:
|
||||
qualities: dict[str, float] = {}
|
||||
for entry in accept_encoding.split(","):
|
||||
parts = [part.strip() for part in entry.split(";")]
|
||||
name = parts[0].lower()
|
||||
if not name:
|
||||
continue
|
||||
quality = 1.0
|
||||
for parameter in parts[1:]:
|
||||
key, separator, value = parameter.partition("=")
|
||||
if separator and key.strip().lower() == "q":
|
||||
try:
|
||||
quality = float(value.strip())
|
||||
except ValueError:
|
||||
quality = 0.0
|
||||
qualities[name] = quality
|
||||
return qualities.get(coding, qualities.get("*", 0.0))
|
||||
|
||||
|
||||
class NegotiatedGZipMiddleware:
|
||||
"""Compress substantial responses only when the client permits gzip."""
|
||||
|
||||
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 6):
|
||||
self.app = app
|
||||
self.minimum_size = minimum_size
|
||||
self.compresslevel = compresslevel
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
accepted = Headers(scope=scope).get("Accept-Encoding", "")
|
||||
path = scope.get("path", "").rsplit("/", 1)[-1]
|
||||
immutable_runtime = path.startswith("runtime-") and path.endswith(".js")
|
||||
if _quality(accepted, "gzip") > 0 and not immutable_runtime:
|
||||
responder: ASGIApp = GZipResponder(
|
||||
self.app, self.minimum_size, compresslevel=self.compresslevel
|
||||
)
|
||||
else:
|
||||
responder = IdentityResponder(self.app, self.minimum_size)
|
||||
await responder(scope, receive, send)
|
||||
+30
-1
@@ -18,6 +18,7 @@ from src.session_store import SessionStore, SessionStoreError
|
||||
SESSION_COOKIE = "stackchain_session"
|
||||
CSRF_COOKIE = "stackchain_csrf"
|
||||
DEFAULT_TTL_SECONDS = 8 * 60 * 60
|
||||
DEFAULT_IDLE_TIMEOUT_SECONDS = 15 * 60
|
||||
STEP_UP_TTL_SECONDS = 90
|
||||
MIN_SECRET_LENGTH = 24
|
||||
OPERATOR_MODE = "operator"
|
||||
@@ -96,6 +97,18 @@ def _session_store(now: int | None = None) -> SessionStore:
|
||||
return SessionStore(database, clock=current)
|
||||
|
||||
|
||||
def idle_timeout_seconds() -> int:
|
||||
return max(
|
||||
1,
|
||||
int(
|
||||
os.getenv(
|
||||
"STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS",
|
||||
str(DEFAULT_IDLE_TIMEOUT_SECONDS),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def issue_session(
|
||||
now: int | None = None, *, device_label: str = "This device"
|
||||
) -> tuple[str, Session]:
|
||||
@@ -146,7 +159,14 @@ def verify_session_with_reason(
|
||||
or not session.csrf
|
||||
):
|
||||
return SessionVerification(None)
|
||||
if not _session_store(now).is_active(session.session_id, session.expires_at):
|
||||
status = _session_store(now).status(
|
||||
session.session_id,
|
||||
session.expires_at,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
if status == "idle":
|
||||
return SessionVerification(None, "session_idle")
|
||||
if status != "active":
|
||||
return SessionVerification(None, "session_revoked")
|
||||
return SessionVerification(session)
|
||||
|
||||
@@ -167,6 +187,15 @@ async def active_devices(session: Session):
|
||||
return await asyncio.to_thread(_session_store().list_active, session.session_id)
|
||||
|
||||
|
||||
async def touch_session(session: Session) -> bool:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().touch,
|
||||
session.session_id,
|
||||
session.expires_at,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
|
||||
|
||||
async def revoke_managed_session(management_id: str) -> bool:
|
||||
return await asyncio.to_thread(_session_store().revoke_managed, management_id)
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Build the content-addressed browser shell served by the dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrontendBuild:
|
||||
dashboard_html: str
|
||||
runtime_bytes: bytes
|
||||
runtime_gzip_bytes: bytes
|
||||
runtime_digest: str
|
||||
runtime_name: str
|
||||
page_sources: tuple[str, ...]
|
||||
service_worker_source: str
|
||||
|
||||
|
||||
def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
||||
chunks = []
|
||||
for source in sources:
|
||||
path = frontend_dir / source.removeprefix("static/")
|
||||
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
"""Return one internally consistent HTML, runtime, and offline-worker build."""
|
||||
source_html = (frontend_dir / "index.html").read_text()
|
||||
sources = tuple(SCRIPT_TAG.findall(source_html))
|
||||
if not sources:
|
||||
raise ValueError("dashboard entry document has no page scripts")
|
||||
|
||||
runtime = _bundle(frontend_dir, sources)
|
||||
runtime_gzip = gzip.compress(runtime, compresslevel=6, mtime=0)
|
||||
digest = hashlib.sha256(runtime).hexdigest()[:16]
|
||||
runtime_name = f"runtime-{digest}.js"
|
||||
dashboard_html = SCRIPT_TAG.sub("", source_html)
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>", f'<script src="{runtime_name}"></script>\n</body>'
|
||||
)
|
||||
|
||||
worker = (frontend_dir / "service-worker.js").read_text()
|
||||
for source in sources:
|
||||
if source != WORKER_RUNTIME_SOURCE:
|
||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n",
|
||||
)
|
||||
worker = re.sub(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-v\d+';",
|
||||
f"const CACHE = 'stackchain-dashboard-shell-{digest}';",
|
||||
worker,
|
||||
count=1,
|
||||
)
|
||||
|
||||
return FrontendBuild(
|
||||
dashboard_html=dashboard_html,
|
||||
runtime_bytes=runtime,
|
||||
runtime_gzip_bytes=runtime_gzip,
|
||||
runtime_digest=digest,
|
||||
runtime_name=runtime_name,
|
||||
page_sources=sources,
|
||||
service_worker_source=worker,
|
||||
)
|
||||
+155
-4
@@ -133,6 +133,14 @@ class WorkItems(list[dict]):
|
||||
self.pagination = pagination
|
||||
|
||||
|
||||
class RepositoryItems(list[dict]):
|
||||
"""A list-compatible repository page carrying truthful pagination metadata."""
|
||||
|
||||
def __init__(self, items: list[dict], pagination: dict):
|
||||
super().__init__(items)
|
||||
self.pagination = pagination
|
||||
|
||||
|
||||
class WorkRouteUnavailableError(ValueError):
|
||||
"""Raised when a shared route no longer belongs in the current user's queue."""
|
||||
|
||||
@@ -261,8 +269,48 @@ async def current_user() -> dict:
|
||||
return await fetch("user")
|
||||
|
||||
|
||||
async def repos() -> list[dict]:
|
||||
return await fetch("user/repos?limit=50")
|
||||
async def repo_page(page: int = 1, limit: int = 50) -> dict:
|
||||
"""Load one bounded page of repositories available to the current user."""
|
||||
response = await _get_client().get(
|
||||
"/api/v1/user/repos",
|
||||
headers=_auth(),
|
||||
params={"page": page, "limit": limit},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea repository response was not a list")
|
||||
items = [item for item in payload if isinstance(item, dict)]
|
||||
try:
|
||||
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
|
||||
except (TypeError, ValueError):
|
||||
total = len(items)
|
||||
return {
|
||||
"items": items,
|
||||
"page": page,
|
||||
"total": total,
|
||||
"has_more": page * limit < total,
|
||||
}
|
||||
|
||||
|
||||
async def repos() -> RepositoryItems:
|
||||
result = await repo_page()
|
||||
return RepositoryItems(
|
||||
result["items"],
|
||||
{key: result[key] for key in ("page", "total", "has_more")},
|
||||
)
|
||||
|
||||
|
||||
async def repository_access(repository: str) -> dict | None:
|
||||
"""Return a repository only when the authenticated user can access it."""
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}", headers=_auth()
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
WORK_SEARCHES = {
|
||||
@@ -830,6 +878,33 @@ async def comment_on_issue(repository: str, number: int, body: str) -> dict:
|
||||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def upload_assigned_issue_attachment(
|
||||
repository: str,
|
||||
number: int,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
content: bytes,
|
||||
) -> dict:
|
||||
if not await is_assigned_issue(repository, number):
|
||||
raise IssueNotAvailableError("Assigned issue not found")
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/assets",
|
||||
headers=_auth(),
|
||||
params={"name": filename},
|
||||
files={"attachment": (filename, content, content_type)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
attachment = response.json()
|
||||
if not isinstance(attachment, dict):
|
||||
raise ValueError("Gitea attachment response was not an object")
|
||||
name = attachment.get("name")
|
||||
url = _safe_web_url(attachment.get("browser_download_url"))
|
||||
size = attachment.get("size")
|
||||
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
|
||||
raise ValueError("Gitea did not confirm the attachment")
|
||||
return {"name": name, "url": url, "size": size}
|
||||
|
||||
|
||||
async def repo_labels(repository: str) -> list[dict]:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/labels",
|
||||
@@ -1081,6 +1156,40 @@ async def issue_handoff_candidates(repository: str) -> list[dict]:
|
||||
return candidates
|
||||
|
||||
|
||||
async def mention_candidates(
|
||||
repository: str, query: str, *, limit: int = 8
|
||||
) -> list[dict]:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/assignees", headers=_auth()
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea assignees response was not a list")
|
||||
needle = query.casefold()
|
||||
matches = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
login = item.get("login")
|
||||
if (
|
||||
not isinstance(login, str)
|
||||
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
|
||||
):
|
||||
continue
|
||||
full_name = item.get("full_name")
|
||||
name = full_name if isinstance(full_name, str) and full_name else login
|
||||
login_match = needle in login.casefold()
|
||||
name_match = needle in name.casefold()
|
||||
if login_match or name_match:
|
||||
matches.append((0 if login.casefold().startswith(needle) else 1, login.casefold(), {
|
||||
"login": login,
|
||||
"name": name,
|
||||
}))
|
||||
matches.sort(key=lambda item: (item[0], item[1]))
|
||||
return [item[2] for item in matches[:limit]]
|
||||
|
||||
|
||||
async def handoff_assigned_issue(
|
||||
repository: str, number: int, recipient: str
|
||||
) -> dict:
|
||||
@@ -1295,9 +1404,19 @@ async def issue_conversation_page(
|
||||
|
||||
async def issue_detail(repository: str, number: int) -> dict:
|
||||
base = f"repos/{repository}/issues/{number}"
|
||||
issue, conversation = await asyncio.gather(
|
||||
fetch(base), issue_conversation_page(repository, number)
|
||||
|
||||
async def load_dependencies() -> tuple[bool, list[dict]]:
|
||||
try:
|
||||
return True, await issue_dependencies(repository, number)
|
||||
except Exception:
|
||||
return False, []
|
||||
|
||||
issue, conversation, dependency_result = await asyncio.gather(
|
||||
fetch(base),
|
||||
issue_conversation_page(repository, number),
|
||||
load_dependencies(),
|
||||
)
|
||||
dependencies_available, dependencies = dependency_result
|
||||
if not isinstance(issue, dict):
|
||||
raise ValueError("Gitea issue response was not an object")
|
||||
labels_value = issue.get("labels")
|
||||
@@ -1329,11 +1448,43 @@ async def issue_detail(repository: str, number: int) -> dict:
|
||||
assignee["login"] for assignee in assignees
|
||||
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
|
||||
],
|
||||
"dependencies_available": dependencies_available,
|
||||
"dependencies": dependencies,
|
||||
"comments": normalized_comments,
|
||||
"conversation": conversation,
|
||||
}
|
||||
|
||||
|
||||
async def issue_dependencies(repository: str, number: int, limit: int = 20) -> list[dict]:
|
||||
"""Return bounded open prerequisites for an issue."""
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/dependencies",
|
||||
headers=_auth(),
|
||||
params={"limit": limit},
|
||||
)
|
||||
response.raise_for_status()
|
||||
value = response.json()
|
||||
items = value if isinstance(value, list) else []
|
||||
dependencies = []
|
||||
for item in items[:limit]:
|
||||
if not isinstance(item, dict) or item.get("state") != "open":
|
||||
continue
|
||||
repo_value = item.get("repository")
|
||||
repo = repo_value if isinstance(repo_value, dict) else {}
|
||||
dependency_repository = repo.get("full_name")
|
||||
dependency_number = item.get("number")
|
||||
if not isinstance(dependency_repository, str) or not isinstance(dependency_number, int):
|
||||
continue
|
||||
dependencies.append({
|
||||
"repository": dependency_repository,
|
||||
"number": dependency_number,
|
||||
"title": item.get("title", "") if isinstance(item.get("title"), str) else "",
|
||||
"state": "open",
|
||||
"url": _safe_web_url(item.get("html_url")),
|
||||
})
|
||||
return dependencies
|
||||
|
||||
|
||||
async def update_assigned_issue(
|
||||
repository: str,
|
||||
number: int,
|
||||
|
||||
+94
-53
@@ -2,14 +2,26 @@
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class LaterStore:
|
||||
def __init__(self, path: str | Path, *, timeout: float = 1.0):
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
timeout: float = 1.0,
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.timeout = timeout
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -29,12 +41,48 @@ class LaterStore:
|
||||
CREATE TABLE IF NOT EXISTS later_operations (
|
||||
login TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
PRIMARY KEY (login, operation_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS later_item_revisions (
|
||||
login TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
PRIMARY KEY (login, item_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(later_operations)")}
|
||||
if "created_at" not in columns:
|
||||
connection.execute("ALTER TABLE later_operations ADD COLUMN created_at REAL")
|
||||
connection.execute(
|
||||
"UPDATE later_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(self.clock(),),
|
||||
)
|
||||
connection.commit()
|
||||
return connection
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
|
||||
(login, operation_id, now),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM later_operations WHERE login = ? AND created_at < ?",
|
||||
(login, now - self.operation_retention_seconds),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM later_operations WHERE login = ? AND rowid NOT IN "
|
||||
"(SELECT rowid FROM later_operations WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||
(login, login, self.operation_limit),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
@@ -74,54 +122,16 @@ class LaterStore:
|
||||
item_id: str,
|
||||
*,
|
||||
wake_at: str | None = None,
|
||||
base_revision: int | None = None,
|
||||
) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"defer", "restore"}:
|
||||
raise ValueError("unsupported Later action")
|
||||
if action == "defer":
|
||||
wake_at = self._validate_wake_at(wake_at)
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
return snapshot
|
||||
|
||||
records = dict(snapshot["records"])
|
||||
before = records.get(item_id)
|
||||
if action == "defer":
|
||||
records[item_id] = wake_at
|
||||
changed = before != wake_at
|
||||
else:
|
||||
changed = item_id in records
|
||||
records.pop(item_id, None)
|
||||
|
||||
revision = snapshot["revision"] + (1 if changed else 0)
|
||||
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
elif changed:
|
||||
connection.execute(
|
||||
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
|
||||
(revision, serialized, login),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
|
||||
(login, operation_id),
|
||||
)
|
||||
return {"revision": revision, "records": records}
|
||||
result = self.apply_batch(login, [{
|
||||
"operation_id": operation_id,
|
||||
"action": action,
|
||||
"item_id": item_id,
|
||||
"wake_at": wake_at,
|
||||
"base_revision": base_revision,
|
||||
}])
|
||||
return {"revision": result["revision"], "records": result["records"]}
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
"""Apply ordered deferrals with one SQLite write transaction."""
|
||||
@@ -136,6 +146,20 @@ class LaterStore:
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
rejected: list[dict[str, str]] = []
|
||||
|
||||
# Existing databases predate per-item revisions. Conservatively mark
|
||||
# active deferrals as changed at the latest known plan revision.
|
||||
for item_id in records:
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
|
||||
(login, item_id, revision),
|
||||
)
|
||||
item_revisions = dict(connection.execute(
|
||||
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
|
||||
(login,),
|
||||
).fetchall())
|
||||
batch_start_item_revisions = dict(item_revisions)
|
||||
|
||||
for operation in operations:
|
||||
operation_id = operation.get("operation_id", "")
|
||||
@@ -156,6 +180,19 @@ class LaterStore:
|
||||
duplicates.append(operation_id)
|
||||
continue
|
||||
|
||||
base_revision = operation.get("base_revision")
|
||||
if base_revision is None:
|
||||
base_revision = snapshot["revision"]
|
||||
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
||||
raise ValueError("base_revision must be a non-negative integer")
|
||||
if batch_start_item_revisions.get(item_id, 0) > base_revision:
|
||||
self._record_operation(connection, login, operation_id)
|
||||
rejected.append({
|
||||
"operation_id": operation_id,
|
||||
"reason": "stale_intent",
|
||||
})
|
||||
continue
|
||||
|
||||
before = records.get(item_id)
|
||||
if action == "defer":
|
||||
records[item_id] = wake_at
|
||||
@@ -164,10 +201,14 @@ class LaterStore:
|
||||
changed = item_id in records
|
||||
records.pop(item_id, None)
|
||||
revision += 1 if changed else 0
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
|
||||
(login, operation_id),
|
||||
)
|
||||
if changed:
|
||||
item_revisions[item_id] = revision
|
||||
connection.execute(
|
||||
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision",
|
||||
(login, item_id, revision),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
|
||||
@@ -186,5 +227,5 @@ class LaterStore:
|
||||
"records": records,
|
||||
"accepted_operation_ids": accepted,
|
||||
"duplicate_operation_ids": duplicates,
|
||||
"rejected_operations": [],
|
||||
"rejected_operations": rejected,
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Process-shared live snapshot state and refresh coordination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import stat
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
SECTIONS = ("context", "events", "notifications")
|
||||
|
||||
|
||||
class RefreshLeaseLost(RuntimeError):
|
||||
"""The refresh is no longer authorized to publish shared state."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveSnapshotState:
|
||||
value: dict | None
|
||||
created_at: dict[str, float | None]
|
||||
failure_count: dict[str, int]
|
||||
retry_at: dict[str, float | None]
|
||||
revisions: dict[str, int]
|
||||
generation: str
|
||||
refreshing_sections: set[str]
|
||||
lease_expires_at: float | None
|
||||
|
||||
|
||||
class LiveSnapshotStore:
|
||||
"""A private SQLite snapshot with an expiring, cross-process refresh lease."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | os.PathLike[str],
|
||||
*,
|
||||
clock: Callable[[], float] | None = None,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.clock = clock or time.time
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA busy_timeout = 1000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(self.path.parent, stat.S_IRWXU)
|
||||
except OSError:
|
||||
pass
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS live_snapshot (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
value_json TEXT,
|
||||
created_at_json TEXT NOT NULL,
|
||||
failure_count_json TEXT NOT NULL,
|
||||
retry_at_json TEXT NOT NULL,
|
||||
revisions_json TEXT NOT NULL,
|
||||
generation TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS live_refresh_lease (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
owner TEXT NOT NULL,
|
||||
sections_json TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS live_read_notification (
|
||||
notification_id INTEGER PRIMARY KEY
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""INSERT OR IGNORE INTO live_snapshot VALUES
|
||||
(1, NULL, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
json.dumps({section: None for section in SECTIONS}),
|
||||
json.dumps({section: 0 for section in SECTIONS}),
|
||||
json.dumps({section: None for section in SECTIONS}),
|
||||
json.dumps({section: 0 for section in SECTIONS}),
|
||||
secrets.token_hex(8),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
|
||||
def try_acquire_refresh(
|
||||
self, sections: Iterable[str], *, lease_seconds: float
|
||||
) -> str | None:
|
||||
requested = sorted(set(sections))
|
||||
if not requested or any(section not in SECTIONS for section in requested):
|
||||
raise ValueError("refresh lease requires known sections")
|
||||
if lease_seconds <= 0:
|
||||
raise ValueError("refresh lease duration must be positive")
|
||||
now = self.clock()
|
||||
owner = secrets.token_hex(16)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
active = connection.execute(
|
||||
"SELECT expires_at FROM live_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if active is not None and active["expires_at"] > now:
|
||||
connection.rollback()
|
||||
return None
|
||||
connection.execute("DELETE FROM live_refresh_lease WHERE singleton = 1")
|
||||
connection.execute(
|
||||
"INSERT INTO live_refresh_lease VALUES (1, ?, ?, ?)",
|
||||
(owner, json.dumps(requested, separators=(",", ":")), now + lease_seconds),
|
||||
)
|
||||
connection.commit()
|
||||
return owner
|
||||
|
||||
def load(self) -> LiveSnapshotState:
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM live_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
lease = connection.execute(
|
||||
"SELECT sections_json, expires_at FROM live_refresh_lease "
|
||||
"WHERE singleton = 1 AND expires_at > ?",
|
||||
(now,),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
return LiveSnapshotState(
|
||||
value=json.loads(row["value_json"]) if row["value_json"] is not None else None,
|
||||
created_at=json.loads(row["created_at_json"]),
|
||||
failure_count=json.loads(row["failure_count_json"]),
|
||||
retry_at=json.loads(row["retry_at_json"]),
|
||||
revisions=json.loads(row["revisions_json"]),
|
||||
generation=row["generation"],
|
||||
refreshing_sections=set(json.loads(lease["sections_json"])) if lease else set(),
|
||||
lease_expires_at=lease["expires_at"] if lease else None,
|
||||
)
|
||||
|
||||
def publish_refresh(
|
||||
self,
|
||||
owner: str,
|
||||
*,
|
||||
value: dict,
|
||||
created_at: dict[str, float | None],
|
||||
failure_count: dict[str, int],
|
||||
retry_at: dict[str, float | None],
|
||||
changed_sections: Iterable[str],
|
||||
) -> LiveSnapshotState:
|
||||
changed = set(changed_sections)
|
||||
if changed - set(SECTIONS):
|
||||
raise ValueError("publication contains unknown sections")
|
||||
if any(set(mapping) != set(SECTIONS) for mapping in (created_at, failure_count, retry_at)):
|
||||
raise ValueError("publication metadata must contain every section")
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
lease = connection.execute(
|
||||
"SELECT owner, expires_at FROM live_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if lease is None or lease["owner"] != owner or lease["expires_at"] <= now:
|
||||
connection.rollback()
|
||||
raise RefreshLeaseLost("live refresh lease expired or changed owner")
|
||||
row = connection.execute(
|
||||
"SELECT revisions_json FROM live_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
revisions = json.loads(row["revisions_json"])
|
||||
notifications = value.get("notifications")
|
||||
if isinstance(notifications, list):
|
||||
returned_ids = {
|
||||
item.get("id") for item in notifications if isinstance(item, dict)
|
||||
}
|
||||
read_ids = {
|
||||
item["notification_id"]
|
||||
for item in connection.execute(
|
||||
"SELECT notification_id FROM live_read_notification"
|
||||
)
|
||||
}
|
||||
value = dict(value)
|
||||
value["notifications"] = [
|
||||
item for item in notifications
|
||||
if not isinstance(item, dict) or item.get("id") not in read_ids
|
||||
]
|
||||
if returned_ids:
|
||||
placeholders = ",".join("?" for _ in returned_ids)
|
||||
connection.execute(
|
||||
f"DELETE FROM live_read_notification WHERE notification_id NOT IN ({placeholders})",
|
||||
tuple(returned_ids),
|
||||
)
|
||||
else:
|
||||
connection.execute("DELETE FROM live_read_notification")
|
||||
for section in changed:
|
||||
revisions[section] += 1
|
||||
connection.execute(
|
||||
"""UPDATE live_snapshot SET value_json = ?, created_at_json = ?,
|
||||
failure_count_json = ?, retry_at_json = ?, revisions_json = ?
|
||||
WHERE singleton = 1""",
|
||||
(
|
||||
json.dumps(value, separators=(",", ":")),
|
||||
json.dumps(created_at, separators=(",", ":")),
|
||||
json.dumps(failure_count, separators=(",", ":")),
|
||||
json.dumps(retry_at, separators=(",", ":")),
|
||||
json.dumps(revisions, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM live_refresh_lease WHERE singleton = 1 AND owner = ?", (owner,)
|
||||
)
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
def remove_notifications(self, notification_ids: Iterable[int]) -> LiveSnapshotState:
|
||||
"""Filter confirmed reads from every worker and subsequent stale refreshes."""
|
||||
read_ids = set(notification_ids)
|
||||
if not read_ids:
|
||||
return self.load()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.executemany(
|
||||
"INSERT OR IGNORE INTO live_read_notification VALUES (?)",
|
||||
((notification_id,) for notification_id in read_ids),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT value_json, revisions_json FROM live_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
value = json.loads(row["value_json"]) if row["value_json"] else None
|
||||
revisions = json.loads(row["revisions_json"])
|
||||
if value is not None and isinstance(value.get("notifications"), list):
|
||||
previous = value["notifications"]
|
||||
retained = [
|
||||
item
|
||||
for item in previous
|
||||
if not isinstance(item, dict) or item.get("id") not in read_ids
|
||||
]
|
||||
if retained != previous:
|
||||
value["notifications"] = retained
|
||||
revisions["notifications"] += 1
|
||||
connection.execute(
|
||||
"UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
|
||||
(
|
||||
json.dumps(value, separators=(",", ":")),
|
||||
json.dumps(revisions, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
def remove_notification(self, notification_id: int) -> LiveSnapshotState:
|
||||
"""Filter one confirmed read from every worker and subsequent stale refresh."""
|
||||
return self.remove_notifications([notification_id])
|
||||
|
||||
def release_refresh(self, owner: str) -> None:
|
||||
"""Relinquish a lease that became unnecessary after a coherent recheck."""
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM live_refresh_lease WHERE singleton = 1 AND owner = ?",
|
||||
(owner,),
|
||||
)
|
||||
|
||||
def fail_refresh(
|
||||
self,
|
||||
owner: str,
|
||||
*,
|
||||
failure_count: dict[str, int],
|
||||
retry_at: dict[str, float | None],
|
||||
) -> LiveSnapshotState:
|
||||
"""Publish backoff metadata and relinquish a failed refresh atomically."""
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
lease = connection.execute(
|
||||
"SELECT owner, expires_at FROM live_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if lease is None or lease["owner"] != owner or lease["expires_at"] <= now:
|
||||
connection.rollback()
|
||||
raise RefreshLeaseLost("live refresh lease expired or changed owner")
|
||||
connection.execute(
|
||||
"""UPDATE live_snapshot SET failure_count_json = ?, retry_at_json = ?
|
||||
WHERE singleton = 1""",
|
||||
(
|
||||
json.dumps(failure_count, separators=(",", ":")),
|
||||
json.dumps(retry_at, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
connection.execute("DELETE FROM live_refresh_lease WHERE singleton = 1")
|
||||
connection.commit()
|
||||
return self.load()
|
||||
+558
-92
@@ -1,7 +1,11 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import math
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import Awaitable, Coroutine
|
||||
@@ -18,6 +22,8 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
|
||||
|
||||
from src import dashboard_auth, gitea_proxy
|
||||
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
||||
from src.compression import NegotiatedGZipMiddleware
|
||||
from src.gitea_proxy import (
|
||||
activity_events,
|
||||
current_user,
|
||||
@@ -32,12 +38,35 @@ from src.gitea_proxy import (
|
||||
)
|
||||
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
|
||||
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
|
||||
from src.live_snapshot_store import LiveSnapshotState, LiveSnapshotStore, RefreshLeaseLost
|
||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
from src.suggestion_engine import compute
|
||||
from src.later_store import LaterStore
|
||||
from src.today_store import TodayPlanFull, TodayStore
|
||||
from src.views import router as frontend_router
|
||||
from src.views import FRONTEND_BUILD, router as frontend_router
|
||||
|
||||
|
||||
async def _drain_authored_action_operations() -> None:
|
||||
tasks = {
|
||||
operation[1]
|
||||
for operation in _authored_action_operations.values()
|
||||
if not operation[1].done()
|
||||
}
|
||||
if not tasks:
|
||||
return
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*tasks, return_exceptions=True),
|
||||
timeout=AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
for key, operation in list(_authored_action_operations.items()):
|
||||
if operation[1] in tasks and operation[1].done():
|
||||
_authored_action_operations.pop(key, None)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
@@ -56,6 +85,7 @@ async def lifespan(_app: FastAPI):
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
try:
|
||||
await _drain_authored_action_operations()
|
||||
await gitea_proxy.stop_client()
|
||||
finally:
|
||||
if _live_snapshot_task is live_task:
|
||||
@@ -66,6 +96,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
|
||||
app.add_middleware(RequestBodyLimitMiddleware, limit_for=request_body_limit)
|
||||
app.add_middleware(NegotiatedGZipMiddleware, minimum_size=1_024)
|
||||
CONTEXT_TIMEOUT_SECONDS = 5.0
|
||||
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
|
||||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
@@ -78,6 +109,9 @@ AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES = 256
|
||||
AUTHORED_ACTION_LEDGER_LOCK_TIMEOUT_SECONDS = float(
|
||||
os.getenv("STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS", "0.1")
|
||||
)
|
||||
AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS = float(
|
||||
os.getenv("STACKCHAIN_AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS", "5.0")
|
||||
)
|
||||
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
|
||||
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
|
||||
WORK_PAGE_TIMEOUT_SECONDS = 5.0
|
||||
@@ -90,6 +124,7 @@ LIVE_SNAPSHOT_RETRY_BASE_SECONDS = 5.0
|
||||
LIVE_SNAPSHOT_RETRY_MAX_SECONDS = 60.0
|
||||
AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS = 15.0
|
||||
AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS = 5.0
|
||||
AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS = WORK_PAGE_TIMEOUT_SECONDS + 1.0
|
||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||
_live_snapshot_task: asyncio.Task | None = None
|
||||
_live_snapshot_value: dict | None = None
|
||||
@@ -108,11 +143,17 @@ _live_snapshot_refreshing_sections: set[str] = set()
|
||||
_live_section_revisions: dict[str, int] = {
|
||||
section: 0 for section in LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
_live_revision_generation = secrets.token_hex(8)
|
||||
_read_notification_ids: set[int] = set()
|
||||
_authored_action_operations: dict[
|
||||
str, tuple[tuple[Any, ...], asyncio.Task, float]
|
||||
] = {}
|
||||
_state_dir = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"))
|
||||
_live_snapshot_clock = time.time
|
||||
_live_snapshot_store = LiveSnapshotStore(
|
||||
os.getenv("STACKCHAIN_LIVE_SNAPSHOT_DB", str(_state_dir / "live-snapshot.sqlite3")),
|
||||
clock=lambda: _live_snapshot_clock(),
|
||||
)
|
||||
_idempotency_ledger = IdempotencyLedger(
|
||||
os.getenv("STACKCHAIN_IDEMPOTENCY_DB", str(_state_dir / "idempotency.sqlite3")),
|
||||
ttl_seconds=AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS,
|
||||
@@ -123,9 +164,16 @@ _idempotency_ledger = IdempotencyLedger(
|
||||
lock_timeout_seconds=AUTHORED_ACTION_LEDGER_LOCK_TIMEOUT_SECONDS,
|
||||
)
|
||||
_available_issue_snapshot_task: asyncio.Task | None = None
|
||||
_available_issue_snapshot_lock = asyncio.Lock()
|
||||
_available_issue_snapshot_value: list[dict] | None = None
|
||||
_available_issue_snapshot_created_at: float | None = None
|
||||
_available_issue_snapshot_retry_at: float | None = None
|
||||
_available_issue_snapshot_store = AvailableIssueSnapshotStore(
|
||||
os.getenv(
|
||||
"STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB",
|
||||
str(_state_dir / "available-issue-snapshot.sqlite3"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ContextPayloadError(ValueError):
|
||||
@@ -204,16 +252,24 @@ class NotificationReadBatch(BaseModel):
|
||||
|
||||
class TodayOperation(BaseModel):
|
||||
operation_id: str = Field(min_length=1, max_length=100)
|
||||
action: Literal["add", "remove", "move"]
|
||||
action: Literal["add", "remove", "move", "configure"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
direction: Literal["up", "down"] | None = None
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
|
||||
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_move_direction(self):
|
||||
def validate_action_fields(self):
|
||||
if self.action == "move" and self.direction is None:
|
||||
raise ValueError("move requires a direction")
|
||||
if self.action != "move" and self.direction is not None:
|
||||
raise ValueError("direction is only valid for move")
|
||||
if self.action != "configure" and (self.capacity_minutes is not None or self.estimates):
|
||||
raise ValueError("capacity and estimates are only valid for configure")
|
||||
if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
|
||||
for item_id, minutes in self.estimates.items()):
|
||||
raise ValueError("estimates must use bounded item IDs and minutes")
|
||||
return self
|
||||
|
||||
|
||||
@@ -222,6 +278,7 @@ class LaterOperation(BaseModel):
|
||||
action: Literal["defer", "restore"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
wake_at: str | None = Field(default=None, max_length=100)
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class TodayOperationBatch(BaseModel):
|
||||
@@ -256,6 +313,46 @@ class IssueComment(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class IssueAttachment(BaseModel):
|
||||
filename: str = Field(min_length=1, max_length=255)
|
||||
content_type: Literal["image/png", "image/jpeg", "image/webp"]
|
||||
data: str = Field(min_length=1, max_length=2_800_000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_filename(self):
|
||||
if (
|
||||
"/" in self.filename
|
||||
or "\\" in self.filename
|
||||
or any(ord(character) < 32 for character in self.filename)
|
||||
):
|
||||
raise ValueError("attachment filename must be a plain file name")
|
||||
extension = self.filename.rsplit(".", 1)[-1].lower() if "." in self.filename else ""
|
||||
expected = {
|
||||
"image/png": {"png"},
|
||||
"image/jpeg": {"jpg", "jpeg"},
|
||||
"image/webp": {"webp"},
|
||||
}
|
||||
if extension not in expected[self.content_type]:
|
||||
raise ValueError("attachment filename must match the selected image type")
|
||||
return self
|
||||
|
||||
def content(self) -> bytes:
|
||||
try:
|
||||
content = base64.b64decode(self.data, validate=True)
|
||||
except (ValueError, binascii.Error) as exc:
|
||||
raise ValueError("attachment data must be valid base64") from exc
|
||||
if len(content) > 2 * 1024 * 1024:
|
||||
raise ValueError("screenshot must be 2 MB or smaller")
|
||||
signatures = {
|
||||
"image/png": content.startswith(b"\x89PNG\r\n\x1a\n"),
|
||||
"image/jpeg": content.startswith(b"\xff\xd8\xff"),
|
||||
"image/webp": content.startswith(b"RIFF") and content[8:12] == b"WEBP",
|
||||
}
|
||||
if not signatures[self.content_type]:
|
||||
raise ValueError("file contents do not match the selected image type")
|
||||
return content
|
||||
|
||||
|
||||
class IssueCreation(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
@@ -498,6 +595,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||
and all(field in p for field in ("id", "number", "title", "state", "html_url"))
|
||||
]
|
||||
payload = compute(user_model, repo_models, issue_models, pr_models).model_dump()
|
||||
repository_pagination = getattr(repo_data, "pagination", None)
|
||||
if isinstance(repository_pagination, dict):
|
||||
payload["repository_pagination"] = repository_pagination
|
||||
pagination = {}
|
||||
pagination.update(getattr(issues_data, "pagination", {}))
|
||||
pagination.update(getattr(prs_data, "pagination", {}))
|
||||
@@ -587,7 +687,13 @@ async def require_operator_session(request: Request, call_next):
|
||||
return await call_next(request)
|
||||
|
||||
public = (
|
||||
path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"}
|
||||
path in {
|
||||
"/healthz",
|
||||
"/readyz",
|
||||
"/login",
|
||||
"/manifest.webmanifest",
|
||||
"/" + FRONTEND_BUILD.runtime_name,
|
||||
}
|
||||
or path.startswith("/static/")
|
||||
or (path == "/api/v1/session" and request.method == "POST")
|
||||
)
|
||||
@@ -607,18 +713,19 @@ async def require_operator_session(request: Request, call_next):
|
||||
if not public and session is None:
|
||||
if path.startswith("/api/"):
|
||||
payload = {"detail": "Authentication required"}
|
||||
if session_reason == "session_revoked":
|
||||
if session_reason in {"session_revoked", "session_idle"}:
|
||||
payload["code"] = session_reason
|
||||
return JSONResponse(
|
||||
payload,
|
||||
status_code=401,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
login_redirect = (
|
||||
"login?reason=session-revoked"
|
||||
if session_reason == "session_revoked"
|
||||
else _share_target_login_redirect(request)
|
||||
)
|
||||
if session_reason == "session_revoked":
|
||||
login_redirect = "login?reason=session-revoked"
|
||||
elif session_reason == "session_idle":
|
||||
login_redirect = "login?reason=session-idle"
|
||||
else:
|
||||
login_redirect = _share_target_login_redirect(request)
|
||||
return RedirectResponse(
|
||||
login_redirect,
|
||||
status_code=303,
|
||||
@@ -865,10 +972,36 @@ async def fresh_authorization(payload: FreshAuthorization, request: Request):
|
||||
@app.get("/api/v1/session")
|
||||
async def session_status(request: Request):
|
||||
session = request.state.dashboard_session
|
||||
return {
|
||||
payload = {
|
||||
"authenticated": session is not None,
|
||||
"csrf_token": session.csrf if session is not None else "",
|
||||
}
|
||||
if session is not None:
|
||||
payload["expires_at"] = session.expires_at
|
||||
return payload
|
||||
|
||||
|
||||
@app.post("/api/v1/session/activity")
|
||||
async def record_session_activity(request: Request):
|
||||
session = request.state.dashboard_session
|
||||
try:
|
||||
active = await dashboard_auth.touch_session(session)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if not active:
|
||||
return JSONResponse(
|
||||
{"detail": "Authentication required"},
|
||||
status_code=401,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return {
|
||||
"active": True,
|
||||
"idle_expires_at": int(time.time()) + dashboard_auth.idle_timeout_seconds(),
|
||||
}
|
||||
|
||||
|
||||
def _today_store() -> TodayStore:
|
||||
@@ -921,6 +1054,12 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
|
||||
login,
|
||||
[operation.model_dump() for operation in payload.operations],
|
||||
)
|
||||
if payload.action == "configure":
|
||||
return await asyncio.to_thread(
|
||||
_today_store().apply_batch,
|
||||
login,
|
||||
[payload.model_dump()],
|
||||
)
|
||||
return await asyncio.to_thread(
|
||||
_today_store().apply,
|
||||
login,
|
||||
@@ -969,6 +1108,7 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
|
||||
payload.action,
|
||||
payload.item_id,
|
||||
wake_at=payload.wake_at,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
@@ -1199,6 +1339,36 @@ async def context() -> JSONResponse:
|
||||
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
|
||||
|
||||
|
||||
@app.get("/api/v1/repositories")
|
||||
async def repository_page(
|
||||
page: int = Query(default=1, ge=1, le=1000),
|
||||
limit: int = Query(default=50, ge=1, le=50),
|
||||
) -> JSONResponse:
|
||||
"""Return one bounded repository page for lazy issue-capture selection."""
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.repo_page(page=page, limit=limit),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
items = [
|
||||
Repo(
|
||||
id=item["id"], name=item["name"], full_name=item["full_name"],
|
||||
description=item.get("description") or "", url=item["html_url"],
|
||||
updated_at=item.get("updated_at", ""),
|
||||
).model_dump()
|
||||
for item in result.get("items", [])
|
||||
if isinstance(item, dict)
|
||||
and all(field in item for field in ("id", "name", "full_name", "html_url"))
|
||||
]
|
||||
return JSONResponse({**result, "items": items})
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Repositories could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/background-identity")
|
||||
async def background_identity() -> JSONResponse:
|
||||
"""Return only the account key required to safely drain a browser outbox."""
|
||||
@@ -1310,14 +1480,33 @@ async def paged_work(
|
||||
})
|
||||
|
||||
|
||||
async def _refresh_available_issue_snapshot() -> list[dict]:
|
||||
async def _refresh_available_issue_snapshot(lease_owner: str) -> list[dict]:
|
||||
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
|
||||
global _available_issue_snapshot_retry_at
|
||||
result = await gitea_proxy.available_issue_snapshot()
|
||||
_available_issue_snapshot_value = result
|
||||
_available_issue_snapshot_created_at = time.monotonic()
|
||||
_available_issue_snapshot_retry_at = None
|
||||
return result
|
||||
try:
|
||||
result = await gitea_proxy.available_issue_snapshot()
|
||||
shared = await asyncio.to_thread(
|
||||
_available_issue_snapshot_store.publish, lease_owner, items=result
|
||||
)
|
||||
_available_issue_snapshot_value = shared.items
|
||||
_available_issue_snapshot_created_at = time.monotonic()
|
||||
_available_issue_snapshot_retry_at = None
|
||||
return shared.items or []
|
||||
except asyncio.CancelledError:
|
||||
await asyncio.to_thread(
|
||||
_available_issue_snapshot_store.release_refresh, lease_owner
|
||||
)
|
||||
raise
|
||||
except Exception:
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
_available_issue_snapshot_store.fail_refresh,
|
||||
lease_owner,
|
||||
retry_at=time.time() + AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _observe_available_issue_refresh(task: asyncio.Task) -> None:
|
||||
@@ -1329,8 +1518,43 @@ def _observe_available_issue_refresh(task: asyncio.Task) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _start_available_issue_refresh() -> bool:
|
||||
"""Atomically start this worker's refresh when the shared lease is available."""
|
||||
global _available_issue_snapshot_task
|
||||
async with _available_issue_snapshot_lock:
|
||||
if (
|
||||
_available_issue_snapshot_task is not None
|
||||
and not _available_issue_snapshot_task.done()
|
||||
):
|
||||
return True
|
||||
lease_owner = await asyncio.to_thread(
|
||||
_available_issue_snapshot_store.try_acquire_refresh,
|
||||
lease_seconds=AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS,
|
||||
)
|
||||
if lease_owner is None:
|
||||
return False
|
||||
_available_issue_snapshot_task = asyncio.create_task(
|
||||
_refresh_available_issue_snapshot(lease_owner)
|
||||
)
|
||||
_available_issue_snapshot_task.add_done_callback(_observe_available_issue_refresh)
|
||||
return True
|
||||
|
||||
|
||||
async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
|
||||
global _available_issue_snapshot_task
|
||||
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
|
||||
shared = await asyncio.to_thread(_available_issue_snapshot_store.load)
|
||||
wall_now = time.time()
|
||||
if shared.items is not None:
|
||||
_available_issue_snapshot_value = shared.items
|
||||
if (
|
||||
shared.created_at is not None
|
||||
and wall_now - shared.created_at < AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS
|
||||
):
|
||||
_available_issue_snapshot_created_at = time.monotonic()
|
||||
return shared.items, False, False, False
|
||||
if shared.retry_at is not None and wall_now < shared.retry_at:
|
||||
return shared.items, True, False, True
|
||||
now = time.monotonic()
|
||||
if (
|
||||
_available_issue_snapshot_value is not None
|
||||
@@ -1345,13 +1569,18 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
|
||||
and now < _available_issue_snapshot_retry_at
|
||||
):
|
||||
return _available_issue_snapshot_value, True, False, True
|
||||
if _available_issue_snapshot_task is None or _available_issue_snapshot_task.done():
|
||||
_available_issue_snapshot_task = asyncio.create_task(
|
||||
_refresh_available_issue_snapshot()
|
||||
)
|
||||
_available_issue_snapshot_task.add_done_callback(_observe_available_issue_refresh)
|
||||
local_refresh = await _start_available_issue_refresh()
|
||||
if _available_issue_snapshot_value is not None:
|
||||
return _available_issue_snapshot_value, True, True, False
|
||||
if not local_refresh:
|
||||
for _ in range(50):
|
||||
await asyncio.sleep(0.02)
|
||||
shared = await asyncio.to_thread(_available_issue_snapshot_store.load)
|
||||
if shared.items is not None:
|
||||
return shared.items, False, False, False
|
||||
if not shared.refreshing:
|
||||
break
|
||||
raise RuntimeError("available issue catalog refresh is owned by another worker")
|
||||
try:
|
||||
return await asyncio.shield(_available_issue_snapshot_task), False, False, False
|
||||
except Exception:
|
||||
@@ -1481,7 +1710,7 @@ def _record_live_section_failure(section: str) -> None:
|
||||
LIVE_SNAPSHOT_RETRY_BASE_SECONDS
|
||||
* (2 ** (_live_section_failure_count[section] - 1)),
|
||||
)
|
||||
_live_section_retry_at[section] = time.monotonic() + delay
|
||||
_live_section_retry_at[section] = _live_snapshot_clock() + delay
|
||||
|
||||
|
||||
def _due_live_sections(now: float) -> set[str]:
|
||||
@@ -1496,6 +1725,23 @@ def _due_live_sections(now: float) -> set[str]:
|
||||
return due
|
||||
|
||||
|
||||
def _apply_shared_live_state(state: LiveSnapshotState) -> None:
|
||||
"""Replace this worker's fast local view with one coherent SQLite read."""
|
||||
global _live_snapshot_value, _live_snapshot_created_at
|
||||
global _live_section_created_at, _live_section_failure_count, _live_section_retry_at
|
||||
global _live_section_revisions, _live_revision_generation
|
||||
global _live_snapshot_refreshing_sections
|
||||
_live_snapshot_value = state.value
|
||||
_live_section_created_at = state.created_at
|
||||
_live_section_failure_count = state.failure_count
|
||||
_live_section_retry_at = state.retry_at
|
||||
_live_section_revisions = state.revisions
|
||||
_live_revision_generation = state.generation
|
||||
_live_snapshot_refreshing_sections = state.refreshing_sections
|
||||
successful = [value for value in state.created_at.values() if value is not None]
|
||||
_live_snapshot_created_at = max(successful) if successful else None
|
||||
|
||||
|
||||
def _merge_live_snapshot(previous: dict | None, refreshed: dict) -> dict:
|
||||
if previous is None:
|
||||
return refreshed
|
||||
@@ -1518,15 +1764,32 @@ def _merge_live_snapshot(previous: dict | None, refreshed: dict) -> dict:
|
||||
return merged
|
||||
|
||||
|
||||
async def _refresh_live_snapshot(sections: set[str]) -> dict:
|
||||
async def _refresh_live_snapshot(sections: set[str], lease_owner: str) -> dict:
|
||||
global _live_snapshot_value, _live_snapshot_created_at
|
||||
try:
|
||||
refreshed = await _build_live_snapshot_before_deadline(sections)
|
||||
except asyncio.CancelledError:
|
||||
try:
|
||||
await asyncio.shield(
|
||||
asyncio.to_thread(_live_snapshot_store.release_refresh, lease_owner)
|
||||
)
|
||||
finally:
|
||||
raise
|
||||
except Exception:
|
||||
for section in sections:
|
||||
_record_live_section_failure(section)
|
||||
try:
|
||||
state = await asyncio.to_thread(
|
||||
_live_snapshot_store.fail_refresh,
|
||||
lease_owner,
|
||||
failure_count=_live_section_failure_count,
|
||||
retry_at=_live_section_retry_at,
|
||||
)
|
||||
_apply_shared_live_state(state)
|
||||
except RefreshLeaseLost:
|
||||
_apply_shared_live_state(await asyncio.to_thread(_live_snapshot_store.load))
|
||||
raise
|
||||
now = time.monotonic()
|
||||
now = _live_snapshot_clock()
|
||||
refreshed_states = refreshed.get("sections") or {}
|
||||
for section in sections:
|
||||
if refreshed_states.get(section) == "fresh":
|
||||
@@ -1538,6 +1801,7 @@ async def _refresh_live_snapshot(sections: set[str]) -> dict:
|
||||
previous = _live_snapshot_value
|
||||
result = _merge_live_snapshot(previous, refreshed)
|
||||
result = _without_read_notifications(result)
|
||||
changed_sections = set()
|
||||
for section in sections:
|
||||
if section not in refreshed_states:
|
||||
continue
|
||||
@@ -1547,11 +1811,24 @@ async def _refresh_live_snapshot(sections: set[str]) -> dict:
|
||||
"notification_pagination"
|
||||
) != result.get("notification_pagination")
|
||||
if changed:
|
||||
_live_section_revisions[section] += 1
|
||||
_live_snapshot_value = result
|
||||
successful_times = [value for value in _live_section_created_at.values() if value is not None]
|
||||
_live_snapshot_created_at = max(successful_times) if successful_times else None
|
||||
return result
|
||||
changed_sections.add(section)
|
||||
try:
|
||||
state = await asyncio.to_thread(
|
||||
_live_snapshot_store.publish_refresh,
|
||||
lease_owner,
|
||||
value=result,
|
||||
created_at=_live_section_created_at,
|
||||
failure_count=_live_section_failure_count,
|
||||
retry_at=_live_section_retry_at,
|
||||
changed_sections=changed_sections,
|
||||
)
|
||||
except RefreshLeaseLost:
|
||||
state = await asyncio.to_thread(_live_snapshot_store.load)
|
||||
if state.value is None:
|
||||
raise
|
||||
_apply_shared_live_state(state)
|
||||
assert state.value is not None
|
||||
return state.value
|
||||
|
||||
|
||||
def _consume_live_snapshot_failure(task: asyncio.Task) -> None:
|
||||
@@ -1560,12 +1837,22 @@ def _consume_live_snapshot_failure(task: asyncio.Task) -> None:
|
||||
task.exception()
|
||||
|
||||
|
||||
def _start_live_snapshot_refresh(sections: set[str]) -> asyncio.Task:
|
||||
def _start_live_snapshot_refresh(
|
||||
sections: set[str], lease_owner: str | None = None
|
||||
) -> asyncio.Task:
|
||||
global _live_snapshot_task
|
||||
global _live_snapshot_refreshing_sections
|
||||
if _live_snapshot_task is None or _live_snapshot_task.done():
|
||||
if lease_owner is None:
|
||||
lease_owner = _live_snapshot_store.try_acquire_refresh(
|
||||
sections, lease_seconds=CONTEXT_TIMEOUT_SECONDS + 1.0
|
||||
)
|
||||
if lease_owner is None:
|
||||
raise RuntimeError("live snapshot refresh lease is already held")
|
||||
_live_snapshot_refreshing_sections = set(sections)
|
||||
_live_snapshot_task = asyncio.create_task(_refresh_live_snapshot(sections))
|
||||
_live_snapshot_task = asyncio.create_task(
|
||||
_refresh_live_snapshot(sections, lease_owner)
|
||||
)
|
||||
_live_snapshot_task.add_done_callback(_consume_live_snapshot_failure)
|
||||
return _live_snapshot_task
|
||||
|
||||
@@ -1575,10 +1862,10 @@ def _live_snapshot_payload(
|
||||
*,
|
||||
stale: bool,
|
||||
revalidating: bool,
|
||||
known_revisions: dict[str, int | None] | None = None,
|
||||
known_revisions: dict[str, str | None] | None = None,
|
||||
) -> dict:
|
||||
payload = dict(value)
|
||||
now = time.monotonic()
|
||||
now = _live_snapshot_clock()
|
||||
section_freshness = {}
|
||||
for section in LIVE_SNAPSHOT_SECTIONS:
|
||||
created_at = _live_section_created_at[section]
|
||||
@@ -1608,9 +1895,13 @@ def _live_snapshot_payload(
|
||||
"retry_in_seconds": min(retries, default=0),
|
||||
"sections": section_freshness,
|
||||
}
|
||||
payload["revisions"] = dict(_live_section_revisions)
|
||||
revision_tokens = {
|
||||
section: f"{_live_revision_generation}.{revision}"
|
||||
for section, revision in _live_section_revisions.items()
|
||||
}
|
||||
payload["revisions"] = revision_tokens
|
||||
for section, known_revision in (known_revisions or {}).items():
|
||||
if known_revision is None or known_revision != _live_section_revisions[section]:
|
||||
if known_revision is None or known_revision != revision_tokens[section]:
|
||||
continue
|
||||
payload.pop(section, None)
|
||||
if section == "notifications":
|
||||
@@ -1618,23 +1909,30 @@ def _live_snapshot_payload(
|
||||
return payload
|
||||
|
||||
|
||||
def _remove_notification_from_live_snapshot(thread_id: int) -> None:
|
||||
async def _remove_notifications_from_live_snapshot(thread_ids: list[int]) -> None:
|
||||
global _live_snapshot_value, _read_notification_ids
|
||||
_read_notification_ids = _read_notification_ids | {thread_id}
|
||||
if _live_snapshot_value is None:
|
||||
read_ids = set(thread_ids)
|
||||
if not read_ids:
|
||||
return
|
||||
retained_notifications = _live_snapshot_value.get("notifications")
|
||||
if not isinstance(retained_notifications, list):
|
||||
return
|
||||
updated = dict(_live_snapshot_value)
|
||||
updated["notifications"] = [
|
||||
notification
|
||||
for notification in retained_notifications
|
||||
if not isinstance(notification, dict) or notification.get("id") != thread_id
|
||||
]
|
||||
if updated["notifications"] != retained_notifications:
|
||||
_live_section_revisions["notifications"] += 1
|
||||
_live_snapshot_value = updated
|
||||
_read_notification_ids = _read_notification_ids | read_ids
|
||||
if _live_snapshot_value is not None:
|
||||
retained_notifications = _live_snapshot_value.get("notifications")
|
||||
if isinstance(retained_notifications, list):
|
||||
updated = dict(_live_snapshot_value)
|
||||
updated["notifications"] = [
|
||||
notification
|
||||
for notification in retained_notifications
|
||||
if not isinstance(notification, dict)
|
||||
or notification.get("id") not in read_ids
|
||||
]
|
||||
if updated["notifications"] != retained_notifications:
|
||||
_live_section_revisions["notifications"] += 1
|
||||
_live_snapshot_value = updated
|
||||
try:
|
||||
await asyncio.to_thread(_live_snapshot_store.remove_notifications, read_ids)
|
||||
except (OSError, sqlite3.Error):
|
||||
# The upstream mutation already succeeded; keep process-local filtering.
|
||||
pass
|
||||
|
||||
|
||||
def _without_read_notifications(snapshot: dict) -> dict:
|
||||
@@ -1658,11 +1956,16 @@ def _without_read_notifications(snapshot: dict) -> dict:
|
||||
return updated
|
||||
|
||||
|
||||
@app.get("/api/v1/live")
|
||||
async def live_snapshot(
|
||||
context_revision: int | None = Query(default=None, ge=0),
|
||||
events_revision: int | None = Query(default=None, ge=0),
|
||||
notifications_revision: int | None = Query(default=None, ge=0),
|
||||
async def _live_snapshot_response(
|
||||
context_revision: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
|
||||
),
|
||||
events_revision: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
|
||||
),
|
||||
notifications_revision: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
|
||||
),
|
||||
) -> JSONResponse:
|
||||
"""Return a freshness-bounded snapshot and share identical upstream loads."""
|
||||
global _live_snapshot_task, _live_snapshot_value, _live_snapshot_created_at
|
||||
@@ -1671,12 +1974,10 @@ async def live_snapshot(
|
||||
"events": events_revision,
|
||||
"notifications": notifications_revision,
|
||||
}
|
||||
now = time.monotonic()
|
||||
_apply_shared_live_state(await asyncio.to_thread(_live_snapshot_store.load))
|
||||
now = _live_snapshot_clock()
|
||||
due_sections = _due_live_sections(now)
|
||||
if (
|
||||
_live_snapshot_value is not None
|
||||
and not due_sections
|
||||
):
|
||||
if _live_snapshot_value is not None and not due_sections:
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(
|
||||
_live_snapshot_value,
|
||||
@@ -1688,7 +1989,77 @@ async def live_snapshot(
|
||||
known_revisions=known_revisions,
|
||||
)
|
||||
)
|
||||
task = _start_live_snapshot_refresh(due_sections)
|
||||
if not due_sections:
|
||||
retries = [
|
||||
retry_at - now
|
||||
for retry_at in _live_section_retry_at.values()
|
||||
if retry_at is not None and retry_at > now
|
||||
]
|
||||
return JSONResponse(
|
||||
{"error": "Gitea live snapshot is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Retry-After": str(max(1, math.ceil(min(retries, default=1.0))))},
|
||||
)
|
||||
|
||||
task = _live_snapshot_task
|
||||
if task is None or task.done():
|
||||
lease_owner = await asyncio.to_thread(
|
||||
_live_snapshot_store.try_acquire_refresh,
|
||||
due_sections,
|
||||
lease_seconds=CONTEXT_TIMEOUT_SECONDS + 1.0,
|
||||
)
|
||||
if lease_owner is not None:
|
||||
latest = await asyncio.to_thread(_live_snapshot_store.load)
|
||||
_apply_shared_live_state(latest)
|
||||
due_sections = _due_live_sections(_live_snapshot_clock())
|
||||
if not due_sections:
|
||||
await asyncio.to_thread(
|
||||
_live_snapshot_store.release_refresh, lease_owner
|
||||
)
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(
|
||||
_live_snapshot_value,
|
||||
stale=any(
|
||||
state != "fresh"
|
||||
for state in (_live_snapshot_value or {}).get("sections", {}).values()
|
||||
),
|
||||
revalidating=False,
|
||||
known_revisions=known_revisions,
|
||||
)
|
||||
)
|
||||
task = _start_live_snapshot_refresh(due_sections, lease_owner)
|
||||
else:
|
||||
shared = await asyncio.to_thread(_live_snapshot_store.load)
|
||||
_apply_shared_live_state(shared)
|
||||
if _live_snapshot_value is not None:
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(
|
||||
_live_snapshot_value,
|
||||
stale=bool(_due_live_sections(_live_snapshot_clock())),
|
||||
revalidating=bool(shared.refreshing_sections),
|
||||
known_revisions=known_revisions,
|
||||
)
|
||||
)
|
||||
deadline = time.perf_counter() + CONTEXT_TIMEOUT_SECONDS
|
||||
while time.perf_counter() < deadline:
|
||||
await asyncio.sleep(0.01)
|
||||
shared = await asyncio.to_thread(_live_snapshot_store.load)
|
||||
if shared.value is not None:
|
||||
_apply_shared_live_state(shared)
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(
|
||||
shared.value,
|
||||
stale=bool(_due_live_sections(_live_snapshot_clock())),
|
||||
revalidating=bool(shared.refreshing_sections),
|
||||
known_revisions=known_revisions,
|
||||
)
|
||||
)
|
||||
return JSONResponse(
|
||||
{"error": f"Gitea live snapshot timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"},
|
||||
status_code=503,
|
||||
headers={"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))},
|
||||
)
|
||||
assert task is not None
|
||||
if _live_snapshot_value is not None:
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(
|
||||
@@ -1721,6 +2092,32 @@ async def live_snapshot(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/live")
|
||||
async def live_snapshot(
|
||||
context_revision: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
|
||||
),
|
||||
events_revision: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
|
||||
),
|
||||
notifications_revision: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
|
||||
),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
return await _live_snapshot_response(
|
||||
context_revision=context_revision,
|
||||
events_revision=events_revision,
|
||||
notifications_revision=notifications_revision,
|
||||
)
|
||||
except (OSError, sqlite3.Error):
|
||||
return JSONResponse(
|
||||
{"error": "Gitea live snapshot state is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@app.get("/api/v1/events")
|
||||
async def event_stream():
|
||||
@@ -1763,7 +2160,6 @@ async def _mark_notification_read_result(thread_id: int) -> tuple[int, bool]:
|
||||
)
|
||||
except Exception:
|
||||
return thread_id, False
|
||||
_remove_notification_from_live_snapshot(thread_id)
|
||||
return thread_id, True
|
||||
|
||||
|
||||
@@ -1793,6 +2189,9 @@ async def read_notifications(batch: NotificationReadBatch) -> JSONResponse:
|
||||
for thread_id, succeeded in [task.result()]
|
||||
if succeeded
|
||||
}
|
||||
await _remove_notifications_from_live_snapshot(
|
||||
[thread_id for thread_id in thread_ids if thread_id in succeeded_ids]
|
||||
)
|
||||
failed = [thread_id for thread_id in thread_ids if thread_id not in succeeded_ids]
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -1893,7 +2292,7 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
{"error": "The update could not be marked read. Please retry."},
|
||||
status_code=503,
|
||||
)
|
||||
_remove_notification_from_live_snapshot(thread_id)
|
||||
await _remove_notifications_from_live_snapshot([thread_id])
|
||||
return JSONResponse({"id": thread_id, "status": "read"})
|
||||
|
||||
|
||||
@@ -1929,6 +2328,7 @@ async def reply_to_notification(
|
||||
async def claim_available_issue(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
) -> JSONResponse:
|
||||
global _available_issue_snapshot_value
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
@@ -1946,12 +2346,16 @@ async def claim_available_issue(
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
if _available_issue_snapshot_value is not None:
|
||||
_available_issue_snapshot_value[:] = [
|
||||
item for item in _available_issue_snapshot_value
|
||||
if not (
|
||||
item.get("repository") == repository and item.get("number") == number
|
||||
)
|
||||
retained = _available_issue_snapshot_value
|
||||
shared = await asyncio.to_thread(
|
||||
_available_issue_snapshot_store.remove_claimed, repository, number
|
||||
)
|
||||
if shared.items is not None:
|
||||
_available_issue_snapshot_value = shared.items
|
||||
elif retained is not None:
|
||||
_available_issue_snapshot_value = [
|
||||
item for item in retained
|
||||
if item.get("repository") != repository or item.get("number") != number
|
||||
]
|
||||
return JSONResponse(result)
|
||||
|
||||
@@ -1976,6 +2380,7 @@ async def release_assigned_issue(
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
if result.get("available"):
|
||||
await asyncio.to_thread(_available_issue_snapshot_store.invalidate)
|
||||
_available_issue_snapshot_value = None
|
||||
_available_issue_snapshot_created_at = None
|
||||
return JSONResponse(result)
|
||||
@@ -2007,6 +2412,28 @@ async def issue_handoff_candidates(
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/mention-candidates")
|
||||
async def repository_mention_candidates(
|
||||
owner: str,
|
||||
repo: str,
|
||||
q: str = Query(min_length=2, max_length=39, pattern=r"^[A-Za-z0-9_.-]+$"),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.mention_candidates(
|
||||
f"{owner}/{repo}", q.casefold(), limit=8
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Teammate suggestions are temporarily unavailable."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff")
|
||||
async def handoff_assigned_issue(
|
||||
handoff: IssueHandoff,
|
||||
@@ -2167,13 +2594,7 @@ async def repository_milestones(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_milestones():
|
||||
available = await gitea_proxy.repos()
|
||||
accessible = {
|
||||
item.get("full_name")
|
||||
for item in (available if isinstance(available, list) else [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if repository not in accessible:
|
||||
if await gitea_proxy.repository_access(repository) is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return await gitea_proxy.repo_milestones(repository)
|
||||
|
||||
@@ -2196,13 +2617,7 @@ async def repository_labels(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_labels():
|
||||
available = await gitea_proxy.repos()
|
||||
accessible = {
|
||||
item.get("full_name")
|
||||
for item in (available if isinstance(available, list) else [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if repository not in accessible:
|
||||
if await gitea_proxy.repository_access(repository) is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return await gitea_proxy.repo_labels(repository)
|
||||
|
||||
@@ -2228,16 +2643,11 @@ async def create_assigned_issue(
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def create_issue():
|
||||
user, available = await asyncio.gather(
|
||||
gitea_proxy.current_user(), gitea_proxy.repos()
|
||||
user, accessible = await asyncio.gather(
|
||||
gitea_proxy.current_user(), gitea_proxy.repository_access(repository)
|
||||
)
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
accessible = {
|
||||
item.get("full_name")
|
||||
for item in (available if isinstance(available, list) else [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if not login or repository not in accessible:
|
||||
if not login or accessible is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
if creation.label_ids:
|
||||
available_labels = await gitea_proxy.repo_labels(repository)
|
||||
@@ -2361,6 +2771,62 @@ async def comment_on_assigned_issue(
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
|
||||
async def attach_to_assigned_issue(
|
||||
attachment: IssueAttachment,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
content = attachment.content()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
async def upload_attachment():
|
||||
result = await gitea_proxy.upload_assigned_issue_attachment(
|
||||
repository,
|
||||
number,
|
||||
attachment.filename,
|
||||
attachment.content_type,
|
||||
content,
|
||||
)
|
||||
safe_name = (
|
||||
result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
|
||||
.replace("\r", " ").replace("\n", " ")
|
||||
)
|
||||
safe_url = result["url"].replace("<", "%3C").replace(">", "%3E")
|
||||
result["markdown"] = f""
|
||||
return result
|
||||
|
||||
try:
|
||||
result = await _run_idempotent_authored_action(
|
||||
upload_attachment(),
|
||||
idempotency_key=idempotency_key,
|
||||
fingerprint=(
|
||||
"issue-attachment",
|
||||
repository,
|
||||
number,
|
||||
attachment.filename,
|
||||
attachment.content_type,
|
||||
hashlib.sha256(content).hexdigest(),
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError as exc:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found") from exc
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The screenshot could not be uploaded. Your draft is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/close")
|
||||
async def close_assigned_issue(
|
||||
request: Request,
|
||||
|
||||
@@ -5,6 +5,7 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
SESSION_BODY_LIMIT = 16 * 1024
|
||||
API_MUTATION_BODY_LIMIT = 64 * 1024
|
||||
ISSUE_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
|
||||
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
||||
|
||||
|
||||
@@ -13,6 +14,13 @@ def request_body_limit(method: str, path: str) -> int | None:
|
||||
normalized_method = method.upper()
|
||||
if normalized_method == "POST" and path == "/api/v1/session":
|
||||
return SESSION_BODY_LIMIT
|
||||
if (
|
||||
normalized_method == "POST"
|
||||
and path.startswith("/api/v1/repos/")
|
||||
and "/issues/" in path
|
||||
and path.endswith("/attachments")
|
||||
):
|
||||
return ISSUE_ATTACHMENT_BODY_LIMIT
|
||||
if normalized_method in MUTATION_METHODS and path.startswith("/api/v1/"):
|
||||
return API_MUTATION_BODY_LIMIT
|
||||
return None
|
||||
|
||||
+59
-3
@@ -58,7 +58,8 @@ class SessionStore:
|
||||
expires_at INTEGER NOT NULL,
|
||||
management_id TEXT,
|
||||
device_label TEXT,
|
||||
created_at INTEGER
|
||||
created_at INTEGER,
|
||||
last_active_at INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
@@ -84,6 +85,7 @@ class SessionStore:
|
||||
"management_id": "TEXT",
|
||||
"device_label": "TEXT",
|
||||
"created_at": "INTEGER",
|
||||
"last_active_at": "INTEGER",
|
||||
}
|
||||
for name, column_type in additions.items():
|
||||
if name not in columns:
|
||||
@@ -102,6 +104,10 @@ class SessionStore:
|
||||
"UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL",
|
||||
(int(self.clock()),),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE active_sessions SET last_active_at = ? WHERE last_active_at IS NULL",
|
||||
(int(self.clock()),),
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id "
|
||||
"ON active_sessions(management_id)"
|
||||
@@ -122,14 +128,15 @@ class SessionStore:
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO active_sessions("
|
||||
"session_hash, expires_at, management_id, device_label, created_at"
|
||||
") VALUES (?, ?, ?, ?, ?)",
|
||||
"session_hash, expires_at, management_id, device_label, created_at, last_active_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
self._digest(session_id),
|
||||
expires_at,
|
||||
secrets.token_urlsafe(18),
|
||||
label,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
@@ -147,6 +154,55 @@ class SessionStore:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
return row is not None and row[0] == expires_at and expires_at > now
|
||||
|
||||
def status(
|
||||
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
|
||||
) -> str:
|
||||
now = int(self.clock())
|
||||
query = (
|
||||
"SELECT expires_at, last_active_at FROM active_sessions "
|
||||
"WHERE session_hash = ?"
|
||||
)
|
||||
parameters = (self._digest(session_id),)
|
||||
try:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(query, parameters).fetchone()
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "no such column: last_active_at" not in str(exc):
|
||||
raise
|
||||
with self._connect(initialize=True) as connection:
|
||||
row = connection.execute(query, parameters).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
if row is None or row[0] != expires_at or expires_at <= now:
|
||||
return "revoked"
|
||||
if row[1] + max(1, idle_timeout_seconds) <= now:
|
||||
return "idle"
|
||||
return "active"
|
||||
|
||||
def touch(
|
||||
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
|
||||
) -> bool:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"UPDATE active_sessions SET last_active_at = ? "
|
||||
"WHERE session_hash = ? AND expires_at = ? AND expires_at > ? "
|
||||
"AND last_active_at + ? > ?",
|
||||
(
|
||||
now,
|
||||
self._digest(session_id),
|
||||
expires_at,
|
||||
now,
|
||||
max(1, idle_timeout_seconds),
|
||||
now,
|
||||
),
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke(self, session_id: str) -> None:
|
||||
try:
|
||||
with self._connect(initialize=True) as connection:
|
||||
|
||||
+119
-26
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -10,10 +11,22 @@ class TodayPlanFull(ValueError):
|
||||
|
||||
|
||||
class TodayStore:
|
||||
def __init__(self, path: str | Path, *, limit: int = 5, timeout: float = 1.0):
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 5,
|
||||
timeout: float = 1.0,
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -24,21 +37,54 @@ class TodayStore:
|
||||
CREATE TABLE IF NOT EXISTS today_plans (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
ids TEXT NOT NULL
|
||||
ids TEXT NOT NULL,
|
||||
capacity_minutes INTEGER,
|
||||
estimates TEXT NOT NULL DEFAULT '{}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
plan_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_plans)")}
|
||||
if "capacity_minutes" not in plan_columns:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN capacity_minutes INTEGER")
|
||||
if "estimates" not in plan_columns:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN estimates TEXT NOT NULL DEFAULT '{}'")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_operations (
|
||||
login TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
PRIMARY KEY (login, operation_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")}
|
||||
if "created_at" not in columns:
|
||||
connection.execute("ALTER TABLE today_operations ADD COLUMN created_at REAL")
|
||||
connection.execute(
|
||||
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(self.clock(),),
|
||||
)
|
||||
connection.commit()
|
||||
return connection
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
connection.execute(
|
||||
"INSERT INTO today_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
|
||||
(login, operation_id, now),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM today_operations WHERE login = ? AND created_at < ?",
|
||||
(login, now - self.operation_retention_seconds),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM today_operations WHERE login = ? AND rowid NOT IN "
|
||||
"(SELECT rowid FROM today_operations WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||
(login, login, self.operation_limit),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
@@ -49,13 +95,20 @@ class TodayStore:
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
if row is None:
|
||||
return {"revision": 0, "ids": []}
|
||||
return {"revision": int(row[0]), "ids": json.loads(row[1])}
|
||||
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}
|
||||
ids = json.loads(row[1])
|
||||
estimates = json.loads(row[3] or "{}")
|
||||
return {
|
||||
"revision": int(row[0]),
|
||||
"ids": ids,
|
||||
"capacity_minutes": row[2],
|
||||
"estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
|
||||
}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?",
|
||||
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
@@ -80,7 +133,7 @@ class TodayStore:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
|
||||
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
duplicate = connection.execute(
|
||||
@@ -91,6 +144,7 @@ class TodayStore:
|
||||
return snapshot
|
||||
|
||||
ids = list(snapshot["ids"])
|
||||
estimates = dict(snapshot["estimates"])
|
||||
changed = False
|
||||
if action == "add":
|
||||
if item_id not in ids:
|
||||
@@ -101,6 +155,7 @@ class TodayStore:
|
||||
elif action == "remove":
|
||||
if item_id in ids:
|
||||
ids.remove(item_id)
|
||||
estimates.pop(item_id, None)
|
||||
changed = True
|
||||
else:
|
||||
try:
|
||||
@@ -113,21 +168,25 @@ class TodayStore:
|
||||
changed = True
|
||||
|
||||
revision = snapshot["revision"] + (1 if changed else 0)
|
||||
serialized_ids = json.dumps(ids, separators=(",", ":"))
|
||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
|
||||
(login, revision, json.dumps(ids, separators=(",", ":"))),
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates),
|
||||
)
|
||||
elif changed:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
||||
(revision, json.dumps(ids, separators=(",", ":")), login),
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, estimates = ? WHERE login = ?",
|
||||
(revision, serialized_ids, serialized_estimates, login),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
|
||||
(login, operation_id),
|
||||
)
|
||||
return {"revision": revision, "ids": ids}
|
||||
self._record_operation(connection, login, operation_id)
|
||||
return {
|
||||
"revision": revision,
|
||||
"ids": ids,
|
||||
"capacity_minutes": snapshot["capacity_minutes"],
|
||||
"estimates": estimates,
|
||||
}
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
"""Apply an ordered batch with one lock and receipt per operation."""
|
||||
@@ -135,10 +194,12 @@ class TodayStore:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
|
||||
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
ids = list(snapshot["ids"])
|
||||
capacity_minutes = snapshot["capacity_minutes"]
|
||||
estimates = dict(snapshot["estimates"])
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
@@ -151,7 +212,7 @@ class TodayStore:
|
||||
direction = operation.get("direction")
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"add", "remove", "move"}:
|
||||
if action not in {"add", "remove", "move", "configure"}:
|
||||
raise ValueError("unsupported Today action")
|
||||
if action == "move" and direction not in {"up", "down"}:
|
||||
raise ValueError("move direction must be up or down")
|
||||
@@ -163,6 +224,16 @@ class TodayStore:
|
||||
duplicates.append(operation_id)
|
||||
continue
|
||||
|
||||
base_revision = operation.get("base_revision")
|
||||
if base_revision is None:
|
||||
base_revision = snapshot["revision"]
|
||||
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
||||
raise ValueError("base_revision must be a non-negative integer")
|
||||
if base_revision < snapshot["revision"]:
|
||||
self._record_operation(connection, login, operation_id)
|
||||
rejected.append({"operation_id": operation_id, "reason": "stale_intent"})
|
||||
continue
|
||||
|
||||
changed = False
|
||||
if action == "add":
|
||||
if item_id not in ids:
|
||||
@@ -174,8 +245,9 @@ class TodayStore:
|
||||
elif action == "remove":
|
||||
if item_id in ids:
|
||||
ids.remove(item_id)
|
||||
estimates.pop(item_id, None)
|
||||
changed = True
|
||||
else:
|
||||
elif action == "move":
|
||||
try:
|
||||
index = ids.index(item_id)
|
||||
except ValueError:
|
||||
@@ -184,27 +256,48 @@ class TodayStore:
|
||||
if index >= 0 and 0 <= target < len(ids):
|
||||
ids[index], ids[target] = ids[target], ids[index]
|
||||
changed = True
|
||||
else:
|
||||
proposed_capacity = operation.get("capacity_minutes")
|
||||
if proposed_capacity is not None and (
|
||||
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
|
||||
or proposed_capacity < 15 or proposed_capacity > 1440
|
||||
):
|
||||
raise ValueError("capacity_minutes must be between 15 and 1440")
|
||||
proposed_estimates = operation.get("estimates", {})
|
||||
if not isinstance(proposed_estimates, dict):
|
||||
raise ValueError("estimates must be an object")
|
||||
normalized_estimates = {}
|
||||
for estimate_id, minutes in proposed_estimates.items():
|
||||
if estimate_id not in ids:
|
||||
continue
|
||||
if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
|
||||
raise ValueError("estimate minutes must be between 5 and 1440")
|
||||
normalized_estimates[estimate_id] = minutes
|
||||
changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
|
||||
capacity_minutes = proposed_capacity
|
||||
estimates = normalized_estimates
|
||||
|
||||
revision += 1 if changed else 0
|
||||
connection.execute(
|
||||
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
|
||||
(login, operation_id),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(ids, separators=(",", ":"))
|
||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
|
||||
(login, revision, serialized),
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized, capacity_minutes, serialized_estimates),
|
||||
)
|
||||
elif accepted:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
||||
(revision, serialized, login),
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ? WHERE login = ?",
|
||||
(revision, serialized, capacity_minutes, serialized_estimates, login),
|
||||
)
|
||||
return {
|
||||
"revision": revision,
|
||||
"ids": ids,
|
||||
"capacity_minutes": capacity_minutes,
|
||||
"estimates": estimates,
|
||||
"accepted_operation_ids": accepted,
|
||||
"duplicate_operation_ids": duplicates,
|
||||
"rejected_operations": rejected,
|
||||
|
||||
+31
-8
@@ -1,12 +1,15 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
from src.compression import _quality
|
||||
|
||||
router = APIRouter()
|
||||
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
|
||||
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
|
||||
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
|
||||
FRONTEND_BUILD = build_frontend(DASHBOARD_FILE.parent)
|
||||
|
||||
LOGIN_HTML = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -26,7 +29,7 @@ class RevalidatingHTMLResponse(HTMLResponse):
|
||||
|
||||
@router.get("/", response_class=RevalidatingHTMLResponse)
|
||||
async def dashboard() -> str:
|
||||
return DASHBOARD_FILE.read_text()
|
||||
return FRONTEND_BUILD.dashboard_html
|
||||
|
||||
|
||||
@router.get("/login", response_class=RevalidatingHTMLResponse)
|
||||
@@ -39,10 +42,30 @@ async def web_app_manifest() -> FileResponse:
|
||||
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
|
||||
|
||||
|
||||
@router.get("/service-worker.js", response_class=FileResponse)
|
||||
async def service_worker() -> FileResponse:
|
||||
return FileResponse(
|
||||
SERVICE_WORKER_FILE,
|
||||
@router.get("/service-worker.js")
|
||||
async def service_worker() -> Response:
|
||||
return Response(
|
||||
FRONTEND_BUILD.service_worker_source,
|
||||
media_type="application/javascript",
|
||||
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/runtime-{digest}.js")
|
||||
async def runtime_bundle(digest: str, request: Request) -> Response:
|
||||
if digest != FRONTEND_BUILD.runtime_digest:
|
||||
raise HTTPException(status_code=404, detail="Runtime revision not found")
|
||||
accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0
|
||||
return Response(
|
||||
(
|
||||
FRONTEND_BUILD.runtime_gzip_bytes
|
||||
if accepts_gzip
|
||||
else FRONTEND_BUILD.runtime_bytes
|
||||
),
|
||||
media_type="application/javascript",
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Vary": "Accept-Encoding",
|
||||
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
|
||||
},
|
||||
)
|
||||
@@ -1,9 +1,17 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from src.views import dashboard as dashboard_html
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
PAGE_SCRIPT_MANIFEST = "\n".join(
|
||||
re.findall(
|
||||
r'^<script src="static/[^\"]+\.js"></script>$',
|
||||
(FRONTEND / "index.html").read_text(),
|
||||
re.MULTILINE,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def dashboard_bundle_text() -> str:
|
||||
@@ -18,11 +26,12 @@ def dashboard_bundle_text() -> str:
|
||||
|
||||
|
||||
async def dashboard() -> str:
|
||||
# Exercise the real view lookup before adding its separately served assets.
|
||||
# Exercise the real view lookup before adding its separately served bootstrap assets.
|
||||
html = await dashboard_html()
|
||||
return "\n".join(
|
||||
(
|
||||
html,
|
||||
PAGE_SCRIPT_MANIFEST,
|
||||
(FRONTEND / "dashboard.css").read_text(),
|
||||
(FRONTEND / "dashboard.js").read_text(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ASSIGN_AND_START = Path(__file__).parents[1] / "frontend" / "assign-and-start.js"
|
||||
QUEUE_TODAY = Path(__file__).parents[1] / "frontend" / "queue-today.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
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_repeated_assign_and_start_taps_claim_once_then_start_confirmed_issue():
|
||||
script = f"""
|
||||
const fs=require('fs');
|
||||
if (!fs.existsSync({json.dumps(str(ASSIGN_AND_START))})) {{
|
||||
process.stdout.write(JSON.stringify({{available:false}}));
|
||||
}} else {{
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
let release;
|
||||
const claimResult=new Promise(resolve=>{{release=resolve;}});
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
|
||||
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
const issue={{repository:'stackchain/dashboard',number:411}};
|
||||
const first=flow.run(issue);
|
||||
const second=flow.run(issue);
|
||||
release({{...issue,assignees:['timmy']}});
|
||||
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{available:true,calls,results,same:first===second}})));
|
||||
}}
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"available": True,
|
||||
"calls": [
|
||||
"claim:411",
|
||||
"start:411",
|
||||
"announce:Assigned, added to Today, and ready to work.",
|
||||
],
|
||||
"results": ["started", "started"],
|
||||
"same": True,
|
||||
}
|
||||
|
||||
|
||||
def test_full_today_blocks_before_assignment_and_explains_recovery():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>false,
|
||||
claim:()=>{{calls.push('claim');return Promise.resolve({{}});}},
|
||||
start:()=>calls.push('start'),
|
||||
recover:()=>calls.push('recover'),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run({{repository:'stackchain/dashboard',number:411}}).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,calls}}))
|
||||
);
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "full",
|
||||
"calls": ["announce:Today is full—remove an item before assigning this issue."],
|
||||
}
|
||||
|
||||
|
||||
def test_confirmed_assignment_recovers_in_issue_detail_when_today_start_fails():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const issue={{repository:'stackchain/dashboard',number:411,assignees:['timmy']}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>Promise.resolve(issue),
|
||||
start:()=> 'sync-unavailable',
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "recovery",
|
||||
"calls": [
|
||||
"announce:Assigned to you, but Today could not start. The issue is open so you can recover.",
|
||||
"recover:411",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_failed_assignment_does_not_start_today_and_can_be_retried():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];let attempt=0;
|
||||
const issue={{repository:'stackchain/dashboard',number:411}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>{{attempt+=1;calls.push('claim:'+attempt);return attempt===1 ? Promise.reject(new Error('conflict')) : Promise.resolve(issue);}},
|
||||
start:()=>{{calls.push('start');return 'started';}},
|
||||
recover:()=>calls.push('recover'),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue).catch(error=>{{
|
||||
calls.push('error:'+error.message);
|
||||
return flow.run(issue);
|
||||
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"claim:1",
|
||||
"error:conflict",
|
||||
"claim:2",
|
||||
"start",
|
||||
"announce:Assigned, added to Today, and ready to work.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_already_owned_search_result_starts_without_claiming():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const issue={{repository:'stackchain/dashboard',number:413,assigned_to_me:true}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>{{calls.push('claim');return Promise.resolve(issue);}},
|
||||
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue, {{alreadyOwned:true}}).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,calls}}))
|
||||
);
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"start:413",
|
||||
"announce:Added to Today and ready to work.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_queue_today_claims_once_without_starting_the_active_session():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
let release;
|
||||
const claimResult=new Promise(resolve=>{{release=resolve;}});
|
||||
const issue={{repository:'stackchain/dashboard',number:415}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
|
||||
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
const first=flow.run(issue, {{destination:'queue'}});
|
||||
const second=flow.run(issue, {{destination:'queue'}});
|
||||
release({{...issue,assignees:['timmy']}});
|
||||
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{
|
||||
calls,results,same:first===second
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": [
|
||||
"claim:415",
|
||||
"queue:415",
|
||||
"announce:Assigned and queued in Today. Keep finding work when ready.",
|
||||
],
|
||||
"results": ["queued", "queued"],
|
||||
"same": True,
|
||||
}
|
||||
|
||||
|
||||
def test_queue_today_persists_syncs_and_warms_without_a_session_side_effect():
|
||||
script = f"""
|
||||
const fs=require('fs');
|
||||
if (!fs.existsSync({json.dumps(str(QUEUE_TODAY))})) {{
|
||||
process.stdout.write(JSON.stringify({{available:false}}));
|
||||
}} else {{
|
||||
const createQueueToday=require({json.dumps(str(QUEUE_TODAY))});
|
||||
const calls=[];
|
||||
const issue={{kind:'issue',repository:'stackchain/dashboard',number:415}};
|
||||
const queue=createQueueToday({{
|
||||
todayWork:{{identity:()=> 'issue:stackchain/dashboard:415:',add:()=>{{calls.push('add');return 'added';}}}},
|
||||
todaySync:{{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}},flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'),
|
||||
warm:()=>calls.push('warm'),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{available:true,result:queue(issue),calls}}));
|
||||
}}
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"available": True,
|
||||
"result": "queued",
|
||||
"calls": [
|
||||
"add",
|
||||
"add:issue:stackchain/dashboard:415:",
|
||||
"refresh",
|
||||
"flush",
|
||||
"warm",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_queue_today_reports_assignment_when_local_planning_needs_recovery():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const issue={{repository:'stackchain/dashboard',number:415,assignees:['timmy']}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>Promise.resolve(issue),
|
||||
start:()=> 'started',
|
||||
queue:()=> 'sync-unavailable',
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue, {{destination:'queue'}}).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,calls}}))
|
||||
);
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "recovery",
|
||||
"calls": [
|
||||
"announce:Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.",
|
||||
"recover:415",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_find_work_renders_phone_safe_assign_and_start_and_wires_offline_shell():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert "data-claim-start-index" in dashboard
|
||||
assert "data-claim-queue-index" in dashboard
|
||||
assert ">Start now</button>" in dashboard
|
||||
assert ">Queue Today</button>" in dashboard
|
||||
assert "const assignAndStart = createAssignAndStart({" in dashboard
|
||||
assert "assignAndStart.run(item)" in dashboard
|
||||
assert "assignAndStart.run(item, { destination:'queue' })" in dashboard
|
||||
assert "queue: confirmed =>" in dashboard
|
||||
assert "const queueToday = createQueueToday({" in dashboard
|
||||
assert "return queueToday(claimed)" in dashboard
|
||||
assert "createAndStart.available" in dashboard
|
||||
assert "createAndStart.complete" in dashboard
|
||||
assert ".find-work-claim-actions" in css
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
assert ".find-work-claim-actions [data-claim-start-index]" in css
|
||||
assert "grid-column:1 / -1" in css
|
||||
assert "BASE + 'static/assign-and-start.js'" in worker
|
||||
assert "BASE + 'static/queue-today.js'" in worker
|
||||
assert '<script src="static/queue-today.js"></script>' in html
|
||||
@@ -77,6 +77,85 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_complete_pull_review_payload():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const calls = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
fetchJson: async (url, options) => {{ calls.push({{url, options}}); return {{id:44}}; }},
|
||||
}});
|
||||
let queued;
|
||||
try {{
|
||||
queued = outbox.enqueue({{
|
||||
kind:'pull-review', repository:'stackchain/web', number:8, operationId:'review-op',
|
||||
body:'Looks good', decision:'approve', expectedHeadSha:'abc123',
|
||||
comments:[{{path:'app.js',body:'Nice',new_position:4}}],
|
||||
draftKey:'stackchain.review-draft.v1:stackchain/web#8@abc123',
|
||||
progressKey:'stackchain.review-progress.v1:stackchain/web#8@abc123',
|
||||
draftFingerprint:'draft', progressFingerprint:'progress',
|
||||
}});
|
||||
}} catch (error) {{
|
||||
process.stdout.write(JSON.stringify({{error:error.message}}));
|
||||
}}
|
||||
if (queued) {{
|
||||
storage.setItem(queued.draftKey, 'draft'); storage.setItem(queued.progressKey, 'progress');
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
||||
queued, calls:calls.map(call=>({{url:call.url,key:call.options.headers['Idempotency-Key'],body:JSON.parse(call.options.body)}})),
|
||||
result, remaining:outbox.list(), draft:storage.getItem(queued.draftKey), progress:storage.getItem(queued.progressKey),
|
||||
}})));
|
||||
}}
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert "error" not in output, output.get("error")
|
||||
|
||||
assert output["queued"]["kind"] == "pull-review"
|
||||
assert output["queued"]["comments"] == [{"path": "app.js", "body": "Nice", "new_position": 4}]
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/web/pulls/8/review",
|
||||
"key": "review-op",
|
||||
"body": {
|
||||
"body": "Looks good", "decision": "approve", "expected_head_sha": "abc123",
|
||||
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
|
||||
},
|
||||
}]
|
||||
assert output["remaining"] == []
|
||||
assert output["draft"] is None
|
||||
assert output["progress"] is None
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_idempotent_issue_closure():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const calls = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
fetchJson: async (url, options) => {{ calls.push({{url,options}}); return {{number:27,state:'closed'}}; }},
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'
|
||||
}});
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
||||
queued,calls,result,remaining:outbox.list()
|
||||
}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["kind"] == "issue-close"
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/27/close",
|
||||
"options": {
|
||||
"method": "PATCH",
|
||||
"headers": {"Accept": "application/json", "Idempotency-Key": "close-op"},
|
||||
},
|
||||
}]
|
||||
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -183,6 +262,52 @@ process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
|
||||
assert len(output["items"]) == 1
|
||||
|
||||
|
||||
def test_authored_outbox_deduplicates_pull_review_for_same_head_after_reload():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();let sequence=0;
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const make=()=>createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-' + (++sequence)}});
|
||||
const first=make().enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'Ready'}});
|
||||
const second=make().enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'Ready'}});
|
||||
process.stdout.write(JSON.stringify({{first,second,items:make().list()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == output["second"]
|
||||
assert len(output["items"]) == 1
|
||||
|
||||
|
||||
def test_authored_outbox_rejects_changed_feedback_while_same_head_is_queued():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy'}});
|
||||
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'First'}});
|
||||
let error='';try{{outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'request_changes',body:'Changed'}});}}catch(e){{error=e.message;}}
|
||||
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["error"] == "A review for this saved head is already queued. Open Drafts to inspect or discard it first."
|
||||
assert len(output["items"]) == 1
|
||||
|
||||
|
||||
def test_confirmed_review_keeps_draft_edits_made_after_it_was_queued():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const draftKey='draft-key';const progressKey='progress-key';storage.setItem(draftKey,'old-draft');storage.setItem(progressKey,'old-progress');
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',fetchJson:async()=>({{id:1}})}});
|
||||
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',body:'Queued',draftKey,progressKey,draftFingerprint:'old-draft',progressFingerprint:'old-progress'}});
|
||||
storage.setItem(draftKey,'newer-draft');storage.setItem(progressKey,'newer-progress');
|
||||
outbox.flush('timmy').then(()=>process.stdout.write(JSON.stringify({{draft:storage.getItem(draftKey),progress:storage.getItem(progressKey)}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"draft": "newer-draft", "progress": "newer-progress"}
|
||||
|
||||
|
||||
def test_authored_outbox_mirrors_to_background_sync_and_reconciles_worker_results():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import threading
|
||||
import os
|
||||
|
||||
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
||||
|
||||
|
||||
def test_independent_workers_allow_only_one_catalog_refresh(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
first = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
second = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
barrier = threading.Barrier(2)
|
||||
results = []
|
||||
|
||||
def acquire(store):
|
||||
barrier.wait()
|
||||
results.append(store.try_acquire_refresh(lease_seconds=5))
|
||||
|
||||
threads = [threading.Thread(target=acquire, args=(store,)) for store in (first, second)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
assert sum(owner is not None for owner in results) == 1
|
||||
|
||||
|
||||
def test_published_catalog_and_metadata_are_visible_to_another_worker(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
writer = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
reader = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
owner = writer.try_acquire_refresh(lease_seconds=5)
|
||||
|
||||
writer.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
|
||||
|
||||
state = reader.load()
|
||||
assert state.items == [{"repository": "stackchain/api", "number": 7}]
|
||||
assert state.created_at == 100.0
|
||||
assert state.retry_at is None
|
||||
assert state.refreshing is False
|
||||
|
||||
|
||||
def test_failed_refresh_backoff_is_shared_and_lease_is_released(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
writer = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
reader = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
owner = writer.try_acquire_refresh(lease_seconds=5)
|
||||
|
||||
writer.fail_refresh(owner, retry_at=105.0)
|
||||
|
||||
state = reader.load()
|
||||
assert state.retry_at == 105.0
|
||||
assert state.refreshing is False
|
||||
assert reader.try_acquire_refresh(lease_seconds=5) is not None
|
||||
|
||||
|
||||
def test_confirmed_claim_is_removed_for_every_worker(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
writer = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
reader = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
owner = writer.try_acquire_refresh(lease_seconds=5)
|
||||
writer.publish(owner, items=[
|
||||
{"repository": "stackchain/api", "number": 7},
|
||||
{"repository": "stackchain/web", "number": 8},
|
||||
])
|
||||
|
||||
writer.remove_claimed("stackchain/api", 7)
|
||||
|
||||
assert reader.load().items == [{"repository": "stackchain/web", "number": 8}]
|
||||
|
||||
|
||||
def test_invalidation_clears_catalog_and_claim_filters(tmp_path):
|
||||
store = AvailableIssueSnapshotStore(tmp_path / "available.sqlite3", clock=lambda: 100.0)
|
||||
owner = store.try_acquire_refresh(lease_seconds=5)
|
||||
store.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
|
||||
store.remove_claimed("stackchain/api", 7)
|
||||
|
||||
store.invalidate()
|
||||
owner = store.try_acquire_refresh(lease_seconds=5)
|
||||
store.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
|
||||
|
||||
assert store.load().items == [{"repository": "stackchain/api", "number": 7}]
|
||||
|
||||
|
||||
def test_catalog_store_is_private(tmp_path):
|
||||
path = tmp_path / "state" / "available.sqlite3"
|
||||
|
||||
AvailableIssueSnapshotStore(path)
|
||||
|
||||
assert os.stat(path.parent).st_mode & 0o777 == 0o700
|
||||
assert os.stat(path).st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_cancelled_refresh_releases_its_lease_for_immediate_takeover(tmp_path):
|
||||
path = tmp_path / "available.sqlite3"
|
||||
first = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
second = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
|
||||
owner = first.try_acquire_refresh(lease_seconds=30)
|
||||
|
||||
first.release_refresh(owner)
|
||||
|
||||
assert second.try_acquire_refresh(lease_seconds=30) is not None
|
||||
@@ -64,6 +64,103 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
}
|
||||
|
||||
|
||||
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item = {{
|
||||
id:'capture-image',operationId:'capture-image',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',title:'Broken mobile layout',body:'At 320px',labelIds:[],
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',data:'iVBORw0KGgo='}},
|
||||
}};
|
||||
const state = {{calls:[],completed:0,released:0}};
|
||||
const store = {{
|
||||
claimNext:async()=>item ? {{...item}} : null,
|
||||
update:async(_id,transform)=>{{item=transform(item);}},
|
||||
complete:async()=>{{state.completed+=1;item=null;}},
|
||||
release:async()=>{{state.released+=1;item={{...item,status:'queued'}};}},
|
||||
fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
let uploadAttempts=0;
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
if(url.endsWith('/issues'))return{{repository:'stackchain/dashboard',number:469,title:'Broken mobile layout'}};
|
||||
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('Upload unavailable');error.status=503;throw error;}}
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
if(url.endsWith('/comments'))return{{id:91}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson}});
|
||||
let firstError='';try{{await sync.flush();}}catch(error){{firstError=error.message;}}
|
||||
const afterFirst={{...item}};
|
||||
const second=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{state,firstError,afterFirst,second}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["firstError"] == "Upload unavailable"
|
||||
assert output["afterFirst"]["deliveredIssue"]["number"] == 469
|
||||
assert [call["url"] for call in output["state"]["calls"]].count(
|
||||
"api/v1/repos/stackchain/dashboard/issues"
|
||||
) == 1
|
||||
assert output["state"]["calls"][-2]["key"] == "capture-image:attachment"
|
||||
assert output["state"]["calls"][-1]["key"] == "capture-image:attachment-comment"
|
||||
assert output["state"]["calls"][-1]["body"] == {
|
||||
"body": ""
|
||||
}
|
||||
assert output["state"]["completed"] == 1
|
||||
assert output["second"]["confirmed"][0]["number"] == 469
|
||||
|
||||
|
||||
def test_capture_attachment_stage_keys_remain_within_idempotency_limit():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const item={{id:'long',operationId:'x'.repeat(128),ownerLogin:'timmy',repository:'o/r',title:'Bug',body:'',labelIds:[],attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}};
|
||||
const keys=[];let queued=true;
|
||||
const store={{claimNext:async()=>queued?(queued=false,item):null,update:async()=>{{}},complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};if(options.headers?.['Idempotency-Key'])keys.push(options.headers['Idempotency-Key']);if(url.endsWith('/issues'))return{{repository:'o/r',number:1}};if(url.endsWith('/attachments'))return{{markdown:''}};return{{id:1}};}};
|
||||
createBackgroundIssueSync({{store,fetchJson}}).flush().then(()=>process.stdout.write(JSON.stringify(keys)));
|
||||
"""
|
||||
keys = run_node(script)
|
||||
|
||||
assert len(keys) == 3
|
||||
assert len(set(keys)) == 3
|
||||
assert all(len(key) <= 128 for key in keys)
|
||||
|
||||
|
||||
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const item = {{
|
||||
id:'capture-start',operationId:'capture-start',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',title:'Resume',body:'',labelIds:[],
|
||||
completionIntent:'create-and-start',
|
||||
}};
|
||||
let queued = item;
|
||||
const state = {{completed:[]}};
|
||||
const store = {{
|
||||
claimNext:async () => queued ? (queued=null,{{...item}}) : null,
|
||||
complete:async (id, deliveredIssue) => state.completed.push({{id,deliveredIssue}}),
|
||||
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson = async url => url === 'api/v1/background-identity' ? {{login:'timmy'}} :
|
||||
{{repository:'stackchain/dashboard',number:389,title:'Resume'}};
|
||||
(async()=>{{
|
||||
await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["completed"] == [{
|
||||
"id": "capture-start",
|
||||
"deliveredIssue": {
|
||||
"repository": "stackchain/dashboard", "number": 389, "title": "Resume"
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -171,6 +268,69 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
assert output["result"]["confirmed"] == [{"id": 42}]
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_pull_review_with_stable_identity_and_full_payload():
|
||||
authored = {
|
||||
"id": "review-op", "operationId": "review-op", "ownerLogin": "timmy", "status": "queued",
|
||||
"kind": "pull-review", "repository": "stackchain/web", "number": 8,
|
||||
"body": "Looks good", "decision": "approve", "expectedHeadSha": "abc123",
|
||||
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
|
||||
}
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let queued = {json.dumps(authored)}; const calls=[];
|
||||
const store = {{
|
||||
claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
|
||||
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{id:45}};}};
|
||||
createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
mutation = output["calls"][1]
|
||||
assert mutation["url"] == "api/v1/repos/stackchain/web/pulls/8/review"
|
||||
assert mutation["options"]["headers"]["Idempotency-Key"] == "review-op"
|
||||
assert json.loads(mutation["options"]["body"]) == {
|
||||
"body": "Looks good", "decision": "approve", "expected_head_sha": "abc123",
|
||||
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
|
||||
}
|
||||
assert output["result"]["confirmed"] == [{"id": 45}]
|
||||
assert output["result"]["receipts"] == [{
|
||||
"id": "review-op", "status": "confirmed", "kind": "message",
|
||||
"route": "#/my-work/review/stackchain/web/8",
|
||||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_issue_closure_and_returns_actionable_receipt():
|
||||
authored = {
|
||||
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
|
||||
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
|
||||
}
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let queued = {json.dumps(authored)}; const calls=[];
|
||||
const store = {{
|
||||
claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
|
||||
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};}};
|
||||
createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
mutation = output["calls"][1]
|
||||
assert mutation["url"] == "api/v1/repos/stackchain/dashboard/issues/27/close"
|
||||
assert mutation["options"]["method"] == "PATCH"
|
||||
assert mutation["options"]["headers"] == {
|
||||
"Accept": "application/json", "Idempotency-Key": "close-op"
|
||||
}
|
||||
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["result"]["receipts"] == [{
|
||||
"id": "close-op", "status": "confirmed", "kind": "message",
|
||||
"route": "#/my-work/issue/stackchain/dashboard/27",
|
||||
}]
|
||||
|
||||
|
||||
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -194,6 +354,54 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
]
|
||||
|
||||
|
||||
def test_reconciling_a_lightweight_attachment_reference_preserves_indexeddb_bytes():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
await store.reconcile([{{
|
||||
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',data:'first-image-bytes'}},
|
||||
}}]);
|
||||
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:'second-image-bytes'}}}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(await store.snapshot()));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output[0]["attachment"]["data"] == "first-image-bytes"
|
||||
assert output[1]["attachment"]["data"] == "second-image-bytes"
|
||||
|
||||
|
||||
def test_stale_foreground_upsert_preserves_confirmed_attachment_delivery_stages():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{getAll:async()=>[...records.values()].map(value=>({{...value}})),put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id)}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
await store.upsert({{id:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
|
||||
await store.update('capture',item=>({{...item,deliveredIssue:{{number:7}},attachmentMarkdown:''}}));
|
||||
const result=await store.upsert({{id:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["deliveredIssue"]["number"] == 7
|
||||
assert output["attachmentMarkdown"] == ""
|
||||
|
||||
|
||||
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -746,6 +954,51 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
assert "error" not in output
|
||||
|
||||
|
||||
def test_resume_after_purge_reopens_background_delivery_with_fresh_identity():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const state={{closed:0,identityLookups:0,mutations:0,keys:[]}};
|
||||
let queued={{
|
||||
id:'capture-after-login',ownerLogin:'timmy',status:'queued',repository:'stackchain/dashboard',
|
||||
title:'Recovered capture',body:'Evidence',labelIds:[],operationId:'op-after-login',
|
||||
}};
|
||||
const store={{
|
||||
close:async()=>{{state.closed+=1;}},
|
||||
claimBatch:async login=>{{
|
||||
if(queued?.ownerLogin!==login)return [];
|
||||
const claimed=queued;queued=null;return [claimed];
|
||||
}},
|
||||
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity'){{state.identityLookups+=1;return {{login:'timmy'}};}}
|
||||
state.mutations+=1;state.keys.push(options.headers['Idempotency-Key']);
|
||||
return {{repository:'stackchain/dashboard',number:425,title:'Recovered capture'}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson}});
|
||||
await sync.purge();
|
||||
const blocked=await sync.flush();
|
||||
await sync.resume();
|
||||
const delivered=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{state,blocked,delivered}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"] == {
|
||||
"closed": 1,
|
||||
"identityLookups": 1,
|
||||
"mutations": 1,
|
||||
"keys": ["op-after-login"],
|
||||
}
|
||||
assert output["blocked"]["login"] == ""
|
||||
assert output["delivered"]["login"] == "timmy"
|
||||
assert output["delivered"]["confirmed"] == [
|
||||
{"repository": "stackchain/dashboard", "number": 425, "title": "Recovered capture"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
@@ -243,6 +243,18 @@ def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
assert "openPreviewIssueInMyWork" in html
|
||||
|
||||
|
||||
def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="start-search-result"' in html
|
||||
assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html
|
||||
assert "const searchAssignAndStart = createAssignAndStart({" in html
|
||||
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
|
||||
assert ".search-preview-primary-actions" in css
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
|
||||
|
||||
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
|
||||
script = f"""
|
||||
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
COMMENT_NEXT = Path(__file__).parents[1] / "frontend" / "comment-next.js"
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
def test_comment_and_next_posts_then_completes_current_today_item_once():
|
||||
script = f"""
|
||||
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||
const calls = [];
|
||||
let finishPost;
|
||||
const controller = createCommentNext({{
|
||||
post: (item, body) => {{ calls.push(`post:${{item.number}}:${{body}}`); return new Promise(resolve => {{ finishPost = resolve; }}); }},
|
||||
queue: () => {{ throw new Error('must not queue'); }},
|
||||
canQueue: () => false,
|
||||
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
|
||||
}});
|
||||
const item = {{kind:'issue', repository:'stackchain/dashboard', number:429}};
|
||||
const first = controller.submit(item, 'Handoff ready');
|
||||
const second = controller.submit(item, 'Handoff ready');
|
||||
if (first !== second) throw new Error('submission was not single-flight');
|
||||
finishPost({{id:7, body:'Handoff ready'}});
|
||||
(async () => {{
|
||||
const result = await first;
|
||||
process.stdout.write(JSON.stringify({{result, calls, busy:controller.busy()}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"result": {
|
||||
"accepted": True,
|
||||
"delivery": "posted",
|
||||
"comment": {"id": 7, "body": "Handoff ready"},
|
||||
"completed": True,
|
||||
},
|
||||
"calls": ["post:429:Handoff ready", "complete:429"],
|
||||
"busy": False,
|
||||
}
|
||||
|
||||
|
||||
def test_comment_and_next_advances_after_durable_offline_admission():
|
||||
script = f"""
|
||||
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||
const calls = [];
|
||||
const retryable = new Error('offline'); retryable.status = 503;
|
||||
const controller = createCommentNext({{
|
||||
post: () => Promise.reject(retryable),
|
||||
canQueue: error => error.status === 503,
|
||||
queue: message => {{ calls.push(message); return Promise.resolve({{durable:true, background:false}}); }},
|
||||
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
|
||||
}});
|
||||
(async () => {{
|
||||
const result = await controller.submit({{kind:'pull', repository:'stackchain/dashboard', number:12}}, 'Please review', 'op-12');
|
||||
process.stdout.write(JSON.stringify({{result, calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"result": {
|
||||
"accepted": True,
|
||||
"delivery": "saved",
|
||||
"background": False,
|
||||
"completed": True,
|
||||
},
|
||||
"calls": [
|
||||
{
|
||||
"kind": "pull-comment",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 12,
|
||||
"body": "Please review",
|
||||
"operationId": "op-12",
|
||||
},
|
||||
"complete:12",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_update_reply_and_next_preserves_notification_identity_online_and_offline():
|
||||
script = f"""
|
||||
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||
const calls = [];
|
||||
const retryable = Object.assign(new Error('offline'), {{status:503}});
|
||||
let offline = false;
|
||||
const controller = createCommentNext({{
|
||||
queueKind: 'update-reply',
|
||||
post: (item, body, operationId) => {{
|
||||
calls.push({{post:[item.notification_id, body, operationId]}});
|
||||
return offline ? Promise.reject(retryable) : Promise.resolve({{id:17}});
|
||||
}},
|
||||
canQueue: () => true,
|
||||
queue: message => {{ calls.push({{queue:message}}); return Promise.resolve({{durable:true}}); }},
|
||||
complete: item => {{ calls.push({{complete:item.notification_id}}); return true; }},
|
||||
}});
|
||||
(async () => {{
|
||||
const item = {{kind:'issue', notification_id:91, repository:'stackchain/dashboard', number:8}};
|
||||
const posted = await controller.submit(item, 'Online reply', 'reply-91-a');
|
||||
offline = true;
|
||||
const saved = await controller.submit(item, 'Offline reply', 'reply-91-b');
|
||||
process.stdout.write(JSON.stringify({{posted, saved, calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"posted": {
|
||||
"accepted": True,
|
||||
"delivery": "posted",
|
||||
"comment": {"id": 17},
|
||||
"completed": True,
|
||||
},
|
||||
"saved": {
|
||||
"accepted": True,
|
||||
"delivery": "saved",
|
||||
"background": False,
|
||||
"completed": True,
|
||||
},
|
||||
"calls": [
|
||||
{"post": [91, "Online reply", "reply-91-a"]},
|
||||
{"complete": 91},
|
||||
{"post": [91, "Offline reply", "reply-91-b"]},
|
||||
{
|
||||
"queue": {
|
||||
"kind": "update-reply",
|
||||
"notificationId": 91,
|
||||
"body": "Offline reply",
|
||||
"operationId": "reply-91-b",
|
||||
}
|
||||
},
|
||||
{"complete": 91},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_comment_and_next_reads_retry_identity_after_the_failed_post():
|
||||
script = f"""
|
||||
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||
let operationId = '';
|
||||
let queued;
|
||||
const controller = createCommentNext({{
|
||||
post: () => {{ operationId = 'post-attempt-id'; return Promise.reject(Object.assign(new Error('timeout'), {{status:503}})); }},
|
||||
canQueue: () => true,
|
||||
queue: message => {{ queued = message; return Promise.resolve({{durable:true}}); }},
|
||||
complete: () => true,
|
||||
}});
|
||||
(async () => {{
|
||||
const result = await controller.submit(
|
||||
{{kind:'issue', repository:'r', number:3}}, 'Status', () => operationId
|
||||
);
|
||||
process.stdout.write(JSON.stringify({{result, queued}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
output = json.loads(run_node(script))
|
||||
assert output["result"]["accepted"] is True
|
||||
assert output["result"]["delivery"] == "saved"
|
||||
assert output["queued"]["operationId"] == "post-attempt-id"
|
||||
|
||||
|
||||
def test_comment_and_next_clears_the_completed_draft_before_opening_next_item():
|
||||
script = f"""
|
||||
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||
const calls = [];
|
||||
const controller = createCommentNext({{
|
||||
post: () => Promise.resolve({{id:1}}), queue: () => null, canQueue: () => false,
|
||||
accept: item => calls.push(`clear:${{item.number}}`),
|
||||
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
|
||||
}});
|
||||
(async () => {{
|
||||
await controller.submit({{kind:'issue',repository:'r',number:8}}, 'Done');
|
||||
process.stdout.write(JSON.stringify(calls));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == ["clear:8", "complete:8"]
|
||||
|
||||
|
||||
def test_comment_and_next_keeps_today_position_when_delivery_or_completion_fails():
|
||||
script = f"""
|
||||
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||
const failures = [];
|
||||
const permanent = createCommentNext({{
|
||||
post: () => Promise.reject(Object.assign(new Error('forbidden'), {{status:403}})),
|
||||
canQueue: () => false,
|
||||
queue: () => Promise.resolve({{durable:true}}),
|
||||
complete: () => {{ failures.push('advanced'); return true; }},
|
||||
}});
|
||||
const notDurable = createCommentNext({{
|
||||
post: () => Promise.reject(Object.assign(new Error('offline'), {{status:503}})),
|
||||
canQueue: () => true,
|
||||
queue: () => Promise.resolve({{durable:false, background:false}}),
|
||||
complete: () => {{ failures.push('advanced'); return true; }},
|
||||
}});
|
||||
const cannotComplete = createCommentNext({{
|
||||
post: () => Promise.resolve({{id:9}}), canQueue: () => false, queue: () => null,
|
||||
complete: () => false,
|
||||
}});
|
||||
(async () => {{
|
||||
for (const [name, controller] of [['permanent', permanent], ['notDurable', notDurable]]) {{
|
||||
try {{ await controller.submit({{kind:'issue',repository:'r',number:1}}, 'draft'); }}
|
||||
catch (error) {{ failures.push(`${{name}}:${{error.message}}`); }}
|
||||
}}
|
||||
const partial = await cannotComplete.submit({{kind:'issue',repository:'r',number:1}}, 'draft');
|
||||
process.stdout.write(JSON.stringify({{failures, partial}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"failures": ["permanent:forbidden", "notDurable:Comment was not saved for delivery."],
|
||||
"partial": {
|
||||
"accepted": True,
|
||||
"delivery": "posted",
|
||||
"comment": {"id": 9},
|
||||
"completed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_today_checkpoint_only_matches_the_current_session_item():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = [
|
||||
{{kind:'issue', repository:'r', number:1}},
|
||||
{{kind:'pull', repository:'r', number:2}},
|
||||
];
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems: () => items, getFilter: () => 'all', checkpointEnabled: () => true,
|
||||
checkpoint: {{save:()=>undefined, clear:()=>undefined, read:()=>null}},
|
||||
onOpen:()=>undefined, onProgress:()=>undefined, onFinish:()=>undefined,
|
||||
}});
|
||||
session.start();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
current: session.checkpointed(items[0]),
|
||||
other: session.checkpointed(items[1]),
|
||||
generic: session.checkpointed(),
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"current": True,
|
||||
"other": False,
|
||||
"generic": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="send-issue-comment-next"' in html
|
||||
assert 'id="send-pull-comment-next"' in html
|
||||
assert html.count('>Comment & next</button>') == 2
|
||||
assert "setCommentNextVisibility('issue')" in html
|
||||
assert "setCommentNextVisibility('pull')" in html
|
||||
assert '.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.comment-actions button { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_current_today_update_offers_reply_and_next_without_marking_read():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="send-update-reply-next"' in html
|
||||
assert '>Reply & next</button>' in html
|
||||
assert "setUpdateReplyNextVisibility();" in html
|
||||
assert "const updateReplyNext = createCommentNext({" in html
|
||||
assert "notificationReplier.saveDraft(item, '');" in html
|
||||
assert "successMessage: 'Reply saved. Next Today item opened.'" in html
|
||||
assert "qs('#mark-update-read-next').click()" not in html
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v81" in worker
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
|
||||
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
@@ -116,6 +117,202 @@ const poller = createContextPoller({{
|
||||
}
|
||||
|
||||
|
||||
def test_context_poller_waits_for_server_cooldown_when_every_section_is_degraded():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const delays = [];
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: () => Promise.resolve({{
|
||||
context: {{}}, events: [], notifications: [],
|
||||
freshness: {{
|
||||
fresh_for_seconds: 8,
|
||||
retry_in_seconds: 60,
|
||||
sections: {{
|
||||
context: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
||||
events: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
||||
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
||||
}},
|
||||
}},
|
||||
}}),
|
||||
onSnapshot: () => {{}},
|
||||
onError: error => {{ throw error; }},
|
||||
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||
clearTimer: () => {{}},
|
||||
intervalMs: 8000,
|
||||
}});
|
||||
(async () => {{
|
||||
await poller.start();
|
||||
process.stdout.write(JSON.stringify({{ delays }}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"delays": [60000]}
|
||||
|
||||
|
||||
def test_context_poller_uses_earliest_healthy_section_freshness_deadline():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const delays = [];
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: () => Promise.resolve({{
|
||||
context: {{}}, events: [], notifications: [],
|
||||
freshness: {{
|
||||
fresh_for_seconds: 8,
|
||||
retry_in_seconds: 60,
|
||||
sections: {{
|
||||
context: {{ degraded: false, age_seconds: 2 }},
|
||||
events: {{ degraded: false, age_seconds: 5 }},
|
||||
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
||||
}},
|
||||
}},
|
||||
}}),
|
||||
onSnapshot: () => {{}},
|
||||
onError: error => {{ throw error; }},
|
||||
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||
clearTimer: () => {{}},
|
||||
intervalMs: 8000,
|
||||
}});
|
||||
(async () => {{
|
||||
await poller.start();
|
||||
process.stdout.write(JSON.stringify({{ delays }}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"delays": [3000]}
|
||||
|
||||
|
||||
def test_context_poller_does_not_spin_while_stale_sections_are_revalidating():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const delays = [];
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: () => Promise.resolve({{
|
||||
context: {{}}, events: [], notifications: [],
|
||||
freshness: {{
|
||||
fresh_for_seconds: 8,
|
||||
sections: {{
|
||||
context: {{ degraded: false, revalidating: true, age_seconds: 12 }},
|
||||
events: {{ degraded: false, revalidating: true, age_seconds: 12 }},
|
||||
notifications: {{ degraded: false, revalidating: true, age_seconds: 12 }},
|
||||
}},
|
||||
}},
|
||||
}}),
|
||||
onSnapshot: () => {{}},
|
||||
onError: error => {{ throw error; }},
|
||||
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||
clearTimer: () => {{}},
|
||||
intervalMs: 8000,
|
||||
}});
|
||||
(async () => {{
|
||||
await poller.start();
|
||||
process.stdout.write(JSON.stringify({{ delays }}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"delays": [8000]}
|
||||
|
||||
|
||||
def test_context_poller_honors_http_retry_after_delay():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const delays = [];
|
||||
const errors = [];
|
||||
const unavailable = new Error('HTTP 503');
|
||||
unavailable.retryAfterMs = 45000;
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: () => Promise.reject(unavailable),
|
||||
onSnapshot: () => {{}},
|
||||
onError: error => errors.push(error.message),
|
||||
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||
clearTimer: () => {{}},
|
||||
intervalMs: 8000,
|
||||
}});
|
||||
(async () => {{
|
||||
await poller.start();
|
||||
process.stdout.write(JSON.stringify({{ delays, errors }}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"delays": [45000], "errors": ["HTTP 503"]}
|
||||
|
||||
|
||||
def test_retry_after_parser_accepts_bounded_delta_seconds():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
valid: createContextPoller.retryAfterMs('45'),
|
||||
zero: createContextPoller.retryAfterMs('0'),
|
||||
invalid: createContextPoller.retryAfterMs('later'),
|
||||
excessive: createContextPoller.retryAfterMs('999999'),
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"valid": 45000,
|
||||
"zero": None,
|
||||
"invalid": None,
|
||||
"excessive": 300000,
|
||||
}
|
||||
|
||||
|
||||
def test_context_poller_backs_off_transport_failures_and_resets_after_success():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const delays = [];
|
||||
let calls = 0;
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: () => {{
|
||||
calls += 1;
|
||||
if (calls < 6) return Promise.reject(new TypeError('network unavailable'));
|
||||
return Promise.resolve({{ context: {{}}, events: [], notifications: [] }});
|
||||
}},
|
||||
onSnapshot: () => {{}},
|
||||
onError: () => {{}},
|
||||
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||
clearTimer: () => {{}},
|
||||
intervalMs: 8000,
|
||||
maxBackoffMs: 60000,
|
||||
}});
|
||||
(async () => {{
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
process.stdout.write(JSON.stringify({{ delays }}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"delays": [8000, 16000, 32000, 60000, 60000, 8000]
|
||||
}
|
||||
|
||||
|
||||
def test_live_fetch_passes_retry_after_to_the_poller_error():
|
||||
source = DASHBOARD.read_text()
|
||||
|
||||
assert "createContextPoller.retryAfterMs(res.headers.get('Retry-After'))" in source
|
||||
assert "error.retryAfterMs" in source
|
||||
|
||||
|
||||
def test_live_revision_query_preserves_bounded_opaque_tokens():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const query = createContextPoller.buildRevisionQuery({{
|
||||
context: '0123456789abcdef.12',
|
||||
events: 'fedcba9876543210.3',
|
||||
notifications: 'not-a-token',
|
||||
extra: '0123456789abcdef.1',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{ query }}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"query": "context_revision=0123456789abcdef.12&events_revision=fedcba9876543210.3"
|
||||
}
|
||||
|
||||
|
||||
def test_context_poller_aborts_a_stalled_request_and_recovers_on_schedule():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CREATE_AND_START = Path(__file__).parents[1] / "frontend" / "create-and-start.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_confirmed_issue_is_planned_synced_warmed_and_started_once():
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
|
||||
process.stdout.write(JSON.stringify({{available:false}}));
|
||||
}} else {{
|
||||
const createCreateAndStart = require({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')}},
|
||||
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);
|
||||
process.stdout.write(JSON.stringify({{available:true,result,calls}}));
|
||||
}}
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"available": True,
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"add",
|
||||
"add:issue:stackchain/dashboard:387:",
|
||||
"refresh",
|
||||
"flush",
|
||||
"warm",
|
||||
"start:387",
|
||||
"announce:Created, added to Today, and ready to work.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_capture_wires_distinct_create_and_start_intent_into_the_offline_shell():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
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 ".create-issue-actions" in css and "grid-template-columns" in css
|
||||
assert "BASE + 'static/create-and-start.js'" in worker
|
||||
|
||||
|
||||
def test_create_and_start_intent_is_durable_and_has_a_one_tap_relaunch_continuation():
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
assert "completionIntent: 'create-and-start'" in dashboard
|
||||
assert "result.completions || []" in dashboard
|
||||
assert "(result.completions || []).forEach(completion =>" in dashboard
|
||||
assert "issueOutbox.pendingCompletions(activeFlushLogin)" in dashboard
|
||||
assert "issueOutbox.completeIntent(completion.id, activeFlushLogin)" in dashboard
|
||||
assert 'class="draft-continue"' in dashboard
|
||||
assert "Continue created work" in dashboard
|
||||
|
||||
|
||||
def test_create_and_start_stops_before_session_when_today_sync_cannot_be_queued():
|
||||
script = f"""
|
||||
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
|
||||
const calls = [];
|
||||
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:388}};
|
||||
const flow = createCreateAndStart({{
|
||||
todayWork: {{limit:5, read:()=>[], identity:()=> 'issue:stackchain/dashboard:388:', add:()=> 'added'}},
|
||||
todaySync: {{enqueue:()=>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(issue), calls}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "sync-unavailable",
|
||||
"calls": ["Created and saved to Today on this device, but account sync is unavailable."],
|
||||
}
|
||||
|
||||
|
||||
def test_create_and_start_can_resume_after_today_sync_admission_recovers():
|
||||
script = f"""
|
||||
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
|
||||
const calls=[];let admitted=false;let added=false;
|
||||
const issue={{kind:'issue',repository:'stackchain/dashboard',number:389}};
|
||||
const flow=createCreateAndStart({{
|
||||
todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:stackchain/dashboard:389:',add:()=>{{
|
||||
const outcome=added?'exists':'added';added=true;return outcome;
|
||||
}}}},
|
||||
todaySync:{{enqueue:()=>admitted,flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'),
|
||||
announce:message=>calls.push(message),
|
||||
}});
|
||||
const first=flow.complete(issue);
|
||||
admitted=true;
|
||||
const second=flow.complete(issue);
|
||||
process.stdout.write(JSON.stringify({{first,second,calls}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == "sync-unavailable"
|
||||
assert output["second"] == "started"
|
||||
assert output["calls"][-5:] == [
|
||||
"refresh", "flush", "warm", "start",
|
||||
"Created, added to Today, and ready to work.",
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
@@ -7,6 +8,7 @@ import pytest
|
||||
|
||||
from src import main
|
||||
from src.session_store import SessionStoreError
|
||||
from src.views import FRONTEND_BUILD
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -244,21 +246,29 @@ async def test_session_status_reuses_the_middleware_validation(access_control, m
|
||||
lookups = 0
|
||||
|
||||
class CountingStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
nonlocal lookups
|
||||
lookups += 1
|
||||
return original_store.is_active(session_id, expires_at)
|
||||
return original_store.status(
|
||||
session_id,
|
||||
expires_at,
|
||||
idle_timeout_seconds=idle_timeout_seconds,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: CountingStore())
|
||||
response = await client.get("/api/v1/session")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["authenticated"] is True
|
||||
payload = response.json()
|
||||
assert payload["authenticated"] is True
|
||||
assert isinstance(payload["expires_at"], int)
|
||||
assert payload["expires_at"] > int(time.time())
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert lookups == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
|
||||
async def test_authenticated_session_status_exposes_csrf_proof_and_offline_lease(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
@@ -266,14 +276,72 @@ async def test_authenticated_session_status_exposes_only_csrf_proof(access_contr
|
||||
)
|
||||
response = await client.get("/api/v1/session")
|
||||
|
||||
payload = response.json()
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"authenticated": True,
|
||||
"csrf_token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
assert payload["authenticated"] is True
|
||||
assert payload["csrf_token"] == client.cookies["stackchain_csrf"]
|
||||
assert payload["expires_at"] > int(time.time())
|
||||
assert "correct horse battery staple" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_session_status_discloses_no_offline_lease(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.get("/api/v1/session")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Authentication required"}
|
||||
assert "expires_at" not in response.text
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_idle_session_is_rejected_with_distinct_api_and_page_recovery(access_control, monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
|
||||
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"}
|
||||
)
|
||||
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE active_sessions SET last_active_at = ?", (int(time.time()) - 900,)
|
||||
)
|
||||
|
||||
api_response = await client.get("/api/v1/session")
|
||||
page_response = await client.get("/")
|
||||
|
||||
assert api_response.status_code == 401
|
||||
assert api_response.json() == {
|
||||
"detail": "Authentication required",
|
||||
"code": "session_idle",
|
||||
}
|
||||
assert page_response.status_code == 303
|
||||
assert page_response.headers["location"] == "login?reason=session-idle"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_heartbeat_extends_the_server_idle_deadline(access_control, monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
|
||||
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"}
|
||||
)
|
||||
heartbeat = await client.post(
|
||||
"/api/v1/session/activity",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
|
||||
assert heartbeat.status_code == 200
|
||||
assert heartbeat.json()["active"] is True
|
||||
assert heartbeat.json()["idle_expires_at"] >= int(time.time()) + 899
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
|
||||
access_control, monkeypatch
|
||||
@@ -598,8 +666,8 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont
|
||||
grant = await fresh_grant(client, "revoke_all_sessions", "all")
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
return True
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
return "active"
|
||||
|
||||
def consume_step_up(self, grant, session_id, *, action, target):
|
||||
return True
|
||||
@@ -676,9 +744,9 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
|
||||
)
|
||||
|
||||
class SlowStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
time.sleep(0.15)
|
||||
return True
|
||||
return "active"
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
|
||||
private_request = asyncio.create_task(client.get("/api/v1/session"))
|
||||
@@ -830,9 +898,9 @@ async def test_single_session_revocation_does_not_block_the_event_loop(
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
|
||||
class SlowStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
time.sleep(0.02)
|
||||
return True
|
||||
return "active"
|
||||
|
||||
def revoke(self, session_id):
|
||||
time.sleep(0.15)
|
||||
@@ -876,7 +944,7 @@ async def test_session_registry_read_failure_fails_closed_before_gitea(
|
||||
)
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
@@ -921,8 +989,8 @@ async def test_logout_registry_failure_does_not_claim_revocation(access_control,
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
return True
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
return "active"
|
||||
|
||||
def revoke(self, session_id):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
@@ -949,16 +1017,23 @@ async def test_public_routes_skip_session_registry_validation(access_control, mo
|
||||
)
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
raise SessionStoreError("public routes must not read the registry")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
login = await client.get("/login")
|
||||
manifest = await client.get("/manifest.webmanifest")
|
||||
static = await client.get("/static/session.js")
|
||||
runtime = await client.get("/" + FRONTEND_BUILD.runtime_name)
|
||||
|
||||
assert signed_in.status_code == 200
|
||||
assert [login.status_code, manifest.status_code, static.status_code] == [200, 200, 200]
|
||||
assert [login.status_code, manifest.status_code, static.status_code, runtime.status_code] == [
|
||||
200,
|
||||
200,
|
||||
200,
|
||||
200,
|
||||
]
|
||||
assert runtime.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -14,11 +14,13 @@ SESSION_JS = ROOT / "frontend" / "session.js"
|
||||
def run_session_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
||||
const storage = {{
|
||||
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||
get length() {{ return this.values.size; }},
|
||||
key(index) {{ return Array.from(this.values.keys())[index] || null; }},
|
||||
getItem(key) {{ return this.values.get(key) || null; }},
|
||||
setItem(key, value) {{ this.values.set(key, String(value)); }},
|
||||
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
|
||||
}};
|
||||
const boundary = createSessionBoundary({{
|
||||
@@ -27,6 +29,7 @@ const boundary = createSessionBoundary({{
|
||||
base: '/dashboard/',
|
||||
fetchImpl: async (url, options = {{}}) => {{
|
||||
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})), body: options.body || null }});
|
||||
if (state.failFetch) throw new Error('offline');
|
||||
const configured = state.responses.length ? state.responses.shift() : {{ status: state.responseStatus, payload: state.responsePayload }};
|
||||
return new Response(JSON.stringify(configured.payload), {{ status: configured.status, headers: {{ 'Content-Type': 'application/json' }} }});
|
||||
}},
|
||||
@@ -53,6 +56,10 @@ const boundary = createSessionBoundary({{
|
||||
replace: value => state.replaced.push(value),
|
||||
}},
|
||||
onClearError: error => state.clearErrors.push(error.message),
|
||||
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
|
||||
addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }},
|
||||
now: () => state.now,
|
||||
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
|
||||
confirmAction: message => {{ state.confirmations.push(message); return true; }},
|
||||
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
|
||||
}});
|
||||
@@ -62,6 +69,160 @@ const boundary = createSessionBoundary({{
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_same_origin_api_fetch_times_out_even_when_fetch_ignores_abort():
|
||||
script = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
let aborted = false;
|
||||
const boundary = createSessionBoundary({{
|
||||
cookie: () => '',
|
||||
origin: 'https://forge.example',
|
||||
base: '/dashboard/',
|
||||
requestTimeoutMs: 10,
|
||||
fetchImpl: (_url, options = {{}}) => new Promise(resolve => {{
|
||||
options.signal?.addEventListener('abort', () => {{ aborted = true; }});
|
||||
setTimeout(() => resolve(new Response('{{}}', {{status:200}})), 80);
|
||||
}}),
|
||||
location: {{ replace: () => {{}} }},
|
||||
}});
|
||||
(async () => {{
|
||||
const started = Date.now();
|
||||
try {{ await boundary.fetch('/dashboard/api/v1/context'); }}
|
||||
catch (error) {{
|
||||
process.stdout.write(JSON.stringify({{
|
||||
name:error.name, message:error.message, method:error.method,
|
||||
outcome:error.outcome, aborted, elapsed:Date.now()-started,
|
||||
}}));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["name"] == "TimeoutError"
|
||||
assert output["message"] == "Request timed out. Try again."
|
||||
assert output["method"] == "GET"
|
||||
assert output["outcome"] == "unknown"
|
||||
assert output["aborted"] is True
|
||||
assert output["elapsed"] < 70
|
||||
|
||||
|
||||
def test_caller_abort_cancels_same_origin_api_fetch_without_waiting_for_deadline():
|
||||
script = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const caller = new AbortController();
|
||||
let requestAborted = false;
|
||||
const boundary = createSessionBoundary({{
|
||||
cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000,
|
||||
fetchImpl: (_url, options = {{}}) => new Promise(() => {{
|
||||
options.signal.addEventListener('abort', () => {{ requestAborted = true; }});
|
||||
}}),
|
||||
location: {{ replace: () => {{}} }},
|
||||
}});
|
||||
(async () => {{
|
||||
const started = Date.now();
|
||||
const pending = boundary.fetch('/dashboard/api/v1/context', {{signal:caller.signal}});
|
||||
setTimeout(() => caller.abort(new DOMException('Superseded', 'AbortError')), 10);
|
||||
try {{ await pending; }} catch (error) {{
|
||||
process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source, requestAborted, elapsed:Date.now()-started}}));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["name"] == "AbortError"
|
||||
assert output["message"] == "Superseded"
|
||||
assert output["source"] == "caller"
|
||||
assert output["requestAborted"] is True
|
||||
assert output["elapsed"] < 100
|
||||
|
||||
|
||||
def test_request_object_abort_signal_is_composed_with_the_api_deadline():
|
||||
script = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const caller = new AbortController();
|
||||
const boundary = createSessionBoundary({{
|
||||
cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000,
|
||||
fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}},
|
||||
}});
|
||||
(async () => {{
|
||||
const request = new Request('https://forge.example/dashboard/api/v1/context', {{signal:caller.signal}});
|
||||
const pending = boundary.fetch(request);
|
||||
setTimeout(() => caller.abort(new DOMException('Request superseded', 'AbortError')), 5);
|
||||
try {{ await pending; }} catch (error) {{
|
||||
process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source}}));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"name": "AbortError",
|
||||
"message": "Request superseded",
|
||||
"source": "caller",
|
||||
}
|
||||
|
||||
|
||||
def test_mutation_timeout_warns_that_the_server_outcome_may_be_ambiguous():
|
||||
script = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const boundary = createSessionBoundary({{
|
||||
cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5,
|
||||
fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}},
|
||||
}});
|
||||
(async () => {{
|
||||
try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/issues/9', {{method:'PATCH'}}); }}
|
||||
catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, message:error.message, method:error.method, safeToRetry:error.safeToRetry}})); }}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output == {
|
||||
"name": "TimeoutError",
|
||||
"message": "Request timed out. Refresh to verify the outcome before retrying.",
|
||||
"method": "PATCH",
|
||||
"safeToRetry": False,
|
||||
}
|
||||
|
||||
|
||||
def test_fresh_authorization_request_uses_the_same_deadline_boundary():
|
||||
script = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
let calls = 0;
|
||||
const boundary = createSessionBoundary({{
|
||||
cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5,
|
||||
fetchImpl: () => {{
|
||||
calls += 1;
|
||||
if (calls === 1) return Promise.resolve(new Response(JSON.stringify({{detail:{{code:'step_up_required', action:'merge_pull', target:'stackchain/app#9'}}}}), {{status:428, headers:{{'Content-Type':'application/json'}}}}));
|
||||
return new Promise(() => {{}});
|
||||
}},
|
||||
promptAuthorization: async () => 'operator-token', location: {{replace:() => {{}}}},
|
||||
}});
|
||||
(async () => {{
|
||||
try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/pulls/9/merge', {{method:'POST'}}); }}
|
||||
catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, phase:error.phase, calls}})); }}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"name": "TimeoutError",
|
||||
"phase": "fresh-authorization",
|
||||
"calls": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_mutating_same_origin_fetch_receives_csrf_proof():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -73,6 +234,69 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
|
||||
|
||||
def test_user_activity_heartbeat_is_throttled_across_pointer_keyboard_and_touch_events():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
boundary.startActivityHeartbeat();
|
||||
await state.activityListeners.pointerdown();
|
||||
await state.activityListeners.keydown();
|
||||
state.now += 60000;
|
||||
await state.activityListeners.touchstart();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"/dashboard/api/v1/session/activity",
|
||||
"/dashboard/api/v1/session/activity",
|
||||
]
|
||||
assert all(request["method"] == "POST" for request in result["requests"])
|
||||
assert all(
|
||||
request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
for request in result["requests"]
|
||||
)
|
||||
|
||||
|
||||
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800};
|
||||
const valid = await boundary.refreshOfflineLease();
|
||||
state.valid = valid;
|
||||
state.savedExpiry = storage.getItem('stackchain.session-expires-at');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["valid"] is True
|
||||
assert result["savedExpiry"] == "4102444800"
|
||||
assert result["workerMessages"] == [
|
||||
{"type": "stackchain-session-lease", "expiresAt": 4102444800}
|
||||
]
|
||||
assert result["leaseDelay"] > 0
|
||||
|
||||
|
||||
def test_expired_stored_lease_purges_private_work_when_session_status_is_offline():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
storage.setItem('stackchain.session-expires-at', '1');
|
||||
state.failFetch = true;
|
||||
const valid = await boundary.refreshOfflineLease();
|
||||
state.valid = valid;
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["valid"] is False
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -106,7 +330,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["requests"][2]["body"] == result["requests"][0]["body"]
|
||||
|
||||
|
||||
def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work():
|
||||
def test_same_origin_unauthorized_response_purges_private_work_before_replacing_dashboard_once():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
@@ -115,6 +339,7 @@ await Promise.all([
|
||||
boundary.fetch('https://forge.example/dashboard/api/v1/context'),
|
||||
]);
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
@@ -122,9 +347,12 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["replaced"] == [
|
||||
"/dashboard/login?reason=session-expired"
|
||||
]
|
||||
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
|
||||
assert result["deletedDatabases"] == []
|
||||
assert result["deletedCaches"] == []
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert result["expiredAfterDeletion"] is True
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_remotely_revoked_response_clears_private_work_before_replacing_dashboard():
|
||||
@@ -147,6 +375,24 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_idle_response_locks_without_clearing_private_queued_work():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
state.responsePayload = {detail:'Authentication required', code:'session_idle'};
|
||||
await boundary.fetch('/dashboard/api/v1/live');
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
|
||||
assert result["deletedDatabases"] == []
|
||||
assert result["deletedCaches"] == []
|
||||
assert result["workerMessages"] == []
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
|
||||
|
||||
|
||||
def test_worker_revocation_message_clears_window_storage_before_login():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -160,6 +406,34 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
|
||||
|
||||
def test_worker_idle_message_locks_without_clearing_window_storage():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-idle'}});
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
|
||||
|
||||
|
||||
def test_worker_expiry_message_clears_window_storage_before_expired_login():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-expired'}});
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
|
||||
+65
-1
@@ -121,6 +121,68 @@ process.stdout.write(JSON.stringify(drafts));
|
||||
assert output[1]["label"] == "Queued issue"
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_queued_review_with_review_route_and_actionable_state():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
|
||||
id:'review-1',kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
|
||||
decision:'approve',expectedHeadSha:'abc',comments:[{{path:'app.js',body:'Nice',new_position:4}}],ownerLogin:'timmy',status:'queued',queuedAt:200
|
||||
}}]}})]]);
|
||||
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["label"] == "Queued review"
|
||||
assert output["outbox_kind"] == "pull-review"
|
||||
assert output["title"] == "stackchain/web#8"
|
||||
assert output["route"] == {"kind": "review", "repository": "stackchain/web", "number": 8}
|
||||
assert output["copy_text"] == "Looks good\n\nInline feedback\napp.js · new line 4: Nice"
|
||||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_queued_issue_closure_for_retry_or_discard():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
|
||||
id:'close-1',kind:'issue-close',repository:'stackchain/dashboard',number:27,body:'',
|
||||
ownerLogin:'timmy',status:'attention',error:'Closure rejected',queuedAt:200
|
||||
}}]}})]]);
|
||||
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["label"] == "Issue closure needs attention"
|
||||
assert output["outbox_kind"] == "issue-close"
|
||||
assert output["title"] == "stackchain/dashboard#27"
|
||||
assert output["preview"] == "Closure rejected"
|
||||
assert output["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
|
||||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[{{
|
||||
id:'continue-1',operationId:'continue-1',repository:'stackchain/dashboard',
|
||||
title:'Resume this issue',body:'',ownerLogin:'timmy',status:'completion',queuedAt:200,
|
||||
completionIntent:'create-and-start',deliveredIssue:{{repository:'stackchain/dashboard',number:389,title:'Resume this issue'}}
|
||||
}}]}})]]);
|
||||
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null}};
|
||||
const item = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["status"] == "completion"
|
||||
assert output["label"] == "Created · ready to start"
|
||||
assert output["continuation"] is True
|
||||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_uncertain_delivery_for_explicit_user_verification():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
@@ -175,8 +237,10 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
assert 'Discard this unfinished draft?' in html
|
||||
assert '.draft-actions button { min-height:44px;' in html
|
||||
assert 'createDraftInbox({ storage: localStorage' in html
|
||||
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
|
||||
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
|
||||
assert "Verified not posted — retry" in html
|
||||
assert "const closureOutbox = item.outbox_kind === 'issue-close';" in html
|
||||
assert "closureOutbox ? 'Open issue' : 'Open message'" in html
|
||||
assert "payload.detail?.message" in html
|
||||
assert "error.code = payload.detail?.code" in html
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import gzip
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from src import main
|
||||
from src.frontend_bundle import build_frontend
|
||||
from src.views import dashboard, runtime_bundle, service_worker
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
PAGE_SCRIPT = re.compile(r'<script src="(static/[^"]+\.js)"></script>')
|
||||
|
||||
|
||||
def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
second = build_frontend(FRONTEND)
|
||||
|
||||
assert first.runtime_name == second.runtime_name
|
||||
assert first.runtime_bytes == second.runtime_bytes
|
||||
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
|
||||
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
|
||||
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
assert first.runtime_name.startswith("runtime-")
|
||||
assert first.runtime_name.endswith(".js")
|
||||
assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
(changed_frontend / "widgets.js").write_text(
|
||||
(changed_frontend / "widgets.js").read_text() + "\n// changed\n"
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
assert changed.runtime_name != first.runtime_name
|
||||
assert changed.runtime_bytes != first.runtime_bytes
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/{main.FRONTEND_BUILD.runtime_name}",
|
||||
headers={"Accept-Encoding": "gzip"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
assert response.headers["vary"] == "Accept-Encoding"
|
||||
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_gzip_bytes)
|
||||
assert response.content == main.FRONTEND_BUILD.runtime_bytes
|
||||
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_route_returns_the_precomputed_gzip_representation():
|
||||
build = build_frontend(FRONTEND)
|
||||
request = Request(
|
||||
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
|
||||
"headers": [(b"accept-encoding", b"gzip")]}
|
||||
)
|
||||
|
||||
response = await runtime_bundle(build.runtime_digest, request)
|
||||
|
||||
assert response.body == build.runtime_gzip_bytes
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
assert response.headers["content-length"] == str(len(build.runtime_gzip_bytes))
|
||||
assert response.headers["vary"] == "Accept-Encoding"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_requests_bypass_dynamic_gzip_compression(monkeypatch):
|
||||
class UnexpectedDynamicCompression:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise AssertionError("immutable runtime entered dynamic compression")
|
||||
|
||||
monkeypatch.setattr("src.compression.GZipResponder", UnexpectedDynamicCompression)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/{main.FRONTEND_BUILD.runtime_name}",
|
||||
headers={"Accept-Encoding": "gzip"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("accept_encoding", ["identity", "gzip;q=0, identity;q=1"])
|
||||
async def test_runtime_stays_unencoded_when_the_client_declines_gzip(accept_encoding):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/{main.FRONTEND_BUILD.runtime_name}",
|
||||
headers={"Accept-Encoding": accept_encoding},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "content-encoding" not in response.headers
|
||||
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_bytes)
|
||||
assert response.content == main.FRONTEND_BUILD.runtime_bytes
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_shell_is_compressed_without_losing_response_policies():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
page = await client.get("/", headers={"Accept-Encoding": "gzip"})
|
||||
stylesheet = await client.get(
|
||||
"/static/dashboard.css", headers={"Accept-Encoding": "gzip"}
|
||||
)
|
||||
health = await client.get("/healthz", headers={"Accept-Encoding": "gzip"})
|
||||
|
||||
assert page.headers["content-encoding"] == "gzip"
|
||||
assert page.headers["cache-control"] == "no-cache"
|
||||
assert page.headers["content-security-policy"]
|
||||
assert main.FRONTEND_BUILD.runtime_name in page.text
|
||||
assert stylesheet.headers["content-encoding"] == "gzip"
|
||||
assert "mobile-task-dock" in stylesheet.text
|
||||
assert health.json() == {"service": "stackchain-dashboard", "status": "ok"}
|
||||
assert "content-encoding" not in health.headers
|
||||
|
||||
|
||||
def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules():
|
||||
build = build_frontend(FRONTEND)
|
||||
|
||||
assert f"BASE + '{build.runtime_name}'" in build.service_worker_source
|
||||
for source in build.page_sources:
|
||||
if source == "static/background-issue-sync.js":
|
||||
continue
|
||||
assert f"BASE + '{source}'" not in build.service_worker_source
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_asset_is_immutable_while_html_and_worker_revalidate():
|
||||
build = build_frontend(FRONTEND)
|
||||
request = Request(
|
||||
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
|
||||
"headers": [(b"accept-encoding", b"identity")]}
|
||||
)
|
||||
|
||||
html = await dashboard()
|
||||
bundle = await runtime_bundle(build.runtime_digest, request)
|
||||
worker = await service_worker()
|
||||
|
||||
assert build.runtime_name in html
|
||||
assert bundle.body == build.runtime_bytes
|
||||
assert bundle.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
assert worker.headers["cache-control"] == "no-cache"
|
||||
assert build.runtime_name.encode() in worker.body
|
||||
@@ -306,3 +306,79 @@ async def test_application_shutdown_cancels_available_work_scan_before_transport
|
||||
main._available_issue_snapshot_task = None
|
||||
|
||||
assert calls == ["start", "available scan cancelled", "stop (done)"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_application_shutdown_drains_authored_mutation_before_transport(monkeypatch):
|
||||
calls = []
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def authored_mutation():
|
||||
started.set()
|
||||
await release.wait()
|
||||
calls.append("mutation persisted")
|
||||
return {"id": 461}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
|
||||
context = main.app.router.lifespan_context(main.app)
|
||||
await context.__aenter__()
|
||||
task = asyncio.create_task(authored_mutation())
|
||||
main._authored_action_operations["shutdown-drain-461"] = (
|
||||
("comment", "stackchain/dashboard", 461),
|
||||
task,
|
||||
0.0,
|
||||
)
|
||||
await started.wait()
|
||||
|
||||
shutdown = asyncio.create_task(context.__aexit__(None, None, None))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert not shutdown.done()
|
||||
assert calls == ["start"]
|
||||
|
||||
release.set()
|
||||
await shutdown
|
||||
|
||||
assert calls == ["start", "mutation persisted", "stop"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_application_shutdown_bounds_and_settles_stalled_authored_mutation(monkeypatch):
|
||||
calls = []
|
||||
started = asyncio.Event()
|
||||
|
||||
async def stalled_mutation():
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
calls.append("mutation cancelled")
|
||||
|
||||
monkeypatch.setattr(main, "AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS", 0.01)
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
|
||||
context = main.app.router.lifespan_context(main.app)
|
||||
await context.__aenter__()
|
||||
task = asyncio.create_task(stalled_mutation())
|
||||
main._authored_action_operations["stalled-shutdown-461"] = (
|
||||
("review", "stackchain/dashboard", 461),
|
||||
task,
|
||||
0.0,
|
||||
)
|
||||
await started.wait()
|
||||
|
||||
await asyncio.wait_for(context.__aexit__(None, None, None), timeout=0.2)
|
||||
|
||||
assert task.cancelled()
|
||||
assert main._authored_action_operations == {}
|
||||
assert calls == ["start", "mutation cancelled", "stop"]
|
||||
@@ -6,11 +6,18 @@ import httpx
|
||||
|
||||
from src import gitea_proxy
|
||||
from src import main
|
||||
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_available_issue_snapshot():
|
||||
def reset_available_issue_snapshot(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_available_issue_snapshot_store",
|
||||
AvailableIssueSnapshotStore(tmp_path / "available-issues.sqlite3"),
|
||||
)
|
||||
main._available_issue_snapshot_task = None
|
||||
main._available_issue_snapshot_lock = asyncio.Lock()
|
||||
main._available_issue_snapshot_value = None
|
||||
main._available_issue_snapshot_created_at = None
|
||||
main._available_issue_snapshot_retry_at = None
|
||||
@@ -19,6 +26,7 @@ def reset_available_issue_snapshot():
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
main._available_issue_snapshot_task = None
|
||||
main._available_issue_snapshot_lock = asyncio.Lock()
|
||||
main._available_issue_snapshot_value = None
|
||||
main._available_issue_snapshot_created_at = None
|
||||
main._available_issue_snapshot_retry_at = None
|
||||
@@ -207,6 +215,31 @@ async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkey
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_reuses_catalog_published_by_another_worker(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
store = AvailableIssueSnapshotStore(tmp_path / "available.sqlite3", clock=lambda: 100.0)
|
||||
owner = store.try_acquire_refresh(lease_seconds=5)
|
||||
store.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
|
||||
monkeypatch.setattr(main, "_available_issue_snapshot_store", store, raising=False)
|
||||
monkeypatch.setattr(main.time, "time", lambda: 100.0)
|
||||
|
||||
async def must_not_scan():
|
||||
raise AssertionError("fresh shared catalog should avoid an upstream scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [{"repository": "stackchain/api", "number": 7}],
|
||||
"page": 1, "total": 1, "has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_coalesces_cold_scan_and_reuses_it_for_pages(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
@@ -8,6 +8,13 @@ from src import gitea_proxy, main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
async def repository_access_from(loader, repository):
|
||||
repositories = await loader()
|
||||
return next(
|
||||
(item for item in repositories if item.get("full_name") == repository), None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone():
|
||||
requests = []
|
||||
@@ -70,6 +77,10 @@ async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch)
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue_milestone", update, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -352,6 +363,10 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -373,6 +388,118 @@ 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_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append((request.url.path, dict(request.url.params)))
|
||||
if request.url.path.endswith("/user/repos"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"id": 51, "name": "later", "full_name": "stackchain/later"}],
|
||||
headers={"X-Total-Count": "51"},
|
||||
)
|
||||
if request.url.path.endswith("/repos/stackchain/later"):
|
||||
return httpx.Response(200, json={"id": 51, "full_name": "stackchain/later"})
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
page = await gitea_proxy.repo_page(page=2, limit=50)
|
||||
first_page = await gitea_proxy.repos()
|
||||
accessible = await gitea_proxy.repository_access("stackchain/later")
|
||||
missing = await gitea_proxy.repository_access("stackchain/missing")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert page == {
|
||||
"items": [{"id": 51, "name": "later", "full_name": "stackchain/later"}],
|
||||
"page": 2,
|
||||
"total": 51,
|
||||
"has_more": False,
|
||||
}
|
||||
assert accessible["full_name"] == "stackchain/later"
|
||||
assert missing is None
|
||||
assert first_page.pagination == {"page": 1, "total": 51, "has_more": True}
|
||||
assert calls == [
|
||||
("/api/v1/user/repos", {"page": "2", "limit": "50"}),
|
||||
("/api/v1/user/repos", {"page": "1", "limit": "50"}),
|
||||
("/api/v1/repos/stackchain/later", {}),
|
||||
("/api/v1/repos/stackchain/missing", {}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch):
|
||||
access_calls = []
|
||||
create_calls = []
|
||||
|
||||
async def page(page, limit):
|
||||
assert (page, limit) == (2, 50)
|
||||
return {
|
||||
"items": [{
|
||||
"id": 51, "name": "later", "full_name": "stackchain/later",
|
||||
"description": "", "html_url": "https://forge.example/stackchain/later",
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
}],
|
||||
"page": 2, "total": 51, "has_more": False,
|
||||
}
|
||||
|
||||
async def access(repository):
|
||||
access_calls.append(repository)
|
||||
return {"id": 51, "full_name": repository} if repository == "stackchain/later" else None
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
create_calls.append((repository, title, assignee))
|
||||
return {
|
||||
"id": 403, "number": 403, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
"url": "https://forge.example/stackchain/later/issues/403",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_page", page)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
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:
|
||||
listed = await client.get("/api/v1/repositories?page=2&limit=50")
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/later/issues", json={"title": "Later-page work"}
|
||||
)
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["items"][0]["full_name"] == "stackchain/later"
|
||||
assert listed.json()["has_more"] is False
|
||||
assert created.status_code == 201
|
||||
assert access_calls == ["stackchain/later"]
|
||||
assert create_calls == [("stackchain/later", "Later-page work", "timmy")]
|
||||
|
||||
|
||||
def test_context_exposes_truthful_repository_pagination():
|
||||
repositories = gitea_proxy.RepositoryItems(
|
||||
[{
|
||||
"id": 1, "name": "api", "full_name": "stackchain/api",
|
||||
"description": "", "html_url": "https://forge.example/stackchain/api",
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
}],
|
||||
{"page": 1, "total": 51, "has_more": True},
|
||||
)
|
||||
|
||||
payload = main._context_payload(
|
||||
{"id": 2, "login": "timmy"}, repositories, [], []
|
||||
)
|
||||
|
||||
assert payload["repository_pagination"] == {
|
||||
"page": 1, "total": 51, "has_more": True
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch):
|
||||
calls = []
|
||||
@@ -400,6 +527,10 @@ async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypa
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -448,6 +579,10 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-concurrent"}
|
||||
@@ -496,6 +631,10 @@ async def test_completed_issue_creation_replays_after_ledger_reconstruction(monk
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "restart-create-201"}
|
||||
@@ -544,6 +683,10 @@ async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-payload-conflict"}
|
||||
@@ -585,6 +728,10 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
|
||||
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-timeout-recovery"}
|
||||
@@ -627,6 +774,10 @@ async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
|
||||
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
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:
|
||||
@@ -735,6 +886,10 @@ async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository(
|
||||
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
@@ -811,6 +966,10 @@ async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -982,6 +1141,32 @@ async def test_gitea_handoff_candidates_exclude_current_and_malformed_users():
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_mention_candidates_match_login_or_name_and_are_bounded():
|
||||
async def handler(request):
|
||||
assert request.url.path == "/api/v1/repos/stackchain/api/assignees"
|
||||
return httpx.Response(200, json=[
|
||||
{"login": "alexa", "full_name": "Alexa Dev"},
|
||||
{"login": "buildbot", "full_name": "Alex Builder"},
|
||||
{"login": "alexb", "full_name": "Alex Backup"},
|
||||
{"login": "casey", "full_name": "Casey"},
|
||||
{"login": "AL", "full_name": "Al"},
|
||||
{"login": "bad space", "full_name": "Malformed"},
|
||||
{"full_name": "Missing login"},
|
||||
])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.mention_candidates("stackchain/api", "alex", limit=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == [
|
||||
{"login": "alexa", "name": "Alexa Dev"},
|
||||
{"login": "alexb", "name": "Alex Backup"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
|
||||
requests = []
|
||||
@@ -1096,6 +1281,31 @@ async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monk
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mention_candidate_endpoint_bounds_query_and_disables_caching(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def candidates(repository, query, *, limit):
|
||||
calls.append((repository, query, limit))
|
||||
return [{"login": "alex", "name": "Alexander"}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mention_candidates", candidates, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
too_short = await client.get(
|
||||
"/api/v1/repos/stackchain/api/mention-candidates?q=a"
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/mention-candidates?q=AlEx"
|
||||
)
|
||||
|
||||
assert too_short.status_code == 422
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == [{"login": "alex", "name": "Alexander"}]
|
||||
assert calls == [("stackchain/api", "alex", 8)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch):
|
||||
async def handoff(_repository, _number, _recipient):
|
||||
@@ -1384,6 +1594,25 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path, request.url.query.decode()))
|
||||
if request.url.path.endswith("/dependencies"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"number": 3,
|
||||
"title": "Restore signing service",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/platform/issues/3",
|
||||
"repository": {"full_name": "stackchain/platform"},
|
||||
},
|
||||
{
|
||||
"number": 2,
|
||||
"title": "Completed prerequisite",
|
||||
"state": "closed",
|
||||
"repository": {"full_name": "stackchain/platform"},
|
||||
},
|
||||
],
|
||||
)
|
||||
if request.url.path.endswith("/comments"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
@@ -1421,6 +1650,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
assert requests == [
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/7", ""),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/7/dependencies", "limit=20"),
|
||||
]
|
||||
assert result == {
|
||||
"repository": "stackchain/api",
|
||||
@@ -1434,6 +1664,16 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": ["P1"],
|
||||
"assignees": ["timmy"],
|
||||
"dependencies_available": True,
|
||||
"dependencies": [
|
||||
{
|
||||
"repository": "stackchain/platform",
|
||||
"number": 3,
|
||||
"title": "Restore signing service",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/platform/issues/3",
|
||||
}
|
||||
],
|
||||
"comments": [
|
||||
{
|
||||
"id": 81,
|
||||
@@ -1460,6 +1700,31 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_issue_detail_keeps_planning_usable_when_dependencies_are_unavailable():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/dependencies"):
|
||||
return httpx.Response(503, json={"message": "upstream timeout"})
|
||||
if request.url.path.endswith("/comments"):
|
||||
return httpx.Response(200, json=[])
|
||||
return httpx.Response(200, json={
|
||||
"number": 7,
|
||||
"title": "Fix mobile flow",
|
||||
"state": "open",
|
||||
"assignees": [{"login": "timmy"}],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.issue_detail("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["title"] == "Fix mobile flow"
|
||||
assert result["dependencies_available"] is False
|
||||
assert result["dependencies"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_conversation_page_opens_newest_page_and_reports_older_history():
|
||||
requests = []
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ATTACHMENT = Path(__file__).parents[1] / "frontend" / "issue-attachment.js"
|
||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
README = Path(__file__).parents[1] / "README.md"
|
||||
|
||||
|
||||
def run_node(script: str) -> str:
|
||||
return subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
def test_mobile_attachment_prepares_comment_and_reuses_confirmed_upload():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const calls = [];
|
||||
const file = {{name:'checkout.png', type:'image/png', size:8}};
|
||||
const controller = attachment.create({{
|
||||
createOperationId: () => 'attachment-comment-471',
|
||||
readDataUrl: async selected => {{ calls.push('read:' + selected.name); return 'data:image/png;base64,iVBORw0KGgo='; }},
|
||||
upload: async payload => {{ calls.push(payload); return {{markdown:''}}; }},
|
||||
}});
|
||||
controller.select(file);
|
||||
(async()=>{{
|
||||
const first = await controller.prepareComment({{repository:'stackchain/api', number:17}}, 'Layout breaks');
|
||||
const second = await controller.prepareComment({{repository:'stackchain/api', number:17}}, 'Layout breaks');
|
||||
process.stdout.write(JSON.stringify({{first,second,calls,state:controller.state()}}));
|
||||
}})().catch(error=>{{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["first"] == (
|
||||
"Layout breaks\n\n"
|
||||
)
|
||||
assert output["second"] == output["first"]
|
||||
assert output["calls"] == [
|
||||
"read:checkout.png",
|
||||
{
|
||||
"repository": "stackchain/api",
|
||||
"number": 17,
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"data": "iVBORw0KGgo=",
|
||||
"operation_id": "attachment-comment-471",
|
||||
},
|
||||
]
|
||||
assert output["state"] == {
|
||||
"name": "checkout.png", "size": 8, "uploaded": True
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const keys=[];
|
||||
let sequence=0;
|
||||
let fail=true;
|
||||
const controller=attachment.create({{
|
||||
createOperationId:()=> 'attachment-' + (++sequence),
|
||||
readDataUrl:async file=>'data:'+file.type+';base64,iVBORw0KGgo=',
|
||||
upload:async payload=>{{keys.push(payload.operation_id);if(fail){{fail=false;throw new Error('offline');}}return {{markdown:''}};}},
|
||||
}});
|
||||
controller.select({{name:'first.png',type:'image/png',size:8}});
|
||||
(async()=>{{
|
||||
try{{await controller.prepareComment({{repository:'stackchain/api',number:17}},'Evidence');}}catch(_error){{}}
|
||||
await controller.prepareComment({{repository:'stackchain/api',number:17}},'Evidence');
|
||||
controller.select({{name:'replacement.png',type:'image/png',size:8}});
|
||||
await controller.prepareComment({{repository:'stackchain/api',number:17}},'Replacement');
|
||||
process.stdout.write(JSON.stringify(keys));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == [
|
||||
"attachment-1", "attachment-1", "attachment-2"
|
||||
]
|
||||
|
||||
|
||||
def test_selected_screenshot_serializes_for_durable_issue_capture_without_uploading():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const calls=[];
|
||||
const controller=attachment.create({{
|
||||
readDataUrl:async file=>{{calls.push('read:'+file.name);return 'data:image/webp;base64,UklGRg==';}},
|
||||
upload:async()=>{{calls.push('upload');}},
|
||||
}});
|
||||
controller.select({{name:'phone.webp',type:'image/webp',size:8}});
|
||||
controller.serialize().then(value=>process.stdout.write(JSON.stringify({{value,calls}})));
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"value": {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"data": "UklGRg==",
|
||||
},
|
||||
"calls": ["read:phone.webp"],
|
||||
}
|
||||
|
||||
|
||||
def test_serialized_capture_attachment_can_be_restored_or_removed_while_editing():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
const controller=attachment.create({{readDataUrl:async()=>{{throw new Error('must not reread');}},upload:async()=>{{}}}});
|
||||
controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0KGgo='}});
|
||||
(async()=>{{const restored={{state:controller.state(),value:await controller.serialize()}};controller.clear();process.stdout.write(JSON.stringify({{restored,removed:await controller.serialize()}}));}})();
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output["restored"]["state"]["name"] == "saved.png"
|
||||
assert output["restored"]["value"]["data"] == "iVBORw0KGgo="
|
||||
assert output["removed"] is None
|
||||
|
||||
|
||||
def test_issue_composer_renders_thumb_reachable_screenshot_preview():
|
||||
html = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="issue-attachment"' in html
|
||||
assert 'type="file"' in html
|
||||
assert 'accept="image/png,image/jpeg,image/webp"' in html
|
||||
assert 'for="issue-attachment"' in html
|
||||
assert 'id="issue-attachment-preview"' in html
|
||||
assert 'id="remove-issue-attachment"' in html
|
||||
assert '.issue-attachment-trigger' in css
|
||||
assert '.issue-attachment-preview' in css
|
||||
assert 'min-height:44px' in css
|
||||
|
||||
|
||||
def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
|
||||
html = INDEX.read_text()
|
||||
source = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="create-issue-attachment"' in html
|
||||
assert 'accept="image/png,image/jpeg,image/webp"' in html
|
||||
assert 'id="create-issue-attachment-preview"' in html
|
||||
assert 'id="remove-create-issue-attachment"' in html
|
||||
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
|
||||
assert "attachment: await createIssueAttachmentController.serialize()" in source
|
||||
assert "createIssueAttachmentController.restore(queued.attachment);" in source
|
||||
assert "createIssueAttachmentController.clear();" in source
|
||||
assert ".create-issue-attachment" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
|
||||
def test_attachment_view_keeps_invalid_draft_and_removes_preview():
|
||||
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=''; }}
|
||||
addEventListener(type, fn) {{ this.listeners[type]=fn; }}
|
||||
dispatch(type) {{ return this.listeners[type]({{target:this}}); }}
|
||||
}}
|
||||
const input=new Element(), preview=new Element(), image=new Element(), meta=new Element(), remove=new Element(), status=new Element();
|
||||
const revoked=[];
|
||||
const controller=attachment.mount({{
|
||||
input,preview,image,meta,remove,status,
|
||||
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()}}}}));
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["invalid"] == {
|
||||
"message": "Choose a PNG, JPEG, or WebP screenshot.", "hidden": True
|
||||
}
|
||||
assert output["selected"]["src"] == "blob:preview"
|
||||
assert output["selected"]["hidden"] is False
|
||||
assert "screen.png" in output["selected"]["meta"]
|
||||
assert output["removed"] == {
|
||||
"hidden": True, "src": "", "revoked": ["blob:preview"], "state": None
|
||||
}
|
||||
|
||||
|
||||
def test_issue_comment_actions_upload_before_posting_and_clear_after_acceptance():
|
||||
source = DASHBOARD.read_text()
|
||||
|
||||
assert "issueAttachment.mount({" in source
|
||||
assert "'/attachments'" in source
|
||||
assert "issueAttachmentController.prepareComment(item, body)" in source
|
||||
assert "issueAttachmentController.prepareComment(selectedIssue, body)" in source
|
||||
assert source.count("issueAttachmentController.clear();") >= 2
|
||||
assert "'Idempotency-Key': payload.operation_id" in source
|
||||
|
||||
|
||||
def test_closing_issue_sheet_cannot_carry_a_screenshot_to_another_issue():
|
||||
source = DASHBOARD.read_text()
|
||||
close_body = re.search(
|
||||
r"function closeIssueSheet\(navigate = true\) \{(?P<body>.*?)\n \}",
|
||||
source,
|
||||
re.DOTALL,
|
||||
).group("body")
|
||||
assert "issueAttachmentController.clear();" in close_body
|
||||
|
||||
|
||||
def test_attachment_runtime_is_available_in_the_offline_app_shell():
|
||||
assert "static/issue-attachment.js" in SERVICE_WORKER.read_text()
|
||||
|
||||
|
||||
def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
|
||||
readme = README.read_text()
|
||||
assert "PNG, JPEG, or WebP" in readme
|
||||
assert "2 MB" in readme
|
||||
assert "uploads before the comment is posted" in readme
|
||||
assert "New issue" in readme
|
||||
assert "retry resumes with the confirmed issue" in readme
|
||||
@@ -0,0 +1,378 @@
|
||||
import asyncio
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"mobile screenshot"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_attachment_idempotency():
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
yield
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_uploads_valid_screenshot_to_assigned_issue(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def upload(repository, number, filename, content_type, content):
|
||||
calls.append((repository, number, filename, content_type, content))
|
||||
return {
|
||||
"name": "checkout.png",
|
||||
"url": "https://forge.example/attachments/checkout.png",
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "upload_assigned_issue_attachment", upload, raising=False
|
||||
)
|
||||
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/17/attachments",
|
||||
json={
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"name": "checkout.png",
|
||||
"url": "https://forge.example/attachments/checkout.png",
|
||||
"size": len(PNG_BYTES),
|
||||
"markdown": "",
|
||||
}
|
||||
assert calls == [
|
||||
("stackchain/api", 17, "checkout.png", "image/png", PNG_BYTES)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_replays_confirmed_upload_for_same_key(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def upload(repository, number, filename, content_type, content):
|
||||
calls.append((repository, number, filename, content_type, content))
|
||||
return {
|
||||
"name": filename,
|
||||
"url": "https://forge.example/attachments/checkout.png",
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
|
||||
payload = {
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||
}
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/17/attachments",
|
||||
json=payload,
|
||||
headers={"Idempotency-Key": "attachment-retry-471"},
|
||||
)
|
||||
replay = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/17/attachments",
|
||||
json=payload,
|
||||
headers={"Idempotency-Key": "attachment-retry-471"},
|
||||
)
|
||||
|
||||
assert first.status_code == replay.status_code == 201
|
||||
assert replay.json() == first.json()
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_coalesces_concurrent_upload_retries(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def upload(_repository, _number, filename, _content_type, content):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
await asyncio.sleep(0.05)
|
||||
return {
|
||||
"name": filename,
|
||||
"url": "https://forge.example/attachments/checkout.png",
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
|
||||
payload = {
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||
}
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first, retry = await asyncio.gather(*[
|
||||
client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/17/attachments",
|
||||
json=payload,
|
||||
headers={"Idempotency-Key": "attachment-concurrent-471"},
|
||||
)
|
||||
for _ in range(2)
|
||||
])
|
||||
|
||||
assert first.status_code == retry.status_code == 201
|
||||
assert first.json() == retry.json()
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_rejects_changed_upload_for_used_key(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def upload(_repository, _number, filename, _content_type, content):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {
|
||||
"name": filename,
|
||||
"url": "https://forge.example/attachments/checkout.png",
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
accepted = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/17/attachments",
|
||||
json={
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||
},
|
||||
headers={"Idempotency-Key": "attachment-conflict-471"},
|
||||
)
|
||||
conflict = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/17/attachments",
|
||||
json={
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(PNG_BYTES + b" changed").decode("ascii"),
|
||||
},
|
||||
headers={"Idempotency-Key": "attachment-conflict-471"},
|
||||
)
|
||||
|
||||
assert accepted.status_code == 201
|
||||
assert conflict.status_code == 409
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_admits_a_normal_phone_screenshot(monkeypatch):
|
||||
screenshot = b"\x89PNG\r\n\x1a\n" + (b"x" * (100 * 1024))
|
||||
|
||||
async def upload(_repository, _number, filename, _content_type, content):
|
||||
return {
|
||||
"name": filename,
|
||||
"url": "https://forge.example/attachments/screen.png",
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "upload_assigned_issue_attachment", upload, raising=False
|
||||
)
|
||||
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/17/attachments",
|
||||
json={
|
||||
"filename": "screen.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(screenshot).decode("ascii"),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["size"] == len(screenshot)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_rejects_spoofed_image_before_upstream(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def upload(*_args):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "upload_assigned_issue_attachment", upload, raising=False
|
||||
)
|
||||
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/17/attachments",
|
||||
json={
|
||||
"filename": "not-really.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(b"<script>alert(1)</script>").decode("ascii"),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_rejects_image_over_two_megabytes_before_upstream(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def upload(*_args):
|
||||
nonlocal called
|
||||
called = True
|
||||
return {"name": "large.png", "url": "https://forge.example/a", "size": 1}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
|
||||
oversized = b"\x89PNG\r\n\x1a\n" + b"x" * (2 * 1024 * 1024 - 7)
|
||||
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/17/attachments",
|
||||
json={
|
||||
"filename": "large.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(oversized).decode("ascii"),
|
||||
},
|
||||
)
|
||||
|
||||
assert len(oversized) == 2 * 1024 * 1024 + 1
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_attachment_revalidates_assignment_and_sends_multipart():
|
||||
requests = []
|
||||
|
||||
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":
|
||||
return httpx.Response(200, json={
|
||||
"state": "open", "pull_request": None,
|
||||
"assignees": [{"login": "timmy"}],
|
||||
})
|
||||
return httpx.Response(201, json={
|
||||
"id": 9,
|
||||
"name": "checkout.png",
|
||||
"size": len(PNG_BYTES),
|
||||
"browser_download_url": "https://forge.example/attachments/checkout.png",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.upload_assigned_issue_attachment(
|
||||
"stackchain/api", 17, "checkout.png", "image/png", PNG_BYTES
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
("POST", "/api/v1/repos/stackchain/api/issues/17/assets"),
|
||||
]
|
||||
upload = requests[-1]
|
||||
assert upload.url.params["name"] == "checkout.png"
|
||||
assert "multipart/form-data" in upload.headers["content-type"]
|
||||
assert b'name="attachment"; filename="checkout.png"' in upload.content
|
||||
assert PNG_BYTES in upload.content
|
||||
assert result == {
|
||||
"name": "checkout.png",
|
||||
"url": "https://forge.example/attachments/checkout.png",
|
||||
"size": len(PNG_BYTES),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_markdown_escapes_untrusted_confirmed_filename(monkeypatch):
|
||||
async def upload(*_args):
|
||||
return {
|
||||
"name": "screen](not-an-image).png",
|
||||
"url": "https://forge.example/attachments/a.png",
|
||||
"size": len(PNG_BYTES),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
|
||||
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/17/attachments",
|
||||
json={
|
||||
"filename": "screen.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["markdown"] == (
|
||||
".png](<https://forge.example/attachments/a.png>)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_hides_an_issue_that_is_no_longer_assigned(monkeypatch):
|
||||
async def upload(*_args):
|
||||
raise gitea_proxy.IssueNotAvailableError("not assigned")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
|
||||
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/17/attachments",
|
||||
json={
|
||||
"filename": "screen.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_rejects_unsafe_or_mismatched_filename(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def upload(*_args):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
traversal = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/17/attachments",
|
||||
json={
|
||||
"filename": "../payload.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||
},
|
||||
)
|
||||
mismatch = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/17/attachments",
|
||||
json={
|
||||
"filename": "payload.html",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||
},
|
||||
)
|
||||
|
||||
assert [traversal.status_code, mismatch.status_code] == [422, 422]
|
||||
assert called is False
|
||||
+277
-2
@@ -68,7 +68,80 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
|
||||
]
|
||||
assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
|
||||
assert all(item["status"] == "queued" for item in output["items"])
|
||||
assert output["stored"]["version"] == 2
|
||||
assert output["stored"]["version"] == 3
|
||||
|
||||
|
||||
def test_issue_outbox_persists_a_validated_screenshot_with_the_capture():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'capture-image-1',
|
||||
}});
|
||||
outbox.enqueue({{
|
||||
repository:'stackchain/dashboard',title:'Layout breaks',body:'At 320px',
|
||||
attachment:{{filename:'phone.webp',contentType:'image/webp',data:'UklGRg=='}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(createIssueOutbox({{storage}}).list()[0]));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["attachment"] == {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"data": "UklGRg==",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'create-start-1',
|
||||
fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}}),
|
||||
}});
|
||||
outbox.enqueue({{
|
||||
repository:'stackchain/dashboard',title:'Resume me',body:'',completionIntent:'create-and-start'
|
||||
}});
|
||||
const reloaded = createIssueOutbox({{
|
||||
storage, fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}})
|
||||
}});
|
||||
reloaded.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
||||
persisted:JSON.parse(values.get('stackchain.issue-outbox.v1')),
|
||||
result,
|
||||
}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["persisted"]["version"] == 3
|
||||
completion = output["result"]["completions"][0]
|
||||
assert completion["intent"] == "create-and-start"
|
||||
assert completion["ownerLogin"] == "timmy"
|
||||
assert completion["operationId"] == "create-start-1"
|
||||
assert completion["issue"]["number"] == 389
|
||||
|
||||
|
||||
def test_editing_a_queued_issue_updates_its_completion_intent_without_rekeying_delivery():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();let sequence=0;
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'intent-' + (++sequence)
|
||||
}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Work',body:''}});
|
||||
const started=outbox.update(queued.id,{{...queued,completionIntent:'create-and-start'}});
|
||||
const ordinary=outbox.update(queued.id,{{...started,completionIntent:undefined}});
|
||||
process.stdout.write(JSON.stringify({{started,ordinary}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["started"]["completionIntent"] == "create-and-start"
|
||||
assert "completionIntent" not in output["ordinary"]
|
||||
assert output["started"]["operationId"] == output["ordinary"]["operationId"] == "intent-1"
|
||||
|
||||
|
||||
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
|
||||
@@ -104,6 +177,50 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{resul
|
||||
assert output["remaining"][0]["status"] == "queued"
|
||||
|
||||
|
||||
def test_foreground_attachment_retry_does_not_create_a_second_issue():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const calls=[];let uploadAttempts=0;
|
||||
const outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'image-op',
|
||||
fetchJson:async(url,options={{}})=>{{
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
|
||||
if(url.endsWith('/issues'))return{{repository:'o/r',number:7,title:'Visual bug'}};
|
||||
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('offline');error.status=503;throw error;}}
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
return{{id:8}};
|
||||
}},
|
||||
}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{filename:'screen.png',contentType:'image/png',data:'abc'}}}});
|
||||
(async()=>{{await outbox.flush('timmy');const partial=outbox.list()[0];const result=await outbox.retry(queued.id,'timmy');process.stdout.write(JSON.stringify({{calls,partial,result,remaining:outbox.list()}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["partial"]["deliveredIssue"]["number"] == 7
|
||||
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
|
||||
assert output["calls"][-1]["url"] == "api/v1/repos/o/r/issues/7/comments"
|
||||
assert output["result"]["confirmed"][0]["number"] == 7
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_replacing_a_partial_capture_screenshot_keeps_issue_and_restarts_upload_stage():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};let sequence=0;
|
||||
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-'+(++sequence)}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Visual',attachment:{{filename:'old.png',contentType:'image/png',data:'old'}}}});
|
||||
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:3,items:[{{...queued,deliveredIssue:{{repository:'o/r',number:7}},attachmentMarkdown:''}}]}}));
|
||||
const updated=outbox.update(queued.id,{{...queued,attachment:{{filename:'new.png',contentType:'image/png',data:'new'}}}});
|
||||
process.stdout.write(JSON.stringify(updated));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["deliveredIssue"]["number"] == 7
|
||||
assert output["attachment"]["filename"] == "new.png"
|
||||
assert "attachmentMarkdown" not in output
|
||||
|
||||
|
||||
def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -286,6 +403,117 @@ Promise.resolve().then(async () => {{
|
||||
assert output["items"][0]["title"] == "Keep this"
|
||||
|
||||
|
||||
def test_durable_screenshot_is_mirrored_before_a_payload_free_local_index_is_committed():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const events = []; const snapshots = [];
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => {{
|
||||
if (value.includes('base64-screenshot-bytes')) throw new Error('screenshot leaked into localStorage');
|
||||
events.push('local-index'); values.set(key,value);
|
||||
}},
|
||||
}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'quota-safe-1',
|
||||
backgroundSync: {{
|
||||
reconcile: async items => {{ events.push('indexeddb'); snapshots.push(items); }},
|
||||
requestSync: async () => {{ events.push('sync'); }},
|
||||
}},
|
||||
}});
|
||||
(async () => {{
|
||||
const result = await outbox.enqueueDurably({{
|
||||
repository:'stackchain/dashboard', title:'Mobile layout',
|
||||
attachment:{{filename:'phone.webp',contentType:'image/webp',data:'base64-screenshot-bytes'}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
events, mirrored:snapshots[0][0], local:outbox.list()[0], result:result.item,
|
||||
raw:values.get('stackchain.issue-outbox.v1'),
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["events"] == ["indexeddb", "local-index", "sync"]
|
||||
assert output["mirrored"]["attachment"]["data"] == "base64-screenshot-bytes"
|
||||
assert output["local"]["attachment"] == {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"stored": True,
|
||||
}
|
||||
assert "base64-screenshot-bytes" not in output["raw"]
|
||||
assert output["result"]["attachment"] == output["local"]["attachment"]
|
||||
|
||||
|
||||
def test_replacing_a_durable_screenshot_updates_indexeddb_before_the_local_reference():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const events = []; const snapshots = [];
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => {{
|
||||
if (value.includes('new-image-bytes')) throw new Error('replacement leaked into localStorage');
|
||||
events.push('local-index'); values.set(key,value);
|
||||
}},
|
||||
}};
|
||||
const backgroundSync = {{
|
||||
reconcile:async items => {{ events.push('indexeddb'); snapshots.push(items); }},
|
||||
requestSync:async () => {{ events.push('sync'); }},
|
||||
}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, backgroundSync, getOwnerLogin:()=> 'timmy',
|
||||
createOperationId:(() => {{ let sequence=0; return () => 'replace-' + (++sequence); }})(),
|
||||
}});
|
||||
(async () => {{
|
||||
const admitted = await outbox.enqueueDurably({{repository:'o/r',title:'Visual'}});
|
||||
events.length = 0;
|
||||
const result = await outbox.updateDurably(admitted.item.id, {{
|
||||
...admitted.item,
|
||||
attachment:{{filename:'new.png',contentType:'image/png',data:'new-image-bytes'}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
events, mirrored:snapshots.at(-1)[0], local:outbox.list()[0], result:result.item,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["events"] == ["indexeddb", "local-index", "sync"]
|
||||
assert output["mirrored"]["attachment"]["data"] == "new-image-bytes"
|
||||
assert output["local"]["attachment"]["stored"] is True
|
||||
assert "data" not in output["local"]["attachment"]
|
||||
assert output["result"] == output["local"]
|
||||
|
||||
|
||||
def test_next_durable_admission_migrates_legacy_screenshot_payloads_out_of_localstorage():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[{{
|
||||
id:'legacy',operationId:'legacy',repository:'o/r',title:'Queued before upgrade',body:'',
|
||||
labelIds:[],ownerLogin:'timmy',status:'queued',queuedAt:1,
|
||||
attachment:{{filename:'old.png',contentType:'image/png',data:'legacy-image-bytes'}},
|
||||
}}]}})]]);
|
||||
const mirrored = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'new-item',
|
||||
backgroundSync:{{reconcile:async items=>mirrored.push(items),requestSync:async()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await outbox.enqueueDurably({{repository:'o/r',title:'New queue item'}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
mirrored:mirrored[0],local:outbox.list(),raw:values.get('stackchain.issue-outbox.v1'),
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["mirrored"][0]["attachment"]["data"] == "legacy-image-bytes"
|
||||
assert output["local"][0]["attachment"]["stored"] is True
|
||||
assert "data" not in output["local"][0]["attachment"]
|
||||
assert "legacy-image-bytes" not in output["raw"]
|
||||
|
||||
|
||||
def test_issue_outbox_reports_degraded_admission_without_losing_foreground_item():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -384,13 +612,60 @@ process.stdout.write(JSON.stringify(outbox.list()));
|
||||
]
|
||||
|
||||
|
||||
def test_page_reconciliation_keeps_confirmed_attachment_delivery_stages():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'staged'}});
|
||||
outbox.enqueue({{repository:'o/r',title:'Visual',attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}});
|
||||
outbox.reconcileBackground([{{id:'staged',status:'queued',deliveredIssue:{{repository:'o/r',number:7}},attachmentMarkdown:''}}]);
|
||||
process.stdout.write(JSON.stringify(outbox.list()[0]));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["deliveredIssue"]["number"] == 7
|
||||
assert output["attachmentMarkdown"] == ""
|
||||
|
||||
|
||||
def test_page_preserves_background_create_and_start_until_the_matching_account_continues_it():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:3,items:[{{
|
||||
id:'resume',operationId:'resume',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',title:'Resume',body:'',completionIntent:'create-and-start'
|
||||
}}]}}));
|
||||
const outbox=createIssueOutbox({{storage}});
|
||||
outbox.reconcileBackground([{{
|
||||
id:'resume',status:'sent',ownerLogin:'timmy',completionIntent:'create-and-start',
|
||||
deliveredIssue:{{repository:'stackchain/dashboard',number:389,title:'Resume'}}
|
||||
}}]);
|
||||
const blocked=outbox.pendingCompletions('alexander');
|
||||
const pending=outbox.pendingCompletions('timmy');
|
||||
const retained=outbox.list();
|
||||
outbox.completeIntent('resume','timmy');
|
||||
process.stdout.write(JSON.stringify({{blocked,pending,retained,remaining:outbox.list()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["blocked"] == []
|
||||
assert output["pending"] == [{
|
||||
"id": "resume", "intent": "create-and-start", "ownerLogin": "timmy",
|
||||
"operationId": "resume",
|
||||
"issue": {"repository": "stackchain/dashboard", "number": 389, "title": "Resume"},
|
||||
}]
|
||||
assert output["retained"][0]["status"] == "completion"
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/issue-outbox.js"></script>' in html
|
||||
assert "const issueOutbox = createIssueOutbox({" in html
|
||||
assert "await issueOutbox.enqueueDurably(captureDraft)" in html
|
||||
assert "await issueOutbox.enqueueDurably(durableDraft)" in html
|
||||
assert "Saving for background delivery…" in html
|
||||
assert "Saved for next launch; background delivery unavailable." in html
|
||||
assert "throw new Error('Background Sync unavailable')" in html
|
||||
|
||||
@@ -86,6 +86,114 @@ def test_batch_applies_in_one_ordered_idempotent_unit(tmp_path):
|
||||
assert replay["duplicate_operation_ids"] == ["first", "second"]
|
||||
|
||||
|
||||
def test_batch_rejects_stale_same_item_intent_without_blocking_other_items(tmp_path):
|
||||
store = LaterStore(tmp_path / "later.sqlite3")
|
||||
first = store.apply_batch("timmy", [{
|
||||
"operation_id": "newer-device",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:1:",
|
||||
"wake_at": "2026-08-12T09:00:00.000Z",
|
||||
"base_revision": 0,
|
||||
}])
|
||||
|
||||
replay = store.apply_batch("timmy", [
|
||||
{
|
||||
"operation_id": "stale-device",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:1:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
"base_revision": 0,
|
||||
},
|
||||
{
|
||||
"operation_id": "unrelated-item",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": "2026-08-11T09:00:00.000Z",
|
||||
"base_revision": 0,
|
||||
},
|
||||
])
|
||||
|
||||
assert first["revision"] == 1
|
||||
assert replay == {
|
||||
"revision": 2,
|
||||
"records": {
|
||||
"issue:r:1:": "2026-08-12T09:00:00.000Z",
|
||||
"issue:r:2:": "2026-08-11T09:00:00.000Z",
|
||||
},
|
||||
"accepted_operation_ids": ["unrelated-item"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [
|
||||
{"operation_id": "stale-device", "reason": "stale_intent"}
|
||||
],
|
||||
}
|
||||
duplicate = store.apply_batch("timmy", [{
|
||||
"operation_id": "stale-device",
|
||||
"action": "restore",
|
||||
"item_id": "issue:r:2:",
|
||||
"base_revision": 2,
|
||||
}])
|
||||
assert duplicate["duplicate_operation_ids"] == ["stale-device"]
|
||||
assert duplicate["revision"] == 2
|
||||
assert duplicate["records"] == replay["records"]
|
||||
|
||||
|
||||
def test_later_receipts_are_bounded_per_account_and_existing_schema_migrates(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE later_operations (login TEXT NOT NULL, operation_id TEXT NOT NULL, "
|
||||
"PRIMARY KEY (login, operation_id))"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id) VALUES ('timmy', 'legacy')"
|
||||
)
|
||||
|
||||
store = LaterStore(path, operation_limit=2, clock=lambda: 1_000)
|
||||
for index in range(4):
|
||||
store.apply(
|
||||
"timmy", f"op-{index}", "defer", f"issue:r:{index}:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
store.apply(
|
||||
"alexander", "other", "defer", "issue:r:99:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(later_operations)")}
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'timmy' ORDER BY rowid"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert "created_at" in columns
|
||||
assert timmy == [("op-2",), ("op-3",)]
|
||||
assert alexander == [("other",)]
|
||||
|
||||
|
||||
def test_later_receipt_age_pruning_is_account_scoped(tmp_path):
|
||||
clock = [1_000.0]
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path, operation_retention_seconds=60, clock=lambda: clock[0])
|
||||
store.apply("timmy", "old", "restore", "issue:r:1:")
|
||||
store.apply("alexander", "other-old", "restore", "issue:r:2:")
|
||||
clock[0] += 61
|
||||
store.apply("timmy", "fresh", "restore", "issue:r:3:")
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'timmy'"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert timmy == [("fresh",)]
|
||||
assert alexander == [("other-old",)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
@@ -129,6 +237,21 @@ async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypat
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
stale = await client.patch(
|
||||
"/api/v1/later",
|
||||
json={
|
||||
"operations": [{
|
||||
"operation_id": "offline-stale",
|
||||
"action": "restore",
|
||||
"item_id": "issue:stackchain/dashboard:363:",
|
||||
"base_revision": 0,
|
||||
}],
|
||||
},
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
fetched = await client.get("/api/v1/later")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
@@ -143,6 +266,10 @@ async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypat
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [],
|
||||
}
|
||||
assert stale.json()["rejected_operations"] == [
|
||||
{"operation_id": "offline-stale", "reason": "stale_intent"}
|
||||
]
|
||||
assert stale.json()["revision"] == 2
|
||||
assert fetched.json() == {"revision": 2, "records": {
|
||||
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
|
||||
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
|
||||
|
||||
@@ -90,12 +90,67 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
"base_revision": 0,
|
||||
}]}
|
||||
assert result["records"]["issue:r:2:"] == "2026-08-10T09:00:00.000Z"
|
||||
assert result["status"] == "saved"
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_later_queue_expires_ancient_edits_before_replay():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const statuses=[];const requests=[];
|
||||
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync=createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'old-later',
|
||||
now:()=>1_800_000_000_000,maxOfflineMs:1000,
|
||||
fetchJson:async(_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:4,records:{{}}}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:(state,detail={{}})=>statuses.push([state,detail.count||0])}});
|
||||
(async()=>{{await sync.flush();sync.enqueue('restore','issue:r:2:');
|
||||
const key=[...values.keys()].find(value=>value.includes('.operation.'));
|
||||
const record=JSON.parse(values.get(key));record.queued_at=1_799_999_000_000;values.set(key,JSON.stringify(record));
|
||||
await sync.flush();process.stdout.write(JSON.stringify({{requests,statuses,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
|
||||
assert result["requests"] == ["GET", "GET"]
|
||||
assert ["expired", 1] in result["statuses"]
|
||||
assert result["statuses"][-1] == ["expired", 1]
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_stale_receipt_adopts_server_truth_and_reports_another_device_conflict():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const requests=[];const statuses=[];const adopted=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync=createLaterSync({{
|
||||
storage,getLogin:()=> 'timmy',createOperationId:()=> 'offline-old',
|
||||
fetchJson:async(_url,options={{}})=>{{
|
||||
if(!options.method)return {{revision:3,records:{{'issue:r:1:':'2026-08-12T09:00:00.000Z'}}}};
|
||||
const body=JSON.parse(options.body);requests.push(body);
|
||||
return {{revision:4,records:{{'issue:r:1:':'2026-08-13T09:00:00.000Z'}},accepted_operation_ids:[],duplicate_operation_ids:[],rejected_operations:[{{operation_id:'offline-old',reason:'stale_intent'}}]}};
|
||||
}},onRemoteRecords:records=>adopted.push(records),onStatus:(state,detail={{}})=>statuses.push([state,detail]),
|
||||
}});
|
||||
(async()=>{{await sync.flush();sync.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{requests,statuses,adopted,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["requests"] == [{"operations": [{
|
||||
"operation_id": "offline-old",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:1:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
"base_revision": 3,
|
||||
}]}]
|
||||
assert result["statuses"][-1] == ["conflict", {"count": 1}]
|
||||
assert result["adopted"][-1] == {
|
||||
"issue:r:1:": "2026-08-13T09:00:00.000Z"
|
||||
}
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_pending_later_edits_are_sent_as_one_batch_without_a_preflight_get():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
@@ -136,6 +191,7 @@ sync.enqueue('restore','issue:r:2:');
|
||||
"action": "restore",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": None,
|
||||
"base_revision": 0,
|
||||
}
|
||||
],
|
||||
"status": "retrying",
|
||||
@@ -283,10 +339,13 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
assert "laterSync.migrate(laterWork.read());" in html
|
||||
assert "laterSync.flush();" in html
|
||||
assert "Later saved to account." in html
|
||||
assert "Another device changed this Later item" in html
|
||||
assert "Today edit expired after 30 days offline" in html
|
||||
assert "Later edit expired after 30 days offline" in html
|
||||
|
||||
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
+199
-16
@@ -1,13 +1,21 @@
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.live_snapshot_store import LiveSnapshotStore
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_live_snapshot_task():
|
||||
def reset_live_snapshot_task(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_live_snapshot_store",
|
||||
LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: main._live_snapshot_clock()),
|
||||
)
|
||||
main._live_snapshot_task = None
|
||||
main._live_snapshot_value = None
|
||||
main._live_snapshot_created_at = None
|
||||
@@ -90,9 +98,9 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
|
||||
assert result["sections"] == {
|
||||
"context": "fresh", "events": "fresh", "notifications": "fresh"
|
||||
}
|
||||
assert result["revisions"] == {
|
||||
"context": 1, "events": 1, "notifications": 1
|
||||
}
|
||||
assert set(result["revisions"]) == {"context", "events", "notifications"}
|
||||
assert len(set(result["revisions"].values())) == 1
|
||||
assert result["revisions"]["context"].endswith(".1")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -129,6 +137,57 @@ async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch
|
||||
assert "freshness" in second
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_token_from_another_worker_never_suppresses_content(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
worker_a = payload(await main.live_snapshot())
|
||||
worker_a_token = worker_a["revisions"]["context"]
|
||||
|
||||
main._live_snapshot_value = None
|
||||
main._live_snapshot_created_at = None
|
||||
main._live_section_created_at = {
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_section_revisions = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_snapshot_store = LiveSnapshotStore(
|
||||
tmp_path / "other-worker.sqlite3", clock=lambda: main._live_snapshot_clock()
|
||||
)
|
||||
|
||||
worker_b = payload(await main.live_snapshot(context_revision=worker_a_token))
|
||||
|
||||
assert worker_a_token.endswith(".1")
|
||||
assert worker_b["revisions"]["context"].endswith(".1")
|
||||
assert worker_b["revisions"]["context"] != worker_a_token
|
||||
assert worker_b["context"]["user"]["login"] == "timmy"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_rejects_malformed_revision_before_upstream_work(monkeypatch):
|
||||
async def unexpected_user():
|
||||
raise AssertionError("malformed revision reached upstream work")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", unexpected_user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.get("/api/v1/live?context_revision=not-a-token")
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_keeps_fresh_context_when_activity_fails(monkeypatch):
|
||||
async def user():
|
||||
@@ -251,7 +310,10 @@ async def test_live_snapshot_reuses_completed_snapshot_inside_freshness_window(m
|
||||
first = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
second = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
for _ in range(100):
|
||||
if user_calls:
|
||||
break
|
||||
await asyncio.to_thread(lambda: None)
|
||||
assert user_calls == 1
|
||||
|
||||
release.set()
|
||||
@@ -281,7 +343,7 @@ async def test_stale_snapshot_returns_immediately_while_one_refresh_revalidates(
|
||||
"sections": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||
|
||||
first = payload(await main.live_snapshot())
|
||||
@@ -322,13 +384,14 @@ async def test_failed_revalidation_enters_cooldown_and_keeps_last_snapshot(monke
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||
|
||||
await main.live_snapshot()
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
stale = payload(await main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
assert main._live_snapshot_task is not None
|
||||
await asyncio.gather(main._live_snapshot_task, return_exceptions=True)
|
||||
degraded = payload(await main.live_snapshot())
|
||||
|
||||
assert stale["freshness"]["revalidating"] is True
|
||||
@@ -370,13 +433,14 @@ async def test_partial_refresh_updates_fresh_sections_and_retains_failed_section
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||
|
||||
await main.live_snapshot()
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
await main.live_snapshot()
|
||||
await asyncio.sleep(0)
|
||||
assert main._live_snapshot_task is not None
|
||||
await main._live_snapshot_task
|
||||
result = payload(await main.live_snapshot())
|
||||
|
||||
assert result["context"] == {"generation": 2}
|
||||
@@ -419,7 +483,7 @@ async def test_notification_cooldown_does_not_stop_due_work_and_activity_refresh
|
||||
raise ConnectionError("notifications unavailable")
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", work)
|
||||
monkeypatch.setattr(main, "issues", empty_work)
|
||||
@@ -486,20 +550,19 @@ async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state
|
||||
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||
|
||||
await main.live_snapshot()
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
await main.live_snapshot()
|
||||
await asyncio.sleep(0)
|
||||
assert main._live_snapshot_task is not None
|
||||
await main._live_snapshot_task
|
||||
now += main.LIVE_SNAPSHOT_RETRY_BASE_SECONDS
|
||||
|
||||
first = asyncio.create_task(main.live_snapshot())
|
||||
second = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
assert retry_started.is_set()
|
||||
await asyncio.wait_for(retry_started.wait(), timeout=1.0)
|
||||
assert builds == 3
|
||||
assert payload(await first)["freshness"]["revalidating"] is True
|
||||
assert payload(await second)["freshness"]["revalidating"] is True
|
||||
@@ -589,3 +652,123 @@ async def test_shared_snapshot_deadline_cancels_upstream_work_for_all_waiters(mo
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cold_refresh_cooldown_remains_a_retryable_503(monkeypatch):
|
||||
now = 100.0
|
||||
|
||||
async def unavailable(_sections=None):
|
||||
raise ConnectionError("private upstream failure")
|
||||
|
||||
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", unavailable)
|
||||
|
||||
first = await main.live_snapshot()
|
||||
second = await main.live_snapshot()
|
||||
|
||||
assert first.status_code == 503
|
||||
assert second.status_code == 503
|
||||
assert payload(second) == {
|
||||
"error": "Gitea live snapshot is temporarily unavailable"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancelled_refresh_releases_shared_lease(monkeypatch):
|
||||
started = asyncio.Event()
|
||||
|
||||
async def blocked(_sections=None):
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", blocked)
|
||||
request = asyncio.create_task(main.live_snapshot())
|
||||
await started.wait()
|
||||
assert main._live_snapshot_task is not None
|
||||
|
||||
main._live_snapshot_task.cancel()
|
||||
await asyncio.gather(request, main._live_snapshot_task, return_exceptions=True)
|
||||
|
||||
replacement = main._live_snapshot_store.try_acquire_refresh(
|
||||
{"context"}, lease_seconds=1
|
||||
)
|
||||
assert replacement is not None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shared_store_outage_returns_retryable_503(monkeypatch):
|
||||
def unavailable():
|
||||
raise sqlite3.OperationalError("private database detail")
|
||||
|
||||
monkeypatch.setattr(main._live_snapshot_store, "load", unavailable)
|
||||
|
||||
response = await main.live_snapshot()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert payload(response) == {
|
||||
"error": "Gitea live snapshot state is temporarily unavailable"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_independent_workers_reuse_shared_live_snapshot(monkeypatch, tmp_path):
|
||||
calls = 0
|
||||
|
||||
async def user():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
path = tmp_path / "worker-shared.sqlite3"
|
||||
monkeypatch.setattr(main, "_live_snapshot_store", LiveSnapshotStore(path, clock=lambda: 100.0))
|
||||
|
||||
first = payload(await main.live_snapshot())
|
||||
main._live_snapshot_value = None
|
||||
main._live_snapshot_created_at = None
|
||||
main._live_section_created_at = {section: None for section in main.LIVE_SNAPSHOT_SECTIONS}
|
||||
main._live_snapshot_store = LiveSnapshotStore(path, clock=lambda: 100.0)
|
||||
second = payload(await main.live_snapshot())
|
||||
|
||||
assert calls == 1
|
||||
assert second["context"] == first["context"]
|
||||
assert second["revisions"] == first["revisions"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_persists_reboot_stable_wall_timestamps(monkeypatch, tmp_path):
|
||||
epoch = 1_700_000_000.0
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: epoch, raising=False)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_live_snapshot_store",
|
||||
LiveSnapshotStore(tmp_path / "wall.sqlite3", clock=lambda: main._live_snapshot_clock()),
|
||||
)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
response = await main.live_snapshot()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert set(main._live_snapshot_store.load().created_at.values()) == {epoch}
|
||||
@@ -0,0 +1,157 @@
|
||||
import threading
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from src import live_snapshot_store
|
||||
from src.live_snapshot_store import LiveSnapshotStore, RefreshLeaseLost
|
||||
|
||||
|
||||
def test_independent_stores_racing_for_refresh_have_one_lease_winner(tmp_path):
|
||||
path = tmp_path / "live.sqlite3"
|
||||
first = LiveSnapshotStore(path, clock=lambda: 100.0)
|
||||
second = LiveSnapshotStore(path, clock=lambda: 100.0)
|
||||
barrier = threading.Barrier(2)
|
||||
results = []
|
||||
|
||||
def acquire(store):
|
||||
barrier.wait()
|
||||
results.append(store.try_acquire_refresh({"context", "events"}, lease_seconds=5))
|
||||
|
||||
threads = [threading.Thread(target=acquire, args=(store,)) for store in (first, second)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
assert sum(result is not None for result in results) == 1
|
||||
|
||||
|
||||
def test_snapshot_and_shared_revisions_publish_atomically(tmp_path):
|
||||
path = tmp_path / "live.sqlite3"
|
||||
writer = LiveSnapshotStore(path, clock=lambda: 100.0)
|
||||
reader = LiveSnapshotStore(path, clock=lambda: 100.0)
|
||||
lease = writer.try_acquire_refresh(set(("context", "events", "notifications")), lease_seconds=5)
|
||||
assert lease is not None
|
||||
|
||||
state = writer.publish_refresh(
|
||||
lease,
|
||||
value={
|
||||
"context": {"generation": 1},
|
||||
"events": [],
|
||||
"notifications": [],
|
||||
"sections": {section: "fresh" for section in ("context", "events", "notifications")},
|
||||
},
|
||||
created_at={section: 100.0 for section in ("context", "events", "notifications")},
|
||||
failure_count={section: 0 for section in ("context", "events", "notifications")},
|
||||
retry_at={section: None for section in ("context", "events", "notifications")},
|
||||
changed_sections=set(("context", "events", "notifications")),
|
||||
)
|
||||
|
||||
observed = reader.load()
|
||||
assert observed.value == state.value
|
||||
assert observed.revisions == {"context": 1, "events": 1, "notifications": 1}
|
||||
assert observed.refreshing_sections == set()
|
||||
with pytest.raises(RefreshLeaseLost):
|
||||
writer.publish_refresh(
|
||||
lease,
|
||||
value={"context": {"generation": 2}},
|
||||
created_at=observed.created_at,
|
||||
failure_count=observed.failure_count,
|
||||
retry_at=observed.retry_at,
|
||||
changed_sections={"context"},
|
||||
)
|
||||
|
||||
|
||||
def test_expired_refresh_lease_can_be_recovered(tmp_path):
|
||||
now = 100.0
|
||||
path = tmp_path / "live.sqlite3"
|
||||
abandoned = LiveSnapshotStore(path, clock=lambda: now)
|
||||
recovery = LiveSnapshotStore(path, clock=lambda: now)
|
||||
|
||||
first = abandoned.try_acquire_refresh({"context"}, lease_seconds=5)
|
||||
assert first is not None
|
||||
assert recovery.try_acquire_refresh({"context"}, lease_seconds=5) is None
|
||||
now = 106.0
|
||||
second = recovery.try_acquire_refresh({"context"}, lease_seconds=5)
|
||||
|
||||
assert second is not None
|
||||
assert second != first
|
||||
|
||||
|
||||
def test_default_store_clock_uses_reboot_stable_wall_time(tmp_path, monkeypatch):
|
||||
epoch = 1_700_000_000.0
|
||||
monkeypatch.setattr(live_snapshot_store.time, "time", lambda: epoch)
|
||||
store = LiveSnapshotStore(tmp_path / "live.sqlite3")
|
||||
|
||||
lease = store.try_acquire_refresh({"context"}, lease_seconds=5)
|
||||
|
||||
assert lease is not None
|
||||
assert store.load().lease_expires_at == epoch + 5
|
||||
|
||||
|
||||
def test_store_is_private_and_does_not_persist_upstream_token(tmp_path, monkeypatch):
|
||||
token = "gitea-super-secret-token-material"
|
||||
monkeypatch.setenv("GITEA_TOKEN", token)
|
||||
path = tmp_path / "private-state" / "live.sqlite3"
|
||||
|
||||
LiveSnapshotStore(path)
|
||||
|
||||
assert os.stat(path).st_mode & 0o777 == 0o600
|
||||
assert os.stat(path.parent).st_mode & 0o777 == 0o700
|
||||
assert token.encode() not in path.read_bytes()
|
||||
|
||||
|
||||
def test_remove_notifications_filters_batch_with_one_revision_advance(tmp_path):
|
||||
store = LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: 100.0)
|
||||
lease = store.try_acquire_refresh({"notifications"}, lease_seconds=5)
|
||||
assert lease is not None
|
||||
metadata = {section: None for section in ("context", "events", "notifications")}
|
||||
published = store.publish_refresh(
|
||||
lease,
|
||||
value={
|
||||
"notifications": [{"id": 7}, {"id": 8}, {"id": 9}],
|
||||
"sections": {"notifications": "fresh"},
|
||||
},
|
||||
created_at=metadata,
|
||||
failure_count={section: 0 for section in metadata},
|
||||
retry_at=metadata,
|
||||
changed_sections={"notifications"},
|
||||
)
|
||||
|
||||
removed = store.remove_notifications([7, 9, 7])
|
||||
|
||||
assert removed.value["notifications"] == [{"id": 8}]
|
||||
assert removed.revisions["notifications"] == published.revisions["notifications"] + 1
|
||||
|
||||
|
||||
def test_read_notification_filter_is_shared_with_future_publications(tmp_path):
|
||||
path = tmp_path / "live.sqlite3"
|
||||
first = LiveSnapshotStore(path, clock=lambda: 100.0)
|
||||
second = LiveSnapshotStore(path, clock=lambda: 100.0)
|
||||
lease = first.try_acquire_refresh({"notifications"}, lease_seconds=5)
|
||||
assert lease is not None
|
||||
metadata = {section: None for section in ("context", "events", "notifications")}
|
||||
first.publish_refresh(
|
||||
lease,
|
||||
value={"notifications": [{"id": 7}, {"id": 8}], "sections": {"notifications": "fresh"}},
|
||||
created_at=metadata,
|
||||
failure_count={section: 0 for section in metadata},
|
||||
retry_at=metadata,
|
||||
changed_sections={"notifications"},
|
||||
)
|
||||
|
||||
removed = second.remove_notification(7)
|
||||
lease = first.try_acquire_refresh({"notifications"}, lease_seconds=5)
|
||||
assert lease is not None
|
||||
republished = first.publish_refresh(
|
||||
lease,
|
||||
value={"notifications": [{"id": 7}, {"id": 8}], "sections": {"notifications": "fresh"}},
|
||||
created_at=metadata,
|
||||
failure_count={section: 0 for section in metadata},
|
||||
retry_at=metadata,
|
||||
changed_sections={"notifications"},
|
||||
)
|
||||
|
||||
assert removed.value["notifications"] == [{"id": 8}]
|
||||
assert republished.value["notifications"] == [{"id": 8}]
|
||||
@@ -37,6 +37,28 @@ process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}))
|
||||
assert state["ignored"] == state["expired"]
|
||||
|
||||
|
||||
def test_idle_session_reason_explains_that_saved_work_will_resume():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const status = {{ textContent: '' }};
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: () => {{}} }},
|
||||
}});
|
||||
controller.showReason('session-idle');
|
||||
process.stdout.write(JSON.stringify({{ status: status.textContent }}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout)["status"] == (
|
||||
"Stackchain locked after inactivity. Your drafts and queued work are still "
|
||||
"on this device. Sign in to resume."
|
||||
)
|
||||
|
||||
|
||||
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v54" in worker
|
||||
assert "stackchain-dashboard-shell-v81" in worker
|
||||
@@ -0,0 +1,85 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MENTIONS = ROOT / "frontend" / "mention-composer.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
completed = subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_active_mention_replaces_only_the_token_at_the_caret():
|
||||
script = f"""
|
||||
const mentions=require({json.dumps(str(MENTIONS))});
|
||||
const text='Thanks @al for this; keep @casey informed';
|
||||
const caret=text.indexOf(' for');
|
||||
const active=mentions.activeMention(text, caret);
|
||||
const inserted=mentions.insertMention(text, active, 'alex');
|
||||
process.stdout.write(JSON.stringify({{active,inserted}}));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
|
||||
assert result["active"] == {"start": 7, "end": 10, "query": "al"}
|
||||
assert result["inserted"] == {
|
||||
"value": "Thanks @alex for this; keep @casey informed",
|
||||
"caret": 13,
|
||||
}
|
||||
|
||||
|
||||
def test_controller_ignores_stale_results_and_keyboard_selects_a_teammate():
|
||||
script = f"""
|
||||
const mentions=require({json.dumps(str(MENTIONS))});
|
||||
class Element {{
|
||||
constructor() {{ this.listeners={{}}; this.children=[]; this.attrs={{}}; this.dataset={{}}; this.hidden=true; this.value=''; this.selectionStart=0; this.selectionEnd=0; this.textContent=''; }}
|
||||
addEventListener(type, fn) {{ (this.listeners[type] ||= []).push(fn); }}
|
||||
dispatch(type, extra={{}}) {{ const event={{preventDefault(){{ this.prevented=true; }}, ...extra}}; (this.listeners[type] || []).forEach(fn=>fn(event)); return event; }}
|
||||
replaceChildren(...children) {{ this.children=children; }}
|
||||
appendChild(child) {{ this.children.push(child); }}
|
||||
setAttribute(name, value) {{ this.attrs[name]=String(value); }}
|
||||
removeAttribute(name) {{ delete this.attrs[name]; }}
|
||||
setSelectionRange(start, end) {{ this.selectionStart=start; this.selectionEnd=end; }}
|
||||
focus() {{ this.focused=true; }}
|
||||
}}
|
||||
const textarea=new Element(), listbox=new Element(), status=new Element();
|
||||
const pending=[];
|
||||
const controller=mentions.create({{
|
||||
textarea,listbox,status,getRepository:()=> 'stackchain/api',
|
||||
loadCandidates:(repository, query)=>new Promise(resolve=>pending.push({{repository,query,resolve}})),
|
||||
setTimer:fn=>{{ fn(); return 1; }}, clearTimer:()=>{{}},
|
||||
createOption:()=>new Element(),
|
||||
}});
|
||||
controller.start();
|
||||
(async()=>{{
|
||||
textarea.value='Ping @al'; textarea.selectionStart=textarea.value.length; textarea.dispatch('input');
|
||||
textarea.value='Ping @alex'; textarea.selectionStart=textarea.value.length; textarea.dispatch('input');
|
||||
pending[1].resolve([{{login:'alex',name:'Alexander'}}]); await Promise.resolve(); await Promise.resolve();
|
||||
pending[0].resolve([{{login:'alice',name:'Alice'}}]); await Promise.resolve(); await Promise.resolve();
|
||||
const before={{queries:pending.map(item=>item.query), options:listbox.children.map(item=>item.dataset.login), expanded:textarea.attrs['aria-expanded']}};
|
||||
textarea.dispatch('keydown', {{key:'ArrowDown'}});
|
||||
const enter=textarea.dispatch('keydown', {{key:'Enter'}});
|
||||
process.stdout.write(JSON.stringify({{before,value:textarea.value,caret:textarea.selectionStart,focused:textarea.focused,prevented:enter.prevented,hidden:listbox.hidden}}));
|
||||
}})().catch(error=>{{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
|
||||
assert result["before"] == {
|
||||
"queries": ["al", "alex"],
|
||||
"options": ["alex"],
|
||||
"expanded": "true",
|
||||
}
|
||||
assert result["value"] == "Ping @alex "
|
||||
assert result["caret"] == 11
|
||||
assert result["focused"] is True
|
||||
assert result["prevented"] is True
|
||||
assert result["hidden"] is True
|
||||
@@ -35,4 +35,22 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v54" in worker
|
||||
assert "stackchain-dashboard-shell-v81" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert '<script src="static/mention-composer.js"></script>' in html
|
||||
for composer in ("issue-comment", "pull-comment", "update-reply"):
|
||||
assert f'id="{composer}-mentions"' in html
|
||||
assert f'id="{composer}-mention-status"' in html
|
||||
assert f"textarea:qs('#{composer}')" in dashboard
|
||||
assert f"listbox:qs('#{composer}-mentions')" in dashboard
|
||||
assert dashboard.count("createMentionComposer({") == 3
|
||||
assert "mention-candidates?q=" in dashboard
|
||||
assert ".mention-options" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "max-width:100%" in css
|
||||
@@ -8,6 +8,39 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
|
||||
|
||||
|
||||
def test_mobile_work_entry_prioritizes_continue_resume_start_then_fallback():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const state = {{active:true, resumable:true, today:2}};
|
||||
const calls = [];
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => state.active,
|
||||
isTodayResumable: () => state.resumable,
|
||||
getTodayCount: () => state.today,
|
||||
continueToday: () => calls.push('continue'),
|
||||
resumeToday: () => calls.push('resume'),
|
||||
startToday: () => calls.push('start'),
|
||||
openFallback: () => calls.push('fallback'),
|
||||
}});
|
||||
const modes = [];
|
||||
modes.push(entry.open());
|
||||
state.active = false; modes.push(entry.open());
|
||||
state.resumable = false; modes.push(entry.open());
|
||||
state.today = 0; modes.push(entry.open());
|
||||
process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"modes": ["continue", "resume", "start", "work"],
|
||||
"calls": ["continue", "resume", "start", "fallback"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
|
||||
@@ -70,6 +103,43 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_shows_contextual_work_label_with_attention_count():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
|
||||
const workLabel = {{ textContent:'' }};
|
||||
const badge = {{ textContent:'', hidden:true }};
|
||||
const dock = createDock({{ nav:{{}}, buttons:{{work}}, workLabel, attentionBadge:badge }});
|
||||
dock.updateWork('continue', 3);
|
||||
const continuing = {{ text:workLabel.textContent, count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('resume', 0);
|
||||
const resuming = {{ text:workLabel.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('start', 0);
|
||||
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('work', 0);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
continuing, resuming, starting,
|
||||
fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"continuing": {
|
||||
"text": "Continue",
|
||||
"count": "3",
|
||||
"hidden": False,
|
||||
"label": "Continue Today, 3 items need attention",
|
||||
},
|
||||
"resuming": {"text": "Resume", "hidden": True, "label": "Resume Today"},
|
||||
"starting": {"text": "Start", "label": "Start Today"},
|
||||
"fallback": {"text": "Work", "label": "Work"},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
@@ -95,6 +165,25 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mobile-work-label">Work</span>' in html
|
||||
assert '<script src="static/mobile-work-entry.js"></script>' in html
|
||||
assert "const mobileWorkEntry = createMobileWorkEntry({" in html
|
||||
assert "isTodayActive: () => workSession.checkpointed()" in html
|
||||
assert "isTodayResumable: () => workSession.resumable()" in html
|
||||
assert "getTodayCount: () => todayMyWork.length" in html
|
||||
assert "continueToday: continueTodaySession" in html
|
||||
assert "resumeToday: resumeTodaySession" in html
|
||||
assert "startToday: startTodaySession" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
|
||||
assert "workSession.reopen(item)" in html
|
||||
assert "runTodayTransition('continue')" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
html = await dashboard()
|
||||
@@ -112,12 +201,12 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: openMobileWork" in html
|
||||
assert "counts.attention ? 'attention' : 'all'" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "const filter = counts.attention ? 'attention' : 'all';" in html
|
||||
assert "qs('[data-work-filter=\"' + filter + '\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
assert "search: () => qs('#open-palette').click()" in html
|
||||
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
|
||||
assert "draftCount.textContent = sourceDraftCount.textContent" in html
|
||||
assert "mobileTaskDock.updateAttention(counts.attention)" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
|
||||
+866
-8
@@ -12,6 +12,7 @@ MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
||||
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
|
||||
LATER_AND_START = Path(__file__).parents[1] / "frontend" / "later-and-start.js"
|
||||
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
||||
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||
@@ -20,6 +21,87 @@ CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
|
||||
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
|
||||
|
||||
|
||||
def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus():
|
||||
script = f"""
|
||||
const createCardPlanning = require({json.dumps(str(CARD_PLANNING))});
|
||||
function disclosure(name) {{
|
||||
const listeners = {{}};
|
||||
const attrs = {{}};
|
||||
const summary = {{
|
||||
focusCount: 0,
|
||||
setAttribute(key, value) {{ attrs[key] = value; }},
|
||||
focus() {{ this.focusCount += 1; }},
|
||||
}};
|
||||
return {{
|
||||
name, open: false, dataset: {{}}, summary, attrs,
|
||||
querySelector(selector) {{ return selector === 'summary' ? summary : null; }},
|
||||
addEventListener(type, listener) {{ listeners[type] = listener; }},
|
||||
fire(type, event = {{}}) {{ listeners[type](event); }},
|
||||
}};
|
||||
}}
|
||||
const first = disclosure('first');
|
||||
const second = disclosure('second');
|
||||
const controller = createCardPlanning({{
|
||||
querySelectorAll() {{ return [first, second]; }},
|
||||
}});
|
||||
controller.wire();
|
||||
first.open = true;
|
||||
first.fire('toggle');
|
||||
second.open = true;
|
||||
second.fire('toggle');
|
||||
const escape = {{
|
||||
key: 'Escape', prevented: 0, stopped: 0,
|
||||
preventDefault() {{ this.prevented += 1; }},
|
||||
stopPropagation() {{ this.stopped += 1; }},
|
||||
}};
|
||||
second.fire('keydown', escape);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
firstOpen: first.open,
|
||||
firstExpanded: first.attrs['aria-expanded'],
|
||||
secondOpen: second.open,
|
||||
secondExpanded: second.attrs['aria-expanded'],
|
||||
secondFocus: second.summary.focusCount,
|
||||
prevented: escape.prevented,
|
||||
stopped: escape.stopped,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"firstOpen": False,
|
||||
"firstExpanded": "false",
|
||||
"secondOpen": False,
|
||||
"secondExpanded": "false",
|
||||
"secondFocus": 1,
|
||||
"prevented": 1,
|
||||
"stopped": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_my_work_cards_progressively_disclose_planning_on_phones():
|
||||
html = await dashboard()
|
||||
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/card-planning.js"></script>' in html
|
||||
assert "const cardPlanning = createCardPlanning(document);" in html
|
||||
assert "cardPlanning.wire();" in html
|
||||
assert (
|
||||
'<details class="card-planning" data-card-planning>'
|
||||
'<summary aria-expanded="false">Plan or defer</summary>'
|
||||
'<div class="card-planning-actions">'
|
||||
) in html
|
||||
assert '.card-planning > summary { display:none;' in html
|
||||
assert '.card-planning:not([open]) > .card-planning-actions { display:grid;' in html
|
||||
mobile = html.index('@media (max-width: 600px)')
|
||||
assert '.card-planning > summary { min-height:44px; display:flex;' in html[mobile:]
|
||||
assert '.card-planning:not([open]) > .card-planning-actions { display:none;' in html[mobile:]
|
||||
assert "BASE + 'static/card-planning.js'" in service_worker
|
||||
|
||||
|
||||
def test_unassigned_issue_update_claims_once_and_becomes_today_ready():
|
||||
@@ -1072,6 +1154,219 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_starting_deferred_work_moves_it_to_today_before_opening_exact_item():
|
||||
script = f"""
|
||||
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||
const calls = [];
|
||||
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Resume me'}};
|
||||
const controller = createLaterAndStart({{
|
||||
todayWork: {{
|
||||
identity:saved => saved.repository + '#' + saved.number,
|
||||
add:saved => {{ calls.push(['today-add', saved.number]); return 'added'; }},
|
||||
remove:saved => {{ calls.push(['today-remove', saved.number]); return true; }},
|
||||
}},
|
||||
todaySync: {{
|
||||
enqueue:(action, identity) => {{ calls.push(['today-sync', action, identity]); return true; }},
|
||||
flush:() => calls.push(['today-flush']),
|
||||
}},
|
||||
laterWork: {{restore:saved => {{ calls.push(['later-restore', saved.number]); return true; }}}},
|
||||
refresh:() => calls.push(['refresh']),
|
||||
warm:() => calls.push(['warm']),
|
||||
start:saved => {{ calls.push(['start', saved.number]); return Promise.resolve('opened'); }},
|
||||
announce:message => calls.push(['announce', message]),
|
||||
}});
|
||||
(async () => {{
|
||||
const result = await controller.start(item);
|
||||
process.stdout.write(JSON.stringify({{result,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) == {
|
||||
"result": "started",
|
||||
"calls": [
|
||||
["today-add", 17],
|
||||
["today-sync", "add", "stackchain/api#17"],
|
||||
["later-restore", 17],
|
||||
["refresh"],
|
||||
["today-flush"],
|
||||
["warm"],
|
||||
["start", 17],
|
||||
["announce", "Moved to Today and opened."],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_starting_deferred_work_keeps_later_when_today_is_full():
|
||||
script = f"""
|
||||
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||
const calls = [];
|
||||
const controller = createLaterAndStart({{
|
||||
todayWork: {{identity:() => 'issue:x/y:7', add:() => 'full', remove:() => calls.push('remove')}},
|
||||
todaySync: {{enqueue:() => calls.push('enqueue'), flush:() => calls.push('flush')}},
|
||||
laterWork: {{restore:() => calls.push('restore')}},
|
||||
refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
|
||||
start:() => calls.push('start'), announce:message => calls.push(message),
|
||||
}});
|
||||
(async () => {{
|
||||
const result = await controller.start({{kind:'issue',repository:'x/y',number:7}});
|
||||
process.stdout.write(JSON.stringify({{result,calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": "full",
|
||||
"calls": ["Today is limited to 5 items. Remove one, then try Start now again."],
|
||||
}
|
||||
|
||||
|
||||
def test_starting_deferred_work_rolls_back_today_when_sync_admission_fails():
|
||||
script = f"""
|
||||
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||
const calls = [];
|
||||
const item = {{kind:'pull',repository:'x/y',number:8}};
|
||||
const controller = createLaterAndStart({{
|
||||
todayWork: {{
|
||||
identity:() => 'pull:x/y:8', add:() => {{ calls.push('add'); return 'added'; }},
|
||||
remove:() => {{ calls.push('remove'); return true; }},
|
||||
}},
|
||||
todaySync: {{enqueue:() => {{ calls.push('enqueue'); return false; }}, flush:() => calls.push('flush')}},
|
||||
laterWork: {{restore:() => calls.push('restore')}},
|
||||
refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
|
||||
start:() => calls.push('start'), announce:message => calls.push(message),
|
||||
}});
|
||||
(async () => {{
|
||||
const result = await controller.start(item);
|
||||
process.stdout.write(JSON.stringify({{result,calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": "sync-unavailable",
|
||||
"calls": [
|
||||
"add", "enqueue", "remove",
|
||||
"Today sync is unavailable. The item remains in Later; try again.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_starting_deferred_work_rolls_back_today_when_later_cannot_be_removed():
|
||||
script = f"""
|
||||
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||
const calls = [];
|
||||
const item = {{kind:'review',repository:'x/y',number:9}};
|
||||
const controller = createLaterAndStart({{
|
||||
todayWork: {{
|
||||
identity:() => 'review:x/y:9', add:() => 'added',
|
||||
remove:() => {{ calls.push('remove'); return true; }},
|
||||
}},
|
||||
todaySync: {{
|
||||
enqueue:(action, id) => {{ calls.push(['enqueue', action, id]); return true; }},
|
||||
flush:() => calls.push('flush'),
|
||||
}},
|
||||
laterWork: {{restore:() => false}}, refresh:() => calls.push('refresh'),
|
||||
warm:() => calls.push('warm'), start:() => calls.push('start'),
|
||||
announce:message => calls.push(message),
|
||||
}});
|
||||
(async () => {{
|
||||
const result = await controller.start(item);
|
||||
process.stdout.write(JSON.stringify({{result,calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": "later-unavailable",
|
||||
"calls": [
|
||||
["enqueue", "add", "review:x/y:9"], "remove",
|
||||
["enqueue", "remove", "review:x/y:9"], "flush",
|
||||
"Could not remove this item from Later. Nothing was started; try again.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_starting_deferred_work_reuses_existing_today_item_without_duplicate_sync():
|
||||
script = f"""
|
||||
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||
const calls = [];
|
||||
const item = {{kind:'issue',repository:'x/y',number:10}};
|
||||
const controller = createLaterAndStart({{
|
||||
todayWork: {{identity:() => 'issue:x/y:10', add:() => 'exists', remove:() => calls.push('remove')}},
|
||||
todaySync: {{enqueue:() => calls.push('enqueue'), flush:() => calls.push('flush')}},
|
||||
laterWork: {{restore:() => {{ calls.push('restore'); return true; }}}},
|
||||
refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
|
||||
start:saved => calls.push(['start', saved.number]), announce:message => calls.push(message),
|
||||
}});
|
||||
(async () => {{
|
||||
const result = await controller.start(item);
|
||||
process.stdout.write(JSON.stringify({{result,calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"restore", "refresh", "warm", ["start", 10],
|
||||
"Opened the existing Today item.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_starting_deferred_work_is_single_flight_for_repeated_taps():
|
||||
script = f"""
|
||||
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||
let adds = 0;
|
||||
let finishStart;
|
||||
const item = {{kind:'issue',repository:'x/y',number:11}};
|
||||
const controller = createLaterAndStart({{
|
||||
todayWork: {{identity:() => 'issue:x/y:11', add:() => {{ adds += 1; return 'added'; }}, remove:() => true}},
|
||||
todaySync: {{enqueue:() => true, flush:() => {{}}}}, laterWork: {{restore:() => true}},
|
||||
refresh:() => {{}}, warm:() => {{}}, announce:() => {{}},
|
||||
start:() => new Promise(resolve => {{ finishStart = resolve; }}),
|
||||
}});
|
||||
const first = controller.start(item);
|
||||
const second = controller.start(item);
|
||||
if (adds !== 1) throw new Error('duplicate admission');
|
||||
finishStart('opened');
|
||||
(async () => {{
|
||||
const results = await Promise.all([first, second]);
|
||||
process.stdout.write(JSON.stringify({{adds,results}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
assert json.loads(result.stdout) == {"adds": 1, "results": ["started", "started"]}
|
||||
|
||||
|
||||
def test_starting_blocked_deferred_work_reports_readiness_gate_without_claiming_open():
|
||||
script = f"""
|
||||
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
||||
const messages = [];
|
||||
const controller = createLaterAndStart({{
|
||||
todayWork: {{identity:() => 'issue:x/y:12', add:() => 'added', remove:() => true}},
|
||||
todaySync: {{enqueue:() => true, flush:() => {{}}}}, laterWork: {{restore:() => true}},
|
||||
refresh:() => {{}}, warm:() => {{}}, start:() => Promise.resolve('gated'),
|
||||
announce:message => messages.push(message),
|
||||
}});
|
||||
(async () => {{
|
||||
const result = await controller.start({{kind:'issue',repository:'x/y',number:12}});
|
||||
process.stdout.write(JSON.stringify({{result,messages}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": "gated",
|
||||
"messages": ["Moved to Today. Choose how to handle its blocker before starting."],
|
||||
}
|
||||
|
||||
|
||||
def test_detail_defer_closes_normal_triage_but_keeps_session_open_for_reconcile():
|
||||
script = f"""
|
||||
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
|
||||
@@ -1142,6 +1437,75 @@ process.stdout.write(JSON.stringify({{saved,calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_detail_defer_completes_checkpointed_today_item_after_saving_later():
|
||||
script = f"""
|
||||
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
|
||||
const calls = [];
|
||||
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Read first'}};
|
||||
const controller = createDetailDefer({{
|
||||
laterWork: {{
|
||||
presetUntil:() => new Date('2026-08-09T09:00:00Z'),
|
||||
defer:(saved, until) => {{ calls.push(['defer', saved.title, until.toISOString()]); return 'deferred'; }},
|
||||
restore:() => calls.push(['restore']),
|
||||
}},
|
||||
session: {{active:() => true, checkpointed:saved => saved === item}},
|
||||
continueSession:saved => {{ calls.push(['continue', saved.title]); return true; }},
|
||||
close:() => calls.push(['close']), refresh:() => calls.push(['refresh']),
|
||||
focus:() => calls.push(['focus']), announce:message => calls.push(['announce', message]),
|
||||
}});
|
||||
const saved = controller.defer(item, 'tomorrow');
|
||||
process.stdout.write(JSON.stringify({{saved,calls}}));
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"saved": True,
|
||||
"calls": [
|
||||
["defer", "Read first", "2026-08-09T09:00:00.000Z"],
|
||||
["continue", "Read first"],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_detail_defer_rolls_back_later_when_today_cannot_be_removed():
|
||||
script = f"""
|
||||
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
|
||||
const calls = [];
|
||||
const item = {{kind:'issue',repository:'stackchain/api',number:17}};
|
||||
const controller = createDetailDefer({{
|
||||
laterWork: {{
|
||||
presetUntil:() => new Date('2026-08-09T09:00:00Z'),
|
||||
defer:() => {{ calls.push('defer'); return 'deferred'; }},
|
||||
restore:saved => {{ calls.push(['restore', saved.number]); return true; }},
|
||||
}},
|
||||
session: {{active:() => true, checkpointed:() => true}},
|
||||
continueSession:() => {{ calls.push('continue'); return false; }},
|
||||
close:() => calls.push('close'), refresh:() => calls.push('refresh'),
|
||||
focus:() => calls.push('focus'), announce:message => calls.push(['announce', message]),
|
||||
}});
|
||||
const saved = controller.defer(item, 'tomorrow');
|
||||
process.stdout.write(JSON.stringify({{saved,calls}}));
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"saved": False,
|
||||
"calls": [
|
||||
"defer",
|
||||
"continue",
|
||||
["restore", 17],
|
||||
"refresh",
|
||||
["announce", "Could not remove this item from Today, so it was restored from Later."],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation():
|
||||
html = await dashboard()
|
||||
@@ -1157,6 +1521,18 @@ async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutat
|
||||
assert "fetch(" not in DETAIL_DEFER.read_text()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_checkpointed_detail_defer_completes_today_and_advertises_next_item():
|
||||
html = await dashboard()
|
||||
|
||||
assert "continueSession: item => completeTodayItem(item, {" in html
|
||||
assert "successMessage: 'Deferred to Later. Next Today item opened.'" in html
|
||||
assert "advance: () => runTodayTransition('complete')" in html
|
||||
assert "button.textContent = active ? 'Later today & next' : 'Later today'" in html
|
||||
assert "button.textContent = active ? 'Tomorrow & next' : 'Tomorrow'" in html
|
||||
assert "button.textContent = active ? 'Choose date & time & next' : 'Choose date & time'" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
|
||||
html = await dashboard()
|
||||
@@ -1177,6 +1553,24 @@ async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
|
||||
assert "'Deferred until ' + fmt(until)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_later_cards_start_exact_item_in_a_resumable_today_session():
|
||||
html = await dashboard()
|
||||
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/later-and-start.js"></script>' in html
|
||||
assert 'data-later-start' in html
|
||||
assert '>Start now</button>' in html
|
||||
assert 'aria-label="Deferred work actions"' in html
|
||||
assert "const laterAndStart = createLaterAndStart({" in html
|
||||
assert "laterAndStart.start(item)" in html
|
||||
assert "qs('[data-work-filter=\"today\"]').click();" in html
|
||||
assert "todayReadiness.run('start', workSession.items(), item)" in html
|
||||
assert "selectedWorkFilter === 'later' ? laterActions" in html
|
||||
assert ".later-actions button { min-height:44px; width:100%; }" in html
|
||||
assert "BASE + 'static/later-and-start.js'" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_later_actions_open_one_keyboard_safe_exact_time_dialog():
|
||||
html = await dashboard()
|
||||
@@ -1347,6 +1741,200 @@ process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
|
||||
assert json.loads(result.stdout) == {"finished": 1, "active": False}
|
||||
|
||||
|
||||
def test_active_today_session_reopens_current_item_without_restarting_checkpoint():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let saves = 0;
|
||||
const checkpoint = {{
|
||||
read: () => null,
|
||||
save: () => {{ saves += 1; return true; }},
|
||||
clear: () => true,
|
||||
}};
|
||||
const items = [1, 2].map(number => ({{
|
||||
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
|
||||
}}));
|
||||
const opened = [];
|
||||
const progress = [];
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems: () => items, getFilter: () => 'all', checkpoint,
|
||||
onOpen: item => opened.push(item.number),
|
||||
onProgress: state => progress.push(state.index), onFinish: () => {{}},
|
||||
}});
|
||||
session.start();
|
||||
session.next();
|
||||
const savesBeforeReopen = saves;
|
||||
const reopened = session.reopen();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
reopened, opened, progress, savesBeforeReopen, savesAfterReopen:saves,
|
||||
todayActive:session.checkpointed(),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"reopened": True,
|
||||
"opened": [1, 2, 2],
|
||||
"progress": [1, 2, 2],
|
||||
"savesBeforeReopen": 2,
|
||||
"savesAfterReopen": 2,
|
||||
"todayActive": True,
|
||||
}
|
||||
|
||||
|
||||
def test_today_session_checkpoint_resumes_current_item_after_reload():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
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 items = [1, 2, 3].map(number => ({{
|
||||
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
|
||||
}}));
|
||||
const opened = [];
|
||||
const firstCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage, getLogin: () => 'timmy',
|
||||
}});
|
||||
const first = buildMyWork.createWorkSession({{
|
||||
getItems: () => items, getFilter: () => 'all',
|
||||
checkpoint: firstCheckpoint,
|
||||
onOpen: item => opened.push('first:' + item.number),
|
||||
onProgress: () => {{}}, onFinish: () => {{}},
|
||||
}});
|
||||
first.start();
|
||||
first.next();
|
||||
|
||||
const secondCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage, getLogin: () => 'timmy',
|
||||
}});
|
||||
const resumed = buildMyWork.createWorkSession({{
|
||||
getItems: () => items, getFilter: () => 'all',
|
||||
checkpoint: secondCheckpoint,
|
||||
onOpen: item => opened.push('resumed:' + item.number),
|
||||
onProgress: () => {{}}, onFinish: () => {{}},
|
||||
}});
|
||||
const available = resumed.resumable();
|
||||
const didResume = resumed.resume();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
available, didResume, opened, checkpoint:JSON.parse(values.values().next().value),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"available": True,
|
||||
"didResume": True,
|
||||
"opened": ["first:1", "first:2", "resumed:2"],
|
||||
"checkpoint": {
|
||||
"version": 1,
|
||||
"login": "timmy",
|
||||
"identity": "issue:stackchain/dashboard:2:",
|
||||
"index": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_today_session_checkpoint_is_account_bound_and_end_clears_it():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let login = 'timmy';
|
||||
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 checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage, getLogin:() => login}});
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems: () => [{{kind:'issue',repository:'stackchain/dashboard',number:7}}],
|
||||
getFilter: () => 'all', checkpoint,
|
||||
onOpen: () => {{}}, onProgress: () => {{}}, onFinish: () => {{}},
|
||||
}});
|
||||
session.start();
|
||||
login = 'alexander';
|
||||
const visibleToOtherAccount = session.resumable();
|
||||
const retainedForOwner = values.size;
|
||||
login = 'timmy';
|
||||
session.end();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
visibleToOtherAccount, retainedForOwner, active:session.active(), stored:values.size,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"visibleToOtherAccount": False,
|
||||
"retainedForOwner": 1,
|
||||
"active": False,
|
||||
"stored": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_resuming_an_empty_today_plan_clears_the_stale_checkpoint():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const values = new Map([['stackchain.today-session.v1', JSON.stringify({{
|
||||
version:1, login:'timmy', identity:'issue:stackchain/dashboard:7:', index:0,
|
||||
}})]]);
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage,getLogin:() => 'timmy'}});
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems:() => [], getFilter:() => 'all', checkpoint,
|
||||
onOpen:() => {{}}, onProgress:() => {{}}, onFinish:() => {{}},
|
||||
}});
|
||||
const resumed = session.resume();
|
||||
process.stdout.write(JSON.stringify({{resumed, stored:values.size, available:session.resumable()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"resumed": False, "stored": 0, "available": False}
|
||||
|
||||
|
||||
def test_today_session_keeps_working_when_checkpoint_storage_fails():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let errors = 0;
|
||||
const opened = [];
|
||||
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage:{{
|
||||
getItem:() => null,
|
||||
setItem:() => {{ throw new Error('quota'); }},
|
||||
removeItem:() => {{ throw new Error('quota'); }},
|
||||
}},
|
||||
getLogin:() => 'timmy',
|
||||
onError:() => {{ errors += 1; }},
|
||||
}});
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems:() => [1,2].map(number => ({{kind:'issue',repository:'stackchain/dashboard',number}})),
|
||||
getFilter:() => 'all', checkpoint,
|
||||
onOpen:item => opened.push(item.number), onProgress:() => {{}}, onFinish:() => {{}},
|
||||
}});
|
||||
session.start();
|
||||
session.next();
|
||||
process.stdout.write(JSON.stringify({{errors,opened,active:session.active()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"errors": 1, "opened": [1, 2], "active": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet():
|
||||
html = await dashboard()
|
||||
@@ -1361,6 +1949,24 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
|
||||
assert 'aria-live="polite" data-work-session-progress' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="resume-today-session"' in html
|
||||
assert 'id="end-today-session"' in html
|
||||
assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
|
||||
assert 'getLogin: () => confirmedOwnerLogin' in html
|
||||
assert 'checkpoint: sessionCheckpoint' in html
|
||||
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
|
||||
assert "qs('#resume-today-session').addEventListener('click'" in html
|
||||
assert "qs('#end-today-session').addEventListener('click'" in html
|
||||
assert "workSession.resume(item)" in html
|
||||
assert "runTodayTransition('resume')" in html
|
||||
assert "workSession.end()" in html
|
||||
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions():
|
||||
html = await dashboard()
|
||||
@@ -1370,12 +1976,142 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi
|
||||
assert "qs('#start-work-session').addEventListener('click'" in html
|
||||
assert "document.querySelectorAll('[data-work-session-previous]')" in html
|
||||
assert "document.querySelectorAll('[data-work-session-next]')" in html
|
||||
assert 'workSession.complete();' in html
|
||||
assert "runTodayTransition('complete')" in html
|
||||
assert 'workSession.reconcile();' in html
|
||||
for opener in ('openIssueSheet(item', 'openPullSheet(item', 'openReviewSheet(item', 'notificationReader.open(item'):
|
||||
assert opener in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_closing_issue_advances_active_session_once_and_exposes_close_and_next():
|
||||
html = await dashboard()
|
||||
|
||||
assert "workSession.active() ? 'Close & next' : 'Close issue'" in html
|
||||
assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
|
||||
close_handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-pull-sheet').addEventListener", 1
|
||||
)[0]
|
||||
assert "refreshMyWorkView({ reconcileSession:false });" in close_handler
|
||||
assert "workSession.active() ? await runTodayTransition('complete') : null" in close_handler
|
||||
assert close_handler.index("await issueController.close(selectedIssue)") < close_handler.index(
|
||||
"await runTodayTransition('complete')"
|
||||
)
|
||||
assert close_handler.index("lastMyWork = lastMyWork.filter") < close_handler.index(
|
||||
"await runTodayTransition('complete')"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_releasing_checkpointed_issue_completes_today_through_readiness_transition():
|
||||
html = await dashboard()
|
||||
|
||||
assert "workSession.checkpointed(item) ? 'Release & next' : 'Release assignment'" in html
|
||||
release_handler = html.split("qs('#release-issue').addEventListener('click'", 1)[1].split(
|
||||
"qs('#load-issue-handoff').addEventListener", 1
|
||||
)[0]
|
||||
assert "const continuingSession = workSession.checkpointed(releasing);" in release_handler
|
||||
assert "await completeOwnershipExitToday(releasing)" in release_handler
|
||||
assert "paintMyWork(lastContextSnapshot);" in release_handler
|
||||
assert release_handler.index("await issueController.release") < release_handler.index(
|
||||
"await completeOwnershipExitToday(releasing)"
|
||||
)
|
||||
assert release_handler.index("buildMyWork.removeIssue") < release_handler.index(
|
||||
"await completeOwnershipExitToday(releasing)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_handing_off_checkpointed_issue_completes_today_through_readiness_transition():
|
||||
html = await dashboard()
|
||||
|
||||
assert "workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff'" in html
|
||||
handoff_handler = html.split("qs('#confirm-issue-handoff').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-issue').addEventListener", 1
|
||||
)[0]
|
||||
assert "const continuingSession = workSession.checkpointed(handingOff);" in handoff_handler
|
||||
assert "await completeOwnershipExitToday(handingOff)" in handoff_handler
|
||||
assert "paintMyWork(lastContextSnapshot);" in handoff_handler
|
||||
assert handoff_handler.index("await issueController.handoff") < handoff_handler.index(
|
||||
"await completeOwnershipExitToday(handingOff)"
|
||||
)
|
||||
assert handoff_handler.index("buildMyWork.removeIssue") < handoff_handler.index(
|
||||
"await completeOwnershipExitToday(handingOff)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_next():
|
||||
html = await dashboard()
|
||||
|
||||
assert "qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';" in html
|
||||
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
||||
"qs('#keep-update-unread').addEventListener", 1
|
||||
)[0]
|
||||
assert "refreshMyWorkView({ reconcileSession:false });" in merge_handler
|
||||
assert "const continuingSession = workSession.active();" in merge_handler
|
||||
assert "workSession.active() ? await runTodayTransition('complete') : null" in merge_handler
|
||||
assert "merged. Next work item opened." in merge_handler
|
||||
assert merge_handler.index("await pullController.merge") < merge_handler.index(
|
||||
"await runTodayTransition('complete')"
|
||||
)
|
||||
assert merge_handler.index("lastMyWork = lastMyWork.filter") < merge_handler.index(
|
||||
"await runTodayTransition('complete')"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_announcement():
|
||||
html = await dashboard()
|
||||
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
||||
"qs('#keep-update-unread').addEventListener", 1
|
||||
)[0]
|
||||
|
||||
assert "else if (!continuingSession)" in merge_handler
|
||||
assert "merging.key + ' merged.'" in merge_handler
|
||||
assert "else if (continuingSession" not in merge_handler
|
||||
assert "onFinish: () =>" in html
|
||||
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_pull_merge_does_not_remove_or_advance_the_session():
|
||||
html = await dashboard()
|
||||
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
||||
"qs('#keep-update-unread').addEventListener", 1
|
||||
)[0]
|
||||
success_path, failure_path = merge_handler.split("} catch (error) {", 1)
|
||||
|
||||
assert "lastMyWork = lastMyWork.filter" in success_path
|
||||
assert "runTodayTransition('complete')" in success_path
|
||||
assert "lastMyWork = lastMyWork.filter" not in failure_path
|
||||
assert "workSession.complete()" not in failure_path
|
||||
assert "The pull request remains in My Work; refresh and retry." in failure_path
|
||||
assert "button.disabled = false;" in failure_path
|
||||
|
||||
|
||||
def test_work_session_can_start_at_a_newly_created_item():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'stackchain/dashboard', number}}));
|
||||
const opened = [];
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems: () => items,
|
||||
getFilter: () => 'all',
|
||||
getMilestone: () => '',
|
||||
onOpen: item => opened.push(item.number),
|
||||
onProgress: () => {{}},
|
||||
onFinish: () => {{}},
|
||||
}});
|
||||
const started = session.start(items[2]);
|
||||
process.stdout.write(JSON.stringify({{started, opened}}));
|
||||
"""
|
||||
|
||||
output = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
assert json.loads(output) == {"started": True, "opened": [3]}
|
||||
|
||||
|
||||
def test_my_work_filter_counts_distinguish_prs_from_review_requests():
|
||||
items = [
|
||||
{"kind": "issue", "is_review": False},
|
||||
@@ -1648,6 +2384,19 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control():
|
||||
assert '.load-more-work { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_capture_can_load_more_repositories_without_leaving_form():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="load-more-issue-repositories"' in html
|
||||
assert '>Load more repositories<' in html
|
||||
assert 'id="create-issue-repository-status" class="small" aria-live="polite"' in html
|
||||
assert 'issueCapture.loadRepositoryPage(nextIssueRepositoryPage)' in html
|
||||
assert 'lastContextSnapshot?.repository_pagination?.has_more === true' in html
|
||||
assert 'appendIssueRepositories(payload.items)' in html
|
||||
assert '.create-issue-repository-more { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||
html = await dashboard()
|
||||
@@ -1750,6 +2499,52 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||
assert output["results"][1]["number"] == 17
|
||||
|
||||
|
||||
def test_issue_capture_repository_pages_are_single_flight_and_retryable_without_touching_draft():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let calls = 0;
|
||||
let release;
|
||||
const capture = createIssueCapture({{
|
||||
storage,
|
||||
fetchJson: url => {{
|
||||
calls += 1;
|
||||
if (calls === 1) return Promise.reject(new Error('temporary'));
|
||||
return new Promise(resolve => {{ release = () => resolve({{
|
||||
items:[{{full_name:'stackchain/later'}}], page:2, total:51, has_more:false
|
||||
}}); }});
|
||||
}},
|
||||
}});
|
||||
capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}});
|
||||
capture.loadRepositoryPage(2).catch(() => {{
|
||||
const first = capture.loadRepositoryPage(2);
|
||||
const duplicate = capture.loadRepositoryPage(2);
|
||||
release();
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, results, draft:capture.loadDraft()
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == 2
|
||||
assert output["results"] == [
|
||||
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
|
||||
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
|
||||
]
|
||||
assert output["draft"] == {
|
||||
"repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3]
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
@@ -3267,7 +4062,7 @@ async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
|
||||
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html
|
||||
assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html
|
||||
assert html.index("issueCapture.needsDuplicateAcknowledgement(captureDraft)") < html.index(
|
||||
"issueOutbox.enqueueDurably(captureDraft)"
|
||||
"issueOutbox.enqueueDurably(durableDraft)"
|
||||
)
|
||||
assert ".create-issue-duplicate-card" in html
|
||||
assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html
|
||||
@@ -3506,12 +4301,45 @@ process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expand
|
||||
assert 'class="review-diff-line review-inline-target removed"' in output["html"]
|
||||
assert 'data-old-position="1"' in output["html"]
|
||||
assert 'data-new-position="1"' in output["html"]
|
||||
assert '<span class="review-line-numbers" aria-hidden="true"><span class="review-line-number old">1</span><span class="review-line-number new"></span></span>' in output["html"]
|
||||
assert '<span class="review-line-numbers" aria-hidden="true"><span class="review-line-number old"></span><span class="review-line-number new">1</span></span>' in output["html"]
|
||||
assert '<span class="review-line-code">-old <token></span>' in output["html"]
|
||||
assert '<span class="review-line-code">+new & safe</span>' in output["html"]
|
||||
assert 'aria-label="Comment on src/<api>.py line 1"' in output["html"]
|
||||
assert 'Mark reviewed' in output["html"]
|
||||
assert output["expanded"] == "true"
|
||||
assert output["hidden"] is False
|
||||
|
||||
|
||||
def test_review_wrap_preference_defaults_to_phone_layout_and_persists_explicit_choice():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}};
|
||||
const phone = reviewSheet.createWrapPreference({{storage, mobile: true}});
|
||||
const initial = phone.snapshot();
|
||||
const disabled = phone.setWrapped(false);
|
||||
const restoredPhone = reviewSheet.createWrapPreference({{storage, mobile: true}}).snapshot();
|
||||
const restoredDesktop = reviewSheet.createWrapPreference({{storage, mobile: false}}).snapshot();
|
||||
process.stdout.write(JSON.stringify({{initial, disabled, restoredPhone, restoredDesktop,
|
||||
stored: values.get('stackchain.review-wrap.v1')}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"initial": {"wrapped": True, "explicit": False},
|
||||
"disabled": {"wrapped": False, "explicit": True},
|
||||
"restoredPhone": {"wrapped": False, "explicit": True},
|
||||
"restoredDesktop": {"wrapped": False, "explicit": True},
|
||||
"stored": "false",
|
||||
}
|
||||
|
||||
|
||||
def test_review_diff_parser_maps_multi_hunk_lines_to_old_or_new_positions():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
@@ -3529,14 +4357,14 @@ process.stdout.write(JSON.stringify(rows));
|
||||
|
||||
assert json.loads(result.stdout) == [
|
||||
{"text": "@@ -10,3 +20,4 @@ function run()", "kind": "hunk", "commentable": False},
|
||||
{"text": " context", "kind": "context", "commentable": True, "new_position": 20},
|
||||
{"text": "-removed", "kind": "removed", "commentable": True, "old_position": 11},
|
||||
{"text": "+added", "kind": "added", "commentable": True, "new_position": 21},
|
||||
{"text": "+second", "kind": "added", "commentable": True, "new_position": 22},
|
||||
{"text": " context", "kind": "context", "commentable": True, "old_line": 10, "new_line": 20, "new_position": 20},
|
||||
{"text": "-removed", "kind": "removed", "commentable": True, "old_line": 11, "old_position": 11},
|
||||
{"text": "+added", "kind": "added", "commentable": True, "new_line": 21, "new_position": 21},
|
||||
{"text": "+second", "kind": "added", "commentable": True, "new_line": 22, "new_position": 22},
|
||||
{"text": "\\ No newline at end of file", "kind": "note", "commentable": False},
|
||||
{"text": "@@ -40 +51 @@", "kind": "hunk", "commentable": False},
|
||||
{"text": "-old tail", "kind": "removed", "commentable": True, "old_position": 40},
|
||||
{"text": "+new tail", "kind": "added", "commentable": True, "new_position": 51},
|
||||
{"text": "-old tail", "kind": "removed", "commentable": True, "old_line": 40, "old_position": 40},
|
||||
{"text": "+new tail", "kind": "added", "commentable": True, "new_line": 51, "new_position": 51},
|
||||
]
|
||||
|
||||
|
||||
@@ -3763,6 +4591,36 @@ async def test_review_requests_open_an_accessible_mobile_detail_sheet():
|
||||
assert '.review-diff' in html and 'overflow-x:auto' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_review_wraps_long_lines_with_gutters_and_persisted_toggle():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="review-wrap-lines"' in html
|
||||
assert 'aria-pressed="false"' in html
|
||||
assert 'aria-controls="review-files"' in html
|
||||
assert "createReviewController.createWrapPreference" in html
|
||||
assert "matchMedia('(max-width: 600px)').matches" in html
|
||||
assert "reviewFilesElement.classList.toggle('wrap-lines', snapshot.wrapped)" in html
|
||||
assert "wrapPreference.setWrapped" in html
|
||||
assert '.review-display-tools button' in html and 'min-height:44px' in html
|
||||
assert '.review-line-numbers' in html and 'grid-template-columns:4ch 4ch' in html
|
||||
assert '.review-line-code' in html and 'overflow-wrap:anywhere' in html
|
||||
assert '.wrap-lines .review-diff' in html and 'overflow-x:hidden' in html
|
||||
assert '.wrap-lines .review-diff-line' in html and 'min-width:0' in html
|
||||
assert '.wrap-lines .review-line-code' in html and 'white-space:pre-wrap' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_attention_drafts_require_revision_instead_of_resending_stale_payload():
|
||||
html = await dashboard()
|
||||
|
||||
assert "const reviewOutbox = item.outbox_kind === 'pull-review';" in html
|
||||
assert "reviewOutbox && item.status === 'attention'" in html
|
||||
assert ">Open current review</button>" in html
|
||||
assert ">Copy feedback</button>" in html
|
||||
assert "item.kind === 'authored-outbox' && !reviewOutbox" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -5,6 +5,17 @@ import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
from src.live_snapshot_store import LiveSnapshotStore
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_live_snapshot_store(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_live_snapshot_store",
|
||||
LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: main._live_snapshot_clock()),
|
||||
)
|
||||
monkeypatch.setattr(main, "_read_notification_ids", set())
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -50,6 +61,37 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
|
||||
assert marked == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_snapshot_maintenance_does_not_block_the_event_loop(monkeypatch):
|
||||
async def mark(_thread_id):
|
||||
return None
|
||||
|
||||
calls = []
|
||||
|
||||
class BlockingStore:
|
||||
def remove_notification(self, thread_id):
|
||||
calls.append([thread_id])
|
||||
time.sleep(0.08)
|
||||
|
||||
def remove_notifications(self, thread_ids):
|
||||
calls.append(list(thread_ids))
|
||||
time.sleep(0.08)
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
monkeypatch.setattr(main, "_live_snapshot_store", BlockingStore())
|
||||
monkeypatch.setattr(main, "_live_snapshot_value", None)
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
|
||||
request = asyncio.create_task(main.read_notification(42))
|
||||
await asyncio.sleep(0.01)
|
||||
heartbeat_delay = asyncio.get_running_loop().time() - started_at
|
||||
response = await request
|
||||
|
||||
assert heartbeat_delay < 0.05
|
||||
assert response.status_code == 200
|
||||
assert calls == [[42]]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bulk_mark_read_reports_partial_progress_and_retains_only_failures(monkeypatch):
|
||||
calls = []
|
||||
@@ -78,6 +120,33 @@ async def test_bulk_mark_read_reports_partial_progress_and_retains_only_failures
|
||||
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bulk_mark_read_updates_shared_snapshot_once_for_successes(monkeypatch):
|
||||
async def mark(thread_id):
|
||||
if thread_id == 43:
|
||||
raise httpx.HTTPError("upstream unavailable")
|
||||
|
||||
calls = []
|
||||
|
||||
class RecordingStore:
|
||||
def remove_notifications(self, thread_ids):
|
||||
calls.append(tuple(sorted(thread_ids)))
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
monkeypatch.setattr(main, "_live_snapshot_store", RecordingStore())
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_live_snapshot_value",
|
||||
{"notifications": [{"id": 42}, {"id": 43}, {"id": 44}]},
|
||||
)
|
||||
|
||||
response = await main.read_notifications(main.NotificationReadBatch(ids=[42, 43, 44]))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert calls == [(42, 44)]
|
||||
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bulk_mark_read_limits_upstream_concurrency(monkeypatch):
|
||||
active = 0
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
OFFLINE_CLOSE = Path(__file__).parents[1] / "frontend" / "offline-issue-close.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_offline_issue_close_waits_for_durable_admission_before_advancing_today():
|
||||
script = f"""
|
||||
const createOfflineIssueClose = require({json.dumps(str(OFFLINE_CLOSE))});
|
||||
const events=[]; let release;
|
||||
const gate=new Promise(resolve=>release=resolve);
|
||||
const close=createOfflineIssueClose({{
|
||||
enqueueDurably:async message=>{{events.push('admit');await gate;events.push('durable');return {{item:message,background:true}};}},
|
||||
completeToday:item=>{{events.push('complete:' + item.number);return true;}},
|
||||
}});
|
||||
const item={{kind:'issue',repository:'stackchain/dashboard',number:27}};
|
||||
const pending=close(item).then(result=>{{events.push('resolved');return result;}});
|
||||
Promise.resolve().then(async()=>{{
|
||||
const before=events.slice();release();const result=await pending;
|
||||
process.stdout.write(JSON.stringify({{before,events,result}}));
|
||||
}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["before"] == ["admit"]
|
||||
assert output["events"] == ["admit", "durable", "complete:27", "resolved"]
|
||||
assert output["result"]["advanced"] is True
|
||||
assert output["result"]["admission"]["background"] is True
|
||||
@@ -264,7 +264,7 @@ async def test_saved_unread_update_opens_offline_with_queued_reply_and_read_cont
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only():
|
||||
async def test_saved_requested_review_queues_complete_sha_scoped_feedback_offline():
|
||||
html = await dashboard()
|
||||
|
||||
assert "item.is_review ? reviewController.load(item)" in html
|
||||
@@ -272,6 +272,51 @@ async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only
|
||||
assert "async function openReviewSheet(item, trigger, cachedDetail = null)" in html
|
||||
assert "cachedDetail || await reviewController.load(selectedReview)" in html
|
||||
assert "Offline review · saved " in html
|
||||
assert "Reconnect to validate & submit" in html
|
||||
assert "qs('#submit-review').disabled = offlineReview;" in html
|
||||
assert "Queue review for reconnect" in html
|
||||
assert "kind: 'pull-review'" in html
|
||||
assert "await authoredOutbox.enqueueDurably" in html
|
||||
assert "expectedHeadSha: selectedReviewHead" in html
|
||||
assert "comments: snapshot.comments" in html
|
||||
assert "draftKey: draft.storageKey" in html
|
||||
assert "progressKey: progress?.storageKey" in html
|
||||
assert "draftFingerprint: localStorage.getItem(draft.storageKey) || ''" in html
|
||||
assert "progressFingerprint: localStorage.getItem(progress?.storageKey) || ''" in html
|
||||
assert "Review queued · it will submit after reconnect." in html
|
||||
assert "if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offline_today_review_queues_durably_before_completing_and_advancing():
|
||||
html = await dashboard()
|
||||
|
||||
assert "function reviewingActiveTodayItem()" in html
|
||||
assert "offlineReview && reviewingActiveTodayItem() ? 'Queue review & next'" in html
|
||||
handler = html.split("qs('#submit-review').addEventListener('click'", 1)[1].split(
|
||||
"qs('#continue-review-to-merge').addEventListener", 1
|
||||
)[0]
|
||||
admission = handler.index("await authoredOutbox.enqueueDurably")
|
||||
completion = handler.index("completeTodayItem(selectedReview")
|
||||
assert admission < completion
|
||||
assert "successMessage: 'Review queued. Next Today item opened.'" in handler
|
||||
assert "failureMessage: 'Review queued, but Today still needs completion.'" in handler
|
||||
assert "if (!advanced)" in handler
|
||||
assert "Review queued, but Today still needs completion." in handler
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offline_today_issue_queues_closure_before_completing_and_advancing():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/offline-issue-close.js"></script>' in html
|
||||
assert "const closeOfflineIssue = createOfflineIssueClose({" in html
|
||||
assert "enqueueDurably: message => authoredOutbox.enqueueDurably(message)" in html
|
||||
assert "completeToday: (item, options) => completeTodayItem(item, options)" in html
|
||||
assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
|
||||
handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-pull-sheet').addEventListener", 1
|
||||
)[0]
|
||||
admission = handler.index("await closeOfflineIssue(closing)")
|
||||
sheet_close = handler.index("closeIssueSheet()")
|
||||
assert admission < sheet_close
|
||||
assert "Issue closure queued for reconnect." in handler
|
||||
assert "The issue remains in Today; retry." in handler
|
||||
@@ -0,0 +1,297 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
PLAN_TODAY = Path(__file__).parents[1] / "frontend" / "plan-today.js"
|
||||
PLAN_TODAY_PREVIEW = Path(__file__).parents[1] / "frontend" / "plan-today-preview.js"
|
||||
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_plan_today_drafts_capacity_order_and_cancel_without_writing():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const saved = [];
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
limit: 3,
|
||||
save: ids => saved.push(ids),
|
||||
}});
|
||||
planner.open([item(1), item(2)], [item(1), item(2), item(3), item(4)]);
|
||||
const add = planner.toggle(item(3));
|
||||
const full = planner.toggle(item(4));
|
||||
const moved = planner.move('issue:stackchain/dashboard:3:', 'up');
|
||||
const snapshot = planner.snapshot();
|
||||
planner.cancel();
|
||||
process.stdout.write(JSON.stringify({{add, full, moved, snapshot, afterCancel:planner.snapshot(), saved}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"add": "added",
|
||||
"full": "full",
|
||||
"moved": True,
|
||||
"snapshot": {
|
||||
"open": True,
|
||||
"ids": [
|
||||
"issue:stackchain/dashboard:1:",
|
||||
"issue:stackchain/dashboard:3:",
|
||||
"issue:stackchain/dashboard:2:",
|
||||
],
|
||||
"count": 3,
|
||||
"limit": 3,
|
||||
},
|
||||
"afterCancel": {"open": False, "ids": [], "count": 0, "limit": 3},
|
||||
"saved": [],
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_saves_exact_draft_and_starts_first_item_only_after_success():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const calls = [];
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
save: ids => {{ calls.push(['save', ...ids]); return true; }},
|
||||
start: first => calls.push(['start', first.number]),
|
||||
}});
|
||||
planner.open([item(1)], [item(1), item(2)]);
|
||||
planner.toggle(item(2));
|
||||
const result = planner.commit({{start:true}});
|
||||
process.stdout.write(JSON.stringify({{result, calls, snapshot:planner.snapshot()}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": "saved",
|
||||
"calls": [
|
||||
["save", "issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
||||
["start", 1],
|
||||
],
|
||||
"snapshot": {"open": False, "ids": [], "count": 0, "limit": 5},
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_calculates_capacity_and_confirms_overcommitment_before_saving():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const saved = [];
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
save: plan => saved.push(plan),
|
||||
}});
|
||||
planner.open([item(1), item(2)], [item(1), item(2)]);
|
||||
planner.setCapacity(90);
|
||||
planner.setEstimate('issue:stackchain/dashboard:1:', 60);
|
||||
planner.setEstimate('issue:stackchain/dashboard:2:', 45);
|
||||
const over = planner.snapshot();
|
||||
const confirmation = planner.commit();
|
||||
const stillOpen = planner.snapshot().open;
|
||||
const result = planner.commit({{confirmOverCapacity:true}});
|
||||
process.stdout.write(JSON.stringify({{over, confirmation, stillOpen, result, saved}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["over"] == {
|
||||
"open": True,
|
||||
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
||||
"count": 2,
|
||||
"limit": 5,
|
||||
"capacity_minutes": 90,
|
||||
"estimates": {
|
||||
"issue:stackchain/dashboard:1:": 60,
|
||||
"issue:stackchain/dashboard:2:": 45,
|
||||
},
|
||||
"planned_minutes": 105,
|
||||
"remaining_minutes": -15,
|
||||
"unestimated_count": 0,
|
||||
"over_capacity": True,
|
||||
}
|
||||
assert result["confirmation"] == "confirm-over-capacity"
|
||||
assert result["stillOpen"] is True
|
||||
assert result["result"] == "saved"
|
||||
assert result["saved"] == [{
|
||||
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
||||
"capacity_minutes": 90,
|
||||
"estimates": {
|
||||
"issue:stackchain/dashboard:1:": 60,
|
||||
"issue:stackchain/dashboard:2:": 45,
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const createPlanTodayPreview = require({json.dumps(str(PLAN_TODAY_PREVIEW))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const events = [];
|
||||
let scroll = 318;
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
save: () => true,
|
||||
}});
|
||||
planner.open([item(1)], [item(1), item(2)]);
|
||||
const preview = createPlanTodayPreview({{
|
||||
planner,
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
getScroll: () => scroll,
|
||||
setScroll: value => {{ scroll = value; }},
|
||||
onOpen: value => events.push(['open', value.number]),
|
||||
onClose: (value, trigger) => events.push(['close', value.number, trigger]),
|
||||
}});
|
||||
preview.open(item(2), 'preview-2');
|
||||
scroll = 0;
|
||||
const first = preview.close({{add:true}});
|
||||
preview.open(item(2), 'preview-2');
|
||||
scroll = 0;
|
||||
const second = preview.close({{add:true}});
|
||||
process.stdout.write(JSON.stringify({{first, second, scroll, events, planner:planner.snapshot()}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"first": "added",
|
||||
"second": "already-added",
|
||||
"scroll": 318,
|
||||
"events": [
|
||||
["open", 2],
|
||||
["close", 2, "preview-2"],
|
||||
["open", 2],
|
||||
["close", 2, "preview-2"],
|
||||
],
|
||||
"planner": {
|
||||
"open": True,
|
||||
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
||||
"count": 2,
|
||||
"limit": 5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_preview_requires_explicit_override_for_blocked_or_unknown_issue():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const createPlanTodayPreview = require({json.dumps(str(PLAN_TODAY_PREVIEW))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
save: () => true,
|
||||
}});
|
||||
planner.open([], [item(2), item(3)]);
|
||||
const preview = createPlanTodayPreview({{
|
||||
planner,
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
}});
|
||||
preview.open(item(2));
|
||||
preview.setDependencies({{available:true, dependencies:[{{repository:'stackchain/api', number:9, title:'Restore API', state:'open'}}]}});
|
||||
const blockedState = preview.snapshot();
|
||||
const blocked = preview.close({{add:true}});
|
||||
const overridden = preview.close({{add:true, override:true}});
|
||||
preview.open(item(3));
|
||||
preview.setDependencies({{available:false, dependencies:[]}});
|
||||
const unavailableState = preview.snapshot();
|
||||
const unavailable = preview.close({{add:true}});
|
||||
const unavailableOverride = preview.close({{add:true, override:true}});
|
||||
process.stdout.write(JSON.stringify({{blockedState, blocked, overridden, unavailableState, unavailable, unavailableOverride, planner:planner.snapshot()}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"blockedState": {
|
||||
"open": True,
|
||||
"item": {"kind": "issue", "repository": "stackchain/dashboard", "number": 2, "title": "Issue 2"},
|
||||
"trigger": None,
|
||||
"scroll": 0,
|
||||
"dependencies_available": True,
|
||||
"dependencies": [{"repository": "stackchain/api", "number": 9, "title": "Restore API", "state": "open"}],
|
||||
"requires_override": True,
|
||||
},
|
||||
"blocked": "blocked",
|
||||
"overridden": "added",
|
||||
"unavailableState": {
|
||||
"open": True,
|
||||
"item": {"kind": "issue", "repository": "stackchain/dashboard", "number": 3, "title": "Issue 3"},
|
||||
"trigger": None,
|
||||
"scroll": 0,
|
||||
"dependencies_available": False,
|
||||
"dependencies": [],
|
||||
"requires_override": True,
|
||||
},
|
||||
"unavailable": "dependencies-unavailable",
|
||||
"unavailableOverride": "added",
|
||||
"planner": {
|
||||
"open": True,
|
||||
"ids": ["issue:stackchain/dashboard:2:", "issue:stackchain/dashboard:3:"],
|
||||
"count": 2,
|
||||
"limit": 5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/plan-today.js"></script>' in html
|
||||
assert 'id="plan-today"' in html
|
||||
assert 'id="plan-today-sheet" role="dialog"' in html
|
||||
assert 'id="plan-today-capacity"' in html
|
||||
assert 'id="plan-today-available"' in html
|
||||
assert 'inputmode="numeric" min="15" max="1440"' in html
|
||||
assert 'data-plan-estimate="' in html
|
||||
assert 'aria-label="Estimate for ' in html
|
||||
assert "formatPlanMinutes(state.planned_minutes)" in html
|
||||
assert "planToday.setCapacity" in html
|
||||
assert "planToday.setEstimate" in html
|
||||
assert "todaySync.enqueueConfiguration" in html
|
||||
assert "todayWork.runway(todayMyWork, state.index - 1)" in html
|
||||
assert "resetPlanTodayConfirmation()" in html
|
||||
assert "result === 'confirm-over-capacity'" in html
|
||||
assert 'id="save-and-start-today"' in html
|
||||
assert "const planToday = createPlanToday({" in html
|
||||
assert "todaySync.enqueue('remove'" in html
|
||||
assert "todaySync.enqueue('add'" in html
|
||||
assert "qs('#plan-today').addEventListener('click'" in html
|
||||
assert ".plan-today-actions { position:sticky; bottom:0;" in html
|
||||
assert ".plan-today-actions button { min-height:44px;" in html
|
||||
assert "padding-bottom:calc(12px + env(safe-area-inset-bottom))" in html
|
||||
assert ".plan-today-item { grid-template-columns:1fr; }" in html
|
||||
assert ".plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr);" in html
|
||||
assert ".plan-preview-actions { position:fixed; z-index:76;" in html
|
||||
assert '<script src="static/plan-today-preview.js"></script>' in html
|
||||
assert 'data-plan-preview="' in html
|
||||
assert 'id="add-plan-preview"' in html
|
||||
assert "taskOverlayHistory.open('plan-today-preview')" in html
|
||||
assert "planTodayPreview.close({ add:true })" in html
|
||||
assert 'id="issue-blockers"' in html
|
||||
assert "planTodayPreview.setDependencies({" in html
|
||||
assert "Add blocked item anyway & back" in html
|
||||
assert "Blocker status unavailable" in html
|
||||
assert ".issue-blockers { max-width:100%; overflow-x:hidden;" in html
|
||||
assert ".issue-blocker { min-width:0; overflow-wrap:anywhere;" in html
|
||||
assert "Preview unavailable offline" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_plan_today_wires_cancel_back_and_success_through_overlay_history():
|
||||
html = await dashboard()
|
||||
|
||||
assert "taskOverlayHistory.open('plan-today')" in html
|
||||
assert "previous === 'plan-today' && kind !== 'plan-today'" in html
|
||||
assert "kind === 'plan-today' && previous !== 'plan-today'" in html
|
||||
assert "openPlanToday(planTodayTrigger, false)" in html
|
||||
assert "closePlanToday(false)" in html
|
||||
assert "if (result === 'saved')" in html
|
||||
assert "taskOverlayHistory.leave();" in html
|
||||
|
||||
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -57,3 +57,12 @@ def test_readme_documents_offline_unread_update_conversations_and_boundaries():
|
||||
assert "previously opened unread update" in text
|
||||
assert "replies enter the account-bound durable outbox" in text
|
||||
assert "mark read, ownership, deferral, and older-message loading remain disabled" in text
|
||||
|
||||
|
||||
def test_readme_documents_planning_sync_retention_contract():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
assert "30 days" in text
|
||||
assert "4,096 operation receipts per account" in text
|
||||
assert "expired edit" in text
|
||||
assert "account plan is kept" in text
|
||||
@@ -112,6 +112,13 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
|
||||
== 64 * 1024
|
||||
)
|
||||
assert main.request_body_limit("GET", "/api/v1/context") is None
|
||||
assert (
|
||||
main.request_body_limit(
|
||||
"POST", "/api/v1/repos/stackchain/project/issues/17/attachments"
|
||||
)
|
||||
== 3 * 1024 * 1024
|
||||
)
|
||||
assert main.request_body_limit("POST", "/unrelated/attachments") is None
|
||||
assert main.request_body_limit("POST", "/unrelated") is None
|
||||
|
||||
|
||||
|
||||
+129
-11
@@ -13,11 +13,24 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
new Response(JSON.stringify({{expires_at: Math.floor(Date.now() / 1000) + 3600}})),
|
||||
);
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
||||
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
|
||||
match: async request => {{
|
||||
const key = String(request.url || request);
|
||||
if (storedResponses.has(key)) return storedResponses.get(key).clone();
|
||||
return state.cachedBody === null ? null : new Response(state.cachedBody);
|
||||
}},
|
||||
put: async (request, response) => {{
|
||||
const key = String(request.url || request);
|
||||
state.puts.push(key);
|
||||
storedResponses.set(key, response.clone());
|
||||
}},
|
||||
}};
|
||||
const context = {{
|
||||
URL, Request, Response, Headers, AbortController,
|
||||
@@ -34,8 +47,9 @@ const context = {{
|
||||
}},
|
||||
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
|
||||
__issueSync: {{
|
||||
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
|
||||
purge: async () => {{ state.outboxPurges += 1; }},
|
||||
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
|
||||
purge: async () => {{ state.outboxPurges += 1; state.outboxLifecycle.push('purge'); }},
|
||||
resume: async () => {{ state.backgroundResumes += 1; state.outboxLifecycle.push('resume'); }},
|
||||
getReceiptPreference: async login => state.receiptLogin === login,
|
||||
}},
|
||||
}},
|
||||
@@ -105,10 +119,34 @@ async function dispatchNotificationClick(route) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -116,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -132,21 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -199,6 +237,26 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
|
||||
]
|
||||
|
||||
|
||||
def test_idle_background_session_preserves_outbox_and_notifies_dashboard_clients():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.clientMessages=[];
|
||||
state.clientList=[{postMessage:message=>state.clientMessages.push(message)}];
|
||||
context.fetch=async()=>new Response(JSON.stringify({detail:'Authentication required',code:'session_idle'}),{status:401,headers:{'Content-Type':'application/json'}});
|
||||
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
|
||||
.then(()=>({ok:true}),error=>({status:error.status,code:error.code}));
|
||||
process.stdout.write(JSON.stringify({state,outcome}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["outcome"] == {"status": 401, "code": "session_idle"}
|
||||
assert result["state"]["outboxPurges"] == 0
|
||||
assert result["state"]["deleted"] == []
|
||||
assert result["state"]["clientMessages"] == [
|
||||
{"type": "stackchain-session-idle"}
|
||||
]
|
||||
|
||||
|
||||
def test_authenticated_page_message_resumes_queued_background_delivery():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -210,6 +268,21 @@ def test_authenticated_page_message_resumes_queued_background_delivery():
|
||||
assert result["backgroundFlushes"] == 1
|
||||
|
||||
|
||||
def test_authenticated_resume_rearms_a_previously_purged_worker_before_flushing():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const replies = [];
|
||||
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:value=>replies.push(value)}]);
|
||||
await dispatchMessage({type:'stackchain-resume-outbox'});
|
||||
process.stdout.write(JSON.stringify({state,replies}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["replies"] == [{"ok": True}]
|
||||
assert result["state"]["outboxLifecycle"] == ["purge", "resume", "flush"]
|
||||
assert result["state"]["backgroundResumes"] == 1
|
||||
|
||||
|
||||
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -309,6 +382,13 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
assert "batch: withSessionCsrf" in source
|
||||
|
||||
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -336,31 +416,45 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/outbox-coordinator.js",
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/offline-issue-close.js",
|
||||
"/dashboard/static/notification-read-outbox.js",
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/offline-today.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/card-planning.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-completion.js",
|
||||
"/dashboard/static/today-readiness.js",
|
||||
"/dashboard/static/comment-next.js",
|
||||
"/dashboard/static/plan-today.js",
|
||||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/later-sync.js",
|
||||
"/dashboard/static/later-and-start.js",
|
||||
"/dashboard/static/detail-defer.js",
|
||||
"/dashboard/static/later-picker.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/issue-attachment.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/create-and-start.js",
|
||||
"/dashboard/static/assign-and-start.js",
|
||||
"/dashboard/static/queue-today.js",
|
||||
"/dashboard/static/pull-sheet.js",
|
||||
"/dashboard/static/review-sheet.js",
|
||||
"/dashboard/static/work-route.js",
|
||||
"/dashboard/static/task-overlay-history.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/mobile-search-viewport.js",
|
||||
"/dashboard/static/mobile-composer-viewport.js",
|
||||
"/dashboard/static/mention-composer.js",
|
||||
"/dashboard/static/background-issue-sync.js",
|
||||
}
|
||||
|
||||
@@ -396,6 +490,30 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
|
||||
]
|
||||
|
||||
|
||||
def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchMessage({type:'stackchain-session-lease', expiresAt:1});
|
||||
state.failFetch = true;
|
||||
state.cachedBody = 'private cached dashboard';
|
||||
const response = await dispatch('fetch', {
|
||||
method:'GET', mode:'navigate', url:'https://forge.example/dashboard/',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({
|
||||
status: response.status,
|
||||
body: await response.text(),
|
||||
state,
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["status"] == 401
|
||||
assert result["body"] == "Your Stackchain session expired. Reconnect and sign in."
|
||||
assert result["state"]["outboxPurges"] == 1
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert "private cached dashboard" not in result["body"]
|
||||
|
||||
|
||||
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
||||
@@ -77,6 +77,43 @@ def test_expired_sessions_are_rejected_without_writing_during_validation(tmp_pat
|
||||
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_session_status_reports_idle_without_background_validation_extending_activity(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
store.activate("phone-session", 3_000)
|
||||
|
||||
now[0] = 1_899.0
|
||||
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active"
|
||||
now[0] = 1_900.0
|
||||
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
|
||||
|
||||
|
||||
def test_touch_extends_only_the_matching_live_session(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
store.activate("phone-session", 3_000)
|
||||
store.activate("laptop-session", 3_000)
|
||||
|
||||
now[0] = 1_800.0
|
||||
assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is True
|
||||
now[0] = 2_000.0
|
||||
|
||||
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active"
|
||||
assert store.status("laptop-session", 3_000, idle_timeout_seconds=900) == "idle"
|
||||
assert store.touch("missing-session", 3_000, idle_timeout_seconds=900) is False
|
||||
|
||||
|
||||
def test_touch_cannot_revive_a_session_at_the_idle_boundary(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
store.activate("phone-session", 3_000)
|
||||
|
||||
now[0] = 1_900.0
|
||||
|
||||
assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is False
|
||||
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
|
||||
|
||||
|
||||
def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate("phone-session-secret", 2_000, device_label="Pixel 9")
|
||||
@@ -121,6 +158,25 @@ def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_pa
|
||||
assert next(device for device in devices if device.current).device_label == "Existing device"
|
||||
|
||||
|
||||
def test_idle_status_migrates_existing_registry_and_starts_legacy_idle_clock_now(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
digest = SessionStore._digest("existing-session")
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE active_sessions (session_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)"
|
||||
)
|
||||
connection.execute("INSERT INTO active_sessions VALUES (?, ?)", (digest, 2_000))
|
||||
|
||||
store = SessionStore(database, clock=lambda: 1_000.0)
|
||||
|
||||
assert store.status("existing-session", 2_000, idle_timeout_seconds=900) == "active"
|
||||
with sqlite3.connect(database) as connection:
|
||||
last_active_at = connection.execute(
|
||||
"SELECT last_active_at FROM active_sessions WHERE session_hash = ?", (digest,)
|
||||
).fetchone()[0]
|
||||
assert last_active_at == 1_000
|
||||
|
||||
|
||||
def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
digest = SessionStore._digest("existing-session")
|
||||
|
||||
@@ -23,7 +23,7 @@ async def test_one_live_snapshot_updates_work_and_activity_on_one_timer():
|
||||
|
||||
assert "fetch('api/v1/live'" in html
|
||||
assert "onSnapshot: renderLiveSnapshot" in html
|
||||
assert "section + '_revision'" in html
|
||||
assert "createContextPoller.buildRevisionQuery(revisions)" in html
|
||||
assert "workChanged = contextChanged || notificationsChanged" in html
|
||||
assert "renderContextSnapshot(snapshot.context)" in html
|
||||
assert "paintEventStream(snapshot.events)" in html
|
||||
|
||||
@@ -117,6 +117,89 @@ process.stdout.write(JSON.stringify({{left, state:history.state, current:control
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_uses_one_history_layer_and_can_leave_after_save():
|
||||
script = f"""
|
||||
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
||||
const listeners = {{}};
|
||||
const changes = [];
|
||||
const stack = [{{ page:'dashboard' }}];
|
||||
let cursor = 0;
|
||||
const history = {{
|
||||
get state() {{ return stack[cursor]; }},
|
||||
pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
|
||||
replaceState(state) {{ stack[cursor] = state; }},
|
||||
back() {{ cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
|
||||
}};
|
||||
const controller = createTaskOverlayHistory({{
|
||||
history,
|
||||
eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
|
||||
onChange(kind, previous) {{ changes.push([kind, previous]); }},
|
||||
}});
|
||||
controller.start();
|
||||
const opened = controller.open('plan-today');
|
||||
const repeated = controller.open('plan-today');
|
||||
const left = controller.leave();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
opened, repeated, left, stack, cursor, current:controller.current(), changes,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": True,
|
||||
"repeated": True,
|
||||
"left": True,
|
||||
"stack": [
|
||||
{"page": "dashboard"},
|
||||
{"page": "dashboard"},
|
||||
],
|
||||
"cursor": 1,
|
||||
"current": None,
|
||||
"changes": [["plan-today", None], [None, "plan-today"]],
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_preview_is_a_nested_browser_history_layer():
|
||||
script = f"""
|
||||
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
||||
const listeners = {{}};
|
||||
const changes = [];
|
||||
const stack = [{{ page:'dashboard' }}];
|
||||
let cursor = 0;
|
||||
const history = {{
|
||||
get state() {{ return stack[cursor]; }},
|
||||
pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
|
||||
back() {{ cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
|
||||
}};
|
||||
const controller = createTaskOverlayHistory({{
|
||||
history,
|
||||
eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
|
||||
onChange(kind, previous) {{ changes.push([kind, previous]); }},
|
||||
}});
|
||||
controller.start();
|
||||
controller.open('plan-today');
|
||||
const previewed = controller.open('plan-today-preview');
|
||||
controller.close();
|
||||
process.stdout.write(JSON.stringify({{previewed, cursor, current:controller.current(), changes}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"previewed": True,
|
||||
"cursor": 1,
|
||||
"current": "plan-today",
|
||||
"changes": [
|
||||
["plan-today", None],
|
||||
["plan-today-preview", "plan-today"],
|
||||
["plan-today", "plan-today-preview"],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_routes_mobile_task_overlays_through_browser_history():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
READINESS = ROOT / "frontend" / "today-readiness.js"
|
||||
SERVICE_WORKER = ROOT / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
def test_readiness_opens_ready_target_without_inspecting_the_rest_of_the_queue():
|
||||
script = f"""
|
||||
const createTodayReadiness = require({json.dumps(str(READINESS))});
|
||||
const calls = [];
|
||||
const inspections = [];
|
||||
const item = (number, kind='issue') => ({{kind, repository:'stackchain/dashboard', number, title:'Item ' + number}});
|
||||
const readiness = createTodayReadiness({{
|
||||
inspect: async candidate => {{ inspections.push(candidate.number); return {{available:true, dependencies:[]}}; }},
|
||||
onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
|
||||
onGate: state => calls.push(['gate', state.status]),
|
||||
}});
|
||||
readiness.run('start', [item(1), item(2), item(3)]).then(result =>
|
||||
process.stdout.write(JSON.stringify({{result, calls, inspections}}))
|
||||
);
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"result": "opened",
|
||||
"calls": [["open", "start", 1]],
|
||||
"inspections": [1],
|
||||
}
|
||||
|
||||
|
||||
def test_readiness_checks_fallbacks_concurrently_but_preserves_queue_order():
|
||||
script = f"""
|
||||
const createTodayReadiness = require({json.dumps(str(READINESS))});
|
||||
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'r', number}}));
|
||||
const starts = [];
|
||||
const resolvers = {{}};
|
||||
const readiness = createTodayReadiness({{
|
||||
inspect: candidate => {{
|
||||
starts.push(candidate.number);
|
||||
if (candidate.number === 1) return Promise.resolve({{available:true, dependencies:[{{state:'open'}}]}});
|
||||
return new Promise(resolve => {{ resolvers[candidate.number] = resolve; }});
|
||||
}},
|
||||
onOpen: () => undefined,
|
||||
onGate: state => {{ globalThis.gated = state.nextReady.number; }},
|
||||
}});
|
||||
(async () => {{
|
||||
const running = readiness.run('start', items);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const startedTogether = starts.slice();
|
||||
if (resolvers[3]) resolvers[3]({{available:true, dependencies:[]}});
|
||||
resolvers[2]({{available:true, dependencies:[]}});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
if (!startedTogether.includes(3)) resolvers[3]({{available:true, dependencies:[]}});
|
||||
const result = await running;
|
||||
process.stdout.write(JSON.stringify({{result, startedTogether, gated:globalThis.gated}}));
|
||||
}})();
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"result": "gated",
|
||||
"startedTogether": [1, 2, 3],
|
||||
"gated": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_readiness_gates_blocked_target_and_offers_earliest_ready_without_reordering():
|
||||
script = f"""
|
||||
const createTodayReadiness = require({json.dumps(str(READINESS))});
|
||||
const calls = [];
|
||||
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'r', number, title:'Issue ' + number}}));
|
||||
const readiness = createTodayReadiness({{
|
||||
inspect: async candidate => candidate.number === 1
|
||||
? {{available:true, dependencies:[{{repository:'r', number:99, title:'Prerequisite', state:'open', url:'https://forge.example/r/issues/99'}}]}}
|
||||
: {{available:true, dependencies:[]}},
|
||||
onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
|
||||
onGate: state => calls.push(['gate', state.status, state.target.number, state.nextReady.number, state.items.map(item => item.number)]),
|
||||
}});
|
||||
readiness.run('resume', items, items[0]).then(async result => {{
|
||||
const opened = readiness.startNextReady();
|
||||
process.stdout.write(JSON.stringify({{result, opened, calls, order:items.map(item => item.number)}}));
|
||||
}});
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"result": "gated",
|
||||
"opened": True,
|
||||
"calls": [
|
||||
["gate", "blocked", 1, 2, [1, 2, 3]],
|
||||
["open", "resume", 2],
|
||||
],
|
||||
"order": [1, 2, 3],
|
||||
}
|
||||
|
||||
|
||||
def test_readiness_previews_only_a_current_blocker_without_closing_or_reordering_gate():
|
||||
script = f"""
|
||||
const createTodayReadiness = require({json.dumps(str(READINESS))});
|
||||
const previews = [];
|
||||
const items = [1, 2].map(number => ({{kind:'issue', repository:'r', number}}));
|
||||
const blocker = {{repository:'r', number:99, title:'Prerequisite', state:'open'}};
|
||||
const readiness = createTodayReadiness({{
|
||||
inspect: async candidate => candidate.number === 1
|
||||
? {{available:true, dependencies:[blocker]}}
|
||||
: {{available:true, dependencies:[]}},
|
||||
onPreview: candidate => previews.push(candidate.number),
|
||||
}});
|
||||
(async () => {{
|
||||
await readiness.run('start', items, items[0]);
|
||||
const accepted = readiness.previewBlocker(blocker);
|
||||
const rejected = readiness.previewBlocker({{repository:'r', number:100}});
|
||||
const snapshot = readiness.snapshot();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
accepted, rejected, previews,
|
||||
open:snapshot.status === 'blocked',
|
||||
target:snapshot.target.number,
|
||||
order:snapshot.items.map(item => item.number),
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"accepted": True,
|
||||
"rejected": False,
|
||||
"previews": [99],
|
||||
"open": True,
|
||||
"target": 1,
|
||||
"order": [1, 2],
|
||||
}
|
||||
|
||||
|
||||
def test_readiness_never_calls_unknown_ready_and_supports_retry_or_explicit_override():
|
||||
script = f"""
|
||||
const createTodayReadiness = require({json.dumps(str(READINESS))});
|
||||
const calls = [];
|
||||
const target = {{kind:'issue', repository:'r', number:1}};
|
||||
let available = false;
|
||||
const readiness = createTodayReadiness({{
|
||||
inspect: async () => available ? {{available:true, dependencies:[]}} : {{available:false, dependencies:[]}},
|
||||
onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
|
||||
onGate: state => calls.push(['gate', state.status]),
|
||||
}});
|
||||
readiness.run('next', [target], target).then(async result => {{
|
||||
available = true;
|
||||
const retried = await readiness.retry();
|
||||
available = false;
|
||||
await readiness.run('continue', [target], target);
|
||||
const overridden = readiness.workAnyway();
|
||||
process.stdout.write(JSON.stringify({{result, retried, overridden, calls}}));
|
||||
}});
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"result": "gated",
|
||||
"retried": "opened",
|
||||
"overridden": True,
|
||||
"calls": [
|
||||
["gate", "unknown"],
|
||||
["open", "next", 1],
|
||||
["gate", "unknown"],
|
||||
["open", "continue", 1],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_readiness_treats_non_issue_work_as_runnable_without_dependency_io():
|
||||
script = f"""
|
||||
const createTodayReadiness = require({json.dumps(str(READINESS))});
|
||||
let inspections = 0;
|
||||
const pull = {{kind:'pull', repository:'r', number:7}};
|
||||
const readiness = createTodayReadiness({{
|
||||
inspect: async () => {{ inspections += 1; throw new Error('not expected'); }},
|
||||
onOpen: () => undefined,
|
||||
onGate: () => undefined,
|
||||
}});
|
||||
readiness.run('start', [pull]).then(result =>
|
||||
process.stdout.write(JSON.stringify({{result, inspections}}))
|
||||
);
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {"result": "opened", "inspections": 0}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_exposes_mobile_today_readiness_gate_and_runtime():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/today-readiness.js"></script>' in html
|
||||
assert 'id="today-readiness-sheet"' in html
|
||||
assert 'id="today-readiness-next"' in html
|
||||
assert 'id="today-readiness-anyway"' in html
|
||||
assert 'id="today-readiness-retry"' in html
|
||||
assert 'id="today-readiness-plan"' in html
|
||||
assert "createTodayReadiness({" in html
|
||||
assert "runTodayTransition('start'" in html
|
||||
assert "runTodayTransition('resume'" in html
|
||||
assert "runTodayTransition('continue'" in html
|
||||
assert "runTodayTransition('next'" in html
|
||||
assert "runTodayTransition('complete'" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'data-today-blocker-index="' in html
|
||||
assert "todayReadiness.previewBlocker(blocker)" in html
|
||||
assert "searchPreview.open({ ...blocker, kind:'issue' })" in html
|
||||
assert "taskOverlayHistory.open('search-preview')" in html
|
||||
assert "suspendTodayReadiness()" in html
|
||||
assert "todayReadiness.cancel()" in html
|
||||
assert "todayReadinessBlockerFocus" in html
|
||||
|
||||
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v81';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
def test_mobile_readiness_gate_has_touch_safe_wrapping_actions():
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
assert ".today-readiness-actions button" in css
|
||||
assert "min-height:44px" in css
|
||||
assert ".today-readiness-item" in css
|
||||
assert "overflow-wrap:anywhere" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert ".today-readiness-blocker" in css
|
||||
assert "min-height:44px" in css
|
||||
+187
-2
@@ -36,6 +36,8 @@ def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
|
||||
assert store.apply("timmy", "op-1", "add", "issue:stackchain/dashboard:1:") == {
|
||||
"revision": 1,
|
||||
"ids": ["issue:stackchain/dashboard:1:"],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
}
|
||||
store.apply("timmy", "op-2", "add", "issue:stackchain/dashboard:2:")
|
||||
store.apply("timmy", "op-3", "add", "issue:stackchain/dashboard:3:")
|
||||
@@ -53,9 +55,49 @@ def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
|
||||
"issue:stackchain/dashboard:3:",
|
||||
"issue:stackchain/dashboard:2:",
|
||||
],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
}
|
||||
assert TodayStore(path, limit=3).get("timmy") == moved
|
||||
assert store.get("alexander") == {"revision": 0, "ids": []}
|
||||
assert store.get("alexander") == {
|
||||
"revision": 0,
|
||||
"ids": [],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
}
|
||||
|
||||
|
||||
def test_capacity_and_estimates_are_durable_account_scoped_and_follow_item_identity(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, limit=3)
|
||||
store.apply("timmy", "add-1", "add", "issue:r:1:")
|
||||
store.apply("timmy", "add-2", "add", "issue:r:2:")
|
||||
|
||||
configured = store.apply_batch("timmy", [{
|
||||
"operation_id": "capacity-1",
|
||||
"action": "configure",
|
||||
"item_id": "plan",
|
||||
"capacity_minutes": 180,
|
||||
"estimates": {"issue:r:1:": 60, "issue:r:2:": 45, "issue:r:99:": 30},
|
||||
"base_revision": 2,
|
||||
}])
|
||||
store.apply("timmy", "move-2", "move", "issue:r:2:", direction="up")
|
||||
store.apply("timmy", "remove-1", "remove", "issue:r:1:")
|
||||
|
||||
assert configured["capacity_minutes"] == 180
|
||||
assert configured["estimates"] == {"issue:r:1:": 60, "issue:r:2:": 45}
|
||||
assert TodayStore(path, limit=3).get("timmy") == {
|
||||
"revision": 5,
|
||||
"ids": ["issue:r:2:"],
|
||||
"capacity_minutes": 180,
|
||||
"estimates": {"issue:r:2:": 45},
|
||||
}
|
||||
assert store.get("alexander") == {
|
||||
"revision": 0,
|
||||
"ids": [],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
}
|
||||
|
||||
|
||||
def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
|
||||
@@ -69,6 +111,8 @@ def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
|
||||
assert store.get("timmy") == {
|
||||
"revision": 2,
|
||||
"ids": ["issue:r:1:", "issue:r:2:"],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
}
|
||||
store.apply("timmy", "remove", "remove", "issue:r:1:")
|
||||
assert store.apply("timmy", "retry-three", "add", "issue:r:3:")["ids"] == [
|
||||
@@ -94,6 +138,8 @@ def test_batch_applies_ordered_operations_once_and_rejects_only_capacity_conflic
|
||||
assert result == {
|
||||
"revision": 4,
|
||||
"ids": ["issue:r:2:", "issue:r:3:"],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
"accepted_operation_ids": ["one", "two", "remove", "three"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [{"operation_id": "full", "reason": "today_full"}],
|
||||
@@ -107,6 +153,138 @@ def test_batch_applies_ordered_operations_once_and_rejects_only_capacity_conflic
|
||||
assert replay["duplicate_operation_ids"] == ["two", "three"]
|
||||
|
||||
|
||||
def test_operation_receipts_are_bounded_per_account_and_existing_schema_migrates(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE today_operations (login TEXT NOT NULL, operation_id TEXT NOT NULL, "
|
||||
"PRIMARY KEY (login, operation_id))"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_operations(login, operation_id) VALUES ('timmy', 'legacy')"
|
||||
)
|
||||
|
||||
store = TodayStore(path, limit=20, operation_limit=3, clock=lambda: 1_000)
|
||||
for index in range(5):
|
||||
store.apply("timmy", f"op-{index}", "add", f"issue:r:{index}:")
|
||||
store.apply("alexander", "other", "add", "issue:r:99:")
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")}
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM today_operations WHERE login = 'timmy' ORDER BY rowid"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM today_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert "created_at" in columns
|
||||
assert timmy == [("op-2",), ("op-3",), ("op-4",)]
|
||||
assert alexander == [("other",)]
|
||||
|
||||
|
||||
def test_pruned_stale_today_operation_cannot_reorder_a_newer_plan(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", limit=5, operation_limit=1)
|
||||
store.apply_batch("timmy", [
|
||||
{"operation_id": "seed-1", "action": "add", "item_id": "issue:r:1:", "base_revision": 0},
|
||||
{"operation_id": "seed-2", "action": "add", "item_id": "issue:r:2:", "base_revision": 0},
|
||||
])
|
||||
store.apply_batch("timmy", [{
|
||||
"operation_id": "offline-move", "action": "move", "item_id": "issue:r:2:",
|
||||
"direction": "up", "base_revision": 2,
|
||||
}])
|
||||
newer = store.apply_batch("timmy", [{
|
||||
"operation_id": "newer-move", "action": "move", "item_id": "issue:r:1:",
|
||||
"direction": "up", "base_revision": 3,
|
||||
}])
|
||||
|
||||
replay = store.apply_batch("timmy", [{
|
||||
"operation_id": "offline-move", "action": "move", "item_id": "issue:r:2:",
|
||||
"direction": "up", "base_revision": 2,
|
||||
}])
|
||||
|
||||
assert replay["ids"] == newer["ids"] == ["issue:r:1:", "issue:r:2:"]
|
||||
assert replay["revision"] == 4
|
||||
assert replay["accepted_operation_ids"] == []
|
||||
assert replay["rejected_operations"] == [
|
||||
{"operation_id": "offline-move", "reason": "stale_intent"}
|
||||
]
|
||||
|
||||
|
||||
def test_today_receipt_age_pruning_is_account_scoped(tmp_path):
|
||||
clock = [1_000.0]
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(
|
||||
path, limit=5, operation_retention_seconds=60, clock=lambda: clock[0]
|
||||
)
|
||||
store.apply("timmy", "old", "add", "issue:r:1:")
|
||||
store.apply("alexander", "other-old", "add", "issue:r:2:")
|
||||
clock[0] += 61
|
||||
store.apply("timmy", "fresh", "remove", "issue:r:1:")
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM today_operations WHERE login = 'timmy'"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM today_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert timmy == [("fresh",)]
|
||||
assert alexander == [("other-old",)]
|
||||
|
||||
|
||||
def test_today_api_model_preserves_client_base_revision():
|
||||
operation = main.TodayOperation(
|
||||
operation_id="offline", action="move", item_id="issue:r:2:",
|
||||
direction="up", base_revision=7,
|
||||
)
|
||||
|
||||
assert operation.model_dump()["base_revision"] == 7
|
||||
|
||||
|
||||
def test_today_api_model_accepts_bounded_capacity_configuration():
|
||||
operation = main.TodayOperation(
|
||||
operation_id="capacity", action="configure", item_id="plan",
|
||||
capacity_minutes=180,
|
||||
estimates={"issue:r:1:": 60, "issue:r:2:": 45},
|
||||
base_revision=2,
|
||||
)
|
||||
|
||||
assert operation.model_dump() == {
|
||||
"operation_id": "capacity",
|
||||
"action": "configure",
|
||||
"item_id": "plan",
|
||||
"direction": None,
|
||||
"base_revision": 2,
|
||||
"capacity_minutes": 180,
|
||||
"estimates": {"issue:r:1:": 60, "issue:r:2:": 45},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_single_capacity_configuration_uses_atomic_batch_path(monkeypatch):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
calls = []
|
||||
class Store:
|
||||
def apply_batch(self, login, operations):
|
||||
calls.append((login, operations))
|
||||
return {"revision": 1, "ids": [], "capacity_minutes": 180, "estimates": {}}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: Store())
|
||||
payload = main.TodayOperation(
|
||||
operation_id="capacity", action="configure", item_id="plan", capacity_minutes=180,
|
||||
)
|
||||
|
||||
result = await main.update_today_plan(payload)
|
||||
|
||||
assert result["capacity_minutes"] == 180
|
||||
assert calls == [("timmy", [payload.model_dump()])]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
@@ -156,9 +334,16 @@ async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypat
|
||||
assert changed.json() == {
|
||||
"revision": 2,
|
||||
"ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
"accepted_operation_ids": ["mobile-1", "mobile-2"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [],
|
||||
}
|
||||
assert fetched.json() == {"revision": 2, "ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"]}
|
||||
assert fetched.json() == {
|
||||
"revision": 2,
|
||||
"ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
}
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
@@ -74,8 +74,8 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
assert result == {
|
||||
"sharesInflight": True,
|
||||
"patches": [
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
|
||||
{"operation_id": "op-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "issue:r:1:", "direction": None, "base_revision": 0},
|
||||
{"operation_id": "op-2", "action": "add", "item_id": "issue:r:2:", "direction": None, "base_revision": 0},
|
||||
],
|
||||
"pending": [],
|
||||
"ids": ["issue:r:1:", "issue:r:2:"],
|
||||
@@ -83,10 +83,51 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
}
|
||||
|
||||
|
||||
def test_today_capacity_configuration_syncs_offline_and_adopts_remote_plan():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map(); let delivered; let adopted; let broadcast;
|
||||
const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync = createTodaySync({{
|
||||
storage, getLogin:()=> 'timmy', createOperationId:()=> 'capacity-op',
|
||||
createChannel:()=>({{addEventListener() {{}},postMessage:plan=>{{broadcast=plan}},close() {{}}}}),
|
||||
fetchJson:async(_url, options={{}})=>{{
|
||||
if (!options.method) return {{revision:2,ids:['issue:r:1:'],capacity_minutes:null,estimates:{{}}}};
|
||||
delivered=JSON.parse(options.body).operations[0];
|
||||
return {{revision:3,ids:['issue:r:1:'],capacity_minutes:120,estimates:{{'issue:r:1:':60}},accepted_operation_ids:['capacity-op'],duplicate_operation_ids:[],rejected_operations:[]}};
|
||||
}},
|
||||
onRemotePlan:plan=>{{adopted=plan}}, onRemoteIds:()=>{{}}, onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{delivered,adopted,broadcast,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
assert json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout) == {
|
||||
"delivered": {
|
||||
"operation_id": "capacity-op", "action": "configure", "item_id": "plan",
|
||||
"direction": None, "capacity_minutes": 120,
|
||||
"estimates": {"issue:r:1:": 60}, "base_revision": 0,
|
||||
},
|
||||
"adopted": {
|
||||
"revision": 3, "ids": ["issue:r:1:"], "capacity_minutes": 120,
|
||||
"estimates": {"issue:r:1:": 60},
|
||||
"accepted_operation_ids": ["capacity-op"],
|
||||
"duplicate_operation_ids": [], "rejected_operations": [],
|
||||
},
|
||||
"broadcast": {
|
||||
"revision": 3, "ids": ["issue:r:1:"], "capacity_minutes": 120,
|
||||
"estimates": {"issue:r:1:": 60},
|
||||
},
|
||||
"pending": [],
|
||||
}
|
||||
|
||||
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
@@ -137,12 +178,41 @@ sync.enqueue('add', 'issue:r:2:');
|
||||
"action": "add",
|
||||
"item_id": "issue:r:2:",
|
||||
"direction": None,
|
||||
"base_revision": 0,
|
||||
}]}
|
||||
assert result["adopted"] == ["issue:r:9:", "issue:r:2:"]
|
||||
assert result["status"] == "saved"
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_today_operations_capture_server_revision_and_expire_before_replay():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map(); const statuses=[]; const requests=[];
|
||||
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync=createTodaySync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'old-op',
|
||||
now:()=>1_800_000_000_000,maxOfflineMs:1000,
|
||||
fetchJson:async(_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:7,ids:['server']}}}},
|
||||
onRemoteIds:()=>{{}},onStatus:(state,detail={{}})=>statuses.push([state,detail.count||0])}});
|
||||
(async()=>{{await sync.flush();sync.enqueue('move','issue:r:2:','up');
|
||||
const pendingBefore=sync.pending();
|
||||
const key=[...values.keys()].find(value=>value.includes('.operation.'));
|
||||
const record=JSON.parse(values.get(key));record.queued_at=1_799_999_000_000;values.set(key,JSON.stringify(record));
|
||||
await sync.flush();process.stdout.write(JSON.stringify({{pendingBefore,requests,statuses,pendingAfter:sync.pending()}}));}})();
|
||||
"""
|
||||
result = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
|
||||
|
||||
assert result["pendingBefore"] == [{
|
||||
"operation_id": "old-op", "action": "move", "item_id": "issue:r:2:",
|
||||
"direction": "up", "base_revision": 7,
|
||||
}]
|
||||
assert result["requests"] == ["GET", "GET"]
|
||||
assert ["expired", 1] in result["statuses"]
|
||||
assert result["statuses"][-1] == ["expired", 1]
|
||||
assert result["pendingAfter"] == []
|
||||
|
||||
|
||||
def test_pending_today_edits_are_sent_as_one_batch_without_a_preflight_get():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
@@ -184,6 +254,7 @@ sync.enqueue('move', 'issue:r:2:', 'up');
|
||||
"action": "move",
|
||||
"item_id": "issue:r:2:",
|
||||
"direction": "up",
|
||||
"base_revision": 0,
|
||||
}],
|
||||
"status": "retrying",
|
||||
}
|
||||
@@ -221,7 +292,7 @@ sync.enqueue('add','issue:r:2:');
|
||||
assert result["scheduled"] == [2000]
|
||||
assert result["attempts"] == 2
|
||||
assert result["patches"] == [{
|
||||
"operation_id": "stable-op", "action": "add", "item_id": "issue:r:2:", "direction": None,
|
||||
"operation_id": "stable-op", "action": "add", "item_id": "issue:r:2:", "direction": None, "base_revision": 0,
|
||||
}]
|
||||
assert result["pending"] == []
|
||||
assert ["retrying", 2000] in result["statuses"]
|
||||
@@ -254,6 +325,7 @@ process.stdout.write(JSON.stringify({{first, duplicate, pending:sync.pending(),
|
||||
"action": "remove",
|
||||
"item_id": "issue:r:1:",
|
||||
"direction": None,
|
||||
"base_revision": 0,
|
||||
}],
|
||||
"statuses": ["pending", "pending"],
|
||||
}
|
||||
@@ -280,8 +352,8 @@ process.stdout.write(JSON.stringify({{first, second, pending:sync.pending()}}));
|
||||
"first": True,
|
||||
"second": False,
|
||||
"pending": [
|
||||
{"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
|
||||
{"operation_id": "migration-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
|
||||
{"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "direction": None, "base_revision": 0},
|
||||
{"operation_id": "migration-2", "action": "add", "item_id": "issue:r:2:", "direction": None, "base_revision": 0},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -346,8 +418,8 @@ sync.enqueue('remove', '5');
|
||||
assert result == {
|
||||
"result": False,
|
||||
"patches": [
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "6", "direction": None},
|
||||
{"operation_id": "op-2", "action": "remove", "item_id": "5", "direction": None},
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "6", "direction": None, "base_revision": 0},
|
||||
{"operation_id": "op-2", "action": "remove", "item_id": "5", "direction": None, "base_revision": 0},
|
||||
],
|
||||
"pending": [],
|
||||
"ids": ["1", "2", "3", "4"],
|
||||
|
||||
+155
-1
@@ -8,6 +8,8 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
||||
TODAY_COMPLETION = Path(__file__).parents[1] / "frontend" / "today-completion.js"
|
||||
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
@@ -62,6 +64,45 @@ process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted,
|
||||
}
|
||||
|
||||
|
||||
def test_today_capacity_metadata_is_account_scoped_and_prunes_removed_estimates():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
let login = 'timmy';
|
||||
const queue = createTodayWork({{storage, getLogin: () => login}});
|
||||
queue.replace(['issue:r:1:', 'issue:r:2:']);
|
||||
const saved = queue.replacePlanning({{capacity_minutes:120,estimates:{{'issue:r:1:':60,'issue:r:2:':45}}}});
|
||||
queue.replace(['issue:r:2:']);
|
||||
const timmy = queue.planning();
|
||||
login = 'alexander';
|
||||
const alexander = queue.planning();
|
||||
process.stdout.write(JSON.stringify({{saved,timmy,alexander}}));
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"saved": True,
|
||||
"timmy": {"capacity_minutes": 120, "estimates": {"issue:r:2:": 45}},
|
||||
"alexander": {"capacity_minutes": None, "estimates": {}},
|
||||
}
|
||||
|
||||
|
||||
def test_today_estimated_runway_follows_current_session_position():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const queue = createTodayWork({{storage, getLogin:()=> 'timmy'}});
|
||||
const item = number => ({{kind:'issue',repository:'r',number}});
|
||||
queue.replace(['issue:r:1:','issue:r:2:','issue:r:3:']);
|
||||
queue.replacePlanning({{capacity_minutes:180,estimates:{{'issue:r:1:':30,'issue:r:2:':60,'issue:r:3:':45}}}});
|
||||
process.stdout.write(JSON.stringify({{first:queue.runway([item(1),item(2),item(3)],0),second:queue.runway([item(1),item(2),item(3)],1)}}));
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"first": {"current_minutes": 30, "remaining_minutes": 135},
|
||||
"second": {"current_minutes": 60, "remaining_minutes": 105},
|
||||
}
|
||||
|
||||
|
||||
def test_today_queue_exposes_reorder_boundaries_for_touch_controls():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
@@ -87,6 +128,102 @@ process.stdout.write(JSON.stringify([
|
||||
]
|
||||
|
||||
|
||||
def test_done_for_today_removes_current_plan_item_syncs_and_advances_without_gitea_mutation():
|
||||
script = f"""
|
||||
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
||||
const calls = [];
|
||||
const current = {{kind:'issue', repository:'stackchain/dashboard', number:421}};
|
||||
const complete = createTodayCompletion({{
|
||||
todayWork: {{
|
||||
identity: item => `issue:${{item.repository}}:${{item.number}}:`,
|
||||
remove: item => {{ calls.push(`remove:${{item.number}}`); return true; }},
|
||||
}},
|
||||
todaySync: {{
|
||||
enqueue: (action, identity) => {{ calls.push(`sync:${{action}}:${{identity}}`); return true; }},
|
||||
flush: () => calls.push('flush'),
|
||||
}},
|
||||
refresh: () => calls.push('refresh'),
|
||||
warm: () => calls.push('warm'),
|
||||
workSession: {{ complete: () => calls.push('advance') }},
|
||||
announce: message => calls.push(`announce:${{message}}`),
|
||||
}});
|
||||
const completed = complete(current);
|
||||
process.stdout.write(JSON.stringify({{completed, calls}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"completed": True,
|
||||
"calls": [
|
||||
"remove:421",
|
||||
"sync:remove:issue:stackchain/dashboard:421:",
|
||||
"flush",
|
||||
"refresh",
|
||||
"warm",
|
||||
"advance",
|
||||
"announce:Done for Today. The Gitea item is unchanged.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_done_for_today_does_not_advance_when_local_plan_persistence_fails():
|
||||
script = f"""
|
||||
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
||||
const calls = [];
|
||||
const complete = createTodayCompletion({{
|
||||
todayWork: {{identity: () => 'issue:r:1:', remove: () => false}},
|
||||
todaySync: {{enqueue: () => calls.push('sync'), flush: () => calls.push('flush')}},
|
||||
refresh: () => calls.push('refresh'),
|
||||
warm: () => calls.push('warm'),
|
||||
workSession: {{complete: () => calls.push('advance')}},
|
||||
announce: message => calls.push(message),
|
||||
}});
|
||||
const completed = complete({{kind:'issue', repository:'r', number:1}});
|
||||
process.stdout.write(JSON.stringify({{completed, calls}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"completed": False,
|
||||
"calls": ["Could not update Today on this device. Try again."],
|
||||
}
|
||||
|
||||
|
||||
def test_today_completion_reports_context_specific_success_and_partial_failure():
|
||||
script = f"""
|
||||
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
||||
const messages = [];
|
||||
let removable = false;
|
||||
const complete = createTodayCompletion({{
|
||||
todayWork: {{identity: () => 'review:r:7:', remove: () => removable}},
|
||||
todaySync: {{enqueue: () => true, flush: () => undefined}},
|
||||
refresh: () => undefined,
|
||||
warm: () => undefined,
|
||||
workSession: {{complete: () => undefined}},
|
||||
announce: message => messages.push(message),
|
||||
}});
|
||||
const options = {{
|
||||
successMessage: 'Review queued. Next Today item opened.',
|
||||
failureMessage: 'Review queued, but Today still needs completion.',
|
||||
}};
|
||||
const failed = complete({{kind:'review', repository:'r', number:7}}, options);
|
||||
removable = true;
|
||||
const completed = complete({{kind:'review', repository:'r', number:7}}, options);
|
||||
process.stdout.write(JSON.stringify({{failed, completed, messages}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"failed": False,
|
||||
"completed": True,
|
||||
"messages": [
|
||||
"Review queued, but Today still needs completion.",
|
||||
"Review queued. Next Today item opened.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_done_for_today_logic_is_available_in_the_offline_app_shell():
|
||||
assert "BASE + 'static/today-completion.js'" in SERVICE_WORKER.read_text()
|
||||
|
||||
|
||||
def test_authoritative_reconciliation_reports_retired_ids_before_local_prune():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
@@ -161,7 +298,8 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||
assert 'data-work-filter="today"' in html
|
||||
assert 'data-work-count="today"' in html
|
||||
assert "const todayWork = createTodayWork({" in html
|
||||
assert "const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');" in html
|
||||
assert "getTodayCount: () => todayMyWork.length" in html
|
||||
assert "startToday: startTodaySession" in html
|
||||
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
|
||||
assert 'data-today-add' in html
|
||||
assert 'data-today-remove' in html
|
||||
@@ -182,6 +320,22 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||
assert "Another device filled Today · showing its saved plan." in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_today_session_sheets_offer_a_touch_safe_plan_only_completion_action():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/today-completion.js"></script>' in html
|
||||
assert html.count('data-work-session-complete=') == 4
|
||||
assert html.count('Done for Today & next') == 4
|
||||
assert "button.hidden = !workSession.checkpointed();" in html
|
||||
assert "const completeTodayItem = createTodayCompletion({" in html
|
||||
assert "refresh: () => refreshMyWorkView({ reconcileSession:false })," in html
|
||||
assert "document.querySelectorAll('[data-work-session-complete]')" in html
|
||||
assert "completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))" in html
|
||||
assert ".work-session-nav button { min-height:44px; width:100%; }" in html
|
||||
assert "grid-template-columns:repeat(3,minmax(0,1fr))" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_activates_today_lifecycle_convergence():
|
||||
html = await dashboard()
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from src.views import FRONTEND_BUILD, dashboard
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -28,7 +28,7 @@ async def test_dashboard_bootstrap_uses_csp_compatible_static_assets():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<link rel="stylesheet" href="static/dashboard.css" />' in html
|
||||
assert '<script src="static/dashboard.js"></script>' in html
|
||||
assert f'<script src="{FRONTEND_BUILD.runtime_name}"></script>' in html
|
||||
assert "<style>" not in html
|
||||
assert "<script>" not in html
|
||||
|
||||
@@ -42,7 +42,7 @@ async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
|
||||
assert 'aria-labelledby="search-preview-title"' in html
|
||||
assert 'id="claim-search-result"' in html
|
||||
assert 'id="open-search-result-gitea"' in html
|
||||
assert 'src="static/search-preview.js"' in html
|
||||
assert "static/search-preview.js" in FRONTEND_BUILD.page_sources
|
||||
assert ".search-preview-panel" in css
|
||||
assert "height:100dvh" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
Reference in new issue
Block a user