Compare commits
263
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19122c69e6 | ||
|
|
2253c40577 | ||
|
|
5c2c0383df | ||
|
|
4e13f5482b | ||
|
|
51278bc86f | ||
|
|
a919242b8f | ||
|
|
56a9a0cba6 | ||
|
|
27fc74ced6 | ||
|
|
f686e2e71e | ||
|
|
8f7a836d28 | ||
|
|
5894eafb3f | ||
|
|
639ce911be | ||
|
|
475acbb92f | ||
|
|
c0fc4eddd0 | ||
|
|
1bf759dc76 | ||
|
|
9441191c18 | ||
|
|
76bea83537 | ||
|
|
7775c3e1f6 | ||
|
|
98249ad9ed | ||
|
|
901691a93b | ||
|
|
6dfc02158d | ||
|
|
454022ee89 | ||
|
|
4bc2881a4b | ||
|
|
3c743a2b71 | ||
|
|
7ec3980713 | ||
|
|
d940e0b37b | ||
|
|
4610afd489 | ||
|
|
020106f001 | ||
|
|
3384e56cb8 | ||
|
|
229d112297 | ||
|
|
34029e624f | ||
|
|
0ff6b5c8fd | ||
|
|
391e8964c4 | ||
|
|
990f0f04d6 | ||
|
|
a995e3b45f | ||
|
|
ac3d1f32d1 | ||
|
|
e11e55149c | ||
|
|
ae6cde29c2 | ||
|
|
0539b0a035 | ||
|
|
a2ba624dc2 | ||
|
|
e78ceb1bf7 | ||
|
|
ac7a716e5c | ||
|
|
fa4ad28961 | ||
|
|
3726360baa | ||
|
|
5d326c30a1 | ||
|
|
2cb92811c9 | ||
|
|
9e68382bd7 | ||
|
|
b4dc785dd8 | ||
|
|
a9051d843c | ||
|
|
4969cadd82 | ||
|
|
bf96560831 | ||
|
|
7342b2e6de | ||
|
|
671c88d287 | ||
|
|
503d6f4988 | ||
|
|
eb2c84a6c2 | ||
|
|
fb6b42bcaf | ||
|
|
41eaf51953 | ||
|
|
c9c5939f0a | ||
|
|
34af4ce903 | ||
|
|
7034ec55ed | ||
|
|
d46d296e09 | ||
|
|
21e0a49d3c | ||
|
|
160753caf8 | ||
|
|
0ea264a942 | ||
|
|
d6c7a495ed | ||
|
|
dc18004e02 | ||
|
|
b5858046ee | ||
|
|
b5b6b4d0a9 | ||
|
|
6143568b95 | ||
|
|
9a152c435e | ||
|
|
80a1fa60bd | ||
|
|
8ba4d48549 | ||
|
|
c0b42b71cf | ||
|
|
d026bbd400 | ||
|
|
9189d13db9 | ||
|
|
9786724678 | ||
|
|
6b99d4f171 | ||
|
|
7d633e67e8 | ||
|
|
bd5e66b4e5 | ||
|
|
add45365ab | ||
|
|
6138b1f24e | ||
|
|
40c66df143 | ||
|
|
7356530318 | ||
|
|
9f6476a5b4 | ||
|
|
de8227f4f4 | ||
|
|
ccf5f69400 | ||
|
|
207df76b69 | ||
|
|
43c7a85eec | ||
|
|
d0b8c3dc0d | ||
|
|
6e6e63e553 | ||
|
|
54029d2668 | ||
|
|
1f57b75d5d | ||
|
|
fefbe7e084 | ||
|
|
31a07e4a46 | ||
|
|
b458dbaa78 | ||
|
|
8c4e729c4a | ||
|
|
80417abd5b | ||
|
|
37d55e9b20 | ||
|
|
94cbde3cf9 | ||
|
|
fedddb246d | ||
|
|
e28472b268 | ||
|
|
3b85b4f35d | ||
|
|
997cb7dcd7 | ||
|
|
04bb6839ae | ||
|
|
1fdf6f417d | ||
|
|
cc38b3d0f6 | ||
|
|
b11ec6a732 | ||
|
|
e58808f2d4 | ||
|
|
e0543b2007 | ||
|
|
0ac1277479 | ||
|
|
6185f6a7ff | ||
|
|
98d6a29fa0 | ||
|
|
3decdb4cac | ||
|
|
0fa3e675dc | ||
|
|
3210b8be6d | ||
|
|
87becb08ad | ||
|
|
334fe77e84 | ||
|
|
c8de28ca0c | ||
|
|
741d768fc3 | ||
|
|
acd2d82d10 | ||
|
|
65d1f9f194 | ||
|
|
c74c63d29c | ||
|
|
94aad3e76e | ||
|
|
00a956b1fb | ||
|
|
7bdfe62495 | ||
|
|
1b10a5a8a8 | ||
|
|
a7afd85634 | ||
|
|
39357263d0 | ||
|
|
e40c75b360 | ||
|
|
fdb39e43ad | ||
|
|
61168dbfd4 | ||
|
|
65a09b5c66 | ||
|
|
8e0389153c | ||
|
|
c3e8ead47b | ||
|
|
5f6dd265a5 | ||
|
|
a4beca2d9e | ||
|
|
5f3f0176f1 | ||
|
|
b3c76dcd69 | ||
|
|
540584a417 | ||
|
|
4f6ba0302d | ||
|
|
d7d6170c3e | ||
|
|
51ac802d77 | ||
|
|
e0bc7271fa | ||
|
|
ddd87e9051 | ||
|
|
8b71de70bd | ||
|
|
2bd8426db8 | ||
|
|
9289434ad2 | ||
|
|
a73b3bc773 | ||
|
|
d155f0081f | ||
|
|
bad7d524f0 | ||
|
|
ea7f3cf098 | ||
|
|
d66c1b48ef | ||
|
|
8df41e2fe7 | ||
|
|
51bd9d41f2 | ||
|
|
7110b11c15 | ||
|
|
3ed3a2be58 | ||
|
|
bb00d3cf35 | ||
|
|
1b2a3c83af | ||
|
|
a579b95f47 | ||
|
|
6bf5512b47 | ||
|
|
1c2dbd2168 | ||
|
|
a25b547d20 | ||
|
|
4499a7f947 | ||
|
|
f655a5115a | ||
|
|
bca27f0330 | ||
|
|
19dfc93b85 | ||
|
|
ce26f54618 | ||
|
|
ea909ef222 | ||
|
|
3824ff180a | ||
|
|
174dac05b9 | ||
|
|
ea1f4ad837 | ||
|
|
d09ceb8650 | ||
|
|
c0cc6ec46e | ||
|
|
aaff109ede | ||
|
|
2fe9b6cc3d | ||
|
|
a08ce31858 | ||
|
|
092234af38 | ||
|
|
c63b23865c | ||
|
|
e04d6753ac | ||
|
|
b5545e380e | ||
|
|
2fecccfbde | ||
|
|
9e697dc63f | ||
|
|
5e826bcc7e | ||
|
|
2be29b684a | ||
|
|
09b2898d1c | ||
|
|
a5f63a6fa6 | ||
|
|
4cc00d2e48 | ||
|
|
f1448e6b58 | ||
|
|
2243b9453c | ||
|
|
fe834a37a4 | ||
|
|
46070d03f6 | ||
|
|
7651e5b090 | ||
|
|
de20406a45 | ||
|
|
2072de337e | ||
|
|
ecd4b55a26 | ||
|
|
c639b09aaf | ||
|
|
5406ee2297 | ||
|
|
937df5d20b | ||
|
|
c0eabbf865 | ||
|
|
2d8a659ebe | ||
|
|
3ff173ca89 | ||
|
|
0bc6e01eb5 | ||
|
|
4da3b198d0 | ||
|
|
87058d72e6 | ||
|
|
e96ff42dff | ||
|
|
b6c7ec83e4 | ||
|
|
b4f984e331 | ||
|
|
b4b13a7db1 | ||
|
|
1947f878e9 | ||
|
|
6f54d5764b | ||
|
|
9293dde1af | ||
|
|
1f997cc504 | ||
|
|
f9e11fca6a | ||
|
|
390ad0d4d2 | ||
|
|
b673911e36 | ||
|
|
484c0d4aeb | ||
|
|
5ce29fbe7d | ||
|
|
625e65e7e4 | ||
|
|
29a786cea3 | ||
|
|
4870b58c5b | ||
|
|
5fe0b28747 | ||
|
|
58376f6d45 | ||
|
|
4b7405aa65 | ||
|
|
b6390d3298 | ||
|
|
e5f001f2b0 | ||
|
|
745c3779c2 | ||
|
|
35e46f81f1 | ||
|
|
bb394be8b2 | ||
|
|
8c889e3ffc | ||
|
|
1c0cacb2b8 | ||
|
|
30a014b619 | ||
|
|
9b81dce8aa | ||
|
|
7f5e671f8d | ||
|
|
83de2a7499 | ||
|
|
62ebabb132 | ||
|
|
f7073e1393 | ||
|
|
5b864c028e | ||
|
|
8ee7dbf79e | ||
|
|
db0354a6e5 | ||
|
|
a86de5cbf4 | ||
|
|
953f59fb19 | ||
|
|
36d7fb9ce8 | ||
|
|
955343a428 | ||
|
|
b51542363c | ||
|
|
54638d2686 | ||
|
|
db9e51e683 | ||
|
|
cafc062b9f | ||
|
|
ea7bd15721 | ||
|
|
3807e7afd1 | ||
|
|
0dc5c2c831 | ||
|
|
efb3ae3400 | ||
|
|
6e900e31e3 | ||
|
|
678005ece9 | ||
|
|
f34fc8b5f5 | ||
|
|
d75a55f3f7 | ||
|
|
30f92d18a2 | ||
|
|
e2a4b60143 | ||
|
|
331eb99c1e | ||
|
|
bd0ae619db | ||
|
|
173af9f698 | ||
|
|
3bf635fb89 | ||
|
|
d8937f0096 | ||
|
|
c4a9c76781 |
No files matched your search
+61
-5
@@ -17,13 +17,69 @@ jobs:
|
||||
- run: pip install -r requirements.txt
|
||||
- run: python3 -m pytest tests/ -q
|
||||
|
||||
build-frontend:
|
||||
build-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Pack frontend
|
||||
run: tar -czf frontend.tar.gz frontend
|
||||
- name: Upload artifact
|
||||
- name: Build deterministic release bundle
|
||||
run: |
|
||||
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
|
||||
python3 scripts/build_release.py \
|
||||
--root . \
|
||||
--output-dir dist \
|
||||
--commit "$GITHUB_SHA" \
|
||||
--source-date-epoch "$SOURCE_DATE_EPOCH"
|
||||
- name: Upload tested release bundle
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: frontend, path: frontend.tar.gz }
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist/
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, build-release]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download tested release bundle
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist
|
||||
- name: Verify and publish release candidate
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET="${{ github.sha }}"
|
||||
TAG="v0.1.0-rc.${{ github.run_number }}"
|
||||
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
(cd dist && sha256sum -c ./*.sha256)
|
||||
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"
|
||||
|
||||
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"CI-tested release candidate for commit %s. Verify downloads with the attached SHA-256 checksum.","draft":true,"prerelease":true}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
|
||||
curl --fail-with-body -sS -X POST "$RELEASE_URL" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/release.json > /tmp/release-response.json
|
||||
RELEASE_ID="$(python3 -c 'import json; print(json.load(open("/tmp/release-response.json"))["id"])')"
|
||||
|
||||
for ASSET in dist/*; do
|
||||
NAME="$(basename "$ASSET")"
|
||||
ENCODED_NAME="$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$NAME")"
|
||||
curl --fail-with-body -sS -X POST "$RELEASE_URL/$RELEASE_ID/assets?name=$ENCODED_NAME" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$ASSET"
|
||||
done
|
||||
|
||||
printf '{"draft":false,"prerelease":true}\n' > /tmp/publish.json
|
||||
curl --fail-with-body -sS -X PATCH "$RELEASE_URL/$RELEASE_ID" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/publish.json
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create Gitea tag and release candidate
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="v0.1.0-rc.${{ github.run_number }}"
|
||||
TARGET="${{ github.sha }}"
|
||||
TAG_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/tags"
|
||||
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
printf '{"tag_name":"%s","target":"%s","message":"Automated release candidate %s"}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" > /tmp/tag.json
|
||||
curl --fail-with-body -sS -X POST "$TAG_URL" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/tag.json
|
||||
|
||||
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"Automated release candidate for commit %s.","draft":false,"prerelease":true}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
|
||||
curl --fail-with-body -sS -X POST "$RELEASE_URL" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/release.json
|
||||
@@ -2,3 +2,4 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.release-engine/
|
||||
.stackchain-state/
|
||||
@@ -14,25 +14,296 @@ python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
||||
read-scoped access token, then start the API and bundled frontend:
|
||||
token that can read dashboard data, update the authenticated user's notification
|
||||
threads, create and self-assign issues, discover, claim, and release issue assignments,
|
||||
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.
|
||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||
issue-comment API; mobile issue capture requires issue
|
||||
creation and assignment permission. Once a repository and meaningful title are selected,
|
||||
the New issue sheet checks for similar open issues in that repository. Candidate links keep
|
||||
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
|
||||
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
|
||||
when search is unavailable. The sheet can also optionally select an open
|
||||
repository milestone and due date; the dashboard validates both and sends them with
|
||||
self-assignment in the single create request, so planned work appears in its release
|
||||
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
|
||||
account-bound title/description captures without selecting a repository or entering the
|
||||
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
|
||||
the same Gitea login, **Choose repository** restores the capture to the normal planning
|
||||
and durable delivery flow. A different or unconfirmed account can only copy or discard
|
||||
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
|
||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
||||
offline operations. Planning edits can remain offline for up to 30 days. After that, the
|
||||
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
|
||||
**Keep My Work available offline** is enabled, every issue or pull request in the bounded
|
||||
Today queue is warmed automatically after a healthy authenticated refresh and as soon as
|
||||
it is added. The readiness indicator reports saved, pending, and retryable items; unchanged
|
||||
`updated_at` revisions make no detail request, transient failures retain the prior copy,
|
||||
and pull-request diffs remain online-only. Saved unread-update conversations remain
|
||||
triageable offline: **Queue read & next** writes an account-bound, notification-ID-
|
||||
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
|
||||
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.
|
||||
Closed-app
|
||||
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
|
||||
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
|
||||
with the unchanged idempotency key. Device purge cancels an active drain before closing
|
||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
||||
writes run outside the request event loop, and lock admission is bounded to 100 ms by
|
||||
default. Tune it with `STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS`; keep the value below
|
||||
route deadlines. Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
|
||||
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
|
||||
`201` response is confirmed. Closing an assigned issue, native Comment,
|
||||
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
|
||||
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
|
||||
`operator` mode. It requires two independent secrets of at least 24 characters;
|
||||
missing, short, reused, or invalid configuration leaves `/healthz` available but
|
||||
returns HTTP 503 before any Gitea access. The access token is entered at `/login`;
|
||||
the browser receives only a short-lived signed session and CSRF proof. Generate
|
||||
both values in your secret manager (for example, `openssl rand -hex 32`) and inject
|
||||
them at runtime—never commit them:
|
||||
|
||||
```bash
|
||||
export GITEA_URL='https://forge.example.com'
|
||||
export GITEA_TOKEN='<read-scoped-token>'
|
||||
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
|
||||
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
|
||||
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
||||
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
|
||||
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
|
||||
# Optional; defaults to eight hours.
|
||||
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
|
||||
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
|
||||
export STACKCHAIN_LOGIN_MAX_FAILURES=5
|
||||
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
|
||||
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
||||
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8000/` for the dashboard. To verify the backend and its
|
||||
Gitea connection directly, request
|
||||
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
|
||||
`user`, `repos`, `issues`, and `pull_requests`. Never commit the token or place
|
||||
it in a tracked configuration file.
|
||||
Sign-in failures are scoped to a hashed canonical client address and persisted across
|
||||
workers and restarts. Once the budget is exhausted, the server returns `429` with
|
||||
`Retry-After`; the mobile login form disables retries for that interval. Expired
|
||||
source records are pruned and the ledger is size-bounded. Keep its SQLite file on
|
||||
shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is
|
||||
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
|
||||
that setting, a reverse proxy is safely treated as one shared source.
|
||||
|
||||
Inbound API mutations are admitted through a body-size boundary before FastAPI
|
||||
parses JSON: sign-in is capped at 16 KiB and other `POST`, `PUT`, and `PATCH`
|
||||
requests under `/api/v1/` are capped at 64 KiB. Both declared and streamed bodies
|
||||
are counted. Oversized requests receive a compact, non-cacheable HTTP 413 response;
|
||||
validation errors expose field locations and messages but never echo submitted
|
||||
values or validation context. Preserve these limits at the reverse proxy or enforce
|
||||
equal or tighter upstream limits.
|
||||
|
||||
Each signed cookie includes an opaque session identifier whose hash and expiry are
|
||||
kept in the SQLite session registry. Keep that registry on persistent, writable
|
||||
storage shared by all dashboard workers. Operators name a device at sign-in and can
|
||||
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.
|
||||
|
||||
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.
|
||||
The server issues a random 90-second grant bound to the active session, exact action,
|
||||
and exact target. Only its digest is stored, and the grant is consumed atomically on
|
||||
first use. Expired, replayed, cross-session, and target-substituted grants fail before
|
||||
Gitea or session state is changed. The browser preserves the pending request and
|
||||
retries it once after the built-in mobile/keyboard-accessible authorization prompt.
|
||||
|
||||
If an active session expires, the first authenticated API rejection replaces the
|
||||
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
|
||||
reason: on its next server contact, Stackchain clears its owned local/session storage,
|
||||
private outbox database, and dashboard caches before enabling sign-in. Revocation
|
||||
blocks server access immediately, but no web application can erase a device that
|
||||
remains offline forever. **Sign out & clear this device** revokes only the current
|
||||
session before clearing browser state, so a copied cookie cannot be replayed
|
||||
afterward; other signed-in devices remain active. **Sign out all devices** is a
|
||||
separately confirmed lost-device safety action that atomically revokes every existing
|
||||
operator session before clearing the current browser and returning to sign-in.
|
||||
Registry read or write failures return a sanitized HTTP 503 before Gitea is contacted.
|
||||
|
||||
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
|
||||
`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath.
|
||||
Every response also defines the browser execution boundary with a Content Security
|
||||
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
|
||||
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
|
||||
the injected value and restarting the service; changing the signing secret expires
|
||||
all sessions immediately. The signing key is never derived from the access token.
|
||||
|
||||
For direct, unproxied local development only, set
|
||||
`STACKCHAIN_DASHBOARD_AUTH_MODE=insecure-local` and open
|
||||
`http://127.0.0.1:8000/`. This mode checks the network peer and rejects non-loopback
|
||||
clients; do not use it behind a reverse proxy, whose loopback connection would hide
|
||||
the browser's peer address. To verify the backend and its Gitea connection in
|
||||
operator mode, sign in and request `http://127.0.0.1:8000/api/v1/context`; a
|
||||
successful response is JSON containing
|
||||
`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard
|
||||
to search commands plus issues and pull requests across every repository visible to
|
||||
the configured Gitea token. Remote search starts after two characters, is debounced,
|
||||
and keeps local commands usable if Gitea search is unavailable. Selecting a remote
|
||||
result opens a mobile-safe, read-only preview without discarding the search query;
|
||||
open unassigned issues can be claimed in place and handed into My Work after Gitea
|
||||
confirms the assignment. Closed work and pull requests remain read-only with a safe
|
||||
canonical Gitea link. The bounded APIs are available at
|
||||
`GET /api/v1/search?q=<query>&limit=<1-25>` and
|
||||
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`. Never commit the token
|
||||
or place it in a tracked configuration file.
|
||||
|
||||
For service monitoring, GET `/healthz` is a liveness check that confirms the
|
||||
API process is running and does not contact Gitea. GET `/readyz` is the
|
||||
readiness check: it validates the configured Gitea credentials and returns
|
||||
HTTP 503 with an error when Gitea is unavailable or authentication fails.
|
||||
|
||||
The application-lifetime Gitea transport bounds aggregate upstream traffic and
|
||||
coalesces identical concurrent GETs without caching completed responses. By
|
||||
default, at most eight requests run at once, with one slot reserved for authored
|
||||
mutations so polling and detail-read bursts cannot starve comments, reviews, or
|
||||
other writes. Requests that cannot enter within 250 ms fail as retryable HTTP
|
||||
503 responses. Tune these limits with `GITEA_MAX_CONCURRENCY` (minimum `2`) and
|
||||
`GITEA_ADMISSION_TIMEOUT_SECONDS` (minimum `0.001`); keep the admission timeout
|
||||
below the route deadlines. Streaming diff reads share the same read capacity,
|
||||
while POST, PATCH, PUT, and DELETE requests are never coalesced.
|
||||
|
||||
Live snapshots expose independent `context`, `events`, and `notifications`
|
||||
revision tokens. Each bounded opaque token includes a per-process generation, so a token
|
||||
from another worker or from before a restart cannot suppress different content. The browser
|
||||
sends its known tokens on later polls, so `/api/v1/live` can omit unchanged section bodies
|
||||
while still returning current freshness and retry metadata. The client retains omitted data
|
||||
and only rebuilds or persists the sections that changed. Malformed or oversized tokens are
|
||||
rejected before any upstream work.
|
||||
|
||||
## Offline mobile shell
|
||||
|
||||
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,
|
||||
**Search**, and **Drafts** thumb-reachable. It reuses the existing sheets and My Work
|
||||
filter, preserves the selected release lane, shows the current draft count, respects
|
||||
the device safe area, and moves out of the way while a full-screen task is open.
|
||||
Desktop layout is unchanged.
|
||||
|
||||
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
|
||||
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
|
||||
accepts a valid future local date and time and returns the item at that exact instant; the picker
|
||||
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
|
||||
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
|
||||
and replay after reconnect. Each queued edit carries the account revision it was based
|
||||
on; if another device has since changed the same item, Stackchain keeps the newer
|
||||
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
|
||||
`500`, `502`, `503`, or `504` response, navigation falls back to that shell when
|
||||
it is available so local issue and review drafts remain reachable. Authentication
|
||||
and other client-error responses are never hidden. A visible, accessible offline
|
||||
or server-outage notice distinguishes cached mode from live Gitea data, and the
|
||||
dashboard keeps polling until it can restore its live snapshot automatically.
|
||||
|
||||
Users can explicitly enable **Keep My Work available offline**. Each healthy live
|
||||
refresh then stores a seven-day, versioned snapshot containing only the signed-in
|
||||
user identity and queue-card metadata for issues, pull requests, unread updates,
|
||||
and pagination totals. For each opened Today issue or pull request, Stackchain additionally retains an allowlisted detail record with its body and
|
||||
newest 20 comments. Requested-review records also retain the head SHA, CI state,
|
||||
newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines
|
||||
per file. A previously opened unread update is retained by notification
|
||||
identity with allowlisted subject context and its newest 20 conversation messages.
|
||||
This account-bound cache is limited to ten records across all detail kinds; credentials,
|
||||
repository catalogs, events, raw patches, and complete API responses are excluded.
|
||||
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.
|
||||
Cached unread updates use the same phone conversation sheet and replies enter the
|
||||
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
|
||||
Cards without a saved detail explain that reconnection is required. **Clear offline
|
||||
work data** deletes both stores, and opting out or seven-day expiry deletes them
|
||||
automatically.
|
||||
|
||||
API responses and mutations are never cached by the service worker. New issue captures,
|
||||
issue comments, pull-request comments, and unread-update replies use bounded local
|
||||
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
|
||||
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
|
||||
work finishes**; permission is requested only from that user gesture and the choice
|
||||
is stored for the confirmed account in the private background outbox database.
|
||||
Successful deliveries produce privacy-safe receipts that open the created issue or
|
||||
source conversation, while permanent validation failures open Drafts for recovery.
|
||||
Authentication expiry during a worker mutation is recoverable: the worker releases the
|
||||
claim without creating an Attention receipt, stops that drain, and keeps the original
|
||||
idempotency key. Loading the authenticated dashboard after signing in asks the worker
|
||||
to resume queued delivery automatically.
|
||||
Notification text never includes issue titles, comment bodies, or validation details.
|
||||
The preference is off by default, unsupported or denied browsers retain foreground
|
||||
reconciliation, and **Sign out & clear this device** removes the account-bound choice.
|
||||
Browsers without
|
||||
IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows
|
||||
queued and needs-attention messages with explicit send/discard controls; reopening the
|
||||
dashboard reconciles worker completions and permanent failures into the visible outbox.
|
||||
Foreground delivery flushes messages sequentially with their original idempotency keys.
|
||||
Tabs coordinate each queue through the browser lock manager (with an expiring local
|
||||
lease fallback), so an installed PWA and a browser tab cannot submit the same operation
|
||||
concurrently. Queue changes are broadcast to other tabs and refresh Drafts without
|
||||
polling; an abandoned lease can be reclaimed after expiry. State-sensitive actions such
|
||||
as reviews, merges, closures, labels, milestones, and assignments are never queued.
|
||||
Service-worker upgrades are atomic and remove only older
|
||||
`stackchain-dashboard-*` caches, preserving unrelated caches on the same origin.
|
||||
|
||||
Run the test suite with:
|
||||
|
||||
```bash
|
||||
@@ -67,6 +338,10 @@ python3 -m src.release_engine \
|
||||
--repo stackchain/stackchain-dashboard \
|
||||
--agent timmy \
|
||||
--issue 19 \
|
||||
--agent-timeout 1800 \
|
||||
--test-timeout 900 \
|
||||
--termination-grace 5 \
|
||||
--agent-env AGENT_CONFIG_HOME \
|
||||
--test-command 'python3 -m pytest tests/ -q'
|
||||
```
|
||||
|
||||
@@ -75,6 +350,42 @@ claim, reruns the test command, pushes the branch, and opens a PR containing
|
||||
`Closes #19` plus test evidence. Replace `19` with the selected issue number;
|
||||
do not run without `--issue` when processing a preselected ticket.
|
||||
|
||||
Durable state defaults to `.release-engine/state.json`. Full behavior and
|
||||
safety gates are documented in
|
||||
Agent and test commands use POSIX argument quoting and run as argument vectors,
|
||||
not through a shell. Issue context is available to the agent only through
|
||||
`RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`,
|
||||
`RELEASE_REPO`, and `RELEASE_BRANCH`; do not put issue placeholders in command
|
||||
text. Shell substitutions, redirects, and pipelines are intentionally not
|
||||
interpreted. Put any trusted shell workflow in a reviewed wrapper script and
|
||||
configure that script as `RELEASE_AGENT_COMMAND` instead.
|
||||
|
||||
Every child process receives an explicit least-privilege environment rather
|
||||
than inheriting the worker's credentials. Coding agents receive a minimal
|
||||
runtime environment plus the five `RELEASE_*` context values above. Tests and
|
||||
Git receive only the minimal runtime environment, so `GITEA_TOKEN` and
|
||||
unrelated parent secrets remain in the release-engine process. If an agent
|
||||
runtime needs another non-secret setting, allow it explicitly with a repeated
|
||||
`--agent-env NAME` option or the comma-separated `RELEASE_AGENT_ENV` variable.
|
||||
`GITEA_TOKEN`, reserved `RELEASE_*` names, and malformed names are rejected
|
||||
before the issue is claimed. Secret-bearing values must not be allowlisted.
|
||||
|
||||
Agent and test deadlines are independent. On expiry, the engine terminates the
|
||||
command's entire process group, escalates from `SIGTERM` to `SIGKILL` after the
|
||||
configured grace period, and records `agent_timed_out` or `tests_timed_out`
|
||||
without advancing the release. The equivalent environment variables are
|
||||
`RELEASE_AGENT_TIMEOUT`, `RELEASE_TEST_TIMEOUT`, and
|
||||
`RELEASE_TERMINATION_GRACE`; every value must be positive.
|
||||
|
||||
Durable state defaults to `.release-engine/state.json`. Successful checkpoints
|
||||
record the repository, issue branch, and exact commit SHA. A restart resumes at
|
||||
tests, push, or PR creation only after validating that identity and a clean
|
||||
worktree; a pushed checkpoint also verifies the remote branch SHA. Invocations
|
||||
are serialized by an OS-backed, non-blocking lease at
|
||||
`.release-engine/run.lock` under the canonical repository path. If an hourly
|
||||
run overlaps a manual or slow prior run, the contender exits with `release run
|
||||
already active` and owner diagnostics before Gitea discovery or any Git/agent
|
||||
work. The kernel releases ownership when the process exits, so never delete a
|
||||
lock file to recover from a crash; stale file metadata cannot retain the lease.
|
||||
Checkpoint replacement uses writer-unique, flushed temporary files and an
|
||||
atomic rename, preventing concurrent writers from colliding or exposing partial
|
||||
JSON. Full behavior and safety gates are documented in
|
||||
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
|
||||
@@ -0,0 +1,9 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def explicit_local_dashboard_auth_mode(monkeypatch):
|
||||
"""Keep tests intentional now that deployed authentication fails closed."""
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
|
||||
@@ -12,15 +12,29 @@ Convert one eligible Gitea issue into a tested, traceable pull request without d
|
||||
- Repository key (`owner/repo`).
|
||||
- Agent username.
|
||||
- Optional explicit issue number; otherwise deterministic queue selection.
|
||||
- Coding-agent command template.
|
||||
- Test command.
|
||||
- Coding-agent command, parsed into an argument vector.
|
||||
- Test command, parsed into an argument vector.
|
||||
- Positive, independent coding-agent and test deadlines plus a termination grace period.
|
||||
- Optional explicit allowlist of non-secret parent environment variables needed by the coding-agent runtime.
|
||||
- Local repository path and durable state-file path.
|
||||
|
||||
## State machine
|
||||
|
||||
`discovered → claimed → agent_complete → tests_passed → pushed → pr_opened`
|
||||
|
||||
Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and `push_failed`. A rerun resumes from persisted state and never opens a duplicate PR.
|
||||
Terminal failure states include `claim_failed`, `agent_failed`, `agent_timed_out`, `tests_failed`, `tests_timed_out`, and `push_failed`. A rerun resumes from persisted state and never opens a duplicate PR.
|
||||
|
||||
Successful checkpoints are schema-versioned and bind the repository, issue,
|
||||
deterministic branch, and exact commit SHA. On restart, the engine:
|
||||
|
||||
- resumes `agent_complete` at tests;
|
||||
- resumes `tests_passed` at push; and
|
||||
- resumes `pushed` at PR creation after verifying the remote branch SHA.
|
||||
|
||||
Before skipping any stage, it checks out the recorded branch, verifies its
|
||||
local `HEAD`, and requires a clean worktree. Missing identity, an unsupported
|
||||
schema, or any repository/issue/branch/SHA mismatch fails closed instead of
|
||||
repeating or advancing delivery.
|
||||
|
||||
## Queue selection
|
||||
|
||||
@@ -30,6 +44,19 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
|
||||
4. Otherwise sort by priority label (`P0`, `P1`, `P2`, unlabeled) then issue number.
|
||||
5. Select exactly one issue per invocation.
|
||||
|
||||
## Single-active-run lease
|
||||
|
||||
- Before issue discovery, every invocation acquires a non-blocking advisory lock
|
||||
at `.release-engine/run.lock` under the canonical repository path.
|
||||
- The lease is held through the final checkpoint and pull-request request. A
|
||||
contended invocation fails immediately with `release run already active`
|
||||
before contacting Gitea or running Git, agent, or test commands.
|
||||
- The lock file records bounded owner PID and acquisition-time diagnostics.
|
||||
These are informational only: kernel lock ownership is authoritative, so a
|
||||
killed or crashed process releases the lease automatically and stale metadata
|
||||
cannot block the next run.
|
||||
- Different canonical repository paths use independent leases.
|
||||
|
||||
## Claiming
|
||||
|
||||
- PATCH the issue with the configured assignee.
|
||||
@@ -39,9 +66,18 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
|
||||
## Execution
|
||||
|
||||
- Branch format: `<agent>/<issue>-<slug>`.
|
||||
- Coding command receives issue number, title, body, repo, and branch through template fields and environment variables.
|
||||
- Coding and test commands are parsed with POSIX argument quoting and executed directly without a shell.
|
||||
- Issue number, title, body, repo, and branch are supplied to the coding agent only through `RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`, `RELEASE_REPO`, and `RELEASE_BRANCH` environment variables. Gitea-controlled content is never interpolated into executable syntax.
|
||||
- Child processes never inherit the complete parent environment. Coding receives a minimal runtime environment, explicitly allowlisted non-secret agent variables, and the five issue-context values. Tests and Git receive only the minimal runtime environment; `GITEA_TOKEN` remains parent-process-only.
|
||||
- Agent variables are allowlisted with repeated `--agent-env NAME` options or comma-separated `RELEASE_AGENT_ENV`. `GITEA_TOKEN`, `RELEASE_*`, and malformed names fail validation before claim.
|
||||
- Shell operators, substitutions, and pipelines are not interpreted. Operators that are intentionally required must live in a separately reviewed wrapper script configured as the command.
|
||||
- Empty or malformed commands abort before the issue claim.
|
||||
- Non-zero coding-agent exit blocks tests and PR creation.
|
||||
- Tests run using the configured command; stdout/stderr and exit code become evidence.
|
||||
- Coding-agent and test commands each run in a new process session. Their deadlines default to 1800 and 900 seconds, respectively.
|
||||
- On expiry, the engine sends `SIGTERM` to the command's entire process group, waits for the termination grace period (5 seconds by default), then sends `SIGKILL` to the group and reaps the command. This prevents descendants from keeping the worker wedged.
|
||||
- Deadlines are configured with `--agent-timeout`, `--test-timeout`, and `--termination-grace`, or `RELEASE_AGENT_TIMEOUT`, `RELEASE_TEST_TIMEOUT`, and `RELEASE_TERMINATION_GRACE`. Non-positive values abort before issue discovery or claim.
|
||||
- Timeout evidence is bounded in durable state. An agent timeout blocks tests; a test timeout blocks push and PR creation.
|
||||
|
||||
## PR and release gate
|
||||
|
||||
@@ -55,6 +91,12 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
|
||||
|
||||
- `--dry-run` performs discovery and planning only: no claim, git mutation, agent command, push, or PR.
|
||||
- State is written atomically after each successful transition.
|
||||
- Each checkpoint write uses a writer-unique temporary file, flushes file data,
|
||||
atomically replaces the checkpoint, and flushes its parent directory. A
|
||||
failed or concurrent writer therefore cannot collide on a shared temp path or
|
||||
expose partial JSON.
|
||||
- Resumable state carries repository and commit identity; successful stages are
|
||||
skipped only after local (and, after push, remote) Git verification.
|
||||
- One invocation handles at most one issue.
|
||||
- Missing token, dirty worktree, failed claim verification, failed tests, or missing evidence blocks PR creation.
|
||||
|
||||
@@ -68,3 +110,13 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
|
||||
6. Passing tests produce a linked PR request with evidence.
|
||||
7. Existing state/PR prevents duplicate work.
|
||||
8. Dry-run against live Gitea returns a plan and performs no mutation.
|
||||
9. A timed-out command terminates its descendants and returns within its deadline plus grace period.
|
||||
10. Agent and test timeouts persist distinct terminal states and block every later delivery stage.
|
||||
11. Non-positive deadline configuration aborts before issue claim.
|
||||
12. Restarts from `agent_complete`, `tests_passed`, and `pushed` skip only the
|
||||
completed stages and open exactly one linked PR.
|
||||
13. Dirty or identity-mismatched checkpoints stop before push or PR creation.
|
||||
14. Parent credentials are absent from coding-agent, test, and Git subprocesses while explicit agent variables and issue context reach only the coding stage.
|
||||
15. Reserved or malformed agent environment allowlist entries abort before claim.
|
||||
16. An overlapping invocation fails before Gitea discovery or local command execution and reports bounded owner diagnostics.
|
||||
17. Concurrent checkpoint-save stress completes without temp-file collisions or partial state.
|
||||
@@ -0,0 +1,215 @@
|
||||
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
||||
const storageKey = 'stackchain.authored-outbox.v1';
|
||||
const makeId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']);
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && supportedKinds.has(item.kind));
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items, mirror = true) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
coordinator?.notify('authored');
|
||||
if (mirror && backgroundSync?.reconcile) {
|
||||
Promise.resolve(backgroundSync.reconcile(items, 'authored'))
|
||||
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
|
||||
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(message, mirror = true) {
|
||||
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
|
||||
const items = read();
|
||||
const requestedOperationId = String(message.operationId || '').slice(0, 128);
|
||||
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
|
||||
if (existing) return { ...existing };
|
||||
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 = {
|
||||
id,
|
||||
operationId: requestedOperationId || id,
|
||||
kind: message.kind,
|
||||
repository: String(message.repository || ''),
|
||||
number: Number(message.number || 0),
|
||||
notificationId: Number(message.notificationId || 0),
|
||||
body: String(message.body || ''),
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function enqueueDurably(message) {
|
||||
const item = enqueue(message, false);
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
try {
|
||||
await backgroundSync.reconcile(read(), 'authored');
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function update(id, changes) {
|
||||
let updated = null;
|
||||
write(read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const body = String(changes?.body ?? item.body);
|
||||
updated = {
|
||||
...item,
|
||||
body,
|
||||
operationId: body === item.body ? item.operationId : String(makeId()).slice(0, 128),
|
||||
status: 'queued',
|
||||
};
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
}));
|
||||
return updated;
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
if (!items.some(item => item.id === id)) return false;
|
||||
write(items.filter(item => item.id !== id));
|
||||
return true;
|
||||
}
|
||||
|
||||
function endpoint(item) {
|
||||
if (item.kind === 'update-reply') {
|
||||
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
|
||||
}
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
|
||||
}
|
||||
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const request = (async () => {
|
||||
try {
|
||||
let result;
|
||||
if (backgroundSync?.send) {
|
||||
const delivery = await backgroundSync.send(item, currentLogin);
|
||||
if (delivery.attention) {
|
||||
const error = delivery.error || new Error('Message needs attention');
|
||||
error.status = Number(error.status || 422);
|
||||
throw error;
|
||||
}
|
||||
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 (!result) return { blocked: true };
|
||||
discard(item.id);
|
||||
return { result };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
const permanent = status >= 400 && status < 500;
|
||||
if (permanent) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate,
|
||||
status: 'attention',
|
||||
error: String(error.message || 'Message needs attention').slice(0, 240),
|
||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !permanent };
|
||||
}
|
||||
})();
|
||||
pending.set(item.id, request);
|
||||
try { return await request; }
|
||||
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
|
||||
}
|
||||
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const outcome = await sendItem(item, currentLogin);
|
||||
if (outcome.result) confirmed.push(outcome.result);
|
||||
if (outcome.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('authored', () => flushQueue(currentLogin));
|
||||
return flushQueue(currentLogin);
|
||||
}
|
||||
|
||||
async function retryItem(id, currentLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return { confirmed: [], remaining: read() };
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
return { confirmed: [], remaining: read(), blocked: 1 };
|
||||
}
|
||||
let queued = item;
|
||||
if (item.status === 'attention') {
|
||||
queued = {
|
||||
...item,
|
||||
operationId: String(makeId()).slice(0, 128),
|
||||
status: 'queued',
|
||||
};
|
||||
delete queued.error;
|
||||
delete queued.deliveryState;
|
||||
write(read().map(candidate => candidate.id === id ? queued : candidate));
|
||||
}
|
||||
const outcome = await sendItem(queued, currentLogin);
|
||||
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 };
|
||||
}
|
||||
|
||||
async function retry(id, currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('authored', () => retryItem(id, currentLogin));
|
||||
return retryItem(id, currentLogin);
|
||||
}
|
||||
|
||||
function reconcileBackground(records) {
|
||||
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 === 'attention') return [{
|
||||
...item,
|
||||
status: 'attention',
|
||||
error: String(background.error || 'Message needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
write(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
|
||||
@@ -0,0 +1,418 @@
|
||||
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-outbox-v1') {
|
||||
let databasePromise;
|
||||
function database() {
|
||||
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains('issues')) {
|
||||
request.result.createObjectStore('issues', { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
databasePromise = undefined;
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
const requested = request => new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const transact = async work => {
|
||||
const db = await database();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('issues', 'readwrite');
|
||||
const objectStore = transaction.objectStore('issues');
|
||||
let result;
|
||||
let failed = false;
|
||||
transaction.oncomplete = () => failed ? undefined : resolve(result);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted'));
|
||||
Promise.resolve(work({
|
||||
getAll: () => requested(objectStore.getAll()),
|
||||
put: value => requested(objectStore.put(value)),
|
||||
delete: id => requested(objectStore.delete(id)),
|
||||
})).then(value => { result = value; }).catch(error => {
|
||||
failed = true;
|
||||
try { transaction.abort(); } catch (_abortError) { reject(error); }
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
transact.close = async () => {
|
||||
if (!databasePromise) return;
|
||||
const db = await databasePromise;
|
||||
db.close();
|
||||
databasePromise = undefined;
|
||||
};
|
||||
return transact;
|
||||
}
|
||||
|
||||
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
|
||||
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
||||
|
||||
async function reconcile(items, outboxLane = 'issue') {
|
||||
return transact(async records => {
|
||||
const existing = await records.getAll();
|
||||
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
|
||||
for (const current of existing) {
|
||||
if (current.recordType === 'receipt-preference') continue;
|
||||
const currentLane = current.outboxLane || 'issue';
|
||||
if (currentLane !== outboxLane) continue;
|
||||
const replacement = incoming.get(current.id);
|
||||
if (!replacement) {
|
||||
if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) {
|
||||
await records.delete(current.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && replacement.status === 'attention') ||
|
||||
current.status === 'sent') {
|
||||
incoming.set(current.id, current);
|
||||
}
|
||||
}
|
||||
for (const item of incoming.values()) await records.put(item);
|
||||
});
|
||||
}
|
||||
|
||||
async function claimNext(ownerLogin) {
|
||||
return transact(async records => {
|
||||
const timestamp = Number(now());
|
||||
const items = await records.getAll();
|
||||
const item = items.find(candidate => candidate.ownerLogin === ownerLogin &&
|
||||
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
||||
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
||||
if (!item) return null;
|
||||
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
|
||||
await records.put(claimed);
|
||||
return claimed;
|
||||
});
|
||||
}
|
||||
|
||||
async function claimBatch(ownerLogin, limit = 70) {
|
||||
return transact(async records => {
|
||||
const timestamp = Number(now());
|
||||
const eligible = (await records.getAll()).filter(candidate =>
|
||||
candidate.recordType !== 'receipt-preference' &&
|
||||
candidate.ownerLogin === ownerLogin &&
|
||||
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
||||
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
||||
const lanes = {
|
||||
issue: eligible.filter(item => (item.outboxLane || 'issue') !== 'authored'),
|
||||
authored: eligible.filter(item => item.outboxLane === 'authored'),
|
||||
};
|
||||
const selected = [];
|
||||
const maximum = Math.max(0, Number(limit) || 0);
|
||||
while (selected.length < maximum && (lanes.issue.length || lanes.authored.length)) {
|
||||
if (lanes.issue.length) selected.push(lanes.issue.shift());
|
||||
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift());
|
||||
}
|
||||
const claimed = selected.map(item => ({
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs,
|
||||
}));
|
||||
for (const item of claimed) await records.put(item);
|
||||
return claimed;
|
||||
});
|
||||
}
|
||||
|
||||
async function claim(id, ownerLogin) {
|
||||
return transact(async records => {
|
||||
const timestamp = Number(now());
|
||||
const item = (await records.getAll()).find(candidate => candidate.id === id);
|
||||
if (!item || item.ownerLogin !== ownerLogin ||
|
||||
!['queued', 'sending'].includes(item.status) ||
|
||||
(item.status === 'sending' && Number(item.claimUntil) > timestamp)) return null;
|
||||
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
|
||||
await records.put(claimed);
|
||||
return claimed;
|
||||
});
|
||||
}
|
||||
|
||||
async function upsert(item) {
|
||||
return transact(async records => {
|
||||
const current = (await records.getAll()).find(candidate => candidate.id === item.id);
|
||||
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && item.status === 'attention') ||
|
||||
current.status === 'sent')) return current;
|
||||
await records.put({ ...item });
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
async function update(id, transform) {
|
||||
return transact(async records => {
|
||||
const item = (await records.getAll()).find(candidate => candidate.id === id);
|
||||
if (item) await records.put(transform(item));
|
||||
});
|
||||
}
|
||||
|
||||
function preferenceId(ownerLogin) {
|
||||
return 'receipt-preference:' + String(ownerLogin || '').trim();
|
||||
}
|
||||
|
||||
async function setReceiptPreference(ownerLogin, enabled) {
|
||||
const login = String(ownerLogin || '').trim();
|
||||
if (!login) return;
|
||||
return transact(async records => {
|
||||
const id = preferenceId(login);
|
||||
if (!enabled) return records.delete(id);
|
||||
return records.put({ id, recordType: 'receipt-preference', ownerLogin: login, enabled: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function getReceiptPreference(ownerLogin) {
|
||||
const id = preferenceId(ownerLogin);
|
||||
return transact(async records => Boolean(
|
||||
(await records.getAll()).find(item => item.id === id)?.enabled
|
||||
));
|
||||
}
|
||||
|
||||
return {
|
||||
reconcile,
|
||||
upsert,
|
||||
claim,
|
||||
claimNext,
|
||||
claimBatch,
|
||||
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
|
||||
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
||||
fail: (id, error, deliveryState) => update(id, item => ({
|
||||
...item, status: 'attention', claimUntil: 0, error,
|
||||
...(deliveryState ? { deliveryState } : {}),
|
||||
})),
|
||||
snapshot: () => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
|
||||
countBlocked: ownerLogin => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference' &&
|
||||
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
||||
setReceiptPreference,
|
||||
getReceiptPreference,
|
||||
close: () => transact.close?.(),
|
||||
};
|
||||
}
|
||||
|
||||
function createBackgroundIssueSync({
|
||||
store, fetchJson, base = '', maxConcurrency = 3, batchSize = 70,
|
||||
batch = work => work(), requestTimeoutMs = 15000,
|
||||
}) {
|
||||
let purgeRequested = false;
|
||||
let activeFlush = null;
|
||||
const activeRequests = new Set();
|
||||
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
activeRequests.add(controller);
|
||||
let timer;
|
||||
const interrupted = new Promise((resolve, reject) => {
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
reject(new Error(purgeRequested
|
||||
? 'Background request canceled.'
|
||||
: 'Background request timed out.'));
|
||||
}, { once: true });
|
||||
timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => fetchJson(url, { ...options, signal: controller.signal })),
|
||||
interrupted,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
activeRequests.delete(controller);
|
||||
}
|
||||
}
|
||||
function receiptFor(item, status, delivered = {}) {
|
||||
if (status === 'attention') {
|
||||
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
|
||||
}
|
||||
if (item.kind === 'notification-read') {
|
||||
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
|
||||
}
|
||||
if (item.kind === 'update-reply') {
|
||||
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 === '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) };
|
||||
}
|
||||
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
|
||||
}
|
||||
|
||||
function deliveryRequest(item) {
|
||||
if (item.kind === 'notification-read') {
|
||||
return {
|
||||
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
|
||||
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
|
||||
};
|
||||
}
|
||||
if (item.kind === 'update-reply') {
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
return authoredRequest(
|
||||
'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
|
||||
item,
|
||||
);
|
||||
}
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues',
|
||||
options: {
|
||||
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' } : {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authoredRequest(url, item) {
|
||||
return {
|
||||
url: base + url,
|
||||
options: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = await requestJson(request.url, request.options);
|
||||
await store.complete(item.id);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status === 401) {
|
||||
await store.release(item.id);
|
||||
throw error;
|
||||
}
|
||||
if (status >= 400 && status < 500) {
|
||||
await store.fail(
|
||||
item.id,
|
||||
String(error?.message || 'Issue needs attention').slice(0, 240),
|
||||
error?.code === 'delivery_uncertain' ? 'uncertain' : undefined,
|
||||
);
|
||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
||||
}
|
||||
await store.release(item.id);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function send(item, currentLogin) {
|
||||
if (purgeRequested) return { blocked: true };
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
await store.upsert(item);
|
||||
const claimed = await store.claim(item.id, currentLogin);
|
||||
if (!claimed) return { busy: true };
|
||||
return deliver(claimed);
|
||||
}
|
||||
|
||||
async function runFlush() {
|
||||
const identity = await requestJson(base + 'api/v1/background-identity', {
|
||||
headers: { Accept: 'application/json' }, cache: 'no-store',
|
||||
});
|
||||
const login = String(identity?.login || '').trim();
|
||||
const confirmed = [];
|
||||
const receipts = [];
|
||||
let attention = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
|
||||
const collect = result => {
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.message) confirmed.push(result.message);
|
||||
if (result.attention) attention += 1;
|
||||
if (result.receipt) receipts.push(result.receipt);
|
||||
};
|
||||
if (store.claimBatch) {
|
||||
const claimed = await store.claimBatch(login, batchSize);
|
||||
let next = 0;
|
||||
let authenticationError = null;
|
||||
let transientError = null;
|
||||
const worker = async () => {
|
||||
while (!purgeRequested && !authenticationError && next < claimed.length) {
|
||||
const item = claimed[next++];
|
||||
try {
|
||||
collect(await deliver(item));
|
||||
} catch (error) {
|
||||
if (Number(error?.status || 0) === 401) authenticationError = error;
|
||||
else if (!transientError) transientError = error;
|
||||
}
|
||||
}
|
||||
};
|
||||
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, claimed.length));
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
if (purgeRequested) {
|
||||
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
|
||||
throw new Error('Background delivery canceled.');
|
||||
}
|
||||
if (authenticationError) {
|
||||
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
|
||||
throw authenticationError;
|
||||
}
|
||||
if (transientError) throw transientError;
|
||||
} else {
|
||||
while (!purgeRequested) {
|
||||
const item = await store.claimNext(login);
|
||||
if (!item) break;
|
||||
collect(await deliver(item));
|
||||
}
|
||||
}
|
||||
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
||||
return { confirmed, blocked, attention, login, receipts };
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (purgeRequested) return Promise.resolve({ confirmed: [], blocked: 0, attention: 0, login: '', receipts: [] });
|
||||
if (activeFlush) return activeFlush;
|
||||
activeFlush = Promise.resolve().then(() => batch(runFlush)).finally(() => { activeFlush = null; });
|
||||
return activeFlush;
|
||||
}
|
||||
|
||||
async function purge() {
|
||||
purgeRequested = true;
|
||||
activeRequests.forEach(controller => controller.abort());
|
||||
if (activeFlush) await activeFlush.catch(() => {});
|
||||
await store.close?.();
|
||||
}
|
||||
|
||||
return {
|
||||
flush, send, purge,
|
||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||
snapshot: () => store.snapshot(),
|
||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
||||
getReceiptPreference: ownerLogin => store.getReceiptPreference(ownerLogin),
|
||||
};
|
||||
}
|
||||
|
||||
createBackgroundIssueSync.createIssueSyncStore = createIssueSyncStore;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createBackgroundIssueSync;
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
|
||||
globalThis.createIssueSyncStore = createIssueSyncStore;
|
||||
}
|
||||
+50
-1
@@ -3,8 +3,57 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = filterCommands;
|
||||
if (root) root.filterCommands = filterCommands;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function filterCommands(commands, filter) {
|
||||
function filterCommands(commands, filter) {
|
||||
const term = String(filter || '').toLowerCase();
|
||||
return commands.filter(command => command.name.toLowerCase().includes(term));
|
||||
}
|
||||
|
||||
filterCommands.nextSelection = function nextSelection(current, key, count) {
|
||||
if (count < 1) return -1;
|
||||
if (key === 'ArrowDown') return (current + 1 + count) % count;
|
||||
if (key === 'ArrowUp') return (current - 1 + count) % count;
|
||||
return current;
|
||||
};
|
||||
|
||||
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
|
||||
const search = options.search;
|
||||
const onState = options.onState;
|
||||
const delay = options.delay === undefined ? 250 : options.delay;
|
||||
let timer = null;
|
||||
let generation = 0;
|
||||
let activeController = null;
|
||||
|
||||
return {
|
||||
setQuery(value) {
|
||||
const query = String(value || '').trim();
|
||||
generation += 1;
|
||||
const current = generation;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
if (activeController !== null) activeController.abort();
|
||||
activeController = null;
|
||||
if (query.length < 2) {
|
||||
onState({ status: 'idle', query, items: [] });
|
||||
return;
|
||||
}
|
||||
onState({ status: 'loading', query, items: [] });
|
||||
timer = setTimeout(async () => {
|
||||
const requestController = new AbortController();
|
||||
activeController = requestController;
|
||||
try {
|
||||
const result = await search(query, requestController.signal);
|
||||
const items = Array.isArray(result) ? result : result.items;
|
||||
const partial = !Array.isArray(result) && result.partial === true;
|
||||
if (current === generation) onState({ status: 'ready', query, items, partial });
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (current === generation) onState({ status: 'error', query, items: [], error });
|
||||
} finally {
|
||||
if (activeController === requestController) activeController = null;
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return filterCommands;
|
||||
});
|
||||
+80
-11
@@ -1,3 +1,15 @@
|
||||
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 createContextPoller({
|
||||
fetchContext,
|
||||
onSnapshot,
|
||||
@@ -5,11 +17,16 @@ function createContextPoller({
|
||||
isHidden = () => false,
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
setDeadlineTimer = setTimeout,
|
||||
clearDeadlineTimer = clearTimeout,
|
||||
intervalMs = 8000,
|
||||
timeoutMs = 12000,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
let activeRequest = null;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
let revisions = {};
|
||||
let retainedSnapshot = null;
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
@@ -25,36 +42,81 @@ function createContextPoller({
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
function abortError() {
|
||||
const error = new Error('Live update superseded.');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
function supersede(request) {
|
||||
if (!request || request.settled) return;
|
||||
request.superseded = true;
|
||||
request.controller.abort();
|
||||
request.rejectDeadline(abortError());
|
||||
}
|
||||
|
||||
function refresh(options = {}) {
|
||||
if (stopped || isHidden()) return Promise.resolve(null);
|
||||
if (inFlight) return inFlight;
|
||||
if (activeRequest && !options.force) return activeRequest.promise;
|
||||
if (activeRequest) supersede(activeRequest);
|
||||
|
||||
const controller = new AbortController();
|
||||
let rejectDeadline;
|
||||
const deadlinePromise = new Promise((resolve, reject) => {
|
||||
rejectDeadline = reject;
|
||||
});
|
||||
const requestState = {
|
||||
controller,
|
||||
deadline: null,
|
||||
promise: null,
|
||||
rejectDeadline,
|
||||
settled: false,
|
||||
superseded: false,
|
||||
};
|
||||
activeRequest = requestState;
|
||||
requestState.deadline = setDeadlineTimer(() => {
|
||||
if (requestState.settled || requestState.superseded) return;
|
||||
controller.abort();
|
||||
const error = new Error(`Live update timed out after ${timeoutMs}ms.`);
|
||||
error.name = 'TimeoutError';
|
||||
rejectDeadline(error);
|
||||
}, timeoutMs);
|
||||
|
||||
let request;
|
||||
try {
|
||||
request = fetchContext();
|
||||
request = fetchContext({ ...revisions }, { signal: controller.signal });
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
inFlight = Promise.resolve(request)
|
||||
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
|
||||
.then((snapshot) => {
|
||||
onSnapshot(snapshot);
|
||||
return snapshot;
|
||||
if (activeRequest !== requestState) return retainedSnapshot;
|
||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||
);
|
||||
retainedSnapshot = retainedSnapshot ? { ...retainedSnapshot, ...snapshot } : { ...snapshot };
|
||||
revisions = { ...revisions, ...(snapshot.revisions || {}) };
|
||||
onSnapshot(retainedSnapshot, changedSections);
|
||||
return retainedSnapshot;
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error);
|
||||
if (activeRequest === requestState && !stopped) onError(error);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
requestState.settled = true;
|
||||
clearDeadlineTimer(requestState.deadline);
|
||||
if (activeRequest !== requestState) return;
|
||||
activeRequest = null;
|
||||
schedule();
|
||||
});
|
||||
return inFlight;
|
||||
return requestState.promise;
|
||||
}
|
||||
|
||||
function setVisible(visible) {
|
||||
cancelTimer();
|
||||
if (!visible) return Promise.resolve(null);
|
||||
return refresh();
|
||||
return refresh({ force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -64,10 +126,17 @@ function createContextPoller({
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelTimer();
|
||||
if (activeRequest) {
|
||||
const request = activeRequest;
|
||||
activeRequest = null;
|
||||
supersede(request);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createContextPoller;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
function createConversationPager({ loadPage }) {
|
||||
let state = { comments: [], page: 1, older_page: null, total: 0 };
|
||||
let olderRequest = null;
|
||||
|
||||
const validComments = comments => Array.isArray(comments)
|
||||
? comments.filter(comment => comment && Number.isInteger(comment.id))
|
||||
: [];
|
||||
|
||||
const unique = comments => {
|
||||
const seen = new Set();
|
||||
return comments.filter(comment => {
|
||||
if (seen.has(comment.id)) return false;
|
||||
seen.add(comment.id);
|
||||
return true;
|
||||
}).sort((left, right) => {
|
||||
const leftTime = Date.parse(left.created_at || '');
|
||||
const rightTime = Date.parse(right.created_at || '');
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
return left.id - right.id;
|
||||
});
|
||||
};
|
||||
|
||||
const snapshot = () => ({ ...state, comments: state.comments.map(comment => ({ ...comment })) });
|
||||
|
||||
return {
|
||||
reset(page) {
|
||||
const comments = unique(validComments(page?.comments));
|
||||
state = {
|
||||
comments,
|
||||
page: Number.isInteger(page?.page) ? page.page : 1,
|
||||
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
|
||||
total: Number.isInteger(page?.total) ? Math.max(page.total, comments.length) : comments.length,
|
||||
};
|
||||
olderRequest = null;
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
loadOlder() {
|
||||
if (olderRequest) return olderRequest;
|
||||
if (!Number.isInteger(state.older_page)) return Promise.resolve(snapshot());
|
||||
const requestedPage = state.older_page;
|
||||
olderRequest = Promise.resolve(loadPage(requestedPage)).then(page => {
|
||||
state = {
|
||||
comments: unique(validComments(page?.comments).concat(state.comments)),
|
||||
page: Number.isInteger(page?.page) ? page.page : requestedPage,
|
||||
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
|
||||
total: Number.isInteger(page?.total) ? Math.max(page.total, state.comments.length) : state.total,
|
||||
};
|
||||
return snapshot();
|
||||
}).finally(() => { olderRequest = null; });
|
||||
return olderRequest;
|
||||
},
|
||||
append(comment) {
|
||||
if (!comment || !Number.isInteger(comment.id)) return snapshot();
|
||||
if (!state.comments.some(existing => existing.id === comment.id)) {
|
||||
state = {
|
||||
...state,
|
||||
comments: state.comments.concat(comment),
|
||||
total: Math.max(state.total + 1, state.comments.length + 1),
|
||||
};
|
||||
}
|
||||
return snapshot();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationPager;
|
||||
@@ -0,0 +1,225 @@
|
||||
function newIssueOperationId() {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
|
||||
}
|
||||
|
||||
function normalizeSharedContent(value = {}) {
|
||||
const clean = input => String(input || '').replace(/\s+/g, ' ').trim();
|
||||
const text = String(value.text || '').trim().slice(0, 9500);
|
||||
const textSummary = clean(text);
|
||||
const sentence = (textSummary.match(/^.{1,80}?[.!?](?:\s|$)/)?.[0] || textSummary.slice(0, 80)).trim();
|
||||
const title = (clean(value.title) || sentence).slice(0, 240);
|
||||
const url = clean(value.url).slice(0, 2000);
|
||||
const body = text && url && text.includes(url) ? text : [text, url].filter(Boolean).join('\n\n');
|
||||
return { title, body };
|
||||
}
|
||||
|
||||
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
let pending = null;
|
||||
let duplicateRequest = 0;
|
||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||
let acknowledgedDuplicateKey = '';
|
||||
const safeLabelIds = value => Array.from(new Set(
|
||||
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||
)).slice(0, 20);
|
||||
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
|
||||
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
|
||||
const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
|
||||
|
||||
function loadStored() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''};
|
||||
const draft = {
|
||||
repository: String(parsed.repository || ''),
|
||||
title: String(parsed.title || ''),
|
||||
body: String(parsed.body || ''),
|
||||
labelIds: safeLabelIds(parsed.labelIds),
|
||||
operationId: String(parsed.operationId || '').slice(0, 128),
|
||||
};
|
||||
const milestoneId = safeMilestoneId(parsed.milestoneId);
|
||||
const dueDate = safeDueDate(parsed.dueDate);
|
||||
if (milestoneId !== null) draft.milestoneId = milestoneId;
|
||||
if (dueDate) draft.dueDate = dueDate;
|
||||
return draft;
|
||||
} catch (_error) {
|
||||
return {...emptyDraft(), operationId: ''};
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(record) {
|
||||
try { storage.setItem(storageKey, JSON.stringify(record)); }
|
||||
catch (_error) { /* Keep the form as the in-memory fallback. */ }
|
||||
}
|
||||
|
||||
function saveDraft(draft) {
|
||||
const previous = loadStored();
|
||||
const safe = {
|
||||
repository: String(draft?.repository || ''),
|
||||
title: String(draft?.title || ''),
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: safeLabelIds(draft?.labelIds),
|
||||
};
|
||||
const milestoneId = safeMilestoneId(draft?.milestoneId);
|
||||
const dueDate = safeDueDate(draft?.dueDate);
|
||||
if (milestoneId !== null) safe.milestoneId = milestoneId;
|
||||
if (dueDate) safe.dueDate = dueDate;
|
||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
|
||||
.every(key => (previous[key] || '') === (safe[key] || '')) &&
|
||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
|
||||
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
|
||||
return safe;
|
||||
}
|
||||
|
||||
function loadDraft() {
|
||||
const {operationId: _operationId, ...draft} = loadStored();
|
||||
return draft;
|
||||
}
|
||||
|
||||
function clearDraft() {
|
||||
try { storage.removeItem(storageKey); }
|
||||
catch (_error) { /* Confirmed creation remains authoritative. */ }
|
||||
}
|
||||
|
||||
function pendingSharedContent() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(sharedStorageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const shared = normalizeSharedContent({title: parsed.title, text: parsed.body});
|
||||
return shared.title || shared.body ? shared : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function acceptSharedContent() {
|
||||
const shared = pendingSharedContent();
|
||||
if (!shared) return loadDraft();
|
||||
const accepted = saveDraft({...loadDraft(), ...shared});
|
||||
try { storage.removeItem(sharedStorageKey); }
|
||||
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function discardSharedContent() {
|
||||
try { storage.removeItem(sharedStorageKey); }
|
||||
catch (_error) { /* The existing issue draft remains authoritative. */ }
|
||||
}
|
||||
|
||||
function stageSharedContent(value) {
|
||||
const shared = normalizeSharedContent(value);
|
||||
if (!shared.title && !shared.body) return {status: 'empty'};
|
||||
try { storage.setItem(sharedStorageKey, JSON.stringify(shared)); }
|
||||
catch (_error) { /* The caller can still use the in-page share payload. */ }
|
||||
const existing = loadDraft();
|
||||
if (existing.title || existing.body) return {status: 'conflict'};
|
||||
acceptSharedContent();
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function loadLabels(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||
return fetchJson('api/v1/repos/' + encoded + '/labels').then(labels =>
|
||||
(Array.isArray(labels) ? labels : []).slice().sort((left, right) => {
|
||||
const leftPriority = priorities.has(String(left?.name || '').toLowerCase());
|
||||
const rightPriority = priorities.has(String(right?.name || '').toLowerCase());
|
||||
return Number(rightPriority) - Number(leftPriority);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function loadMilestones(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return fetchJson('api/v1/repos/' + encoded + '/milestones').then(milestones =>
|
||||
Array.isArray(milestones) ? milestones : []
|
||||
);
|
||||
}
|
||||
|
||||
function duplicateKey(draft) {
|
||||
const repository = String(draft?.repository || '').trim();
|
||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||
return { repository, title, key: repository + '\n' + title.toLowerCase() };
|
||||
}
|
||||
|
||||
async function findDuplicates(draft) {
|
||||
const input = duplicateKey(draft);
|
||||
const request = ++duplicateRequest;
|
||||
if (!input.repository || input.title.length < 4) {
|
||||
duplicateState = {status: 'idle', key: input.key, candidates: []};
|
||||
return duplicateState;
|
||||
}
|
||||
try {
|
||||
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(input.title) + '&limit=10');
|
||||
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
|
||||
duplicateState = {
|
||||
status: 'ready',
|
||||
key: input.key,
|
||||
partial: payload?.partial === true,
|
||||
candidates: (Array.isArray(payload?.items) ? payload.items : []).filter(item =>
|
||||
item?.kind === 'issue' && item?.state === 'open' && item?.repository === input.repository
|
||||
).slice(0, 3),
|
||||
};
|
||||
return duplicateState;
|
||||
} catch (error) {
|
||||
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
|
||||
duplicateState = {status: 'failed', key: input.key, candidates: [], error};
|
||||
return duplicateState;
|
||||
}
|
||||
}
|
||||
|
||||
function needsDuplicateAcknowledgement(draft) {
|
||||
const {key} = duplicateKey(draft);
|
||||
return duplicateState.status === 'ready' && duplicateState.partial !== true && duplicateState.key === key &&
|
||||
duplicateState.candidates.length > 0 && acknowledgedDuplicateKey !== key;
|
||||
}
|
||||
|
||||
function acknowledgeDuplicates(draft) {
|
||||
const {key} = duplicateKey(draft);
|
||||
if (duplicateState.status === 'ready' && duplicateState.key === key && duplicateState.candidates.length) {
|
||||
acknowledgedDuplicateKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
function submit(draft) {
|
||||
if (pending) return pending;
|
||||
const saved = saveDraft(draft);
|
||||
const stored = loadStored();
|
||||
const operationId = stored.operationId || String(createOperationId()).slice(0, 128);
|
||||
writeStored({...saved, operationId});
|
||||
const repository = saved.repository.split('/').map(encodeURIComponent).join('/');
|
||||
pending = fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: saved.title, body: saved.body, label_ids: saved.labelIds,
|
||||
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
|
||||
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
|
||||
}),
|
||||
}).then(issue => {
|
||||
clearDraft();
|
||||
return issue;
|
||||
}).finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
};
|
||||
}
|
||||
|
||||
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
@@ -0,0 +1,362 @@
|
||||
:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow-x: hidden; }
|
||||
#bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .35; }
|
||||
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.app-menu { margin-left:auto; }
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
#sign-out-all { min-height:44px; }
|
||||
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.active-devices-sheet[hidden] { display:none; }
|
||||
.active-devices-panel { box-sizing:border-box; width:min(560px,100%); height:100%; overflow:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.active-devices-header h2, .active-devices-header p { margin-top:0; }
|
||||
.active-devices-header button, .active-device button { min-height:44px; }
|
||||
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
|
||||
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
|
||||
.active-device-current { color:#55d6be; font-weight:700; }
|
||||
button:hover { filter: brightness(1.15); }
|
||||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||
.panel > summary h2 { display: inline-block; margin-left: 4px; }
|
||||
.panel[open] > summary { margin-bottom: 8px; }
|
||||
aside, section { position: relative; z-index: 1; }
|
||||
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
|
||||
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
|
||||
.sidebar { display:flex; flex-direction:column; gap: 12px; }
|
||||
.stack { display:flex; flex-direction:column; gap:10px; }
|
||||
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
||||
a { color: #60a5fa; text-decoration: none; }
|
||||
.muted { color: #94a3b8; }
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
||||
.kv .label { color: #94a3b8; }
|
||||
.kv .value { color: #e5e7eb; }
|
||||
.suggestion { padding: 10px; border-radius: 12px; border: 1px solid #1b2d45; background: #0f1d33; margin-bottom: 8px; animation: fadein .35s ease; }
|
||||
.suggestion.high { border-color: #fca5a5; background: #2a1216; }
|
||||
.suggestion.medium { border-color: #fcd34d; background: #2a2012; }
|
||||
.suggestion.low { border-color: #d1fae5; background: #122a1e; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
|
||||
.pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; font-size: 12px; }
|
||||
canvas { display: block; width: 100%; height: 260px; background: linear-gradient(180deg,#0a1525,#060c16); border: 1px solid #1f3a5f; border-radius: 10px; }
|
||||
pre { margin: 0; padding: 8px; background: #0b1220; color: #dbe7f2; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; border:1px solid #1f3a5f }
|
||||
input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||||
#cmd-palette.open { display: block; }
|
||||
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
|
||||
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
|
||||
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
|
||||
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
|
||||
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
|
||||
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
||||
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
|
||||
.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; }
|
||||
.small { font-size: 12px; color: #94a3b8; }
|
||||
.status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.offline-status { position:relative; z-index:19; padding:10px 16px; border-bottom:1px solid #f59e0b; background:#35210b; color:#fde68a; font-size:13px; text-align:center; }
|
||||
.offline-status[hidden] { display:none; }
|
||||
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.offline-today-readiness[hidden] { display:none; }
|
||||
.offline-today-readiness button { min-height:44px; }
|
||||
.install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.install-app-card[hidden] { display:none; }
|
||||
.install-app-card p { margin:0; flex:1 1 240px; }
|
||||
.install-app-card button { min-height:44px; }
|
||||
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
|
||||
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
|
||||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||||
.event:last-child { border-bottom: 0; }
|
||||
.my-work { grid-column: 1 / -1; }
|
||||
.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; }
|
||||
.work-settings-panel { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.work-filter { min-height: 44px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
|
||||
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
|
||||
.draft-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%; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.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%; }
|
||||
.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; }
|
||||
.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; }
|
||||
.later-picker { box-sizing:border-box; width:100%; height:100%; max-width:none; max-height:none; margin:0; padding:0; border:0; position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
|
||||
.later-picker[hidden] { display:none; }
|
||||
.later-picker-panel { box-sizing:border-box; width:min(560px,100%); max-height:100%; overflow:auto; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.later-picker-header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.later-picker-header h2 { margin:0; }
|
||||
.later-picker input, .later-picker button { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.later-picker input { padding:10px; border:1px solid #2a496e; border-radius:10px; color:var(--text); background:#08111f; color-scheme:dark; font:inherit; }
|
||||
.later-picker-error { min-height:1.4em; color:#fca5a5; }
|
||||
.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.mark-update-read { min-height:44px; width:100%; }
|
||||
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
|
||||
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
|
||||
.load-more-notifications[hidden] { display:none; }
|
||||
.load-more-work { min-height:44px; width:100%; margin-top:10px; }
|
||||
.load-more-work[hidden] { display:none; }
|
||||
.retry-work-route { min-height:44px; width:100%; margin-top:10px; }
|
||||
.retry-work-route[hidden] { display:none; }
|
||||
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-bulk button { min-height:44px; width:100%; }
|
||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.review-sheet.open { display:flex; }
|
||||
.review-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.review-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.review-sheet-body { white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.review-file, .review-history { padding:8px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||
.review-file-toggle { min-height:44px; width:100%; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; text-align:left; }
|
||||
.review-file-toggle strong { overflow-wrap:anywhere; }
|
||||
.review-file.reviewed { opacity:.72; }
|
||||
.review-file.reviewed .review-file-toggle { border-color:#22c55e; }
|
||||
.review-mark { min-height:44px; width:100%; margin-top:8px; }
|
||||
.review-mark[aria-pressed="true"] { border-color:#22c55e; background:#123c2a; }
|
||||
.review-note { min-height:88px; margin-top:6px; }
|
||||
.review-feedback { display:grid; gap:8px; }
|
||||
.review-feedback label { display:grid; gap:6px; }
|
||||
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.review-handoff button { min-height:44px; }
|
||||
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
|
||||
.review-handoff-link[hidden] { display:none; }
|
||||
.review-merge-continuation { position:sticky; bottom:0; z-index:5; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); }
|
||||
#continue-review-to-merge { min-height:44px; width:100%; }
|
||||
#continue-review-to-merge[hidden] { display:none; }
|
||||
.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-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-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; }
|
||||
.review-inline-composer textarea { min-height:96px; width:100%; }
|
||||
.review-inline-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.review-inline-actions button { min-height:44px; }
|
||||
.review-diff-line.hunk { color:#93c5fd; }
|
||||
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
|
||||
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
|
||||
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
|
||||
.review-action { min-height:44px; }
|
||||
.review-retry { min-height:44px; margin-top:10px; }
|
||||
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.update-sheet.open { display:flex; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.update-sheet-header button { min-height:44px; }
|
||||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.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-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; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.issue-sheet.open { display:flex; }
|
||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.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%; }
|
||||
#edit-issue-content { min-height:44px; width:100%; }
|
||||
.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; }
|
||||
.issue-edit-form label { display:grid; gap:6px; min-width:0; }
|
||||
.issue-edit-form input, .issue-edit-form textarea { box-sizing:border-box; width:100%; max-width:100%; }
|
||||
.issue-edit-form textarea { min-height:132px; resize:vertical; }
|
||||
.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px; }
|
||||
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.issue-comment-composer 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; }
|
||||
.issue-label-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||
.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-due-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-due-editor input { box-sizing:border-box; width:100%; max-width:100%; }
|
||||
.issue-due-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-due-editor input, .issue-due-editor button { min-height:44px; }
|
||||
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff select, .issue-handoff button { min-height:44px; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.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; }
|
||||
.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%; }
|
||||
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
|
||||
.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-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; }
|
||||
.search-preview { position:fixed; inset:0; z-index:60; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.search-preview.open { display:flex; }
|
||||
.search-preview-panel { box-sizing:border-box; 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; overflow-wrap:anywhere; }
|
||||
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
|
||||
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
|
||||
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.search-preview-actions 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; }
|
||||
.markdown-content > :last-child { margin-bottom:0; }
|
||||
.markdown-content pre { max-width:100%; overflow-x:auto; padding:10px; border-radius:8px; background:#07101d; white-space:pre; -webkit-overflow-scrolling:touch; }
|
||||
.markdown-content code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }
|
||||
.markdown-content :not(pre) > code { padding:2px 4px; border-radius:4px; background:#07101d; }
|
||||
.markdown-content blockquote { margin:10px 0; padding-left:12px; border-left:3px solid #60a5fa; color:#b9c9dd; }
|
||||
.markdown-content ul { padding-left:24px; }
|
||||
.markdown-content .task-list { padding-left:0; list-style:none; }
|
||||
.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; }
|
||||
.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; }
|
||||
.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; }
|
||||
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.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-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-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; }
|
||||
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
|
||||
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.create-issue-label-option input { width:20px; height:20px; margin:0; }
|
||||
.create-issue-duplicates { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2b210f; }
|
||||
.create-issue-duplicates[hidden] { display:none; }
|
||||
.create-issue-duplicate-list { display:grid; gap:8px; min-width:0; }
|
||||
.create-issue-duplicate-card { display:grid; gap:4px; min-width:0; padding:10px; border:1px solid #6b5220; border-radius:10px; background:#171d29; overflow-wrap:anywhere; }
|
||||
.create-issue-duplicate-card a, #create-issue-anyway { display:flex; align-items:center; min-height:44px; max-width:100%; }
|
||||
.create-issue-duplicate-card a { color:#93c5fd; }
|
||||
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; }
|
||||
.shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.shared-content-actions button { min-height:44px; }
|
||||
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.pull-sheet.open { display:flex; }
|
||||
.pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.pull-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.pull-sheet-header button, .pull-sheet-actions button, .pull-sheet-actions a, .pull-comment-composer button { min-height:44px; }
|
||||
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
|
||||
.conversation-more { min-height:44px; width:100%; margin:8px 0; }
|
||||
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
|
||||
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
|
||||
.pull-review-file { margin-top:8px; }
|
||||
.pull-diff { overflow-x:auto; margin:8px 0; padding:10px; background:#07111f; border-radius:8px; font-size:12px; }
|
||||
.pull-diff-line { display:block; width:max-content; min-width:100%; }
|
||||
.pull-diff-line.added { color:#86efac; background:#123422; }
|
||||
.pull-diff-line.removed { color:#fca5a5; background:#3b161b; }
|
||||
.pull-diff-line.hunk, .pull-diff-note { color:#93c5fd; }
|
||||
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
|
||||
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
|
||||
.pull-review-tools button { min-height:44px; }
|
||||
.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; }
|
||||
.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
|
||||
.pull-review summary h2 { margin:0; font-size:16px; }
|
||||
.pull-review > #merge-pull { min-height:44px; width:100%; margin-top:10px; }
|
||||
.pull-comment-composer textarea { width:100%; min-height:110px; resize:vertical; }
|
||||
.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.mobile-task-dock { display:none; }
|
||||
.mobile-task-dock[hidden] { display:none; }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
|
||||
@media (max-width: 600px) {
|
||||
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
||||
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
|
||||
.app-brand .muted, #clock { display:none; }
|
||||
.app-live-status { margin-left:auto; }
|
||||
.app-menu { position:relative; margin-left:0; }
|
||||
.app-menu > summary { min-width:44px; min-height:44px; display:grid; place-items:center; cursor:pointer; border:1px solid #2a496e; border-radius:10px; list-style:none; }
|
||||
.app-menu > summary::-webkit-details-marker { display:none; }
|
||||
.app-menu:not([open]) > .app-menu-panel { display:none; }
|
||||
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
|
||||
.app-menu-panel button { min-height:44px; width:100%; }
|
||||
.my-work { margin:0; }
|
||||
.my-work-header { align-items:flex-start; }
|
||||
.my-work-actions { width:100%; flex-wrap:nowrap; }
|
||||
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
|
||||
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
|
||||
#cmd-palette.open { display:flex; flex-direction:column; }
|
||||
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
|
||||
#close-command-palette { min-height:44px; }
|
||||
#cmd-input { flex:0 0 auto; min-height:44px; }
|
||||
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-sheet-panel.composer-keyboard-active,
|
||||
.pull-sheet-panel.composer-keyboard-active,
|
||||
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.composer-keyboard-active .issue-comment-composer,
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
function createDetailDefer({
|
||||
laterWork,
|
||||
session,
|
||||
close,
|
||||
refresh,
|
||||
focus,
|
||||
announce,
|
||||
formatTime = value => new Date(value).toLocaleString(),
|
||||
}) {
|
||||
return {
|
||||
deferUntil(item, until, { closeSheet = true, restoreFocus = true } = {}) {
|
||||
if (!item) return false;
|
||||
const result = laterWork.defer(item, until);
|
||||
if (result !== 'deferred') {
|
||||
announce(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
|
||||
return false;
|
||||
}
|
||||
const inSession = session.active();
|
||||
if (!inSession && closeSheet) close();
|
||||
refresh();
|
||||
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
|
||||
if (!inSession && restoreFocus) focus();
|
||||
return true;
|
||||
},
|
||||
defer(item, preset) {
|
||||
return this.deferUntil(item, laterWork.presetUntil(preset));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDetailDefer;
|
||||
@@ -0,0 +1,220 @@
|
||||
function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Date.now() }) {
|
||||
const indexKey = 'stackchain.draft-index.v1';
|
||||
|
||||
function readIndex() {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(indexKey) || '{}');
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
} catch (_error) { return {}; }
|
||||
}
|
||||
|
||||
function writeIndex(index) {
|
||||
try { storage?.setItem(indexKey, JSON.stringify(index)); }
|
||||
catch (_error) { /* Draft discovery remains available without metadata. */ }
|
||||
}
|
||||
|
||||
function keys() {
|
||||
try {
|
||||
return Array.from({ length: Number(storage?.length || 0) }, (_, index) => storage.key(index))
|
||||
.filter(Boolean);
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function textPreview(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 180);
|
||||
}
|
||||
|
||||
function parseTarget(value) {
|
||||
const match = String(value || '').match(/^(.+)#(\d+)$/);
|
||||
return match ? { repository: match[1], number: Number(match[2]) } : null;
|
||||
}
|
||||
|
||||
function parse(key, raw) {
|
||||
let match;
|
||||
if (key === 'stackchain.issue-capture.v1') {
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
|
||||
if (!preview && !value?.repository) return null;
|
||||
return {
|
||||
kind: 'new-issue', label: 'New issue', repository: String(value?.repository || ''),
|
||||
title: textPreview(value?.title) || 'Untitled new issue', preview,
|
||||
};
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
match = key.match(/^stackchain\.(issue|pull)-comment\.v1:(.+#\d+)$/);
|
||||
if (match) {
|
||||
const target = parseTarget(match[2]);
|
||||
const preview = textPreview(raw);
|
||||
if (!target || !preview) return null;
|
||||
const routeKind = match[1] === 'issue' ? 'issue' : 'pull';
|
||||
return {
|
||||
kind: routeKind + '-comment', label: routeKind === 'issue' ? 'Issue comment' : 'PR comment',
|
||||
...target, title: target.repository + '#' + target.number, preview,
|
||||
route: { kind: routeKind, ...target },
|
||||
};
|
||||
}
|
||||
match = key.match(/^stackchain\.issue-content\.v1:(.+#\d+)$/);
|
||||
if (match) {
|
||||
const target = parseTarget(match[1]);
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
|
||||
if (!target || !preview || typeof value?.expectedUpdatedAt !== 'string') return null;
|
||||
return {
|
||||
kind: 'issue-edit', label: 'Issue edit', ...target,
|
||||
title: target.repository + '#' + target.number, preview,
|
||||
route: { kind: 'issue', ...target },
|
||||
};
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
match = key.match(/^stackchain\.update-reply\.v1\.(\d+)$/);
|
||||
if (match) {
|
||||
const preview = textPreview(raw);
|
||||
if (!preview) return null;
|
||||
const notificationId = Number(match[1]);
|
||||
return {
|
||||
kind: 'update-reply', label: 'Update reply', notification_id: notificationId,
|
||||
title: 'Update #' + notificationId, preview,
|
||||
route: { kind: 'update', notification_id: notificationId },
|
||||
};
|
||||
}
|
||||
match = key.match(/^stackchain\.review-draft\.v1:(.+#\d+)@([^:]+)$/);
|
||||
if (match) {
|
||||
const target = parseTarget(match[1]);
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
const notes = Object.values(value?.notes || {});
|
||||
const comments = Array.isArray(value?.comments) ? value.comments.map(item => item?.body) : [];
|
||||
const preview = textPreview([value?.summary, ...notes, ...comments].filter(Boolean).join(' — '));
|
||||
if (!target || !preview) return null;
|
||||
return {
|
||||
kind: 'review', label: 'PR review', ...target, head_sha: match[2],
|
||||
title: target.repository + '#' + target.number, preview,
|
||||
route: { kind: 'review', ...target },
|
||||
};
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (![1, 2].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'
|
||||
).map(item => {
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
return {
|
||||
id: 'stackchain.issue-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
kind: 'issue-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued issue'),
|
||||
delivery_state: item.deliveryState,
|
||||
repository: item.repository,
|
||||
title: textPreview(item.title) || 'Untitled queued issue',
|
||||
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: [item.title, item.body].filter(Boolean).join('\n\n'),
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
};
|
||||
});
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function parseAuthoredOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (![1, 2].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.body === 'string')
|
||||
.map(item => {
|
||||
const isUpdate = item.kind === 'update-reply';
|
||||
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
return {
|
||||
id: 'stackchain.authored-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? 'Needs attention' : '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,
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
|
||||
{ kind:routeKind, repository:item.repository, number:item.number },
|
||||
};
|
||||
});
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function list() {
|
||||
const index = readIndex();
|
||||
const seen = new Set();
|
||||
const drafts = [];
|
||||
keys().forEach(key => {
|
||||
if (key === indexKey || key.endsWith(':operation')) return;
|
||||
let raw;
|
||||
try { raw = storage.getItem(key); }
|
||||
catch (_error) { return; }
|
||||
if (key === 'stackchain.issue-outbox.v1') {
|
||||
parseOutbox(raw).forEach(item => drafts.push(item));
|
||||
seen.add(key);
|
||||
return;
|
||||
}
|
||||
if (key === 'stackchain.authored-outbox.v1') {
|
||||
parseAuthoredOutbox(raw).forEach(item => drafts.push(item));
|
||||
seen.add(key);
|
||||
return;
|
||||
}
|
||||
const parsed = parse(key, raw);
|
||||
if (!parsed) return;
|
||||
seen.add(key);
|
||||
const fingerprint = String(raw);
|
||||
const previous = index[key];
|
||||
if (!previous || previous.fingerprint !== fingerprint) {
|
||||
index[key] = { fingerprint, updated_at: Number(now()) };
|
||||
}
|
||||
drafts.push({ id: key, ...parsed, updated_at: index[key].updated_at });
|
||||
});
|
||||
Object.keys(index).forEach(key => { if (!seen.has(key)) delete index[key]; });
|
||||
writeIndex(index);
|
||||
return drafts.sort((left, right) =>
|
||||
Number(right.updated_at || 0) - Number(left.updated_at || 0) || left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
if (typeof id !== 'string' || !parse(id, (() => {
|
||||
try { return storage?.getItem(id); } catch (_error) { return null; }
|
||||
})())) return false;
|
||||
try {
|
||||
storage?.removeItem(id);
|
||||
storage?.removeItem(id + ':operation');
|
||||
const index = readIndex();
|
||||
delete index[id];
|
||||
writeIndex(index);
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
return { list, discard };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 966 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
+451
-424
@@ -3,123 +3,109 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0b1220" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<title>Stackchain Dashboard</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow-x: hidden; }
|
||||
#bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .35; }
|
||||
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
button:hover { filter: brightness(1.15); }
|
||||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||
.panel > summary h2 { display: inline-block; margin-left: 4px; }
|
||||
.panel[open] > summary { margin-bottom: 8px; }
|
||||
aside, section { position: relative; z-index: 1; }
|
||||
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
|
||||
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
|
||||
.sidebar { display:flex; flex-direction:column; gap: 12px; }
|
||||
.stack { display:flex; flex-direction:column; gap:10px; }
|
||||
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
||||
a { color: #60a5fa; text-decoration: none; }
|
||||
.muted { color: #94a3b8; }
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
||||
.kv .label { color: #94a3b8; }
|
||||
.kv .value { color: #e5e7eb; }
|
||||
.suggestion { padding: 10px; border-radius: 12px; border: 1px solid #1b2d45; background: #0f1d33; margin-bottom: 8px; animation: fadein .35s ease; }
|
||||
.suggestion.high { border-color: #fca5a5; background: #2a1216; }
|
||||
.suggestion.medium { border-color: #fcd34d; background: #2a2012; }
|
||||
.suggestion.low { border-color: #d1fae5; background: #122a1e; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
|
||||
.pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; font-size: 12px; }
|
||||
canvas { display: block; width: 100%; height: 260px; background: linear-gradient(180deg,#0a1525,#060c16); border: 1px solid #1f3a5f; border-radius: 10px; }
|
||||
pre { margin: 0; padding: 8px; background: #0b1220; color: #dbe7f2; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; border:1px solid #1f3a5f }
|
||||
input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||||
#cmd-palette.open { display: block; }
|
||||
.cmd-item { padding: 10px; cursor: pointer; border-radius: 10px; color:#e5e7eb; }
|
||||
.cmd-item:hover { background: #10233a; }
|
||||
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
||||
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
|
||||
.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; }
|
||||
.small { font-size: 12px; color: #94a3b8; }
|
||||
.status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
|
||||
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
|
||||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||||
.event:last-child { border-bottom: 0; }
|
||||
.my-work { grid-column: 1 / -1; }
|
||||
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.work-filter { min-height: 44px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.my-work-card { min-height: 44px; display:block; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.my-work-card.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.review-sheet.open { display:flex; }
|
||||
.review-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.review-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.review-sheet-body { white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.review-file, .review-history { padding:8px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||
.review-file-toggle { min-height:44px; width:100%; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; text-align:left; }
|
||||
.review-file-toggle strong { overflow-wrap:anywhere; }
|
||||
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
|
||||
.review-diff-line { display:block; min-width:max-content; }
|
||||
.review-diff-line.hunk { color:#93c5fd; }
|
||||
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
|
||||
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
|
||||
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
|
||||
.review-action { min-height:44px; }
|
||||
@media (max-width: 600px) {
|
||||
header { align-items:flex-start; }
|
||||
.my-work { margin:0; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
<link rel="stylesheet" href="static/dashboard.css" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="bg"></canvas>
|
||||
<header>
|
||||
<div><span class="obi"></span> <strong style="margin-left:6px;">Stackchain</strong> <span class="muted">Dashboard</span></div>
|
||||
<div class="toolbar">
|
||||
<div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div>
|
||||
<button id="refresh">Refresh</button>
|
||||
<button id="open-palette">Command</button>
|
||||
<span class="small muted" id="clock"></span>
|
||||
</div>
|
||||
<div class="app-brand"><span class="obi"></span><strong>Stackchain</strong><span class="muted">Dashboard</span></div>
|
||||
<div class="app-live-status"><span class="dot"></span><span class="small" id="status">Live</span></div>
|
||||
<details class="app-menu">
|
||||
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
|
||||
<div class="app-menu-panel">
|
||||
<button id="refresh">Refresh</button>
|
||||
<button id="open-palette">Command</button>
|
||||
<button id="active-devices" type="button">Active devices</button>
|
||||
<button id="sign-out" type="button">Sign out & clear this device</button>
|
||||
<button id="sign-out-all" type="button">Sign out all devices</button>
|
||||
<span class="small muted" id="clock"></span>
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
<div class="offline-status" id="offline-status" role="status" aria-live="polite" hidden>
|
||||
Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
|
||||
</div>
|
||||
|
||||
<div id="active-devices-sheet" class="active-devices-sheet" hidden>
|
||||
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices">
|
||||
<div class="active-devices-header">
|
||||
<div><h2>Active devices</h2><p class="small muted">Review signed-in devices and remove access you no longer trust.</p></div>
|
||||
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
||||
</div>
|
||||
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="active-devices-list" class="active-devices-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<section class="panel my-work" id="my-work">
|
||||
<section class="panel my-work" id="my-work" tabindex="-1">
|
||||
<div class="my-work-header">
|
||||
<div>
|
||||
<h2>My Work</h2>
|
||||
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
|
||||
</div>
|
||||
<div class="work-filters" aria-label="Filter My Work">
|
||||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||
<div class="my-work-actions">
|
||||
<button class="start-work-session" id="start-work-session" type="button">Start work</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>
|
||||
<details class="work-settings">
|
||||
<summary id="work-settings-toggle">Queue & settings · <span id="active-work-queue">All</span></summary>
|
||||
<div class="work-settings-panel">
|
||||
<div class="work-filters" aria-label="Filter My Work">
|
||||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
||||
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
|
||||
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
|
||||
<button class="work-filter" data-work-filter="later" aria-pressed="false">Later <span data-work-count="later">0</span></button>
|
||||
<button class="work-filter" data-work-filter="draft" aria-pressed="false">Drafts <span data-work-count="draft">0</span></button>
|
||||
</div>
|
||||
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
|
||||
<select class="work-milestone-filter" id="work-milestone-filter">
|
||||
<option value="all">All milestones</option>
|
||||
<option value="unplanned">Unplanned</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="offline-work-controls">
|
||||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||
<span class="offline-today-readiness" id="offline-today-readiness" hidden>
|
||||
<span class="small" id="offline-today-status" role="status" aria-live="polite"></span>
|
||||
<button id="retry-offline-today" type="button" hidden>Retry offline items</button>
|
||||
</span>
|
||||
<span class="small" id="delivery-receipt-status" role="status" aria-live="polite"></span>
|
||||
<div class="install-app-card" id="install-app-card" hidden>
|
||||
<p><strong>Install Stackchain</strong><br><span class="small">Keep mobile work one tap away and launch the saved app shell during an outage.</span></p>
|
||||
<p class="small" id="install-app-guidance" hidden>On Safari, tap Share, then choose <strong>Add to Home Screen</strong>.</p>
|
||||
<button id="install-app" type="button">Install Stackchain</button>
|
||||
<button id="dismiss-install-app" type="button">Not now</button>
|
||||
</div>
|
||||
<span class="small" id="install-app-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
||||
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
||||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
||||
<div class="small" id="later-sync-status" aria-live="polite">Later is saved on this device.</div>
|
||||
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
|
||||
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
||||
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
||||
<button id="bulk-mark-read" type="button"></button>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="sidebar">
|
||||
<details class="panel stack" data-panel-key="context" open>
|
||||
@@ -185,8 +171,31 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
</main>
|
||||
|
||||
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
||||
<input id="cmd-input" type="text" placeholder="Type a command, e.g. whiteboard, markdown..." />
|
||||
<div id="cmd-results" class="stack" style="margin-top:8px;"></div>
|
||||
<div class="cmd-palette-header">
|
||||
<strong>Search work</strong>
|
||||
<button id="close-command-palette" type="button">Close</button>
|
||||
</div>
|
||||
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
|
||||
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
|
||||
</div>
|
||||
|
||||
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
|
||||
<section class="search-preview-panel">
|
||||
<div class="search-preview-header">
|
||||
<div>
|
||||
<div class="small" id="search-preview-key"></div>
|
||||
<h3 id="search-preview-title">Work preview</h3>
|
||||
</div>
|
||||
<button id="close-search-preview" type="button">Back to search</button>
|
||||
</div>
|
||||
<div id="search-preview-status" class="small" aria-live="polite">Choose a search result.</div>
|
||||
<div id="search-preview-meta" class="row"></div>
|
||||
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
|
||||
<div class="search-preview-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="whiteboard-modal">
|
||||
@@ -206,6 +215,247 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="issue-sheet" id="issue-sheet" role="dialog" aria-modal="true" aria-labelledby="issue-sheet-title">
|
||||
<section class="issue-sheet-panel">
|
||||
<div class="issue-sheet-header">
|
||||
<div>
|
||||
<div class="small" id="issue-sheet-key"></div>
|
||||
<h3 id="issue-sheet-title">Assigned issue</h3>
|
||||
</div>
|
||||
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||||
</div>
|
||||
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
||||
<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>
|
||||
<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>
|
||||
<div id="issue-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<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 id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<details class="issue-planning" id="issue-planning">
|
||||
<summary>Plan & edit</summary>
|
||||
<button class="issue-planning-retry" id="retry-issue-planning" type="button" hidden>Retry planning controls</button>
|
||||
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
|
||||
<form class="issue-edit-form" id="issue-edit-form" hidden>
|
||||
<label for="issue-edit-title">Title
|
||||
<input id="issue-edit-title" type="text" maxlength="255" required autocomplete="off" />
|
||||
</label>
|
||||
<label for="issue-edit-body">Description
|
||||
<textarea id="issue-edit-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<div class="issue-edit-actions">
|
||||
<button id="save-issue-content" type="submit">Save issue</button>
|
||||
<button id="cancel-issue-content" type="button">Cancel</button>
|
||||
</div>
|
||||
<div id="issue-edit-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
<fieldset class="issue-label-editor" id="issue-label-editor" aria-describedby="issue-label-status">
|
||||
<legend>Labels</legend>
|
||||
<div class="issue-label-list" id="issue-label-list"></div>
|
||||
<div class="small" id="issue-label-status" aria-live="polite">Expand planning controls to load labels.</div>
|
||||
<button id="save-issue-labels" type="button" disabled>Save labels</button>
|
||||
</fieldset>
|
||||
<fieldset class="issue-due-editor" aria-describedby="issue-due-status">
|
||||
<legend>Due date</legend>
|
||||
<label for="issue-due-date" class="small">Schedule this assigned issue</label>
|
||||
<input id="issue-due-date" type="date" disabled />
|
||||
<div class="issue-due-actions">
|
||||
<button id="save-issue-due-date" type="button" disabled>Save due date</button>
|
||||
<button id="clear-issue-due-date" type="button" disabled>Clear due date</button>
|
||||
</div>
|
||||
<div id="issue-due-status" class="small" aria-live="assertive">Load an issue to schedule it.</div>
|
||||
</fieldset>
|
||||
<fieldset class="issue-milestone-editor" aria-describedby="issue-milestone-status">
|
||||
<legend>Milestone</legend>
|
||||
<label for="issue-milestone" class="small">Plan this issue into a release</label>
|
||||
<select id="issue-milestone" disabled><option value="">No milestone</option></select>
|
||||
<button id="save-issue-milestone" type="button" disabled>Save milestone</button>
|
||||
<div id="issue-milestone-status" class="small" aria-live="assertive">Expand planning controls to load milestones.</div>
|
||||
</fieldset>
|
||||
</details>
|
||||
<details class="issue-handoff" id="issue-handoff">
|
||||
<summary>Hand off to teammate</summary>
|
||||
<div>
|
||||
<button id="load-issue-handoff" type="button">Choose teammate</button>
|
||||
<label for="issue-handoff-recipient" class="small">Eligible repository assignee</label>
|
||||
<select id="issue-handoff-recipient" disabled><option value="">Select a teammate</option></select>
|
||||
<button id="confirm-issue-handoff" type="button" disabled>Confirm handoff</button>
|
||||
<div id="issue-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="issue-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="release-issue" type="button">Release assignment</button>
|
||||
<button id="close-issue" type="button">Close issue</button>
|
||||
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<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-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="find-work-sheet" id="find-work-sheet" role="dialog" aria-modal="true" aria-labelledby="find-work-heading">
|
||||
<section class="find-work-panel">
|
||||
<div class="find-work-header">
|
||||
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
|
||||
<button id="close-find-work" type="button">Close</button>
|
||||
</div>
|
||||
<p class="small">Claim an available issue and continue it in My Work.</p>
|
||||
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||
<div class="find-work-list" id="find-work-list"></div>
|
||||
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
|
||||
<section class="create-issue-panel">
|
||||
<div class="create-issue-header">
|
||||
<h3 id="create-issue-heading">New issue</h3>
|
||||
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||
</div>
|
||||
<aside class="shared-content-conflict" id="shared-content-conflict" aria-live="assertive" hidden>
|
||||
<strong>You already have an unfinished issue draft.</strong>
|
||||
<span class="small">Resume it, or replace its title and description with the content shared to Stackchain.</span>
|
||||
<div class="shared-content-actions">
|
||||
<button id="resume-issue-draft" type="button">Resume existing draft</button>
|
||||
<button id="use-shared-content" type="button">Use shared content</button>
|
||||
</div>
|
||||
</aside>
|
||||
<form class="create-issue-form" id="create-issue-form">
|
||||
<label for="create-issue-repository">Repository
|
||||
<select id="create-issue-repository" required></select>
|
||||
</label>
|
||||
<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>
|
||||
<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>
|
||||
</select>
|
||||
<span class="small" id="create-issue-milestone-status" aria-live="polite">Choose a repository to load milestones.</span>
|
||||
</label>
|
||||
<label for="create-issue-due-date">Due date <span class="small">Optional</span>
|
||||
<input id="create-issue-due-date" type="date" />
|
||||
</label>
|
||||
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
|
||||
<legend>Labels <span class="small">Optional</span></legend>
|
||||
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
|
||||
<div class="create-issue-label-list" id="create-issue-label-list"></div>
|
||||
</fieldset>
|
||||
<section class="create-issue-duplicates" id="create-issue-duplicates" aria-live="polite" hidden>
|
||||
<strong>Possible existing issues</strong>
|
||||
<div id="create-issue-duplicate-status" class="small"></div>
|
||||
<div id="create-issue-duplicate-list" class="create-issue-duplicate-list"></div>
|
||||
<button id="create-issue-anyway" type="button" hidden>Create anyway</button>
|
||||
</section>
|
||||
<div class="small">The issue will be assigned to you.</div>
|
||||
<div class="create-issue-actions">
|
||||
<button id="save-unfiled-issue" type="button">Save for filing</button>
|
||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||||
<section class="update-sheet-panel">
|
||||
<div class="update-sheet-header">
|
||||
<div>
|
||||
<div class="small" id="update-sheet-key"></div>
|
||||
<h3 id="update-sheet-title">Unread update</h3>
|
||||
</div>
|
||||
<button id="keep-update-unread" type="button">Keep unread</button>
|
||||
</div>
|
||||
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
|
||||
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
|
||||
<div class="row">
|
||||
<span class="pill" id="update-subject-type">Update</span>
|
||||
<span class="pill" id="update-subject-state"></span>
|
||||
</div>
|
||||
<h2>Full conversation</h2>
|
||||
<div id="update-comments"></div>
|
||||
<button class="conversation-more" id="load-older-update-comments" type="button" hidden>Load older messages</button>
|
||||
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<details>
|
||||
<summary><h2>Subject context</h2></summary>
|
||||
<div class="update-sheet-content muted markdown-content" id="update-subject-body"></div>
|
||||
</details>
|
||||
<section class="update-reply" aria-labelledby="update-reply-title">
|
||||
<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 id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="update-sheet-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<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-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
|
||||
<section class="pull-sheet-panel">
|
||||
<div class="pull-sheet-header">
|
||||
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div>
|
||||
<button id="close-pull-sheet" type="button">Close</button>
|
||||
</div>
|
||||
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
|
||||
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
|
||||
<div class="pull-sheet-content markdown-content" id="pull-sheet-body"></div>
|
||||
<h2>Full conversation</h2><div id="pull-comments"></div>
|
||||
<button class="conversation-more" id="load-older-pull-comments" type="button" hidden>Load older messages</button>
|
||||
<div id="pull-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<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 id="pull-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<details class="pull-review" id="pull-review">
|
||||
<summary><h2>Review & merge</h2></summary>
|
||||
<div id="pull-review-status" class="small" aria-live="polite">Expand to load changed files and merge readiness.</div>
|
||||
<button class="pull-retry" id="pull-review-retry" type="button" hidden>Retry review data</button>
|
||||
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Review data not loaded</span></div>
|
||||
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
|
||||
<div id="pull-files"></div>
|
||||
<button id="merge-pull" type="button" disabled>Merge</button>
|
||||
</details>
|
||||
<div class="pull-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="review-sheet" id="review-sheet" role="dialog" aria-modal="true" aria-labelledby="review-sheet-title">
|
||||
<section class="review-sheet-panel">
|
||||
<div class="review-sheet-header">
|
||||
@@ -216,341 +466,118 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<button class="review-action" id="close-review-sheet">Close</button>
|
||||
</div>
|
||||
<div id="review-sheet-status" class="small" aria-live="polite">Choose a review request.</div>
|
||||
<p class="review-sheet-body" id="review-sheet-body"></p>
|
||||
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
|
||||
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
|
||||
<div class="review-sheet-body markdown-content" id="review-sheet-body"></div>
|
||||
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><button class="share-work-route" type="button">Share</button><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
|
||||
<h2>Changed files</h2>
|
||||
<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>
|
||||
<label class="small" id="review-inline-location" for="review-inline-body"></label>
|
||||
<textarea id="review-inline-body" maxlength="10000" placeholder="Write a line-specific comment"></textarea>
|
||||
<div class="review-inline-actions">
|
||||
<button id="save-inline-comment" type="button">Save draft comment</button>
|
||||
<button id="delete-inline-comment" type="button">Delete draft</button>
|
||||
<button id="cancel-inline-comment" type="button">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="review-progress-actions">
|
||||
<span class="small" id="review-progress" aria-live="polite">0 of 0 files reviewed</span>
|
||||
<button id="next-unreviewed-review" disabled>Next unreviewed</button>
|
||||
</div>
|
||||
<section class="review-feedback" aria-labelledby="review-feedback-title">
|
||||
<h2 id="review-feedback-title">Draft feedback</h2>
|
||||
<label>Intended decision
|
||||
<select id="review-decision">
|
||||
<option value="comment">Comment</option>
|
||||
<option value="approve">Approve</option>
|
||||
<option value="request_changes">Request changes</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Overall summary
|
||||
<textarea id="review-summary" placeholder="Summarize your review"></textarea>
|
||||
</label>
|
||||
<div class="review-handoff">
|
||||
<button id="submit-review">Submit review</button>
|
||||
<span class="small" id="review-submit-status" aria-live="assertive"></span>
|
||||
<button id="copy-review-feedback">Copy feedback & continue in Gitea</button>
|
||||
<span class="small" id="review-handoff-status" aria-live="polite"></span>
|
||||
<textarea class="review-copy-fallback" id="review-copy-fallback" readonly hidden aria-label="Feedback to copy manually"></textarea>
|
||||
<a class="review-handoff-link" id="review-handoff-link" target="_blank" rel="noopener noreferrer" hidden>Continue to Gitea</a>
|
||||
</div>
|
||||
<div class="review-merge-continuation">
|
||||
<button id="continue-review-to-merge" type="button" hidden>Continue to merge</button>
|
||||
</div>
|
||||
</section>
|
||||
<h2>Review history</h2>
|
||||
<div id="review-history" class="muted"></div>
|
||||
<div class="review-sheet-actions"><details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details></div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<dialog class="later-picker" id="later-picker" role="dialog" aria-modal="true" aria-labelledby="later-picker-heading" hidden>
|
||||
<form class="later-picker-panel" id="later-picker-form">
|
||||
<div class="later-picker-header"><h2 id="later-picker-heading">Choose return time</h2><button id="cancel-later-picker" type="button">Cancel</button></div>
|
||||
<p class="small">The item stays unread and unchanged in Gitea.</p>
|
||||
<label for="later-picker-time">Return this work at</label>
|
||||
<input id="later-picker-time" type="datetime-local" required />
|
||||
<div class="small" id="later-picker-timezone"></div>
|
||||
<div class="small later-picker-error" id="later-picker-error" aria-live="assertive"></div>
|
||||
<button id="confirm-later-picker" type="submit">Defer until…</button>
|
||||
</form>
|
||||
</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="find" type="button">Find</button>
|
||||
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
|
||||
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
||||
<button class="mobile-task-action" data-mobile-task="drafts" type="button">Drafts <span class="mobile-task-count" id="mobile-draft-count" aria-label="0 drafts">0</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/unfiled-captures.js"></script>
|
||||
<script src="static/outbox-coordinator.js"></script>
|
||||
<script src="static/background-issue-sync.js"></script>
|
||||
<script src="static/issue-outbox.js"></script>
|
||||
<script src="static/authored-outbox.js"></script>
|
||||
<script src="static/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/today-work.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/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-sheet.js"></script>
|
||||
<script src="static/create-issue-sheet.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>
|
||||
(function(){
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
const fmt = (d) => new Date(d).toLocaleString();
|
||||
const PANEL_STATE_KEY = "stackchain.panel-state.v1";
|
||||
const panels = Array.from(document.querySelectorAll('details[data-panel-key]'));
|
||||
let savedState = {};
|
||||
try {
|
||||
savedState = JSON.parse(localStorage.getItem(PANEL_STATE_KEY) || '{}');
|
||||
} catch (e) {
|
||||
savedState = {};
|
||||
}
|
||||
panels.forEach((panel) => {
|
||||
if (Object.prototype.hasOwnProperty.call(savedState, panel.dataset.panelKey)) {
|
||||
panel.open = savedState[panel.dataset.panelKey];
|
||||
}
|
||||
panel.addEventListener('toggle', () => {
|
||||
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
|
||||
try {
|
||||
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
console.warn('Could not persist panel state', e);
|
||||
}
|
||||
});
|
||||
});
|
||||
let liveMode = true;
|
||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||||
let selectedWorkFilter = 'all';
|
||||
try {
|
||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||
if (['all', 'issue', 'pull', 'review'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||
} catch (e) {
|
||||
console.warn('Could not restore My Work filter', e);
|
||||
}
|
||||
let lastMyWork = [];
|
||||
let hasContextSnapshot = false;
|
||||
let selectedReview = null;
|
||||
let reviewTrigger = null;
|
||||
|
||||
async function fetchReviewJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Review request failed.');
|
||||
return payload;
|
||||
}
|
||||
const reviewController = createReviewController({ fetchJson: fetchReviewJson });
|
||||
|
||||
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
|
||||
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
||||
setClock(); setInterval(setClock, 1000);
|
||||
|
||||
async function fetchContextSnapshot() {
|
||||
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function renderContextSnapshot(data) {
|
||||
liveMode = true;
|
||||
hasContextSnapshot = true;
|
||||
if (data.error && lastMyWork.length) markMyWorkStale();
|
||||
else paintMyWork(data);
|
||||
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
|
||||
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
|
||||
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
|
||||
'<div class="label">PRs</div><div class="value">' + (data.pull_requests?.length || 0) + '</div></div>';
|
||||
qs('#view-hint').textContent = 'Active view: ' + (data.view || 'dashboard');
|
||||
|
||||
const issuesBox = qs('#issues-content');
|
||||
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
|
||||
issuesBox.innerHTML = (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
|
||||
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
|
||||
|
||||
const prsBox = qs('#prs-content');
|
||||
const openPrs = (data.pull_requests || []).slice(0, 12);
|
||||
prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
|
||||
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
|
||||
|
||||
paintDeltas(data.deltas || []);
|
||||
qs('#layout-hint').innerHTML = '<div class="kv"><div class="label">Active view</div><div class="value">' + escapeHtml(data.view || 'dashboard') + '</div><div class="label">Deltas</div><div class="value">' + (data.deltas||[]).length + '</div></div>';
|
||||
renderRepoMix(qs('#repo-mix'), data);
|
||||
setStatus(data.error ? 'Degraded · ' + data.error : 'Live · updated just now');
|
||||
setClock();
|
||||
}
|
||||
|
||||
function handleContextError(e) {
|
||||
console.error('context failed', e);
|
||||
liveMode = false;
|
||||
setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable');
|
||||
if (!hasContextSnapshot) {
|
||||
qs('#context').innerHTML = '<div class="muted">Context unavailable.</div>';
|
||||
qs('#view-hint').textContent = 'Active view unavailable.';
|
||||
qs('#issues-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
||||
qs('#prs-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
||||
paintDeltas([]);
|
||||
}
|
||||
markMyWorkStale();
|
||||
}
|
||||
|
||||
function paintMyWork(data) {
|
||||
lastMyWork = buildMyWork(data);
|
||||
const counts = countMyWork(lastMyWork);
|
||||
Object.entries(counts).forEach(([filter, count]) => {
|
||||
const element = qs('[data-work-count="' + filter + '"]');
|
||||
if (element) element.textContent = count;
|
||||
});
|
||||
qs('#my-work').removeAttribute('data-stale');
|
||||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||||
summarizeMyWork(lastMyWork) : 'No assigned work or review requests.';
|
||||
renderMyWork();
|
||||
}
|
||||
|
||||
function renderMyWork() {
|
||||
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
|
||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||
const contents =
|
||||
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : 'Issue') + '</span>' +
|
||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
|
||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||||
if (item.is_review) {
|
||||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||||
return '<button class="my-work-card review-trigger" data-review-index="' + index + '">' + contents + '</button>';
|
||||
}
|
||||
return '<a class="my-work-card" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>';
|
||||
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')) + '.</div>';
|
||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button));
|
||||
});
|
||||
}
|
||||
|
||||
async function openReviewSheet(item, trigger) {
|
||||
selectedReview = item;
|
||||
reviewTrigger = trigger;
|
||||
qs('#review-sheet').classList.add('open');
|
||||
qs('#review-sheet-key').textContent = item.key;
|
||||
qs('#review-sheet-title').textContent = item.title;
|
||||
qs('#review-sheet-status').textContent = 'Loading review details…';
|
||||
qs('#review-sheet-body').textContent = '';
|
||||
qs('#review-files').textContent = '';
|
||||
qs('#review-history').textContent = '';
|
||||
qs('#open-review-gitea').href = item.url;
|
||||
qs('#close-review-sheet').focus();
|
||||
try {
|
||||
const detail = await reviewController.load(selectedReview);
|
||||
if (selectedReview !== item) return;
|
||||
qs('#review-sheet-body').textContent = detail.body || 'No description provided.';
|
||||
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
|
||||
qs('#review-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
|
||||
createReviewController.renderDiffFile(file, index, escapeHtml)
|
||||
).join('') : '<div>No changed files reported.</div>';
|
||||
document.querySelectorAll('.review-file-toggle').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const panel = document.getElementById(button.getAttribute('aria-controls'));
|
||||
if (panel) createReviewController.toggleDiff(button, panel);
|
||||
});
|
||||
});
|
||||
qs('#review-history').innerHTML = (detail.reviews || []).length ? detail.reviews.map(review =>
|
||||
'<div class="review-history"><strong>' + escapeHtml(review.user?.login || 'Reviewer') + '</strong> · ' +
|
||||
escapeHtml(review.state || 'commented') + (review.body ? '<div class="small">' + escapeHtml(review.body) + '</div>' : '') + '</div>'
|
||||
).join('') : '<div>No prior reviews.</div>';
|
||||
qs('#review-sheet-status').textContent = 'Ready to review · by ' + (detail.author || 'unknown author');
|
||||
} catch (error) {
|
||||
qs('#review-sheet-status').textContent = error.message + ' Use Open in Gitea or close and retry.';
|
||||
}
|
||||
}
|
||||
|
||||
function closeReviewSheet() {
|
||||
qs('#review-sheet').classList.remove('open');
|
||||
selectedReview = null;
|
||||
if (reviewTrigger?.isConnected) reviewTrigger.focus();
|
||||
}
|
||||
|
||||
function markMyWorkStale() {
|
||||
qs('#my-work').setAttribute('data-stale', 'true');
|
||||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||||
'Update failed · showing last known work' : 'Work inbox unavailable.';
|
||||
}
|
||||
|
||||
function paintDeltas(deltas) {
|
||||
const el = qs('#ai');
|
||||
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
|
||||
}
|
||||
|
||||
function paintEventStream(events) {
|
||||
const el = qs('#gitea-events');
|
||||
el.innerHTML = events.length ? events.slice(0, 12).map(event =>
|
||||
'<div class="event"><strong>' + escapeHtml(event.actor?.login || 'Gitea') + '</strong> ' +
|
||||
escapeHtml(event.type || 'activity') +
|
||||
'<div class="small">' + escapeHtml(event.repo?.full_name || '') +
|
||||
(event.created_at ? ' · ' + escapeHtml(fmt(event.created_at)) : '') + '</div></div>'
|
||||
).join('') : '<div class="muted">No recent Gitea activity.</div>';
|
||||
}
|
||||
|
||||
function setEventStreamStatus(message) {
|
||||
qs('#gitea-events-status').textContent = message;
|
||||
}
|
||||
|
||||
async function loadEventStream() {
|
||||
setEventStreamStatus('Updating…');
|
||||
try {
|
||||
const res = await fetch('api/v1/events', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
paintEventStream(await res.json());
|
||||
setEventStreamStatus('Updated ' + fmt(new Date()));
|
||||
} catch (e) {
|
||||
setEventStreamStatus('Update failed · showing last activity');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function escAttr(s) { return escapeHtml(s); }
|
||||
function openModal(id) { qs('#' + id).classList.add('open'); }
|
||||
function closeModal(id) { qs('#' + id).classList.remove('open'); }
|
||||
|
||||
/* Creative ambient background */
|
||||
(function bg(){
|
||||
const c=qs('#bg'),ctx=c.getContext('2d');
|
||||
let w,h,time=0;
|
||||
const resize=()=>{ w=c.width=innerWidth; h=c.height=innerHeight; };
|
||||
resize(); addEventListener('resize', resize);
|
||||
const draw=()=>{
|
||||
time+=0.012;
|
||||
ctx.clearRect(0,0,w,h);
|
||||
ctx.strokeStyle='#1f3a5f'; ctx.lineWidth=1;
|
||||
const step=60;
|
||||
for(let x=0;x<w;x+=step){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
|
||||
for(let y=0;y<h;y+=step){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }
|
||||
const cx=w/2+Math.sin(time*0.31)*90, cy=h/2+Math.cos(time*0.37)*70;
|
||||
const grad=ctx.createRadialGradient(cx,cy,20,cx,cy,260);
|
||||
grad.addColorStop(0,'rgba(96,165,250,.28)'); grad.addColorStop(1,'rgba(96,165,250,0)');
|
||||
ctx.fillStyle=grad; ctx.fillRect(0,0,w,h);
|
||||
requestAnimationFrame(draw);
|
||||
};
|
||||
draw();
|
||||
})();
|
||||
|
||||
/* Whiteboard */
|
||||
function initWhiteboard() {
|
||||
const canvas = qs('#wb'), ctx = canvas.getContext('2d');
|
||||
let drawing = false;
|
||||
function resize() { const dpr = window.devicePixelRatio||1; const r=canvas.getBoundingClientRect(); canvas.width=r.width*dpr; canvas.height=r.height*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#e5e7eb'; ctx.lineWidth=2; }
|
||||
resize(); addEventListener('resize', resize);
|
||||
const p = (e) => ({ x: e.clientX - canvas.getBoundingClientRect().left, y: e.clientY - canvas.getBoundingClientRect().top });
|
||||
canvas.addEventListener('pointerdown', (e)=>{ drawing=true; const pos=p(e); ctx.beginPath(); ctx.moveTo(pos.x, pos.y); });
|
||||
canvas.addEventListener('pointermove', (e)=>{ if (!drawing) return; const pos=p(e); ctx.lineTo(pos.x, pos.y); ctx.stroke(); });
|
||||
canvas.addEventListener('pointerup', () => drawing=false);
|
||||
canvas.addEventListener('pointerleave', () => drawing=false);
|
||||
qs('#wb-clear').addEventListener('click', () => { const r=canvas.getBoundingClientRect(); ctx.clearRect(0,0,r.width,r.height); });
|
||||
qs('#wb-save').addEventListener('click', () => { const a=document.createElement('a'); a.href=canvas.toDataURL(); a.download='whiteboard.png'; a.click(); });
|
||||
}
|
||||
|
||||
/* Markdown */
|
||||
qs('#md-input').addEventListener('input', renderMD);
|
||||
function renderMD() {
|
||||
const raw = qs('#md-input').value || '';
|
||||
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>';
|
||||
}
|
||||
|
||||
/* Commands */
|
||||
const commands = [
|
||||
{ name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
|
||||
{ name: 'Open markdown widget', run: () => { qs('#md-input').focus(); } },
|
||||
{ name: 'Refresh now', run: load },
|
||||
{ name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) },
|
||||
];
|
||||
function renderCommands(filter) {
|
||||
const el = qs('#cmd-results');
|
||||
const items = filterCommands(commands, filter);
|
||||
el.innerHTML = items.map((c, idx) => '<div class="cmd-item" data-idx="' + idx + '">' + escapeHtml(c.name) + '</div>').join('');
|
||||
el.querySelectorAll('.cmd-item').forEach((item) => {
|
||||
item.addEventListener('click', () => { items[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value=''; });
|
||||
});
|
||||
}
|
||||
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
|
||||
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
|
||||
document.addEventListener('keydown', (e) => { if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); qs('#cmd-palette').classList.toggle('open'); if(qs('#cmd-palette').classList.contains('open')){ qs('#cmd-input').focus(); renderCommands(''); } } });
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
|
||||
|
||||
const contextPoller = createContextPoller({
|
||||
fetchContext: fetchContextSnapshot,
|
||||
onSnapshot: renderContextSnapshot,
|
||||
onError: handleContextError,
|
||||
isHidden: () => document.hidden,
|
||||
intervalMs: 8000,
|
||||
});
|
||||
function load() { return contextPoller.refresh(); }
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
document.querySelectorAll('[data-work-filter]').forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
|
||||
button.addEventListener('click', () => {
|
||||
selectedWorkFilter = button.dataset.workFilter;
|
||||
try {
|
||||
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
|
||||
} catch (e) {
|
||||
console.warn('Could not persist My Work filter', e);
|
||||
}
|
||||
document.querySelectorAll('[data-work-filter]').forEach(item =>
|
||||
item.setAttribute('aria-pressed', String(item === button))
|
||||
);
|
||||
renderMyWork();
|
||||
});
|
||||
});
|
||||
contextPoller.start();
|
||||
loadEventStream();
|
||||
setInterval(() => { if (!document.hidden) loadEventStream(); }, 5000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
contextPoller.setVisible(!document.hidden);
|
||||
if (!document.hidden) loadEventStream();
|
||||
});
|
||||
|
||||
/* Widgets */
|
||||
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
||||
setInterval(widgetTick, 1000);
|
||||
})();
|
||||
</script>
|
||||
<script src="static/mobile-task-dock.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/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createInstallApp = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
|
||||
const dismissedKey = 'stackchain.install.dismissed.v1';
|
||||
let deferredPrompt = null;
|
||||
|
||||
function hide() {
|
||||
options.card.hidden = true;
|
||||
}
|
||||
|
||||
function showNativePrompt(event) {
|
||||
event.preventDefault();
|
||||
deferredPrompt = event;
|
||||
options.guidance.hidden = true;
|
||||
options.installButton.hidden = false;
|
||||
options.card.hidden = false;
|
||||
}
|
||||
|
||||
async function install() {
|
||||
if (!deferredPrompt) return;
|
||||
const prompt = deferredPrompt;
|
||||
deferredPrompt = null;
|
||||
options.installButton.disabled = true;
|
||||
await prompt.prompt();
|
||||
const choice = await prompt.userChoice;
|
||||
if (choice.outcome === 'accepted') {
|
||||
hide();
|
||||
options.status.textContent = 'Stackchain was added to your device.';
|
||||
} else {
|
||||
hide();
|
||||
options.installButton.disabled = false;
|
||||
options.status.textContent = 'Installation was not completed.';
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
options.storage.setItem(dismissedKey, '1');
|
||||
hide();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (options.isStandalone() || options.storage.getItem(dismissedKey) === '1') {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
if (options.isIosSafari()) {
|
||||
options.installButton.hidden = true;
|
||||
options.guidance.hidden = false;
|
||||
options.card.hidden = false;
|
||||
}
|
||||
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
|
||||
options.window.addEventListener('appinstalled', () => {
|
||||
deferredPrompt = null;
|
||||
hide();
|
||||
});
|
||||
options.installButton.addEventListener('click', install);
|
||||
options.dismissButton.addEventListener('click', dismiss);
|
||||
}
|
||||
|
||||
return {start};
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) {
|
||||
const storageKey = 'stackchain.issue-outbox.v1';
|
||||
const operationId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (![1, 2].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 }));
|
||||
coordinator?.notify('issue');
|
||||
if (mirror && backgroundSync?.reconcile) {
|
||||
Promise.resolve(backgroundSync.reconcile(items))
|
||||
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
|
||||
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(draft, mirror = true) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
|
||||
const items = read();
|
||||
if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.');
|
||||
const item = {
|
||||
id: String(operationId()).slice(0, 128),
|
||||
operationId: '',
|
||||
repository: String(draft?.repository || ''),
|
||||
title: String(draft?.title || ''),
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
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);
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function enqueueDurably(draft) {
|
||||
const item = enqueue(draft, false);
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
try {
|
||||
await backgroundSync.reconcile(read());
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function update(id, draft, mirror = true) {
|
||||
let updated = null;
|
||||
write(read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const nextRepository = String(draft?.repository || '');
|
||||
const nextTitle = String(draft?.title || '');
|
||||
const nextBody = String(draft?.body || '');
|
||||
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
||||
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
|
||||
? Number(draft.milestoneId) : undefined;
|
||||
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||
? String(draft.dueDate) : undefined;
|
||||
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
|
||||
updated = {
|
||||
...item,
|
||||
repository: nextRepository, title: nextTitle,
|
||||
body: nextBody, labelIds: nextLabelIds,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||
if (nextDueDate === undefined) delete updated.dueDate;
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
}), mirror);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function updateDurably(id, draft) {
|
||||
const item = update(id, draft, false);
|
||||
if (!item || !backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
try {
|
||||
await backgroundSync.reconcile(read());
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
if (!items.some(item => item.id === id)) return false;
|
||||
write(items.filter(item => item.id !== id));
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
const request = (async () => {
|
||||
try {
|
||||
let issue;
|
||||
if (backgroundSync?.send) {
|
||||
const delivery = await backgroundSync.send(item, currentLogin);
|
||||
if (delivery.attention) {
|
||||
const error = delivery.error || new Error('Issue needs attention');
|
||||
error.status = Number(error.status || 422);
|
||||
throw error;
|
||||
}
|
||||
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' } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (!issue) return { blocked: true };
|
||||
discard(item.id);
|
||||
return { issue };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status >= 400 && status < 500) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
|
||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !(status >= 400 && status < 500) };
|
||||
}
|
||||
})();
|
||||
pending.set(item.id, request);
|
||||
try { return await request; }
|
||||
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
|
||||
}
|
||||
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const result = await sendItem(item, currentLogin);
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('issue', () => flushQueue(currentLogin));
|
||||
return flushQueue(currentLogin);
|
||||
}
|
||||
|
||||
async function retryItem(id, currentLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return { confirmed: [], remaining: read() };
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
return { confirmed: [], remaining: read(), blocked: 1 };
|
||||
}
|
||||
let queued = item;
|
||||
if (item.status === 'attention') {
|
||||
queued = {
|
||||
...item,
|
||||
operationId: String(operationId()).slice(0, 128),
|
||||
status: 'queued',
|
||||
};
|
||||
delete queued.error;
|
||||
delete queued.deliveryState;
|
||||
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 };
|
||||
}
|
||||
|
||||
async function retry(id, currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('issue', () => retryItem(id, currentLogin));
|
||||
return retryItem(id, currentLogin);
|
||||
}
|
||||
|
||||
function reconcileBackground(records) {
|
||||
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 === 'attention') return [{
|
||||
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
write(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
|
||||
@@ -0,0 +1,264 @@
|
||||
function createPlanningLoader({ loadLabels, loadMilestones }) {
|
||||
const requests = new Map();
|
||||
const keyFor = item => item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
open(item) {
|
||||
const key = keyFor(item);
|
||||
if (requests.has(key)) return requests.get(key);
|
||||
const request = Promise.all([loadLabels(item), loadMilestones(item)])
|
||||
.then(([labels, milestones]) => ({ labels, milestones }))
|
||||
.catch(error => {
|
||||
if (requests.get(key) === request) requests.delete(key);
|
||||
throw error;
|
||||
});
|
||||
requests.set(key, request);
|
||||
return request;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
let handoffRequest = null;
|
||||
let labelRequest = null;
|
||||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
let milestoneRequest = null;
|
||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
||||
const operationKey = item => draftKey(item) + ':operation';
|
||||
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
|
||||
const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number;
|
||||
const dueDateDraftKey = item => 'stackchain.issue-due-date.v1:' + item.repository + '#' + item.number;
|
||||
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
load(item) {
|
||||
return fetchJson(issuePath(item) + '/detail', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
conversation(item, initialPage) {
|
||||
const pager = createConversationPager({
|
||||
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
});
|
||||
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
|
||||
return pager;
|
||||
},
|
||||
loadLabels(item) {
|
||||
return fetchJson(issuePath(item) + '/labels', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadMilestones(item) {
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
return fetchJson('api/v1/repos/' + repository + '/milestones', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
return fetchJson(issuePath(item) + '/handoff-candidates', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
},
|
||||
saveDraft(item, body) {
|
||||
try {
|
||||
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
|
||||
storage?.setItem(draftKey(item), body);
|
||||
}
|
||||
catch (_error) { /* The textarea remains the fallback. */ }
|
||||
},
|
||||
loadEditDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
|
||||
return value && typeof value.title === 'string' && typeof value.body === 'string' &&
|
||||
typeof value.expectedUpdatedAt === 'string' ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
},
|
||||
saveEditDraft(item, draft) {
|
||||
try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); }
|
||||
catch (_error) { /* The edit fields remain the fallback. */ }
|
||||
},
|
||||
updateContent(item, draft) {
|
||||
if (editRequest) return editRequest;
|
||||
this.saveEditDraft(item, draft);
|
||||
editRequest = fetchJson(issuePath(item) + '/content', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
body: draft.body,
|
||||
expected_updated_at: draft.expectedUpdatedAt,
|
||||
}),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || result?.title !== draft.title || result?.body !== draft.body) {
|
||||
throw new Error('Issue update was not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(editDraftKey(item)); }
|
||||
catch (_error) { /* Confirmed upstream content is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { editRequest = null; });
|
||||
return editRequest;
|
||||
},
|
||||
loadDueDateDraft(item) {
|
||||
try {
|
||||
const raw = storage?.getItem(dueDateDraftKey(item));
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const value = JSON.parse(raw);
|
||||
return typeof value === 'string' ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
},
|
||||
updateDueDate(item, dueDate) {
|
||||
if (dueDateRequest) return dueDateRequest;
|
||||
try { storage?.setItem(dueDateDraftKey(item), JSON.stringify(dueDate)); }
|
||||
catch (_error) { /* The date input remains the fallback. */ }
|
||||
dueDateRequest = fetchJson(issuePath(item) + '/due-date', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ due_date: dueDate }),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || result?.due_date !== dueDate) {
|
||||
throw new Error('Issue due date was not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(dueDateDraftKey(item)); }
|
||||
catch (_error) { /* Confirmed upstream deadline is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { dueDateRequest = null; });
|
||||
return dueDateRequest;
|
||||
},
|
||||
loadMilestoneDraft(item) {
|
||||
try {
|
||||
const raw = storage?.getItem(milestoneDraftKey(item));
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const value = JSON.parse(raw);
|
||||
return value === null || Number.isInteger(value) ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
},
|
||||
updateMilestone(item, milestoneId) {
|
||||
if (milestoneRequest) return milestoneRequest;
|
||||
try { storage?.setItem(milestoneDraftKey(item), JSON.stringify(milestoneId)); }
|
||||
catch (_error) { /* The select remains the fallback. */ }
|
||||
milestoneRequest = fetchJson(issuePath(item) + '/milestone', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ milestone_id: milestoneId }),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number ||
|
||||
(milestoneId === null ? result?.milestone !== null : result?.milestone?.id !== milestoneId)) {
|
||||
throw new Error('Issue milestone was not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(milestoneDraftKey(item)); }
|
||||
catch (_error) { /* Confirmed upstream milestone is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { milestoneRequest = null; });
|
||||
return milestoneRequest;
|
||||
},
|
||||
loadLabelDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
|
||||
return Array.isArray(value) ? value.filter(Number.isInteger) : [];
|
||||
} catch (_error) { return []; }
|
||||
},
|
||||
updateLabels(item, labelIds) {
|
||||
if (labelRequest) return labelRequest;
|
||||
try { storage?.setItem(labelDraftKey(item), JSON.stringify(labelIds)); }
|
||||
catch (_error) { /* The checked controls remain the fallback. */ }
|
||||
labelRequest = fetchJson(issuePath(item) + '/labels', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label_ids: labelIds }),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || !Array.isArray(result.labels)) {
|
||||
throw new Error('Issue labels were not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(labelDraftKey(item)); }
|
||||
catch (_error) { /* The upstream label set is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { labelRequest = null; });
|
||||
return labelRequest;
|
||||
},
|
||||
close(item) {
|
||||
if (closeRequest) return closeRequest;
|
||||
closeRequest = fetchJson(issuePath(item) + '/close', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
return result;
|
||||
}).finally(() => { closeRequest = null; });
|
||||
return closeRequest;
|
||||
},
|
||||
release(item, currentLogin) {
|
||||
if (releaseRequest) return releaseRequest;
|
||||
releaseRequest = fetchJson(issuePath(item) + '/release', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (
|
||||
result?.number !== item.number ||
|
||||
!Array.isArray(result.assignees) ||
|
||||
result.assignees.includes(currentLogin)
|
||||
) {
|
||||
throw new Error('Issue release was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { releaseRequest = null; });
|
||||
return releaseRequest;
|
||||
},
|
||||
handoff(item, recipient, currentLogin) {
|
||||
if (handoffRequest) return handoffRequest;
|
||||
handoffRequest = fetchJson(issuePath(item) + '/handoff', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient }),
|
||||
}).then(result => {
|
||||
if (
|
||||
result?.number !== item.number ||
|
||||
result?.recipient !== recipient ||
|
||||
!Array.isArray(result.assignees) ||
|
||||
!result.assignees.includes(recipient) ||
|
||||
result.assignees.includes(currentLogin)
|
||||
) {
|
||||
throw new Error('Issue handoff was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { handoffRequest = null; });
|
||||
return handoffRequest;
|
||||
},
|
||||
comment(item, body) {
|
||||
if (commentRequest) return commentRequest;
|
||||
this.saveDraft(item, body);
|
||||
let operationId;
|
||||
try {
|
||||
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(operationKey(item), operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
commentRequest = fetchJson(issuePath(item) + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
|
||||
body: JSON.stringify({ body }),
|
||||
}).then(result => {
|
||||
try { storage?.removeItem(draftKey(item)); }
|
||||
catch (_error) { /* The upstream comment is authoritative. */ }
|
||||
try { storage?.removeItem(operationKey(item)); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
return result;
|
||||
}).finally(() => { commentRequest = null; });
|
||||
return commentRequest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createIssueSheet.createPlanningLoader = createPlanningLoader;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
|
||||
@@ -0,0 +1,104 @@
|
||||
function createLaterPicker({
|
||||
now = () => new Date(),
|
||||
history = null,
|
||||
eventTarget = null,
|
||||
onState = () => {},
|
||||
onConfirm = () => false,
|
||||
} = {}) {
|
||||
let active = false;
|
||||
let item = null;
|
||||
let trigger = null;
|
||||
let context = null;
|
||||
let afterClose = null;
|
||||
let committed = false;
|
||||
let started = false;
|
||||
|
||||
function parse(input) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(String(input || ''));
|
||||
if (!match) return { ok:false, message:'Choose a valid local date and time.' };
|
||||
const parts = match.slice(1).map(Number);
|
||||
const value = new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], 0, 0);
|
||||
const sameLocalTime = value.getFullYear() === parts[0] &&
|
||||
value.getMonth() === parts[1] - 1 && value.getDate() === parts[2] &&
|
||||
value.getHours() === parts[3] && value.getMinutes() === parts[4];
|
||||
if (!sameLocalTime) return { ok:false, message:'Choose a valid local date and time.' };
|
||||
if (value <= now()) return { ok:false, message:'Choose a future date and time.' };
|
||||
return { ok:true, value };
|
||||
}
|
||||
|
||||
function formatLocal(value) {
|
||||
const pad = part => String(part).padStart(2, '0');
|
||||
return value.getFullYear() + '-' + pad(value.getMonth() + 1) + '-' + pad(value.getDate()) +
|
||||
'T' + pad(value.getHours()) + ':' + pad(value.getMinutes());
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
if (!active) return false;
|
||||
const restore = trigger;
|
||||
active = false;
|
||||
item = null;
|
||||
trigger = null;
|
||||
context = null;
|
||||
committed = false;
|
||||
onState({ open:false, message:'' });
|
||||
restore?.focus?.();
|
||||
const cleanup = afterClose;
|
||||
afterClose = null;
|
||||
cleanup?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started || !eventTarget) return;
|
||||
started = true;
|
||||
eventTarget.addEventListener('popstate', event => {
|
||||
if (active && !event.state?.laterPicker) deactivate();
|
||||
});
|
||||
eventTarget.addEventListener('keydown', event => {
|
||||
if (active && event.key === 'Escape') {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function open(nextItem, nextTrigger, nextContext = 'card') {
|
||||
if (!nextItem || active) return false;
|
||||
active = true;
|
||||
item = nextItem;
|
||||
trigger = nextTrigger || null;
|
||||
context = nextContext;
|
||||
committed = false;
|
||||
const suggested = new Date(now().getTime() + 60 * 60 * 1000);
|
||||
suggested.setMinutes(Math.ceil(suggested.getMinutes() / 15) * 15, 0, 0);
|
||||
if (history) history.pushState({ ...(history.state || {}), laterPicker:true }, '');
|
||||
onState({ open:true, message:'', value:formatLocal(suggested) });
|
||||
return true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!active) return false;
|
||||
if (history?.state?.laterPicker) history.back();
|
||||
else deactivate();
|
||||
return true;
|
||||
}
|
||||
|
||||
function submit(input) {
|
||||
if (!active || committed) return false;
|
||||
const result = parse(input);
|
||||
if (!result.ok) {
|
||||
onState({ open:true, message:result.message, value:String(input || '') });
|
||||
return false;
|
||||
}
|
||||
const confirmed = onConfirm(item, result.value, context);
|
||||
if (confirmed !== true && typeof confirmed !== 'function') return false;
|
||||
committed = true;
|
||||
afterClose = typeof confirmed === 'function' ? confirmed : null;
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
|
||||
return { parse, start, open, close, submit, current:() => active };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterPicker;
|
||||
@@ -0,0 +1,273 @@
|
||||
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, 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.later-sync.v1.';
|
||||
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
|
||||
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
|
||||
let flushing = null;
|
||||
let channel = null;
|
||||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
let expiredCount = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
if (retryTimer !== null) clearTimer?.(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
|
||||
function scheduleRetry(error, ownerKey) {
|
||||
if (retryTimer !== null || !pending().length || !ownerKey) return;
|
||||
const advised = Number(error?.retryAfter);
|
||||
const delayMs = Number.isFinite(advised) && advised >= 0
|
||||
? advised * 1000
|
||||
: Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt));
|
||||
retryAttempt += 1;
|
||||
onStatus?.('retrying', { delayMs });
|
||||
retryTimer = setTimer?.(async () => {
|
||||
retryTimer = null;
|
||||
if (key() !== ownerKey) return false;
|
||||
return flush();
|
||||
}, delayMs);
|
||||
retryTimer?.unref?.();
|
||||
}
|
||||
|
||||
function key() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function snapshotKey() {
|
||||
const storageKey = key();
|
||||
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
|
||||
}
|
||||
|
||||
function savedRevision() {
|
||||
try {
|
||||
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
|
||||
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
|
||||
} catch (_error) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function validRecords(records) {
|
||||
return records && typeof records === 'object' && !Array.isArray(records);
|
||||
}
|
||||
|
||||
function adopt(plan, broadcast = true) {
|
||||
if (!Number.isInteger(plan?.revision) || !validRecords(plan?.records)) return false;
|
||||
if (plan.revision < savedRevision()) return false;
|
||||
const snapshot = { revision: plan.revision, records: plan.records };
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||
} catch (_error) {
|
||||
// Server truth remains usable in this tab when storage is unavailable.
|
||||
}
|
||||
onRemoteRecords?.(plan.records);
|
||||
if (broadcast) channel?.postMessage(snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureChannel() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || channelKey === storageKey) return;
|
||||
channel?.close?.();
|
||||
const factory = createChannel || (globalThis.window?.BroadcastChannel
|
||||
? name => new globalThis.window.BroadcastChannel(name)
|
||||
: null);
|
||||
channelKey = storageKey;
|
||||
channel = factory?.('stackchain-later-' + storageKey.slice(prefix.length)) || null;
|
||||
channel?.addEventListener?.('message', event => {
|
||||
if (key() === storageKey) adopt(event.data, false);
|
||||
});
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
const recordPrefix = storageKey + '.operation.';
|
||||
try {
|
||||
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
if (Array.isArray(legacy)) {
|
||||
legacy.forEach((operation, index) => {
|
||||
if (!operation?.operation_id) return;
|
||||
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
});
|
||||
if (legacy.length) storage.removeItem(storageKey);
|
||||
}
|
||||
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
|
||||
for (let index = 0; index < Number(storage.length || 0); index += 1) {
|
||||
const candidate = storage.key?.(index);
|
||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
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'));
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function removeOperation(operationId) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
|
||||
try {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function operationId() {
|
||||
if (createOperationId) return createOperationId();
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, wakeAt = null) {
|
||||
if (!['defer', 'restore'].includes(action) || !itemId ||
|
||||
(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,
|
||||
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: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('later');
|
||||
} catch (_error) { /* Report the persistence failure below. */ }
|
||||
onStatus?.(saved ? 'pending' : 'error');
|
||||
return saved;
|
||||
}
|
||||
|
||||
function migrate(records) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||
try {
|
||||
if (storage.getItem(marker)) return false;
|
||||
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
|
||||
enqueue('defer', itemId, wakeAt)
|
||||
);
|
||||
storage.setItem(marker, '1');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
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;
|
||||
const batch = operations.slice(0, 50);
|
||||
plan = await fetchJson('api/v1/later', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operations: batch }),
|
||||
});
|
||||
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
|
||||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
|
||||
const received = hasReceipts ? [
|
||||
...(plan.accepted_operation_ids || []),
|
||||
...(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)) {
|
||||
throw new Error('Could not persist Later delivery receipt');
|
||||
}
|
||||
}
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' :
|
||||
(conflictCount ? 'conflict' : expiredCount ? 'expired' : 'saved'),
|
||||
conflictCount ? { count: conflictCount } : expiredCount ? { count: expiredCount } : {});
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (pending().length) scheduleRetry(error, ownerKey);
|
||||
else onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (!flushing) {
|
||||
const delivery = coordinator ? coordinator.runExclusive('later', run) : run();
|
||||
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
|
||||
}
|
||||
return flushing;
|
||||
}
|
||||
|
||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||
windowObject?.addEventListener?.('online', flush);
|
||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||
documentObject.hidden ? false : flush()
|
||||
);
|
||||
}
|
||||
|
||||
coordinator?.subscribe(change => {
|
||||
if (change.queue === 'later' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterSync;
|
||||
@@ -0,0 +1,133 @@
|
||||
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {}, onChange = () => {}, onExpire = () => {} }) {
|
||||
const prefix = 'stackchain.later-work.v1.';
|
||||
let timer = null;
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function read() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return {};
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || '{}');
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function write(records) {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return false;
|
||||
try {
|
||||
if (Object.keys(records).length) storage.setItem(key, JSON.stringify(records));
|
||||
else storage.removeItem(key);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function defer(item, until) {
|
||||
const key = storageKey();
|
||||
const id = identity(item);
|
||||
const wake = new Date(until);
|
||||
if (!key) return 'unavailable';
|
||||
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
|
||||
const records = read();
|
||||
const wakeAt = wake.toISOString();
|
||||
records[id] = wakeAt;
|
||||
if (!write(records)) return 'unavailable';
|
||||
onChange('defer', id, wakeAt);
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function presetUntil(preset) {
|
||||
const current = new Date(now());
|
||||
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
|
||||
if (preset === 'tomorrow') {
|
||||
const wake = new Date(current);
|
||||
wake.setDate(wake.getDate() + 1);
|
||||
wake.setHours(9, 0, 0, 0);
|
||||
return wake;
|
||||
}
|
||||
return new Date(NaN);
|
||||
}
|
||||
|
||||
function restore(item) {
|
||||
const id = identity(item);
|
||||
const records = read();
|
||||
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
|
||||
delete records[id];
|
||||
if (!write(records)) return false;
|
||||
onChange('restore', id, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function adopt(records) {
|
||||
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
|
||||
const normalized = {};
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
|
||||
});
|
||||
return write(normalized);
|
||||
}
|
||||
|
||||
function schedule(wakeTimes, current) {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
if (!wakeTimes.length) return;
|
||||
const delay = Math.max(0, Math.min(...wakeTimes) - current);
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
onWake();
|
||||
}, Math.min(delay, 2147483647));
|
||||
}
|
||||
|
||||
function partition(items, { pruneMissing = true } = {}) {
|
||||
const current = now().getTime();
|
||||
const records = read();
|
||||
const available = new Map((items || []).map(item => [identity(item), item]));
|
||||
const retained = {};
|
||||
const wakeTimes = [];
|
||||
const expired = [];
|
||||
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
|
||||
if (Number.isFinite(wakeTime)) expired.push(id);
|
||||
return;
|
||||
}
|
||||
if (pruneMissing && !available.has(id)) return;
|
||||
retained[id] = new Date(wakeTime).toISOString();
|
||||
wakeTimes.push(wakeTime);
|
||||
});
|
||||
|
||||
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
|
||||
onExpire(expired);
|
||||
}
|
||||
schedule(wakeTimes, current);
|
||||
const deferredIds = new Set(Object.keys(retained));
|
||||
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
|
||||
const later = (items || []).flatMap(item => {
|
||||
const wake = retained[identity(item)];
|
||||
return wake ? [{ ...item, deferred_until: wake }] : [];
|
||||
});
|
||||
return { active, later };
|
||||
}
|
||||
|
||||
return { identity, read, adopt, defer, restore, presetUntil, partition };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
|
||||
@@ -0,0 +1,119 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createLoginController = factory;
|
||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||
function validShareContinuation(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
||||
const limits = { title: 200, text: 8000, url: 2048 };
|
||||
const params = new URLSearchParams(value.slice(3));
|
||||
const entries = Array.from(params.entries());
|
||||
if (!entries.length) return './';
|
||||
const invalid = entries.some(([name, content]) => (
|
||||
!Object.prototype.hasOwnProperty.call(limits, name)
|
||||
|| !content
|
||||
|| content.length > limits[name]
|
||||
));
|
||||
if (invalid) return './';
|
||||
return value;
|
||||
}
|
||||
|
||||
const form = options.form;
|
||||
const status = options.status;
|
||||
const button = options.button;
|
||||
const fetchImpl = options.fetchImpl;
|
||||
const location = options.location;
|
||||
const clearPrivateDeviceData = options.clearPrivateDeviceData;
|
||||
const continuation = validShareContinuation(options.continuation);
|
||||
const setIntervalImpl = options.setIntervalImpl || setInterval;
|
||||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||||
let timer = null;
|
||||
|
||||
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
|
||||
|
||||
async function showReason(reason) {
|
||||
if (reason === 'session-expired') {
|
||||
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
|
||||
return;
|
||||
}
|
||||
if (reason !== 'session-revoked') return;
|
||||
button.disabled = true;
|
||||
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
|
||||
try {
|
||||
if (typeof clearPrivateDeviceData !== 'function') throw new Error('Private data purger unavailable.');
|
||||
await clearPrivateDeviceData();
|
||||
status.textContent = 'This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.';
|
||||
button.disabled = false;
|
||||
} catch (_error) {
|
||||
status.textContent = 'This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this site’s data before signing in.';
|
||||
}
|
||||
}
|
||||
|
||||
function showRetryCountdown(seconds) {
|
||||
let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1);
|
||||
button.disabled = true;
|
||||
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
|
||||
timer = setIntervalImpl(() => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
clearIntervalImpl(timer);
|
||||
timer = null;
|
||||
button.disabled = false;
|
||||
status.textContent = 'You can try signing in again.';
|
||||
return;
|
||||
}
|
||||
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function submit(accessToken, deviceLabel = 'This device') {
|
||||
status.textContent = 'Signing in…';
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl('api/v1/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }),
|
||||
});
|
||||
} catch (_error) {
|
||||
form.reset();
|
||||
status.textContent = 'Sign-in failed. Check your connection and try again.';
|
||||
return;
|
||||
}
|
||||
form.reset();
|
||||
if (response.ok) {
|
||||
location.replace(continuation);
|
||||
return;
|
||||
}
|
||||
if (response.status === 429) {
|
||||
showRetryCountdown(response.headers.get('Retry-After'));
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Sign-in failed. Check the token and try again.';
|
||||
}
|
||||
|
||||
return { submit, showReason };
|
||||
}));
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const form = document.getElementById('sign-in');
|
||||
const status = document.getElementById('status');
|
||||
const button = document.getElementById('submit-sign-in');
|
||||
const loginParams = new URLSearchParams(window.location.search);
|
||||
const controller = createLoginController({
|
||||
form,
|
||||
status,
|
||||
button,
|
||||
fetchImpl: fetch.bind(window),
|
||||
location: window.location,
|
||||
continuation: loginParams.get('continue'),
|
||||
clearPrivateDeviceData: window.stackchainPrivateDeviceData,
|
||||
});
|
||||
controller.showReason(loginParams.get('reason'));
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(form);
|
||||
const accessToken = data.get('access_token');
|
||||
const deviceLabel = data.get('device_label');
|
||||
controller.submit(accessToken, deviceLabel);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Stackchain Dashboard",
|
||||
"short_name": "Stackchain",
|
||||
"description": "Capture, plan, and complete Gitea work from mobile.",
|
||||
"id": "./",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#07111f",
|
||||
"theme_color": "#0b1220",
|
||||
"icons": [
|
||||
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "./",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {"title": "title", "text": "text", "url": "url"}
|
||||
}
|
||||
}
|
||||
+88
-10
@@ -13,17 +13,95 @@
|
||||
})[character]);
|
||||
}
|
||||
|
||||
return function renderMarkdown(raw) {
|
||||
return escapeHtml(raw)
|
||||
.replace(/^#{1,6}\s.*$/gm, (line) => {
|
||||
const level = line.match(/^(#{1,6})/)[1].length;
|
||||
return '<h' + level + '>' + line.replace(/^#{1,6}\s/, '') + '</h' + level + '>';
|
||||
function renderInline(value) {
|
||||
const code = [];
|
||||
let rendered = escapeHtml(value).replace(/`([^`\n]+)`/g, (_match, body) => {
|
||||
const marker = '\u0000CODE' + code.length + '\u0000';
|
||||
code.push('<code>' + body + '</code>');
|
||||
return marker;
|
||||
});
|
||||
rendered = rendered
|
||||
.replace(/\[([^\]\n]+)\]\(([^\s)]+)(?:\s+"[^&]*?")?\)/g, (_match, label, escapedUrl) => {
|
||||
const url = escapedUrl.replace(/&/g, '&');
|
||||
const safe = /^(https?:|mailto:)/i.test(url) || /^(\/|#|\.\/|\.\.\/)/.test(url);
|
||||
if (!safe) return label;
|
||||
return '<a href="' + escapedUrl + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
|
||||
})
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/^-\s?(.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
||||
.replace(/\n/g, '<br>');
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||||
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]);
|
||||
}
|
||||
|
||||
function renderList(lines) {
|
||||
const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line));
|
||||
const items = lines.map(line => {
|
||||
let body = line.replace(/^[-*+]\s+/, '');
|
||||
if (!taskList) return '<li>' + renderInline(body) + '</li>';
|
||||
const checked = /^\[[xX]\]\s+/.test(body);
|
||||
body = body.replace(/^\[[ xX]\]\s+/, '');
|
||||
return '<li class="task-list-item"><input type="checkbox" disabled' +
|
||||
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
|
||||
}).join('');
|
||||
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
|
||||
}
|
||||
|
||||
return function renderMarkdown(raw) {
|
||||
const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n');
|
||||
const output = [];
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
|
||||
if (fence) {
|
||||
const body = [];
|
||||
index += 1;
|
||||
while (index < lines.length && !/^```\s*$/.test(lines[index])) {
|
||||
body.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) index += 1;
|
||||
const language = fence[1] ? ' class="language-' + fence[1] + '"' : '';
|
||||
output.push('<pre><code' + language + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
output.push('<h' + level + '>' + renderInline(heading[2]) + '</h' + level + '>');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^>\s?/.test(line)) {
|
||||
const quote = [];
|
||||
while (index < lines.length && /^>\s?/.test(lines[index])) {
|
||||
quote.push(lines[index].replace(/^>\s?/, ''));
|
||||
index += 1;
|
||||
}
|
||||
output.push('<blockquote><p>' + renderInline(quote.join('\n')).replace(/\n/g, '<br>') + '</p></blockquote>');
|
||||
continue;
|
||||
}
|
||||
if (/^[-*+]\s+/.test(line)) {
|
||||
const list = [];
|
||||
while (index < lines.length && /^[-*+]\s+/.test(lines[index])) {
|
||||
list.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
output.push(renderList(list));
|
||||
continue;
|
||||
}
|
||||
const paragraph = [];
|
||||
while (index < lines.length && lines[index].trim() &&
|
||||
!/^```/.test(lines[index]) && !/^(#{1,6})\s+/.test(lines[index]) &&
|
||||
!/^>\s?/.test(lines[index]) && !/^[-*+]\s+/.test(lines[index])) {
|
||||
paragraph.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
output.push('<p>' + renderInline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
|
||||
}
|
||||
return output.join('');
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
(function (root, factory) {
|
||||
const exported = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = exported;
|
||||
else root.createMobileComposerViewport = exported;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
return function createMobileComposerViewport(options) {
|
||||
const entries = options.entries || [];
|
||||
const viewport = options.viewport;
|
||||
const mediaQuery = options.mediaQuery;
|
||||
const documentRef = options.document || document;
|
||||
const schedule = options.schedule || (callback => requestAnimationFrame(callback));
|
||||
let active = null;
|
||||
let started = false;
|
||||
|
||||
function reveal() {
|
||||
if (!active) return;
|
||||
active.workspace.scrollIntoView({ block:'end', inline:'nearest' });
|
||||
}
|
||||
|
||||
function applyGeometry() {
|
||||
if (!active || !viewport || !mediaQuery.matches) return;
|
||||
active.panel.style.setProperty('--composer-viewport-top', `${viewport.offsetTop || 0}px`);
|
||||
active.panel.style.setProperty('--composer-viewport-height', `${viewport.height}px`);
|
||||
reveal();
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
schedule(applyGeometry);
|
||||
}
|
||||
|
||||
function activate(entry) {
|
||||
if (!viewport || !mediaQuery.matches) return;
|
||||
if (active === entry) {
|
||||
schedule(applyGeometry);
|
||||
return;
|
||||
}
|
||||
if (active) deactivate(active, false);
|
||||
active = entry;
|
||||
entry.scrollTop = entry.panel.scrollTop;
|
||||
entry.panel.classList?.add('composer-keyboard-active');
|
||||
viewport.addEventListener('resize', onViewportChange);
|
||||
viewport.addEventListener('scroll', onViewportChange);
|
||||
applyGeometry();
|
||||
}
|
||||
|
||||
function deactivate(entry, restore = true) {
|
||||
if (active !== entry) return;
|
||||
viewport?.removeEventListener('resize', onViewportChange);
|
||||
viewport?.removeEventListener('scroll', onViewportChange);
|
||||
entry.panel.classList?.remove('composer-keyboard-active');
|
||||
entry.panel.style.removeProperty('--composer-viewport-top');
|
||||
entry.panel.style.removeProperty('--composer-viewport-height');
|
||||
active = null;
|
||||
if (restore) schedule(() => { entry.panel.scrollTop = entry.scrollTop; });
|
||||
}
|
||||
|
||||
function bind(entry) {
|
||||
entry.onFocus = () => activate(entry);
|
||||
entry.onFocusOut = () => schedule(() => {
|
||||
if (!entry.workspace.contains(documentRef.activeElement)) deactivate(entry);
|
||||
});
|
||||
entry.composer.addEventListener('focus', entry.onFocus);
|
||||
entry.workspace.addEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
function unbind(entry) {
|
||||
entry.composer.removeEventListener('focus', entry.onFocus);
|
||||
entry.workspace.removeEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
entries.forEach(bind);
|
||||
},
|
||||
close(panel) {
|
||||
const entry = entries.find(candidate => candidate.panel === panel);
|
||||
if (entry) deactivate(entry, false);
|
||||
},
|
||||
stop() {
|
||||
if (!started) return;
|
||||
if (active) deactivate(active, false);
|
||||
entries.forEach(unbind);
|
||||
started = false;
|
||||
},
|
||||
revealActive() {
|
||||
schedule(applyGeometry);
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.mobileLaunch = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const allowedFilters = new Set([
|
||||
'all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft',
|
||||
]);
|
||||
|
||||
function chooseFilter({ saved, today = 0, attention = 0 } = {}) {
|
||||
if (allowedFilters.has(saved)) return saved;
|
||||
if (today > 0) return 'today';
|
||||
if (attention > 0) return 'attention';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function createDisclosure({ disclosure, launcher }) {
|
||||
function onKeydown(event) {
|
||||
if (event.key !== 'Escape' || !disclosure.open) return;
|
||||
event.preventDefault();
|
||||
disclosure.open = false;
|
||||
launcher.focus();
|
||||
}
|
||||
return {
|
||||
start() {
|
||||
disclosure.addEventListener('keydown', onKeydown);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { chooseFilter, createDisclosure };
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
(function (root, factory) {
|
||||
const exported = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = exported;
|
||||
else root.createMobileSearchViewport = exported;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
return function createMobileSearchViewport(options) {
|
||||
const palette = options.palette;
|
||||
const results = options.results;
|
||||
const viewport = options.viewport;
|
||||
const mediaQuery = options.mediaQuery;
|
||||
const schedule = options.schedule || (callback => requestAnimationFrame(callback));
|
||||
let active = false;
|
||||
let scrollTop = 0;
|
||||
|
||||
function applyGeometry() {
|
||||
if (!active || !mediaQuery.matches || !viewport) return;
|
||||
palette.style.setProperty('--search-viewport-top', `${viewport.offsetTop || 0}px`);
|
||||
palette.style.setProperty('--search-viewport-height', `${viewport.height}px`);
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
schedule(applyGeometry);
|
||||
}
|
||||
|
||||
return {
|
||||
open() {
|
||||
if (active || !mediaQuery.matches || !viewport) return;
|
||||
active = true;
|
||||
applyGeometry();
|
||||
viewport.addEventListener('resize', onViewportChange);
|
||||
viewport.addEventListener('scroll', onViewportChange);
|
||||
},
|
||||
close() {
|
||||
if (active && viewport) {
|
||||
viewport.removeEventListener('resize', onViewportChange);
|
||||
viewport.removeEventListener('scroll', onViewportChange);
|
||||
}
|
||||
active = false;
|
||||
palette.style.removeProperty('--search-viewport-top');
|
||||
palette.style.removeProperty('--search-viewport-height');
|
||||
},
|
||||
rememberScroll() {
|
||||
scrollTop = results.scrollTop;
|
||||
},
|
||||
restoreScroll() {
|
||||
schedule(() => { results.scrollTop = scrollTop; });
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileTaskDock = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileTaskDock(options) {
|
||||
const nav = options.nav;
|
||||
const buttons = options.buttons;
|
||||
const overlays = options.overlays || [];
|
||||
let launcher = null;
|
||||
let wasHidden = false;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (key === name) button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function refreshVisibility() {
|
||||
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
|
||||
nav.hidden = hidden;
|
||||
if (wasHidden && !hidden && launcher) launcher.focus();
|
||||
wasHidden = hidden;
|
||||
}
|
||||
|
||||
function start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
button.addEventListener('click', event => {
|
||||
launcher = event.currentTarget;
|
||||
select(name);
|
||||
options.actions[name]();
|
||||
});
|
||||
});
|
||||
options.observe(refreshVisibility, overlays);
|
||||
refreshVisibility();
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
const total = Math.max(0, Number(count) || 0);
|
||||
const badge = options.attentionBadge;
|
||||
if (badge) {
|
||||
badge.textContent = String(total);
|
||||
badge.hidden = total === 0;
|
||||
}
|
||||
if (buttons.work) {
|
||||
buttons.work.setAttribute(
|
||||
'aria-label',
|
||||
total ? 'Work, ' + total + ' items need attention' : 'Work'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention};
|
||||
});
|
||||
+584
-11
@@ -1,58 +1,631 @@
|
||||
function buildMyWork(data) {
|
||||
function issueDueState(dueDate, now) {
|
||||
if (!dueDate) return null;
|
||||
const due = new Date(dueDate);
|
||||
if (Number.isNaN(due.getTime())) return null;
|
||||
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(value.getDate()).padStart(2, '0');
|
||||
const dueDay = day(due);
|
||||
const today = day(now);
|
||||
if (dueDay < today) return { label: 'Overdue', priority: 2 };
|
||||
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
|
||||
return {
|
||||
label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
priority: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function urgencyReason(item) {
|
||||
if (!item?.is_assigned || item.kind !== 'issue') return '';
|
||||
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||
const priorityLabel = (item.labels || []).find(label =>
|
||||
priorityLabels.includes(String(label).toLowerCase())
|
||||
);
|
||||
if (priorityLabel) return priorityLabel + ' priority';
|
||||
return ['Overdue', 'Due today'].includes(item.due_label) ? item.due_label : '';
|
||||
}
|
||||
|
||||
function attentionReason(item) {
|
||||
if (item?.has_update) return 'Unread update';
|
||||
return urgencyReason(item) || (item?.is_review ? 'Needs your review' : '');
|
||||
}
|
||||
|
||||
function needsAttention(item) {
|
||||
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
|
||||
}
|
||||
|
||||
function buildMyWork(data, now = new Date()) {
|
||||
const login = data.user?.login || '';
|
||||
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
||||
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
|
||||
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||
|
||||
return issues.concat(pulls).map((item) => {
|
||||
const work = issues.concat(pulls).map((item) => {
|
||||
const labels = item.labels || [];
|
||||
const priorityLabel = labels.find((label) =>
|
||||
priorityLabels.includes(String(label).toLowerCase())
|
||||
);
|
||||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
return {
|
||||
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||
const normalized = {
|
||||
...item,
|
||||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
is_assigned: assigned,
|
||||
has_update: false,
|
||||
...(due ? { due_label: due.label } : {}),
|
||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')),
|
||||
_priority: priorityLabel ? 0 : (isReview ? 1 : (assigned ? 2 : 3)),
|
||||
(due && due.priority < 4 ? due.label :
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))),
|
||||
_priority: priorityLabel ? 0 :
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
|
||||
};
|
||||
}).sort((left, right) =>
|
||||
normalized.needs_attention = needsAttention(normalized);
|
||||
normalized.attention_reason = attentionReason(normalized);
|
||||
return normalized;
|
||||
});
|
||||
|
||||
const byKey = new Map(work.map((item) => [item.kind + ':' + item.key, item]));
|
||||
(data.notifications || []).filter((item) => item && item.unread).forEach((update) => {
|
||||
const key = (update.repository || 'unknown') + '#' + update.number;
|
||||
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
|
||||
const existing = byKey.get(subjectKind + ':' + key);
|
||||
if (existing) {
|
||||
existing.has_update = true;
|
||||
existing.needs_attention = true;
|
||||
existing.attention_reason = 'Unread update';
|
||||
existing.notification_id = update.id;
|
||||
existing.url = update.url || existing.url;
|
||||
existing.updated_at = update.updated_at || existing.updated_at;
|
||||
existing._priority = Math.min(existing._priority, 1);
|
||||
return;
|
||||
}
|
||||
if (!update.url) return;
|
||||
const item = {
|
||||
...update,
|
||||
key,
|
||||
kind: 'update',
|
||||
is_review: false,
|
||||
is_assigned: false,
|
||||
has_update: true,
|
||||
needs_attention: true,
|
||||
attention_reason: 'Unread update',
|
||||
notification_id: update.id,
|
||||
reason: 'Unread update',
|
||||
_priority: 1,
|
||||
};
|
||||
work.push(item);
|
||||
byKey.set(subjectKind + ':' + key, item);
|
||||
});
|
||||
|
||||
return work.sort((left, right) =>
|
||||
left._priority - right._priority ||
|
||||
String(right.updated_at || '').localeCompare(String(left.updated_at || '')) ||
|
||||
left.key.localeCompare(right.key)
|
||||
).map(({ _priority, ...item }) => item);
|
||||
}
|
||||
|
||||
function filterMyWork(items, selectedFilter) {
|
||||
if (selectedFilter === 'all') return items;
|
||||
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
|
||||
return items.filter((item) => item.kind === selectedFilter);
|
||||
function acknowledgeNotification(items, notificationId) {
|
||||
return items.flatMap((item) => {
|
||||
if (item.notification_id !== notificationId) return [item];
|
||||
if (item.kind === 'update') return [];
|
||||
const { notification_id, ...acknowledged } = item;
|
||||
const next = { ...acknowledged, has_update: false };
|
||||
if ('needs_attention' in item) next.needs_attention = needsAttention(next);
|
||||
if ('attention_reason' in item) next.attention_reason = attentionReason(next);
|
||||
return [next];
|
||||
});
|
||||
}
|
||||
|
||||
function createNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
||||
const pending = new Set();
|
||||
return {
|
||||
async acknowledge(items, notificationId) {
|
||||
if (pending.has(notificationId)) return false;
|
||||
pending.add(notificationId);
|
||||
onItems(acknowledgeNotification(items, notificationId));
|
||||
onStatus('Marking update read…');
|
||||
try {
|
||||
await markRead(notificationId);
|
||||
onStatus('Update marked read.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onItems(items);
|
||||
onStatus('Could not mark update read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending.delete(notificationId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function notificationIds(items) {
|
||||
return Array.from(new Set((items || [])
|
||||
.filter((item) => item && item.has_update && Number.isInteger(item.notification_id))
|
||||
.map((item) => item.notification_id)));
|
||||
}
|
||||
|
||||
function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
||||
let pending = false;
|
||||
return {
|
||||
async acknowledge(items, requestedIds) {
|
||||
if (pending) return false;
|
||||
const ids = Array.from(new Set((requestedIds || []).filter(Number.isInteger)));
|
||||
if (!ids.length) return false;
|
||||
pending = true;
|
||||
onStatus('Marking ' + ids.length + ' updates read…');
|
||||
try {
|
||||
const result = await markRead(ids);
|
||||
const marked = (result.marked || []).filter(Number.isInteger);
|
||||
const failed = (result.failed || []).filter(Number.isInteger);
|
||||
const updated = marked.reduce(
|
||||
(current, notificationId) => acknowledgeNotification(current, notificationId),
|
||||
items
|
||||
);
|
||||
onItems(updated);
|
||||
onStatus(failed.length ?
|
||||
marked.length + ' marked read · ' + failed.length + ' could not be updated — retry.' :
|
||||
marked.length + ' updates marked read.');
|
||||
return { marked, failed };
|
||||
} catch (_error) {
|
||||
onStatus('Could not mark updates read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
return {
|
||||
reset(next) {
|
||||
pagination = { ...pagination, ...(next || {}) };
|
||||
onPagination(pagination);
|
||||
},
|
||||
async loadMore(existing) {
|
||||
if (pending || !pagination.has_more) return false;
|
||||
pending = true;
|
||||
onStatus('Loading older updates…');
|
||||
try {
|
||||
const result = await load(pagination.page + 1);
|
||||
const byId = new Map((existing || [])
|
||||
.filter(item => item && Number.isInteger(item.id))
|
||||
.map(item => [item.id, item]));
|
||||
(result.items || []).forEach(item => {
|
||||
if (item && Number.isInteger(item.id) && !byId.has(item.id)) byId.set(item.id, item);
|
||||
});
|
||||
pagination = {
|
||||
page: result.page,
|
||||
total: result.total,
|
||||
has_more: result.has_more === true,
|
||||
};
|
||||
const loaded = Math.min(pagination.total, pagination.page * 50);
|
||||
onNotifications(Array.from(byId.values()));
|
||||
onPagination(pagination);
|
||||
onStatus(loaded + ' of ' + pagination.total + ' unread updates loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not load older updates. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
let pagination = {};
|
||||
const pending = new Set();
|
||||
const labels = {
|
||||
issue: 'issues',
|
||||
pull: 'pull requests',
|
||||
review: 'review requests',
|
||||
};
|
||||
return {
|
||||
reset(next) {
|
||||
Object.entries(next || {}).forEach(([stream, value]) => {
|
||||
const current = pagination[stream];
|
||||
pagination[stream] = current && current.page > 1 ?
|
||||
{ ...value, page: current.page, has_more: current.page * 50 < value.total } :
|
||||
{ ...value };
|
||||
});
|
||||
onPagination({ ...pagination });
|
||||
},
|
||||
async loadMore(stream, existing) {
|
||||
const page = pagination[stream];
|
||||
if (pending.has(stream) || !page?.has_more) return false;
|
||||
pending.add(stream);
|
||||
const label = labels[stream] || 'work';
|
||||
onStatus('Loading older ' + label + '…');
|
||||
try {
|
||||
const result = await load(stream, page.page + 1);
|
||||
const merged = new Map((existing || [])
|
||||
.filter(item => item && Number.isInteger(item.id))
|
||||
.map(item => [item.id, { ...item }]));
|
||||
(result.items || []).forEach(item => {
|
||||
if (!item || !Number.isInteger(item.id)) return;
|
||||
const current = merged.get(item.id);
|
||||
if (!current) {
|
||||
merged.set(item.id, { ...item });
|
||||
return;
|
||||
}
|
||||
const reasons = Array.from(new Set(
|
||||
(current.work_reasons || []).concat(item.work_reasons || [])
|
||||
));
|
||||
merged.set(item.id, {
|
||||
...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}),
|
||||
});
|
||||
});
|
||||
pagination[stream] = {
|
||||
page: result.page, total: result.total, has_more: result.has_more === true,
|
||||
};
|
||||
onItems(stream, Array.from(merged.values()));
|
||||
onPagination({ ...pagination });
|
||||
onStatus(Math.min(result.total, result.page * 50) + ' of ' + result.total + ' ' + label + ' loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not load older ' + label + '. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending.delete(stream);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationReader({
|
||||
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
|
||||
queueRead = null,
|
||||
loadSaved = () => null,
|
||||
loadConversation = null,
|
||||
onConversation = () => {},
|
||||
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
|
||||
}) {
|
||||
let selected = null;
|
||||
let loadVersion = 0;
|
||||
let marking = false;
|
||||
let conversationPager = null;
|
||||
let offlineHydrated = false;
|
||||
|
||||
async function open(item, savedDetail = null) {
|
||||
selected = item;
|
||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||
const version = ++loadVersion;
|
||||
onOpen(item);
|
||||
onStatus('Loading update…');
|
||||
try {
|
||||
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
onDetail(detail);
|
||||
if (createPager && loadConversation && detail.conversation) {
|
||||
conversationPager = createPager({
|
||||
loadPage: page => loadConversation(item.notification_id, page),
|
||||
});
|
||||
onConversation(conversationPager.reset(detail.conversation));
|
||||
} else {
|
||||
conversationPager = null;
|
||||
}
|
||||
onStatus('Update ready.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (selected === item && version === loadVersion) {
|
||||
onStatus('Could not load update. Retry or open it in Gitea.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
appendReply(comment) {
|
||||
if (!conversationPager) return false;
|
||||
onConversation(conversationPager.append(comment));
|
||||
return true;
|
||||
},
|
||||
async loadOlder() {
|
||||
if (!conversationPager || !selected || offlineHydrated) return false;
|
||||
const pager = conversationPager;
|
||||
const version = loadVersion;
|
||||
onStatus('Loading older messages…');
|
||||
try {
|
||||
const state = await pager.loadOlder();
|
||||
if (pager !== conversationPager || version !== loadVersion) return false;
|
||||
onConversation(state);
|
||||
onStatus(state.comments.length + ' of ' + state.total + ' messages loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (pager === conversationPager && version === loadVersion) {
|
||||
onStatus('Could not load older messages. Retry.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async markReadAndNext(items) {
|
||||
if (!selected || marking || (offlineHydrated && !queueRead)) return false;
|
||||
const current = selected;
|
||||
const queueing = offlineHydrated;
|
||||
marking = true;
|
||||
onStatus(queueing ? 'Queueing update read…' : 'Marking update read…');
|
||||
try {
|
||||
if (queueing) await queueRead(current.notification_id);
|
||||
else await markRead(current.notification_id);
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item => item && item.has_update &&
|
||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
} catch (_error) {
|
||||
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
marking = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationReplier({
|
||||
post, storage, onStatus, authoredOutbox,
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
}) {
|
||||
let pending = false;
|
||||
const keyFor = item => 'stackchain.update-reply.v1.' + item.notification_id;
|
||||
const operationKeyFor = item => keyFor(item) + ':operation';
|
||||
return {
|
||||
loadDraft(item) {
|
||||
try { return storage.getItem(keyFor(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
},
|
||||
saveDraft(item, body) {
|
||||
try {
|
||||
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
|
||||
storage.setItem(keyFor(item), body);
|
||||
}
|
||||
catch (_error) { /* Keep the editable textarea as the fallback. */ }
|
||||
},
|
||||
async submit(item, body) {
|
||||
if (pending) return false;
|
||||
pending = true;
|
||||
this.saveDraft(item, body);
|
||||
let operationId;
|
||||
try {
|
||||
operationId = storage.getItem(operationKeyFor(item)) || String(createOperationId()).slice(0, 128);
|
||||
storage.setItem(operationKeyFor(item), operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
onStatus('Sending reply…');
|
||||
try {
|
||||
const result = await post(item.notification_id, body, operationId);
|
||||
try { storage.removeItem(keyFor(item)); }
|
||||
catch (_error) { /* The posted reply is still authoritative. */ }
|
||||
try { storage.removeItem(operationKeyFor(item)); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
onStatus('Reply posted. You can mark this update read when ready.');
|
||||
return result;
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (authoredOutbox && (!status || status >= 500)) {
|
||||
onStatus('Saving for background delivery…');
|
||||
const admission = await authoredOutbox.enqueueDurably({
|
||||
kind: 'update-reply', notificationId: item.notification_id, body, operationId,
|
||||
});
|
||||
if (admission.background) {
|
||||
onStatus('Queued for sync when the connection returns.');
|
||||
return { queued: true };
|
||||
}
|
||||
onStatus('Saved for next launch; background delivery unavailable.');
|
||||
return { queued: false, degraded: true };
|
||||
}
|
||||
onStatus('Could not send reply. Your draft is safe; retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
let filtered = items;
|
||||
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
|
||||
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||
if (selectedMilestone === 'all') return filtered;
|
||||
if (selectedMilestone === 'unplanned') {
|
||||
return filtered.filter(item => item.kind === 'issue' && !item.milestone);
|
||||
}
|
||||
return filtered.filter(item =>
|
||||
item.kind === 'issue' && String(item.milestone?.id || '') === String(selectedMilestone)
|
||||
);
|
||||
}
|
||||
|
||||
function milestoneLanes(items) {
|
||||
const lanes = new Map();
|
||||
(items || []).forEach(item => {
|
||||
if (item?.kind === 'issue' && Number.isInteger(item.milestone?.id) &&
|
||||
typeof item.milestone?.title === 'string') {
|
||||
lanes.set(item.milestone.id, { id: item.milestone.id, title: item.milestone.title });
|
||||
}
|
||||
});
|
||||
return Array.from(lanes.values()).sort((left, right) => left.title.localeCompare(right.title));
|
||||
}
|
||||
|
||||
function workIdentity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const repository = item.repository || '';
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, repository, number, notification].join(':');
|
||||
}
|
||||
|
||||
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
|
||||
let currentIdentity = '';
|
||||
let currentIndex = -1;
|
||||
let running = false;
|
||||
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||
const report = (items, index) => onProgress({
|
||||
index: index + 1,
|
||||
total: items.length,
|
||||
can_previous: index > 0,
|
||||
can_next: index < items.length - 1,
|
||||
});
|
||||
const finish = () => {
|
||||
running = false;
|
||||
currentIdentity = '';
|
||||
currentIndex = -1;
|
||||
onFinish();
|
||||
return false;
|
||||
};
|
||||
const openAt = (items, index) => {
|
||||
if (!items.length || index < 0 || index >= items.length) return finish();
|
||||
currentIndex = index;
|
||||
currentIdentity = workIdentity(items[index]);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
start() {
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
return openAt(items, 0);
|
||||
},
|
||||
reconcile() {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (index < 0) return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
|
||||
currentIndex = index;
|
||||
report(items, index);
|
||||
return true;
|
||||
},
|
||||
previous() {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
return index > 0 ? openAt(items, index - 1) : false;
|
||||
},
|
||||
next() {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
|
||||
},
|
||||
complete() {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueLabels(data, repository, number, labels) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, labels: [...labels] } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueContent(data, repository, number, content) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, ...content } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueDueDate(data, repository, number, dueDate) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, due_date: dueDate } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueMilestone(data, repository, number, milestone) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, milestone } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function removeIssue(data, repository, number) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).filter(item =>
|
||||
item.repository !== repository || item.number !== number
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeMyWork(items) {
|
||||
const updates = items.filter((item) => item.has_update).length;
|
||||
const reviews = items.filter((item) => item.is_review).length;
|
||||
const assigned = items.filter((item) => item.is_assigned).length;
|
||||
const updateLabel = updates + ' unread update' + (updates === 1 ? '' : 's');
|
||||
const reviewLabel = reviews + ' review' + (reviews === 1 ? '' : 's');
|
||||
const assignedLabel = assigned + ' assigned';
|
||||
return reviewLabel + ' · ' + assignedLabel;
|
||||
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
|
||||
}
|
||||
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
attention: items.filter(needsAttention).length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
update: items.filter((item) => item.has_update).length,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
buildMyWork.workIdentity = workIdentity;
|
||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||
buildMyWork.replaceIssueDueDate = replaceIssueDueDate;
|
||||
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
buildMyWork.notificationIds = notificationIds;
|
||||
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
||||
buildMyWork.createNotificationPager = createNotificationPager;
|
||||
buildMyWork.createWorkPager = createWorkPager;
|
||||
buildMyWork.createNotificationReader = createNotificationReader;
|
||||
buildMyWork.createNotificationReplier = createNotificationReplier;
|
||||
module.exports = buildMyWork;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
function createNotificationReadOutbox({
|
||||
storage, fetchJson, backgroundSync, coordinator, getOwnerLogin = () => '', now = () => Date.now(), maxItems = 100,
|
||||
}) {
|
||||
const storageKey = 'stackchain.notification-read-outbox.v1';
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item?.kind === 'notification-read' &&
|
||||
Number.isInteger(item.notificationId) && item.notificationId > 0 && item.ownerLogin);
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items, mirror = true) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
|
||||
coordinator?.notify('notification-read');
|
||||
if (mirror && backgroundSync?.reconcile) {
|
||||
Promise.resolve(backgroundSync.reconcile(items, 'notification-read'))
|
||||
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
|
||||
.catch(() => { /* Foreground reconnect remains available. */ });
|
||||
}
|
||||
}
|
||||
|
||||
function itemId(ownerLogin, notificationId) {
|
||||
return 'notification-read:' + ownerLogin + ':' + notificationId;
|
||||
}
|
||||
|
||||
async function enqueueDurably(notificationId) {
|
||||
notificationId = Number(notificationId);
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing this update.');
|
||||
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid update.');
|
||||
const items = read();
|
||||
const id = itemId(ownerLogin, notificationId);
|
||||
let item = items.find(candidate => candidate.id === id);
|
||||
if (!item) {
|
||||
if (items.length >= maxItems) throw new Error('Update acknowledgement queue is full. Reconnect before clearing more updates.');
|
||||
item = {
|
||||
id, kind: 'notification-read', notificationId, ownerLogin,
|
||||
status: 'queued', queuedAt: Number(now()),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, false);
|
||||
}
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item: { ...item }, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
try {
|
||||
await backgroundSync.reconcile(read(), 'notification-read');
|
||||
await backgroundSync.requestSync();
|
||||
return { item: { ...item }, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item: { ...item }, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function suppress(items, login = getOwnerLogin()) {
|
||||
const pending = new Set(read().filter(item => item.ownerLogin === String(login || '').trim())
|
||||
.map(item => item.notificationId));
|
||||
return (items || []).filter(item => !pending.has(Number(item?.notification_id ?? item?.id)));
|
||||
}
|
||||
|
||||
async function send(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (backgroundSync?.send) return backgroundSync.send(item, currentLogin);
|
||||
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
return { confirmed: true };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
const confirmed = [];
|
||||
let items = read();
|
||||
for (const item of items) {
|
||||
if (item.ownerLogin !== currentLogin || item.status === 'attention') continue;
|
||||
try {
|
||||
const result = await send(item, currentLogin);
|
||||
if (result?.blocked || result?.busy) continue;
|
||||
if (result?.attention) {
|
||||
items = items.map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(result.error?.message || 'Update needs attention').slice(0, 240),
|
||||
} : candidate);
|
||||
write(items);
|
||||
continue;
|
||||
}
|
||||
confirmed.push(item.notificationId);
|
||||
items = items.filter(candidate => candidate.id !== item.id);
|
||||
write(items);
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status >= 400 && status < 500) {
|
||||
items = items.map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(error?.message || 'Update needs attention').slice(0, 240),
|
||||
} : candidate);
|
||||
write(items);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { confirmed, remaining: read() };
|
||||
}
|
||||
|
||||
function reconcileBackground(records) {
|
||||
const states = new Map((records || []).filter(item => item?.kind === 'notification-read')
|
||||
.map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
const state = states.get(item.id);
|
||||
if (state?.status === 'sent') return [];
|
||||
if (state?.status === 'attention') return [{
|
||||
...item, status: 'attention', error: String(state.error || 'Update needs attention').slice(0, 240),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
write(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueueDurably, flush, suppress, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createNotificationReadOutbox;
|
||||
@@ -0,0 +1,83 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.createOfflineToday = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
const itemKey = item => [item?.kind, item?.repository, Number(item?.number || 0)].join(':');
|
||||
|
||||
function createOfflineToday({
|
||||
loadDetail, loadSavedDetail, saveDetail, onStatus = () => {}, concurrency = 2, maxItems = 5,
|
||||
}) {
|
||||
let failed = new Set();
|
||||
let generation = 0;
|
||||
|
||||
function bounded(items) {
|
||||
return (Array.isArray(items) ? items : []).filter(item =>
|
||||
['issue', 'pull'].includes(item?.kind) && item?.repository && Number(item?.number) > 0
|
||||
).slice(0, Math.max(1, maxItems));
|
||||
}
|
||||
|
||||
async function run(login, sourceItems, onlyFailed) {
|
||||
login = String(login || '').trim();
|
||||
const items = bounded(sourceItems);
|
||||
const runGeneration = ++generation;
|
||||
if (!login || !items.length) {
|
||||
failed = new Set();
|
||||
const empty = { total: items.length, ready: 0, failed: 0, pending: 0 };
|
||||
onStatus(empty);
|
||||
return empty;
|
||||
}
|
||||
|
||||
const previousFailed = failed;
|
||||
const readyKeys = new Set(items.filter(item => loadSavedDetail(login, item)).map(itemKey));
|
||||
const snapshot = pending => ({
|
||||
total: items.length, ready: readyKeys.size, failed: failed.size, pending,
|
||||
});
|
||||
const candidates = items.filter(item => {
|
||||
const key = itemKey(item);
|
||||
if (onlyFailed && !previousFailed.has(key)) return false;
|
||||
const saved = loadSavedDetail(login, item);
|
||||
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
|
||||
});
|
||||
failed = new Set();
|
||||
onStatus(snapshot(candidates.length));
|
||||
let cursor = 0;
|
||||
|
||||
async function worker() {
|
||||
while (cursor < candidates.length && runGeneration === generation) {
|
||||
const item = candidates[cursor++];
|
||||
try {
|
||||
const detail = await loadDetail(item);
|
||||
if (runGeneration !== generation) return;
|
||||
const saved = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
|
||||
if (saved !== false) readyKeys.add(itemKey(item));
|
||||
} catch (_error) {
|
||||
if (runGeneration === generation) failed.add(itemKey(item));
|
||||
}
|
||||
if (runGeneration === generation) {
|
||||
onStatus(snapshot(Math.max(0, candidates.length - cursor)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from(
|
||||
{ length: Math.min(Math.max(1, concurrency), candidates.length) }, worker
|
||||
));
|
||||
if (runGeneration !== generation) return snapshot(0);
|
||||
const status = snapshot(0);
|
||||
onStatus(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
return {
|
||||
warm: (login, items) => run(login, items, false),
|
||||
retry: (login, items) => run(login, items, true),
|
||||
cancel() { generation += 1; failed = new Set(); },
|
||||
failedKeys: () => Array.from(failed),
|
||||
};
|
||||
}
|
||||
|
||||
return createOfflineToday;
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.createOfflineWorkStore = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
const ENABLED_KEY = 'stackchain.offline-work.enabled.v1';
|
||||
const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1';
|
||||
const DETAILS_KEY = 'stackchain.offline-work.details.v1';
|
||||
const VERSION = 1;
|
||||
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const ITEM_FIELDS = [
|
||||
'id', 'number', 'title', 'state', 'repository', 'labels', 'assignees',
|
||||
'updated_at', 'due_date', 'milestone', 'url', 'work_reasons',
|
||||
];
|
||||
const NOTIFICATION_FIELDS = [
|
||||
'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url',
|
||||
];
|
||||
const DETAIL_FIELDS = [
|
||||
'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone',
|
||||
'id', 'repository', 'subject_type', 'subject_body', 'source_updated_at', 'head_sha', 'ci_state',
|
||||
];
|
||||
const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url'];
|
||||
const REVIEW_FILE_FIELDS = [
|
||||
'filename', 'status', 'additions', 'deletions', 'diff_available', 'diff_binary', 'diff_truncated',
|
||||
];
|
||||
const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at'];
|
||||
|
||||
function pick(source, fields) {
|
||||
const output = {};
|
||||
fields.forEach(field => {
|
||||
if (source && source[field] !== undefined) output[field] = source[field];
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function createOfflineWorkStore({
|
||||
storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10,
|
||||
}) {
|
||||
function enabled() {
|
||||
try { return storage.getItem(ENABLED_KEY) === 'true'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
try {
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
}
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function detailKey(item) {
|
||||
if (item?.kind === 'update') {
|
||||
const notificationId = Number(item?.notification_id || 0);
|
||||
return Number.isInteger(notificationId) && notificationId > 0 ? 'update:' + notificationId : '';
|
||||
}
|
||||
const kind = item?.kind === 'review' ? 'review' :
|
||||
item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : '';
|
||||
const repository = String(item?.repository || '');
|
||||
const number = Number(item?.number || 0);
|
||||
return kind && repository && number > 0 ? [kind, repository, number].join(':') : '';
|
||||
}
|
||||
|
||||
function readDetails() {
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||
return Array.isArray(value) ? value : [];
|
||||
} catch (_) {
|
||||
try { storage.removeItem(DETAILS_KEY); } catch (_error) { /* Best effort purge. */ }
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveDetail(login, item, detail) {
|
||||
const key = detailKey(item);
|
||||
login = String(login || '').trim();
|
||||
if (!enabled() || !login || !key || !detail) return false;
|
||||
const conversation = detail.conversation || {};
|
||||
const data = {
|
||||
...pick(detail, DETAIL_FIELDS),
|
||||
conversation: {
|
||||
comments: (conversation.comments || []).slice(-20).map(comment => pick(comment, COMMENT_FIELDS)),
|
||||
page: Number(conversation.page || 1),
|
||||
older_page: conversation.older_page ?? null,
|
||||
total: Number(conversation.total || 0),
|
||||
},
|
||||
};
|
||||
if (item?.is_review || item?.kind === 'review') {
|
||||
data.files = (detail.files || []).slice(0, 50).map(file => ({
|
||||
...pick(file, REVIEW_FILE_FIELDS),
|
||||
diff_lines: (file?.diff_lines || []).slice(0, 400).map(line => String(line)),
|
||||
}));
|
||||
data.reviews = (detail.reviews || []).slice(-20).map(review => ({
|
||||
...pick(review, REVIEW_FIELDS),
|
||||
user: pick(review?.user, ['login']),
|
||||
}));
|
||||
}
|
||||
const record = {
|
||||
key,
|
||||
user_login: login,
|
||||
saved_at: now().toISOString(),
|
||||
data,
|
||||
};
|
||||
const records = readDetails().filter(candidate =>
|
||||
!(candidate?.key === key && candidate?.user_login === login)
|
||||
);
|
||||
records.push(record);
|
||||
try { storage.setItem(DETAILS_KEY, JSON.stringify(records.slice(-Math.max(1, maxDetails)))); }
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadDetail(login, item) {
|
||||
const key = detailKey(item);
|
||||
login = String(login || '').trim();
|
||||
if (!login || !key) return null;
|
||||
const records = readDetails();
|
||||
const currentTime = now().getTime();
|
||||
const valid = records.filter(record => {
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
return record?.key && record?.user_login && record?.data && Number.isFinite(savedAt) &&
|
||||
currentTime - savedAt <= maxAgeMs;
|
||||
});
|
||||
if (valid.length !== records.length) {
|
||||
try { storage.setItem(DETAILS_KEY, JSON.stringify(valid)); } catch (_) { /* Best effort purge. */ }
|
||||
}
|
||||
const record = valid.find(candidate => candidate.key === key && candidate.user_login === login);
|
||||
return record ? { ...record.data, saved_at: record.saved_at } : null;
|
||||
}
|
||||
|
||||
function setEnabled(value) {
|
||||
try {
|
||||
storage.setItem(ENABLED_KEY, value ? 'true' : 'false');
|
||||
if (!value) clear();
|
||||
return true;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
function save(snapshot) {
|
||||
if (!enabled() || !snapshot?.user?.login) return false;
|
||||
const record = {
|
||||
version: VERSION,
|
||||
user_login: String(snapshot.user.login),
|
||||
saved_at: now().toISOString(),
|
||||
data: {
|
||||
user: pick(snapshot.user, ['login', 'full_name']),
|
||||
issues: (snapshot.issues || []).map(item => pick(item, ITEM_FIELDS)),
|
||||
pull_requests: (snapshot.pull_requests || []).map(item => pick(item, ITEM_FIELDS)),
|
||||
notifications: (snapshot.notifications || []).map(item => pick(item, NOTIFICATION_FIELDS)),
|
||||
work_pagination: snapshot.work_pagination || {},
|
||||
notification_pagination: snapshot.notification_pagination || {},
|
||||
},
|
||||
};
|
||||
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function load(expectedLogin) {
|
||||
let record;
|
||||
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
|
||||
catch (_) { clear(); return null; }
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
|
||||
!Number.isFinite(savedAt);
|
||||
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
|
||||
if (invalid || expired) {
|
||||
clear();
|
||||
return null;
|
||||
}
|
||||
if (expectedLogin && record.user_login !== expectedLogin) return null;
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
|
||||
return { enabled, setEnabled, save, load, saveDetail, loadDetail, clear };
|
||||
}
|
||||
|
||||
return createOfflineWorkStore;
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
function createOutboxCoordinator({
|
||||
storage,
|
||||
locks = globalThis.navigator?.locks,
|
||||
channelFactory = typeof globalThis.BroadcastChannel === 'function' ?
|
||||
() => new globalThis.BroadcastChannel('stackchain-outbox-v1') : null,
|
||||
addStorageListener = globalThis.addEventListener?.bind(globalThis),
|
||||
removeStorageListener = globalThis.removeEventListener?.bind(globalThis),
|
||||
tabId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
now = () => Date.now(),
|
||||
leaseMs = 15000,
|
||||
} = {}) {
|
||||
const changeKey = 'stackchain.outbox-change.v1';
|
||||
const subscribers = new Set();
|
||||
const channel = channelFactory ? channelFactory() : null;
|
||||
let sequence = 0;
|
||||
|
||||
function validChange(value) {
|
||||
return value && ['issue', 'authored', 'today', 'later'].includes(value.queue) ? value : null;
|
||||
}
|
||||
|
||||
function publish(change) {
|
||||
subscribers.forEach(listener => listener(change));
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
channel.onmessage = event => {
|
||||
const change = validChange(event?.data);
|
||||
if (change && change.tabId !== tabId) publish(change);
|
||||
};
|
||||
}
|
||||
|
||||
const onStorage = event => {
|
||||
if (event?.key !== changeKey || !event.newValue) return;
|
||||
try {
|
||||
const change = validChange(JSON.parse(event.newValue));
|
||||
if (change && change.tabId !== tabId) publish(change);
|
||||
} catch (_error) { /* Ignore malformed cross-tab signals. */ }
|
||||
};
|
||||
if (addStorageListener) addStorageListener('storage', onStorage);
|
||||
|
||||
function notify(queue) {
|
||||
const change = { queue, tabId, changedAt: Number(now()), sequence: ++sequence };
|
||||
if (!validChange(change)) return;
|
||||
try { storage?.setItem(changeKey, JSON.stringify(change)); } catch (_error) { /* Broadcast may still work. */ }
|
||||
try { channel?.postMessage(change); } catch (_error) { /* Storage events remain available. */ }
|
||||
}
|
||||
|
||||
function skipped() {
|
||||
return { confirmed: [], remaining: [], blocked: 0, lease_skipped: true };
|
||||
}
|
||||
|
||||
async function withFallbackLease(queue, work) {
|
||||
const key = 'stackchain.outbox-lease.' + queue + '.v1';
|
||||
const token = tabId + ':' + (++sequence);
|
||||
const timestamp = Number(now());
|
||||
try {
|
||||
const current = JSON.parse(storage?.getItem(key) || 'null');
|
||||
if (current?.token && Number(current.expiresAt) > timestamp) return skipped();
|
||||
storage?.setItem(key, JSON.stringify({ token, expiresAt: timestamp + leaseMs }));
|
||||
const claimed = JSON.parse(storage?.getItem(key) || 'null');
|
||||
if (claimed?.token !== token) return skipped();
|
||||
} catch (_error) {
|
||||
return work();
|
||||
}
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
try {
|
||||
const current = JSON.parse(storage?.getItem(key) || 'null');
|
||||
if (current?.token === token) storage?.removeItem(key);
|
||||
} catch (_error) { /* An expired lease will be reclaimed. */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function runExclusive(queue, work) {
|
||||
if (locks?.request) {
|
||||
return locks.request('stackchain-outbox-' + queue + '-v1', { mode: 'exclusive', ifAvailable: true },
|
||||
lock => lock ? work() : skipped());
|
||||
}
|
||||
return withFallbackLease(queue, work);
|
||||
}
|
||||
|
||||
function subscribe(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
subscribers.add(listener);
|
||||
return () => subscribers.delete(listener);
|
||||
}
|
||||
|
||||
function close() {
|
||||
subscribers.clear();
|
||||
try { channel?.close(); } catch (_error) { /* No-op. */ }
|
||||
if (removeStorageListener) removeStorageListener('storage', onStorage);
|
||||
}
|
||||
|
||||
return { runExclusive, notify, subscribe, close };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createOutboxCoordinator;
|
||||
@@ -0,0 +1,104 @@
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
let available = [];
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let loadRequest = null;
|
||||
let claimRequest = null;
|
||||
const previewed = new Set();
|
||||
|
||||
function itemKey(item) {
|
||||
return String(item?.repository || '') + '#' + String(item?.number || '');
|
||||
}
|
||||
|
||||
function apply(result, append) {
|
||||
const incoming = Array.isArray(result?.items) ? result.items : [];
|
||||
if (append) {
|
||||
const byId = new Map(available.map(item => [item.id, item]));
|
||||
incoming.forEach(item => {
|
||||
if (item && !byId.has(item.id)) byId.set(item.id, { ...item });
|
||||
});
|
||||
available = Array.from(byId.values());
|
||||
} else {
|
||||
available = incoming.map(item => ({ ...item }));
|
||||
}
|
||||
const availableKeys = new Set(available.map(itemKey));
|
||||
previewed.forEach(key => {
|
||||
if (!availableKeys.has(key)) previewed.delete(key);
|
||||
});
|
||||
pagination = {
|
||||
page: Number(result?.page) || 1,
|
||||
total: Number(result?.total) || available.length,
|
||||
has_more: result?.has_more === true,
|
||||
};
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
}
|
||||
|
||||
function loadPage(page, append) {
|
||||
if (loadRequest) return loadRequest;
|
||||
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
apply(result, append);
|
||||
if (result?.refresh_failed === true) {
|
||||
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
|
||||
} else if (result?.revalidating === true) {
|
||||
onStatus('Showing saved available work while the catalog refreshes…');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { loadRequest = null; });
|
||||
return loadRequest;
|
||||
}
|
||||
|
||||
return {
|
||||
reset(result) {
|
||||
apply(result, false);
|
||||
},
|
||||
load() {
|
||||
return loadPage(1, false);
|
||||
},
|
||||
loadMore() {
|
||||
if (!pagination.has_more) return Promise.resolve(false);
|
||||
return loadPage(pagination.page + 1, true);
|
||||
},
|
||||
items() {
|
||||
return available.slice();
|
||||
},
|
||||
togglePreview(item) {
|
||||
const key = itemKey(item);
|
||||
if (previewed.has(key)) previewed.delete(key);
|
||||
else previewed.add(key);
|
||||
return previewed.has(key);
|
||||
},
|
||||
isPreviewed(item) {
|
||||
return previewed.has(itemKey(item));
|
||||
},
|
||||
claim(item) {
|
||||
if (claimRequest) return claimRequest;
|
||||
const key = item.repository + '#' + item.number;
|
||||
onStatus('Assigning ' + key + '…');
|
||||
const path = 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number) + '/claim';
|
||||
claimRequest = fetchJson(path, {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(confirmed => {
|
||||
if (!Array.isArray(confirmed?.assignees) || !confirmed.assignees.length) {
|
||||
throw new Error('Issue assignment was not confirmed.');
|
||||
}
|
||||
available = available.filter(candidate =>
|
||||
candidate.repository !== item.repository || candidate.number !== item.number
|
||||
);
|
||||
pagination.total = Math.max(available.length, pagination.total - 1);
|
||||
pagination.has_more = available.length < pagination.total;
|
||||
previewed.delete(itemKey(item));
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
onStatus('Assigned ' + key + ' to you.');
|
||||
return confirmed;
|
||||
}).finally(() => { claimRequest = null; });
|
||||
return claimRequest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
|
||||
@@ -0,0 +1,61 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.stackchainPrivateDeviceData = factory({
|
||||
localStorage: root.localStorage,
|
||||
sessionStorage: root.sessionStorage,
|
||||
indexedDB: root.indexedDB,
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
MessageChannel: root.MessageChannel,
|
||||
});
|
||||
})(typeof window !== 'undefined' ? window : this, function createPrivateDeviceDataPurger({
|
||||
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel,
|
||||
}) {
|
||||
function removeOwnedStorage(storage) {
|
||||
if (!storage) return;
|
||||
const keys = [];
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (key?.startsWith('stackchain.')) keys.push(key);
|
||||
}
|
||||
keys.forEach(key => storage.removeItem(key));
|
||||
}
|
||||
|
||||
async function stopWorkerOutbox() {
|
||||
const registration = await serviceWorker?.ready;
|
||||
if (!registration?.active || !MessageChannel) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
|
||||
channel.port1.onmessage = event => {
|
||||
clearTimeout(timeout);
|
||||
if (event.data?.ok) resolve();
|
||||
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
|
||||
};
|
||||
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrivateOutbox() {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
return async function clearPrivateDeviceData() {
|
||||
removeOwnedStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateOutbox();
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
);
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
function mergeEligibility(detail, reviewState) {
|
||||
if (!detail || detail.state !== 'open' || detail.merged) {
|
||||
return { allowed: false, reason: 'Pull request is not open' };
|
||||
}
|
||||
if (detail.draft) return { allowed: false, reason: 'Draft pull requests cannot be merged' };
|
||||
if (!detail.mergeable) return { allowed: false, reason: 'Resolve conflicts before merging' };
|
||||
if (detail.ci_state !== 'success') {
|
||||
return { allowed: false, reason: 'CI must succeed before merging' };
|
||||
}
|
||||
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
|
||||
if (reviewState && !reviewState.complete) {
|
||||
return { allowed: false, reason: 'Review every changed file before merging' };
|
||||
}
|
||||
return { allowed: true, reason: 'Ready to merge' };
|
||||
}
|
||||
|
||||
function renderFile(file, index, reviewed, escapeHtml) {
|
||||
const filename = escapeHtml(file.filename || 'Unknown file');
|
||||
const panelId = 'pull-diff-' + index;
|
||||
const lines = (file.diff_lines || []).map(line => {
|
||||
const text = String(line);
|
||||
const kind = text.startsWith('@@') ? 'hunk' : text.startsWith('+') ? 'added' :
|
||||
text.startsWith('-') ? 'removed' : 'context';
|
||||
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(text) + '</span>';
|
||||
}).join('');
|
||||
const preview = file.diff_available
|
||||
? '<pre class="pull-diff" id="' + panelId + '" hidden>' + lines +
|
||||
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
|
||||
: '<div class="pull-diff-empty" id="' + panelId + '" hidden>' +
|
||||
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') +
|
||||
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
|
||||
return '<article class="pull-file" data-pull-filename="' + filename + '">' +
|
||||
'<button type="button" class="pull-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
|
||||
'<strong>' + filename + '</strong><span class="small">' + escapeHtml(file.status || 'changed') +
|
||||
' · +' + Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span></button>' + preview +
|
||||
'<button type="button" class="pull-review-file" data-pull-review-file="' + filename +
|
||||
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
|
||||
}
|
||||
|
||||
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let mergeRequest = null;
|
||||
const reviewRequests = new Map();
|
||||
const reviewCache = new Map();
|
||||
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
|
||||
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
|
||||
const operationKey = item => draftKey(item) + ':operation';
|
||||
const reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha;
|
||||
const fileNames = detail => (detail?.files || []).map(file => file.filename).filter(Boolean);
|
||||
|
||||
function reviewState(item, detail) {
|
||||
const files = fileNames(detail);
|
||||
let saved = [];
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(reviewKey(item, detail)) || '[]');
|
||||
if (Array.isArray(value)) saved = value;
|
||||
} catch (_error) { /* Corrupt progress safely starts over. */ }
|
||||
const reviewed = files.filter(filename => saved.includes(filename));
|
||||
return { reviewed, total: files.length, complete: reviewed.length === files.length };
|
||||
}
|
||||
|
||||
return {
|
||||
load(item) {
|
||||
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
|
||||
},
|
||||
loadReview(item, headSha) {
|
||||
const key = item.repository + '#' + item.number + ':' + headSha;
|
||||
if (reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
|
||||
if (reviewRequests.has(key)) return reviewRequests.get(key);
|
||||
const request = fetchJson(pathFor(item) + '/review-data', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(detail => {
|
||||
const confirmedKey = item.repository + '#' + item.number + ':' + detail.head_sha;
|
||||
reviewCache.set(confirmedKey, detail);
|
||||
return detail;
|
||||
}).finally(() => reviewRequests.delete(key));
|
||||
reviewRequests.set(key, request);
|
||||
return request;
|
||||
},
|
||||
conversation(item, initialPage) {
|
||||
const pager = createConversationPager({
|
||||
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
});
|
||||
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
|
||||
return pager;
|
||||
},
|
||||
reviewState,
|
||||
toggleReviewed(item, detail, filename) {
|
||||
const state = reviewState(item, detail);
|
||||
const reviewed = new Set(state.reviewed);
|
||||
if (reviewed.has(filename)) reviewed.delete(filename);
|
||||
else if (fileNames(detail).includes(filename)) reviewed.add(filename);
|
||||
try { storage?.setItem(reviewKey(item, detail), JSON.stringify(Array.from(reviewed))); }
|
||||
catch (_error) { /* In-memory controls still work for this render. */ }
|
||||
return reviewState(item, detail);
|
||||
},
|
||||
nextUnreviewed(detail, state) {
|
||||
return fileNames(detail).find(filename => !state.reviewed.includes(filename)) || null;
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
},
|
||||
saveDraft(item, body) {
|
||||
try {
|
||||
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
|
||||
storage?.setItem(draftKey(item), body);
|
||||
}
|
||||
catch (_error) { /* The textarea remains the fallback. */ }
|
||||
},
|
||||
comment(item, body) {
|
||||
if (commentRequest) return commentRequest;
|
||||
this.saveDraft(item, body);
|
||||
let operationId;
|
||||
try {
|
||||
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(operationKey(item), operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
commentRequest = fetchJson(pathFor(item) + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
|
||||
body: JSON.stringify({ body }),
|
||||
}).then(result => {
|
||||
try { storage?.removeItem(draftKey(item)); }
|
||||
catch (_error) { /* The upstream comment is authoritative. */ }
|
||||
try { storage?.removeItem(operationKey(item)); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
return result;
|
||||
}).finally(() => { commentRequest = null; });
|
||||
return commentRequest;
|
||||
},
|
||||
merge(item, expectedHeadSha) {
|
||||
if (mergeRequest) return mergeRequest;
|
||||
mergeRequest = fetchJson(pathFor(item) + '/merge', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
|
||||
}).then(result => {
|
||||
if (!result?.merged) {
|
||||
throw new Error(result?.confirmation_pending && result?.error
|
||||
? result.error
|
||||
: 'Pull request merge was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { mergeRequest = null; });
|
||||
return mergeRequest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createPullSheet.mergeEligibility = mergeEligibility;
|
||||
createPullSheet.renderFile = renderFile;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||
+286
-9
@@ -1,4 +1,10 @@
|
||||
function createReviewController({ fetchJson }) {
|
||||
function createReviewController({
|
||||
fetchJson,
|
||||
storage,
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
}) {
|
||||
let pendingSubmission = null;
|
||||
|
||||
function endpoint(item) {
|
||||
const [owner, repo] = String(item.repository || '').split('/');
|
||||
if (!owner || !repo || !Number.isInteger(Number(item.number))) {
|
||||
@@ -12,7 +18,33 @@ function createReviewController({ fetchJson }) {
|
||||
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
|
||||
}
|
||||
|
||||
return { load };
|
||||
function submit(item, payload) {
|
||||
if (pendingSubmission) return pendingSubmission;
|
||||
const operationKey = 'stackchain.review-submit.v1:' + item.repository + '#' + item.number;
|
||||
const fingerprint = JSON.stringify(payload);
|
||||
let operationId;
|
||||
try {
|
||||
const saved = JSON.parse(storage?.getItem(operationKey) || 'null');
|
||||
operationId = saved?.fingerprint === fingerprint && saved?.operationId
|
||||
? saved.operationId : String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(operationKey, JSON.stringify({ fingerprint, operationId }));
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
pendingSubmission = fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': operationId,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).then(result => {
|
||||
try { storage?.removeItem(operationKey); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
return result;
|
||||
}).finally(() => { pendingSubmission = null; });
|
||||
return pendingSubmission;
|
||||
}
|
||||
|
||||
return { load, submit };
|
||||
}
|
||||
|
||||
function diffLineClass(line) {
|
||||
@@ -22,14 +54,54 @@ function diffLineClass(line) {
|
||||
return 'context';
|
||||
}
|
||||
|
||||
function parseDiffLines(lines) {
|
||||
let oldLine = null;
|
||||
let newLine = null;
|
||||
return (lines || []).map(value => {
|
||||
const text = String(value);
|
||||
const hunk = text.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (hunk) {
|
||||
oldLine = Number(hunk[1]);
|
||||
newLine = Number(hunk[2]);
|
||||
return { text, kind: 'hunk', commentable: false };
|
||||
}
|
||||
if (text.startsWith('\\')) return { text, kind: 'note', commentable: false };
|
||||
if (oldLine === null || newLine === null) {
|
||||
return { text, kind: diffLineClass(text), commentable: false };
|
||||
}
|
||||
if (text.startsWith('-')) {
|
||||
const row = { text, kind: 'removed', commentable: true, old_position: oldLine };
|
||||
oldLine += 1;
|
||||
return row;
|
||||
}
|
||||
if (text.startsWith('+')) {
|
||||
const row = { text, kind: 'added', commentable: true, new_position: newLine };
|
||||
newLine += 1;
|
||||
return row;
|
||||
}
|
||||
const row = { text, kind: 'context', commentable: true, new_position: newLine };
|
||||
oldLine += 1;
|
||||
newLine += 1;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDiffFile(file, index, escapeHtml) {
|
||||
const panelId = 'review-diff-' + index;
|
||||
let preview;
|
||||
if (file.diff_available) {
|
||||
const lines = (file.diff_lines || []).map(line =>
|
||||
'<span class="review-diff-line ' + diffLineClass(String(line)) + '">' +
|
||||
escapeHtml(String(line)) + '</span>'
|
||||
).join('');
|
||||
const lines = parseDiffLines(file.diff_lines).map(row => {
|
||||
if (!row.commentable) {
|
||||
return '<span class="review-diff-line ' + row.kind + '">' + escapeHtml(row.text) + '</span>';
|
||||
}
|
||||
const position = row.old_position
|
||||
? ' data-old-position="' + row.old_position + '"'
|
||||
: ' data-new-position="' + row.new_position + '"';
|
||||
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>';
|
||||
}).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>' : '') +
|
||||
'</pre>';
|
||||
@@ -38,10 +110,14 @@ function renderDiffFile(file, index, escapeHtml) {
|
||||
preview = '<div class="review-diff-empty" id="' + panelId + '" hidden>' + message +
|
||||
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
|
||||
}
|
||||
return '<div class="review-file"><button class="review-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
|
||||
'<strong>' + escapeHtml(file.filename || 'Unknown file') + '</strong><span class="small">' +
|
||||
const filename = escapeHtml(file.filename || 'Unknown file');
|
||||
return '<div class="review-file" data-review-filename="' + filename + '"><button class="review-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
|
||||
'<strong>' + filename + '</strong><span class="small">' +
|
||||
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / −' +
|
||||
Number(file.deletions || 0) + '</span></button>' + preview + '</div>';
|
||||
Number(file.deletions || 0) + '</span></button>' + preview +
|
||||
'<label class="small" for="review-note-' + index + '">Note for ' + filename + '</label>' +
|
||||
'<textarea class="review-note" id="review-note-' + index + '" data-review-filename="' + filename + '" placeholder="Capture feedback for this file"></textarea>' +
|
||||
'<button class="review-mark" data-review-filename="' + filename + '" aria-pressed="false">Mark reviewed</button></div>';
|
||||
}
|
||||
|
||||
function toggleDiff(button, panel) {
|
||||
@@ -50,8 +126,209 @@ function toggleDiff(button, panel) {
|
||||
panel.hidden = expanded;
|
||||
}
|
||||
|
||||
function createProgress({ storage, repository, number, headSha, files }) {
|
||||
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
|
||||
const key = 'stackchain.review-progress.v1:' + repository + '#' + number + '@' + headSha;
|
||||
const headKey = 'stackchain.review-progress.v1:' + repository + '#' + number + ':head';
|
||||
let reviewed = [];
|
||||
let newHead = false;
|
||||
try {
|
||||
const previousHead = storage.getItem(headKey);
|
||||
newHead = Boolean(previousHead && previousHead !== headSha);
|
||||
storage.setItem(headKey, headSha);
|
||||
const saved = JSON.parse(storage.getItem(key) || '[]');
|
||||
if (Array.isArray(saved)) reviewed = filenames.filter(filename => saved.includes(filename));
|
||||
} catch (error) {
|
||||
reviewed = [];
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const pending = filenames.find(filename => !reviewed.includes(filename)) || null;
|
||||
return {
|
||||
reviewed: [...reviewed],
|
||||
reviewedCount: reviewed.length,
|
||||
total: filenames.length,
|
||||
nextFilename: pending,
|
||||
...(newHead ? { newHead: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function markReviewed(filename) {
|
||||
if (filenames.includes(filename) && !reviewed.includes(filename)) {
|
||||
reviewed.push(filename);
|
||||
reviewed = filenames.filter(item => reviewed.includes(item));
|
||||
try { storage.setItem(key, JSON.stringify(reviewed)); } catch (error) { /* local progress remains usable */ }
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
reviewed = [];
|
||||
newHead = false;
|
||||
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, markReviewed, clear };
|
||||
}
|
||||
|
||||
function createDraft({ storage, repository, number, headSha, files }) {
|
||||
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
|
||||
const key = 'stackchain.review-draft.v1:' + repository + '#' + number + '@' + headSha;
|
||||
let draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || '{}');
|
||||
if (saved && typeof saved === 'object') {
|
||||
draft.notes = Object.fromEntries(filenames
|
||||
.filter(filename => typeof saved.notes?.[filename] === 'string' && saved.notes[filename])
|
||||
.map(filename => [filename, saved.notes[filename]]));
|
||||
if (Array.isArray(saved.comments)) {
|
||||
draft.comments = saved.comments.filter(comment =>
|
||||
comment && filenames.includes(comment.path) && typeof comment.body === 'string' &&
|
||||
Boolean(comment.body.trim()) &&
|
||||
((Number.isInteger(comment.new_position) && !comment.old_position) ||
|
||||
(Number.isInteger(comment.old_position) && !comment.new_position))
|
||||
).map(comment => ({
|
||||
path: comment.path, body: comment.body,
|
||||
...(comment.new_position ? { new_position: comment.new_position } : {}),
|
||||
...(comment.old_position ? { old_position: comment.old_position } : {}),
|
||||
}));
|
||||
}
|
||||
if (typeof saved.summary === 'string') draft.summary = saved.summary;
|
||||
if (['comment', 'approve', 'request_changes'].includes(saved.decision)) draft.decision = saved.decision;
|
||||
}
|
||||
} catch (error) { /* start with an empty in-memory draft */ }
|
||||
|
||||
function persist() {
|
||||
try { storage.setItem(key, JSON.stringify(draft)); } catch (error) { /* draft remains usable */ }
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
notes: { ...draft.notes }, comments: draft.comments.map(comment => ({ ...comment })),
|
||||
summary: draft.summary, decision: draft.decision,
|
||||
};
|
||||
}
|
||||
|
||||
function setNote(filename, note) {
|
||||
if (!filenames.includes(filename)) return snapshot();
|
||||
if (String(note)) draft.notes[filename] = String(note);
|
||||
else delete draft.notes[filename];
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function inlineKey(comment) {
|
||||
return comment.path + ':' + (comment.new_position ? 'new:' + comment.new_position : 'old:' + comment.old_position);
|
||||
}
|
||||
|
||||
function setInlineComment(anchor, body) {
|
||||
if (!anchor || !filenames.includes(anchor.path)) return snapshot();
|
||||
const position = Number.isInteger(anchor.new_position) && anchor.new_position > 0
|
||||
? { new_position: anchor.new_position }
|
||||
: Number.isInteger(anchor.old_position) && anchor.old_position > 0
|
||||
? { old_position: anchor.old_position } : null;
|
||||
const text = String(body || '').trim();
|
||||
if (!position || !text) return snapshot();
|
||||
const comment = { path: anchor.path, body: text, ...position };
|
||||
const key = inlineKey(comment);
|
||||
const index = draft.comments.findIndex(item => inlineKey(item) === key);
|
||||
if (index >= 0) draft.comments[index] = comment;
|
||||
else draft.comments.push(comment);
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function removeInlineComment(anchor) {
|
||||
if (!anchor) return snapshot();
|
||||
const key = inlineKey(anchor);
|
||||
draft.comments = draft.comments.filter(comment => inlineKey(comment) !== key);
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setSummary(summary) {
|
||||
draft.summary = String(summary || '');
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setDecision(decision) {
|
||||
if (['comment', 'approve', 'request_changes'].includes(decision)) {
|
||||
draft.decision = decision;
|
||||
persist();
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
|
||||
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear };
|
||||
}
|
||||
|
||||
function formatFeedback(draft, files) {
|
||||
const decisions = {
|
||||
comment: 'Comment',
|
||||
approve: 'Approve',
|
||||
request_changes: 'Request changes',
|
||||
};
|
||||
const sections = [
|
||||
'## Intended decision\n' + (decisions[draft.decision] || decisions.comment),
|
||||
];
|
||||
if (String(draft.summary || '').trim()) {
|
||||
sections.push('## Summary\n' + String(draft.summary).trim());
|
||||
}
|
||||
const fileNotes = (files || []).flatMap(file => {
|
||||
const filename = file && file.filename;
|
||||
const note = filename && String(draft.notes?.[filename] || '').trim();
|
||||
if (!note) return [];
|
||||
return ['### `' + String(filename).replaceAll('`', '\\`') + '`\n' + note];
|
||||
});
|
||||
if (fileNotes.length) sections.push('## File notes\n' + fileNotes.join('\n\n'));
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
async function copyAndContinue({ text, url, copy, open, fallback }) {
|
||||
const destination = open();
|
||||
try {
|
||||
await copy(text);
|
||||
} catch (error) {
|
||||
destination?.close?.();
|
||||
fallback(text);
|
||||
return { copied: false, opened: false };
|
||||
}
|
||||
if (!destination) {
|
||||
fallback(text);
|
||||
return { copied: true, opened: false };
|
||||
}
|
||||
destination.location.href = url;
|
||||
return { copied: true, opened: true };
|
||||
}
|
||||
|
||||
function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }) {
|
||||
const reasons = Array.isArray(item?.work_reasons) ? item.work_reasons : [];
|
||||
if (decision !== 'approve' || !reasons.includes('assigned_to_me') || !headSha) return false;
|
||||
const filenames = Array.isArray(reviewed)
|
||||
? reviewed.filter(filename => typeof filename === 'string' && filename)
|
||||
: [];
|
||||
const key = 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + headSha;
|
||||
try { storage?.setItem(key, JSON.stringify(filenames)); }
|
||||
catch (_error) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
createReviewController.renderDiffFile = renderDiffFile;
|
||||
createReviewController.parseDiffLines = parseDiffLines;
|
||||
createReviewController.toggleDiff = toggleDiff;
|
||||
createReviewController.createProgress = createProgress;
|
||||
createReviewController.createDraft = createDraft;
|
||||
createReviewController.formatFeedback = formatFeedback;
|
||||
createReviewController.copyAndContinue = copyAndContinue;
|
||||
createReviewController.prepareMergeContinuation = prepareMergeContinuation;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createReviewController;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
(function (root, factory) {
|
||||
const createSearchPreview = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
||||
if (root) root.createSearchPreview = createSearchPreview;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, claim, onState }) {
|
||||
let generation = 0;
|
||||
let current = null;
|
||||
let claimRequest = null;
|
||||
|
||||
return {
|
||||
open(item) {
|
||||
generation += 1;
|
||||
const requestGeneration = generation;
|
||||
current = { ...item };
|
||||
onState({ status: 'loading', item: current });
|
||||
return fetchJson(current).then(detail => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'ready', item: current, detail });
|
||||
}
|
||||
return detail;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'error', item: current, error });
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
close() {
|
||||
generation += 1;
|
||||
current = null;
|
||||
onState({ status: 'closed' });
|
||||
},
|
||||
claim(detail) {
|
||||
if (claimRequest) return claimRequest;
|
||||
onState({ status: 'claiming', item: current, detail });
|
||||
claimRequest = claim(detail).then(result => {
|
||||
onState({ status: 'claimed', item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { claimRequest = null; });
|
||||
return claimRequest;
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v56';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
BASE + 'static/dashboard.css',
|
||||
BASE + 'static/dashboard.js',
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/notification-read-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/later-sync.js',
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/create-issue-sheet.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-launch.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
|
||||
async function fetchNavigation(request) {
|
||||
const controller = new AbortController();
|
||||
let timeout;
|
||||
const deadline = new Promise((resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error('Dashboard navigation timed out.'));
|
||||
}, NAVIGATION_TIMEOUT_MS);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
fetch(request, { signal: controller.signal }),
|
||||
deadline,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function sessionCsrf(signal) {
|
||||
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) return '';
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
return typeof payload.csrf_token === 'string' ? payload.csrf_token : '';
|
||||
}
|
||||
|
||||
let batchedCsrf = null;
|
||||
let batchedCsrfController = null;
|
||||
async function withSessionCsrf(work) {
|
||||
batchedCsrfController = new AbortController();
|
||||
batchedCsrf = sessionCsrf(batchedCsrfController.signal);
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
batchedCsrfController.abort();
|
||||
batchedCsrf = null;
|
||||
batchedCsrfController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeRevokedSessionData() {
|
||||
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-revoked' }));
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const requestOptions = { ...options };
|
||||
const method = String(options.method || 'GET').toUpperCase();
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
const cancelBatchedCsrf = () => batchedCsrfController?.abort();
|
||||
if (batchedCsrf && options.signal) {
|
||||
if (options.signal.aborted) cancelBatchedCsrf();
|
||||
else options.signal.addEventListener('abort', cancelBatchedCsrf, { once: true });
|
||||
}
|
||||
let csrf;
|
||||
try {
|
||||
csrf = await (batchedCsrf || sessionCsrf(options.signal));
|
||||
} finally {
|
||||
options.signal?.removeEventListener('abort', cancelBatchedCsrf);
|
||||
}
|
||||
if (options.signal?.aborted) throw new Error('Background request aborted.');
|
||||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||
requestOptions.headers = headers;
|
||||
}
|
||||
const response = await fetch(new URL(url, self.location.origin), requestOptions);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const code = payload.code || payload.detail?.code;
|
||||
if (response.status === 401 && code === 'session_revoked') {
|
||||
await purgeRevokedSessionData();
|
||||
}
|
||||
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
|
||||
error.status = response.status;
|
||||
error.code = code;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
const issueSync = self.__issueSync || createBackgroundIssueSync({
|
||||
store: createIssueSyncStore(), fetchJson, base: BASE, batch: withSessionCsrf,
|
||||
});
|
||||
|
||||
async function flushAndNotify() {
|
||||
const result = await issueSync.flush();
|
||||
if (!result?.login || !result.receipts?.length ||
|
||||
!await issueSync.getReceiptPreference?.(result.login)) return;
|
||||
for (const receipt of result.receipts) {
|
||||
const needsAttention = receipt.status === 'attention';
|
||||
const title = needsAttention
|
||||
? 'Queued work needs attention'
|
||||
: receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent';
|
||||
await self.registration.showNotification(title, {
|
||||
body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
||||
tag: 'stackchain-delivery-' + receipt.id,
|
||||
data: { route: receipt.route },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(caches.keys().then(keys => Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
)).then(() => self.clients.claim()));
|
||||
});
|
||||
|
||||
self.addEventListener('sync', event => {
|
||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
||||
});
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
const route = String(event.notification.data?.route || '');
|
||||
if (!route.startsWith('#/my-work/')) return;
|
||||
const target = new URL(BASE + route, self.location.origin).href;
|
||||
event.waitUntil((async () => {
|
||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (!client) return self.clients.openWindow(target);
|
||||
if (client.navigate) await client.navigate(target);
|
||||
return client.focus();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request;
|
||||
if (request.method !== 'GET' || request.url.includes('/api/')) return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetchNavigation(request).then(async response => {
|
||||
const cache = await caches.open(CACHE);
|
||||
const responseUrl = new URL(response.url || request.url);
|
||||
const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE;
|
||||
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);
|
||||
if (cached) return cached;
|
||||
}
|
||||
return response;
|
||||
}).catch(async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
const cached = await cache.match(BASE);
|
||||
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' } },
|
||||
);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else {
|
||||
const base = new URL('./', root.location.href).pathname;
|
||||
const originalFetch = root.fetch.bind(root);
|
||||
const boundary = factory({
|
||||
cookie: () => root.document.cookie,
|
||||
origin: root.location.origin,
|
||||
base,
|
||||
fetchImpl: originalFetch,
|
||||
localStorage: root.localStorage,
|
||||
sessionStorage: root.sessionStorage,
|
||||
indexedDB: root.indexedDB,
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
MessageChannel: root.MessageChannel,
|
||||
location: root.location,
|
||||
confirmAction: message => root.confirm(message),
|
||||
promptAuthorization: details => root.prompt(
|
||||
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
|
||||
),
|
||||
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
||||
onClearError: error => {
|
||||
root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message }));
|
||||
root.alert('Signed out, but private queued work could not be cleared. Close other Stackchain tabs and clear this site’s data.');
|
||||
},
|
||||
});
|
||||
root.fetch = boundary.fetch;
|
||||
const attach = () => {
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const closeDevices = root.document.getElementById('close-active-devices');
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
try {
|
||||
const devices = await boundary.listActiveDevices();
|
||||
devices.forEach(device => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'active-device';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = device.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
|
||||
details.append(label, timing);
|
||||
if (device.current) {
|
||||
const current = root.document.createElement('span');
|
||||
current.className = 'active-device-current';
|
||||
current.textContent = 'This device';
|
||||
details.append(current);
|
||||
}
|
||||
const revoke = root.document.createElement('button');
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
});
|
||||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
renderDevices();
|
||||
});
|
||||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
});
|
||||
boundary.resumeQueuedWork();
|
||||
};
|
||||
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
|
||||
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
||||
else attach();
|
||||
root.stackchainSession = boundary;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
|
||||
promptAuthorization = () => null,
|
||||
onExpired = () => {},
|
||||
onClearError = () => {},
|
||||
}) {
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
let expirationStarted = false;
|
||||
|
||||
async function handleRemoteRevocation() {
|
||||
if (expirationStarted) return;
|
||||
expirationStarted = true;
|
||||
await clearPrivateDeviceData();
|
||||
location.replace(base + 'login?reason=session-revoked');
|
||||
}
|
||||
|
||||
async function handleServiceWorkerMessage(event) {
|
||||
if (event?.data?.type === 'stackchain-session-revoked') {
|
||||
await handleRemoteRevocation();
|
||||
}
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
const entry = String(cookie?.() || '').split(';')
|
||||
.map(value => value.trim())
|
||||
.find(value => value.startsWith('stackchain_csrf='));
|
||||
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
|
||||
}
|
||||
|
||||
function isSameOrigin(input) {
|
||||
try { return new URL(String(input?.url || input), origin).origin === origin; }
|
||||
catch (_error) { return false; }
|
||||
}
|
||||
|
||||
async function sessionFetch(input, options = {}, allowStepUp = true) {
|
||||
const method = String(options.method || input?.method || 'GET').toUpperCase();
|
||||
const requestOptions = { ...options };
|
||||
if (!SAFE_METHODS.has(method) && isSameOrigin(input)) {
|
||||
const headers = new Headers(options.headers || input?.headers || {});
|
||||
const csrf = csrfToken();
|
||||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||
requestOptions.headers = headers;
|
||||
}
|
||||
const response = await fetchImpl(input, requestOptions);
|
||||
if (response.status === 428 && isSameOrigin(input) && allowStepUp) {
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
const detail = payload?.detail || {};
|
||||
if (detail.code === 'step_up_required' && detail.action && detail.target) {
|
||||
const accessToken = await promptAuthorization({
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
});
|
||||
if (!accessToken) return response;
|
||||
const authorizationHeaders = new Headers({
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
const csrf = csrfToken();
|
||||
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
|
||||
const authorization = await fetchImpl(base + 'api/v1/fresh-authorization', {
|
||||
method: 'POST',
|
||||
headers: authorizationHeaders,
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
}),
|
||||
});
|
||||
if (!authorization.ok) return authorization;
|
||||
const grant = await authorization.json().catch(() => ({}));
|
||||
if (!grant.grant) return response;
|
||||
const retryHeaders = new Headers(requestOptions.headers || input?.headers || {});
|
||||
retryHeaders.set('X-Step-Up-Grant', grant.grant);
|
||||
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
|
||||
}
|
||||
}
|
||||
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
|
||||
expirationStarted = true;
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
if (payload.code === 'session_revoked') {
|
||||
expirationStarted = false;
|
||||
await handleRemoteRevocation();
|
||||
} else {
|
||||
onExpired();
|
||||
location.replace(base + 'login?reason=session-expired');
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function removeDashboardStorage(storage) {
|
||||
if (!storage) return;
|
||||
const keys = [];
|
||||
try {
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (key?.startsWith('stackchain.')) keys.push(key);
|
||||
}
|
||||
keys.forEach(key => storage.removeItem(key));
|
||||
} catch (_error) { /* Cookie invalidation still protects server data. */ }
|
||||
}
|
||||
|
||||
async function stopWorkerOutbox() {
|
||||
const registration = await serviceWorker?.ready;
|
||||
if (!registration?.active || !MessageChannel) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
|
||||
channel.port1.onmessage = event => {
|
||||
clearTimeout(timeout);
|
||||
if (event.data?.ok) resolve();
|
||||
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
|
||||
};
|
||||
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrivateOutbox() {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
async function clearPrivateDeviceData() {
|
||||
removeDashboardStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
||||
try {
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateOutbox();
|
||||
} catch (_error) {
|
||||
const error = new Error('Could not clear private queued work from this device.');
|
||||
onClearError(error);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));
|
||||
} catch (_error) { /* A later service-worker activation can clear stale caches. */ }
|
||||
}
|
||||
|
||||
async function listActiveDevices() {
|
||||
const response = await sessionFetch(base + 'api/v1/sessions');
|
||||
if (!response.ok) throw new Error('Could not load active devices');
|
||||
const payload = await response.json();
|
||||
return Array.isArray(payload.devices) ? payload.devices : [];
|
||||
}
|
||||
|
||||
async function revokeActiveDevice(device) {
|
||||
if (!device?.management_id || device.current) return false;
|
||||
const confirmed = confirmAction?.(`Sign out ${device.device_label}?`);
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(
|
||||
base + 'api/v1/sessions/' + encodeURIComponent(device.management_id),
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!response.ok) throw new Error('Could not revoke active device');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await sessionFetch(base + 'api/v1/session', { method: 'DELETE' });
|
||||
} finally {
|
||||
await clearPrivateDeviceData();
|
||||
location.assign(base + 'login');
|
||||
}
|
||||
}
|
||||
|
||||
async function signOutAllDevices() {
|
||||
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Could not sign out all devices');
|
||||
await clearPrivateDeviceData();
|
||||
location.assign(base + 'login');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function resumeQueuedWork() {
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({ type: 'stackchain-resume-outbox' });
|
||||
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
|
||||
}
|
||||
|
||||
return {
|
||||
fetch: sessionFetch,
|
||||
signOut,
|
||||
signOutAllDevices,
|
||||
listActiveDevices,
|
||||
revokeActiveDevice,
|
||||
clearPrivateDeviceData,
|
||||
handleServiceWorkerMessage,
|
||||
resumeQueuedWork,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createTaskOverlayHistory = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const allowed = new Set(['new', 'find', 'search', 'search-preview']);
|
||||
|
||||
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
||||
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
||||
let started = false;
|
||||
|
||||
function stateKind(state = history.state) {
|
||||
return allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||
}
|
||||
|
||||
function apply(state) {
|
||||
const next = stateKind(state);
|
||||
if (next === active) return;
|
||||
const previous = active;
|
||||
active = next;
|
||||
onChange(next, previous);
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
eventTarget.addEventListener('popstate', event => apply(event.state));
|
||||
},
|
||||
open(kind) {
|
||||
if (!allowed.has(kind)) return false;
|
||||
if (active === kind) return true;
|
||||
const previous = active;
|
||||
const state = { ...(history.state || {}), taskOverlay: kind };
|
||||
history.pushState(state, '');
|
||||
active = kind;
|
||||
onChange(kind, previous);
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (!active) return false;
|
||||
history.back();
|
||||
return true;
|
||||
},
|
||||
leave() {
|
||||
if (!active) return false;
|
||||
const previous = active;
|
||||
const state = { ...(history.state || {}) };
|
||||
delete state.taskOverlay;
|
||||
history.replaceState(state, '');
|
||||
active = null;
|
||||
onChange(null, previous);
|
||||
return true;
|
||||
},
|
||||
current() { return active; },
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, 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.';
|
||||
let flushing = null;
|
||||
let channel = null;
|
||||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
let expiredCount = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
if (retryTimer !== null) clearTimer?.(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
|
||||
function scheduleRetry(error, ownerKey) {
|
||||
if (retryTimer !== null || !pending().length || !ownerKey) return;
|
||||
const advised = Number(error?.retryAfter);
|
||||
const delayMs = Number.isFinite(advised) && advised >= 0
|
||||
? advised * 1000
|
||||
: Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt));
|
||||
retryAttempt += 1;
|
||||
onStatus?.('retrying', { delayMs });
|
||||
retryTimer = setTimer?.(async () => {
|
||||
retryTimer = null;
|
||||
if (key() !== ownerKey) return false;
|
||||
return flush();
|
||||
}, delayMs);
|
||||
retryTimer?.unref?.();
|
||||
}
|
||||
|
||||
function key() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function snapshotKey() {
|
||||
const storageKey = key();
|
||||
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
|
||||
}
|
||||
|
||||
function savedRevision() {
|
||||
try {
|
||||
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
|
||||
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
|
||||
} catch (_error) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function adopt(plan, broadcast = true) {
|
||||
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 }));
|
||||
} 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 });
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureChannel() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || channelKey === storageKey) return;
|
||||
channel?.close?.();
|
||||
const factory = createChannel || (globalThis.window?.BroadcastChannel
|
||||
? name => new globalThis.window.BroadcastChannel(name)
|
||||
: null);
|
||||
channelKey = storageKey;
|
||||
channel = factory?.('stackchain-today-' + storageKey.slice(prefix.length)) || null;
|
||||
channel?.addEventListener?.('message', event => {
|
||||
if (key() === storageKey) adopt(event.data, false);
|
||||
});
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
const recordPrefix = storageKey + '.operation.';
|
||||
try {
|
||||
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
if (Array.isArray(legacy)) {
|
||||
legacy.forEach((operation, index) => {
|
||||
if (!operation?.operation_id) return;
|
||||
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
});
|
||||
if (legacy.length) storage.removeItem(storageKey);
|
||||
}
|
||||
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
|
||||
for (let index = 0; index < Number(storage.length || 0); index += 1) {
|
||||
const candidate = storage.key?.(index);
|
||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||
}
|
||||
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,
|
||||
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');
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function removeOperation(operationId) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
|
||||
try {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function operationId() {
|
||||
if (createOperationId) return createOperationId();
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, direction = null) {
|
||||
const operations = pending();
|
||||
if (action === 'remove' && operations.some(operation =>
|
||||
operation.action === 'remove' && operation.item_id === itemId
|
||||
)) {
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
}
|
||||
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: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('today');
|
||||
} catch (_error) { /* Report the persistence failure below. */ }
|
||||
onStatus?.(saved ? 'pending' : 'error');
|
||||
return saved;
|
||||
}
|
||||
|
||||
function migrate(ids) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||
try {
|
||||
if (storage.getItem(marker)) return false;
|
||||
for (const id of ids || []) enqueue('add', id);
|
||||
storage.setItem(marker, '1');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey) return false;
|
||||
ensureChannel();
|
||||
expiredCount = 0;
|
||||
try {
|
||||
let operations = pending();
|
||||
let plan;
|
||||
let hadConflict = false;
|
||||
if (!operations.length) plan = await fetchJson('api/v1/today');
|
||||
while (operations.length) {
|
||||
if (key() !== ownerKey) return false;
|
||||
const batch = operations.slice(0, 50);
|
||||
plan = await fetchJson('api/v1/today', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operations: batch }),
|
||||
});
|
||||
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
|
||||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
|
||||
const received = hasReceipts ? [
|
||||
...(plan.accepted_operation_ids || []),
|
||||
...(plan.duplicate_operation_ids || []),
|
||||
...(plan.rejected_operations || []).map(item => item.operation_id),
|
||||
] : batch.map(item => item.operation_id);
|
||||
hadConflict = hadConflict || Boolean(plan.rejected_operations?.length);
|
||||
for (const operationId of received) {
|
||||
if (pending().some(candidate => candidate.operation_id === operationId) &&
|
||||
!removeOperation(operationId)) {
|
||||
throw new Error('Could not persist Today delivery receipt');
|
||||
}
|
||||
}
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
|
||||
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return !hadConflict;
|
||||
} catch (error) {
|
||||
if (pending().length) scheduleRetry(error, ownerKey);
|
||||
else onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (!flushing) {
|
||||
const delivery = coordinator ? coordinator.runExclusive('today', run) : run();
|
||||
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
|
||||
}
|
||||
return flushing;
|
||||
}
|
||||
|
||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||
windowObject?.addEventListener?.('online', flush);
|
||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||
documentObject.hidden ? false : flush()
|
||||
);
|
||||
}
|
||||
|
||||
coordinator?.subscribe(change => {
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
@@ -0,0 +1,105 @@
|
||||
function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
const prefix = 'stackchain.today-work.v1.';
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function read() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return [];
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || '[]');
|
||||
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(ids) {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return false;
|
||||
try {
|
||||
if (ids.length) storage.setItem(key, JSON.stringify(ids));
|
||||
else storage.removeItem(key);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function add(item) {
|
||||
if (!storageKey()) return 'unavailable';
|
||||
const id = identity(item);
|
||||
if (!id) return 'unavailable';
|
||||
const ids = read();
|
||||
if (ids.includes(id)) return 'exists';
|
||||
if (ids.length >= limit) return 'full';
|
||||
ids.push(id);
|
||||
return write(ids) ? 'added' : 'unavailable';
|
||||
}
|
||||
|
||||
function replace(ids) {
|
||||
const unique = [];
|
||||
for (const id of ids || []) {
|
||||
if (typeof id === 'string' && id && !unique.includes(id) && unique.length < limit) {
|
||||
unique.push(id);
|
||||
}
|
||||
}
|
||||
return write(unique);
|
||||
}
|
||||
|
||||
function remove(item) {
|
||||
const id = identity(item);
|
||||
const ids = read();
|
||||
const next = ids.filter(candidate => candidate !== id);
|
||||
return next.length !== ids.length && write(next);
|
||||
}
|
||||
|
||||
function move(item, direction) {
|
||||
const ids = read();
|
||||
const index = ids.indexOf(identity(item));
|
||||
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
|
||||
if (index < 0 || target < 0 || target >= ids.length) return false;
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
return write(ids);
|
||||
}
|
||||
|
||||
function reconcile(items, { pruneMissing = false, onPrune } = {}) {
|
||||
const available = new Map((items || []).map(item => [identity(item), item]));
|
||||
const ids = read();
|
||||
const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids;
|
||||
const retired = ids.filter(id => !retained.includes(id));
|
||||
if (retired.length) {
|
||||
const reported = onPrune?.(retired);
|
||||
if (reported !== false) write(retained);
|
||||
}
|
||||
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
|
||||
}
|
||||
|
||||
function contains(item) {
|
||||
return read().includes(identity(item));
|
||||
}
|
||||
|
||||
function position(item) {
|
||||
const ids = read();
|
||||
const index = ids.indexOf(identity(item));
|
||||
return {
|
||||
can_up: index > 0,
|
||||
can_down: index >= 0 && index < ids.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, add, remove, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
@@ -0,0 +1,65 @@
|
||||
function createUnfiledCaptures({
|
||||
storage,
|
||||
getCaptureLogin = () => '',
|
||||
getCurrentLogin = () => '',
|
||||
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
now = () => Date.now(),
|
||||
maxItems = 20,
|
||||
}) {
|
||||
const storageKey = 'stackchain.unfiled-issues.v1';
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
|
||||
typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string'
|
||||
);
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
storage?.setItem(storageKey, JSON.stringify({version:1, items}));
|
||||
}
|
||||
|
||||
function list() {
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt))
|
||||
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
if (!title) throw new Error('Add a title before saving.');
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
|
||||
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
|
||||
write(items);
|
||||
return item;
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
const remaining = items.filter(item => item.id !== id);
|
||||
if (remaining.length === items.length) return false;
|
||||
write(remaining);
|
||||
return true;
|
||||
}
|
||||
|
||||
function resume(id, confirmedLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) throw new Error('This capture is no longer available.');
|
||||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||
throw new Error('Reconnect with the account that saved this capture.');
|
||||
}
|
||||
discard(id);
|
||||
return {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
}
|
||||
|
||||
return {list, save, discard, resume};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
@@ -0,0 +1,78 @@
|
||||
function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
let detail = null;
|
||||
let update = null;
|
||||
let claimedItem = null;
|
||||
let request = null;
|
||||
|
||||
function state(value) {
|
||||
onState?.(value);
|
||||
}
|
||||
|
||||
function open(nextDetail, nextUpdate) {
|
||||
detail = nextDetail;
|
||||
update = nextUpdate;
|
||||
claimedItem = null;
|
||||
request = null;
|
||||
state({
|
||||
action: nextDetail?.issue?.claimable ? 'claim' : 'hidden',
|
||||
busy: false,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
|
||||
function act() {
|
||||
if (request) return request;
|
||||
if (claimedItem) {
|
||||
const result = addToday(claimedItem);
|
||||
const messages = {
|
||||
added: 'Added to Today.',
|
||||
exists: 'This issue is already in Today.',
|
||||
full: 'Today is limited to 5 items. Remove one before adding more.',
|
||||
unavailable: 'Could not save Today on this device.',
|
||||
};
|
||||
state({ action: 'today', busy: false, message: messages[result] || messages.unavailable });
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
if (!detail?.issue?.claimable) return Promise.resolve('hidden');
|
||||
|
||||
state({ action: 'claim', busy: true, message: 'Assigning…' });
|
||||
request = Promise.resolve(claim({
|
||||
repository: detail.repository,
|
||||
number: detail.issue.number,
|
||||
})).then(result => {
|
||||
claimedItem = {
|
||||
...result,
|
||||
repository: detail.repository,
|
||||
kind: 'issue',
|
||||
notification_id: update?.notification_id,
|
||||
updated_at: update?.updated_at || result.updated_at,
|
||||
has_update: true,
|
||||
};
|
||||
detail.issue.claimable = false;
|
||||
onClaimed?.(claimedItem);
|
||||
state({ action: 'today', busy: false, message: 'Assigned to you. The update is still unread.' });
|
||||
return claimedItem;
|
||||
}).catch(error => {
|
||||
if (error?.status === 409) {
|
||||
detail.issue.claimable = false;
|
||||
state({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Someone else claimed or closed this issue. The update is still unread.',
|
||||
});
|
||||
return 'conflict';
|
||||
}
|
||||
state({
|
||||
action: 'claim',
|
||||
busy: false,
|
||||
message: (error?.message || 'The issue could not be assigned.') + ' The update and your reply draft are safe; retry.',
|
||||
});
|
||||
return 'retry';
|
||||
}).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { open, act };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateOwnership;
|
||||
@@ -0,0 +1,161 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createWorkRoute = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
return Number.isSafeInteger(number) && number > 0 ? number : null;
|
||||
}
|
||||
|
||||
function parse(fragment) {
|
||||
const parts = String(fragment || '').split('/');
|
||||
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
}
|
||||
if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
|
||||
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
|
||||
const number = positiveInteger(parts[5]);
|
||||
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
|
||||
}
|
||||
|
||||
function serialize(item) {
|
||||
if (item?.kind === 'update') {
|
||||
const notificationId = positiveInteger(item.notification_id);
|
||||
return notificationId ? '#/my-work/update/' + notificationId : '';
|
||||
}
|
||||
if (!['issue', 'pull', 'review'].includes(item?.kind)) return '';
|
||||
const repository = String(item.repository || '').split('/');
|
||||
const number = positiveInteger(item.number);
|
||||
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
|
||||
return '#/my-work/' + item.kind + '/' + repository.join('/') + '/' + number;
|
||||
}
|
||||
|
||||
function sameRoute(item, route) {
|
||||
if (route.kind === 'update') {
|
||||
return Number(item.notification_id) === route.notification_id;
|
||||
}
|
||||
const itemKind = item.is_review ? 'review' : item.kind;
|
||||
return itemKind === route.kind && item.repository === route.repository &&
|
||||
Number(item.number) === route.number;
|
||||
}
|
||||
|
||||
function createController({
|
||||
location, history, eventTarget, onOpen, onClose, onInvalid,
|
||||
resolve, onResolving = function () {}, onError = function () {},
|
||||
}) {
|
||||
let items = [];
|
||||
let started = false;
|
||||
let active = '';
|
||||
let ready = false;
|
||||
let resolving = '';
|
||||
let resolution = 0;
|
||||
|
||||
function resolveMissing(fragment, route) {
|
||||
if (typeof resolve !== 'function') {
|
||||
onInvalid();
|
||||
return;
|
||||
}
|
||||
if (resolving === fragment) return;
|
||||
resolving = fragment;
|
||||
const request = ++resolution;
|
||||
onResolving(route);
|
||||
Promise.resolve(resolve(route)).then(item => {
|
||||
if (request !== resolution || String(location.hash || '') !== fragment) return;
|
||||
resolving = '';
|
||||
if (!item || !sameRoute(item, route)) {
|
||||
onInvalid();
|
||||
return;
|
||||
}
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
}).catch(error => {
|
||||
if (request !== resolution || String(location.hash || '') !== fragment) return;
|
||||
resolving = '';
|
||||
if (error?.unavailable) onInvalid();
|
||||
else onError(error, route);
|
||||
});
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const fragment = String(location.hash || '');
|
||||
if (!fragment) {
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
if (active) onClose();
|
||||
active = '';
|
||||
return;
|
||||
}
|
||||
const route = parse(fragment);
|
||||
if (!route) {
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
if (fragment.startsWith('#/my-work/')) onInvalid();
|
||||
active = '';
|
||||
return;
|
||||
}
|
||||
const item = items.find(candidate => sameRoute(candidate, route));
|
||||
if (!item) {
|
||||
if (ready) resolveMissing(fragment, route);
|
||||
return;
|
||||
}
|
||||
if (active === fragment) return;
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
eventTarget.addEventListener('popstate', sync);
|
||||
eventTarget.addEventListener('hashchange', sync);
|
||||
sync();
|
||||
},
|
||||
setItems(nextItems) {
|
||||
items = Array.isArray(nextItems) ? nextItems.slice() : [];
|
||||
ready = true;
|
||||
sync();
|
||||
},
|
||||
open(item, options = {}) {
|
||||
const fragment = serialize(item);
|
||||
if (!fragment) return false;
|
||||
const method = options.replace ? 'replaceState' : 'pushState';
|
||||
history[method]({ workRoute: fragment }, '', fragment);
|
||||
active = fragment;
|
||||
onOpen(item);
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (parse(location.hash)) history.back();
|
||||
else {
|
||||
active = '';
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
sync,
|
||||
};
|
||||
}
|
||||
|
||||
async function share(url, navigatorObject, clipboard) {
|
||||
if (typeof navigatorObject?.share === 'function') {
|
||||
await navigatorObject.share({ url });
|
||||
return 'shared';
|
||||
}
|
||||
if (typeof clipboard?.writeText === 'function') {
|
||||
await clipboard.writeText(url);
|
||||
return 'copied';
|
||||
}
|
||||
throw new Error('Sharing is unavailable.');
|
||||
}
|
||||
|
||||
return { parse, serialize, createController, share };
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"commit": "8ff742c0fe0970d43339b8931054d63b1029591d",
|
||||
"buildDate": "2026-07-12T12:56:54Z",
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "./.gitea/workflows/ci.yml",
|
||||
"type": "file",
|
||||
"bytes": 662,
|
||||
"sha256": "6ea3766610118ac34d6e9e8996eb13771ba6428ac8df6969f1150c0e719ed915"
|
||||
},\n {
|
||||
"path": "./.gitea/workflows/release.yml",
|
||||
"type": "file",
|
||||
"bytes": 884,
|
||||
"sha256": "647c8d8696fe77a04ab44a051816489a94d57caf33b8184a064fe8218193917c"
|
||||
},\n {
|
||||
"path": "./README.md",
|
||||
"type": "file",
|
||||
"bytes": 94,
|
||||
"sha256": "00230b3e64de86031df67093173ae57dd38490b6ca43ce188f046e59756d86e2"
|
||||
},\n {
|
||||
"path": "./creative-deliverables/widget-board-5.html",
|
||||
"type": "file",
|
||||
"bytes": 799,
|
||||
"sha256": "380da6f03fc3836ea2db2bbf15cd33a54991a48b67c22c6a4a02d58e1c82323a"
|
||||
},\n {
|
||||
"path": "./docs/ops-fundamentals.md",
|
||||
"type": "file",
|
||||
"bytes": 1123,
|
||||
"sha256": "1d4cb0620a1ae2576ba0eac7cd3dc45f74771cc69277a9b39c30a953a2aa81ac"
|
||||
},\n {
|
||||
"path": "./frontend/index.html",
|
||||
"type": "file",
|
||||
"bytes": 19183,
|
||||
"sha256": "5177c9fcf406ae13bd571f8d2679d075127af0eca0162f27a9021b209028304a"
|
||||
},\n {
|
||||
"path": "./frontend/static/index.html",
|
||||
"type": "file",
|
||||
"bytes": 15834,
|
||||
"sha256": "d4a930b6b6604093481c667a16c8bf55e720bbbab9c0f8f15dcb515b6e3fdba1"
|
||||
},\n {
|
||||
"path": "./scripts/gitea_sync.py",
|
||||
"type": "file",
|
||||
"bytes": 2987,
|
||||
"sha256": "15be3cf91384e4bc972d227d96868e92a3a0f50c97cd05fa85eb8f131377321b"
|
||||
},\n {
|
||||
"path": "./src/__init__.py",
|
||||
"type": "file",
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},\n {
|
||||
"path": "./src/gitea_proxy.py",
|
||||
"type": "file",
|
||||
"bytes": 914,
|
||||
"sha256": "6af2ba285d5c070e9bbc95c81c5366d375c7d3293302a82bbf1812917e2191e4"
|
||||
},\n {
|
||||
"path": "./src/main.py",
|
||||
"type": "file",
|
||||
"bytes": 2771,
|
||||
"sha256": "9f0de0f0ca911744bc6ac1796c84de4c11bb7f709f69a672e228117b322319c7"
|
||||
},\n {
|
||||
"path": "./src/models.py",
|
||||
"type": "file",
|
||||
"bytes": 850,
|
||||
"sha256": "f0fdde5620006fb05cfe688607fe6815e424bf9a2b9798586a2c86cb7a62ba5a"
|
||||
},\n {
|
||||
"path": "./src/suggestion_engine.py",
|
||||
"type": "file",
|
||||
"bytes": 1774,
|
||||
"sha256": "531d0187cfa348995e45ceacc510ac557d5ae4c7ef3d58fa56dc4b39e08ca3f8"
|
||||
},\n {
|
||||
"path": "./src/views.py",
|
||||
"type": "file",
|
||||
"bytes": 219,
|
||||
"sha256": "b511ee830c4ae2c1564a81dfb52da9ded643b0612c3f46e0693fecc05d954b63"
|
||||
},\n {
|
||||
"path": "./tests/__init__.py",
|
||||
"type": "file",
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},\n {
|
||||
"path": "./tests/test_suggestion_engine.py",
|
||||
"type": "file",
|
||||
"bytes": 810,
|
||||
"sha256": "d5c0ad9d51a9ec25556af190fe97a806bdfcee7d27e3f7666c584bbea2e3ef9c"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a reproducible, verifiable Stackchain Dashboard release bundle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
|
||||
RUNTIME_FILES = ("README.md", "requirements.txt")
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def runtime_files(root: Path) -> list[Path]:
|
||||
files = [root / name for name in RUNTIME_FILES if (root / name).is_file()]
|
||||
for name in RUNTIME_DIRECTORIES:
|
||||
directory = root / name
|
||||
if directory.is_dir():
|
||||
files.extend(path for path in directory.rglob("*") if path.is_file())
|
||||
return sorted(files, key=lambda path: path.relative_to(root).as_posix())
|
||||
|
||||
|
||||
def manifest_for(root: Path, files: list[Path], commit: str) -> dict:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"commit": commit,
|
||||
"files": {
|
||||
path.relative_to(root).as_posix(): {
|
||||
"sha256": sha256(path.read_bytes()),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
for path in files
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def json_bytes(payload: dict) -> bytes:
|
||||
return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode()
|
||||
|
||||
|
||||
def tar_info(name: str, size: int, epoch: int, executable: bool = False) -> tarfile.TarInfo:
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = size
|
||||
info.mtime = epoch
|
||||
info.mode = 0o755 if executable else 0o644
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = "root"
|
||||
return info
|
||||
|
||||
|
||||
def build(root: Path, output_dir: Path, commit: str, epoch: int) -> tuple[Path, Path, Path]:
|
||||
root = root.resolve()
|
||||
files = runtime_files(root)
|
||||
missing = [name for name in RUNTIME_FILES if not (root / name).is_file()]
|
||||
if missing or not (root / "src").is_dir() or not (root / "frontend").is_dir():
|
||||
raise ValueError("release root is missing required runtime files")
|
||||
|
||||
embedded_manifest = manifest_for(root, files, commit)
|
||||
archive_members: dict[str, tuple[bytes, bool]] = {
|
||||
path.relative_to(root).as_posix(): (
|
||||
path.read_bytes(),
|
||||
bool(path.stat().st_mode & 0o111),
|
||||
)
|
||||
for path in files
|
||||
}
|
||||
archive_members["release-manifest.json"] = (json_bytes(embedded_manifest), False)
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"stackchain-dashboard-{commit[:12]}"
|
||||
archive_path = output_dir / f"{stem}.tar.gz"
|
||||
with archive_path.open("wb") as raw:
|
||||
with gzip.GzipFile(fileobj=raw, mode="wb", filename="", mtime=epoch) as compressed:
|
||||
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive:
|
||||
for name in sorted(archive_members):
|
||||
data, executable = archive_members[name]
|
||||
archive.addfile(tar_info(name, len(data), epoch, executable), io.BytesIO(data))
|
||||
|
||||
archive_data = archive_path.read_bytes()
|
||||
digest = sha256(archive_data)
|
||||
public_manifest = {
|
||||
**embedded_manifest,
|
||||
"artifact": {
|
||||
"name": archive_path.name,
|
||||
"sha256": digest,
|
||||
"size": len(archive_data),
|
||||
},
|
||||
}
|
||||
manifest_path = output_dir / f"{stem}.manifest.json"
|
||||
checksum_path = output_dir / f"{stem}.sha256"
|
||||
manifest_path.write_bytes(json_bytes(public_manifest))
|
||||
checksum_path.write_text(f"{digest} {archive_path.name}\n")
|
||||
return archive_path, manifest_path, checksum_path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--output-dir", type=Path, default=Path("dist"))
|
||||
parser.add_argument("--commit", required=True)
|
||||
parser.add_argument("--source-date-epoch", type=int, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
for path in build(args.root, args.output_dir, args.commit, args.source_date_epoch):
|
||||
print(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify a Stackchain Dashboard release bundle before promotion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
def one(directory: Path, pattern: str) -> Path:
|
||||
matches = sorted(directory.glob(pattern))
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"expected exactly one {pattern} file, found {len(matches)}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def digest(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def verify(input_dir: Path, commit: str) -> None:
|
||||
archive_path = one(input_dir, "*.tar.gz")
|
||||
manifest_path = one(input_dir, "*.manifest.json")
|
||||
checksum_path = one(input_dir, "*.sha256")
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
|
||||
if manifest.get("commit") != commit:
|
||||
raise ValueError("manifest commit does not match the tested commit")
|
||||
artifact = manifest.get("artifact", {})
|
||||
archive_data = archive_path.read_bytes()
|
||||
archive_digest = digest(archive_data)
|
||||
if artifact.get("name") != archive_path.name:
|
||||
raise ValueError("manifest artifact name does not match the bundle")
|
||||
if artifact.get("size") != len(archive_data) or artifact.get("sha256") != archive_digest:
|
||||
raise ValueError("bundle does not match its manifest")
|
||||
|
||||
checksum_parts = checksum_path.read_text().strip().split()
|
||||
if checksum_parts != [archive_digest, archive_path.name]:
|
||||
raise ValueError("checksum file does not match the bundle")
|
||||
|
||||
expected_files = manifest.get("files")
|
||||
if not isinstance(expected_files, dict):
|
||||
raise ValueError("manifest files must be an object")
|
||||
with tarfile.open(archive_path, "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
for member in members:
|
||||
path = PurePosixPath(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or not member.isfile():
|
||||
raise ValueError("bundle contains an unsafe member")
|
||||
names = {member.name for member in members}
|
||||
if names != set(expected_files) | {"release-manifest.json"}:
|
||||
raise ValueError("bundle contents do not match the manifest")
|
||||
embedded_file = archive.extractfile("release-manifest.json")
|
||||
if embedded_file is None:
|
||||
raise ValueError("bundle manifest is missing")
|
||||
embedded = json.load(embedded_file)
|
||||
if embedded.get("commit") != commit or embedded.get("files") != expected_files:
|
||||
raise ValueError("embedded manifest does not match the public manifest")
|
||||
for name, metadata in expected_files.items():
|
||||
bundled = archive.extractfile(name)
|
||||
if bundled is None:
|
||||
raise ValueError(f"bundle member is missing: {name}")
|
||||
data = bundled.read()
|
||||
if metadata != {"sha256": digest(data), "size": len(data)}:
|
||||
raise ValueError(f"bundle member failed verification: {name}")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--input-dir", type=Path, required=True)
|
||||
parser.add_argument("--commit", required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
try:
|
||||
verify(args.input_dir, args.commit)
|
||||
except (OSError, ValueError, json.JSONDecodeError, tarfile.TarError) as error:
|
||||
print(f"release verification failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
print(f"verified release bundle for {args.commit}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Signed, short-lived single-operator sessions for the dashboard boundary."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from src.session_store import SessionStore, SessionStoreError
|
||||
|
||||
SESSION_COOKIE = "stackchain_session"
|
||||
CSRF_COOKIE = "stackchain_csrf"
|
||||
DEFAULT_TTL_SECONDS = 8 * 60 * 60
|
||||
STEP_UP_TTL_SECONDS = 90
|
||||
MIN_SECRET_LENGTH = 24
|
||||
OPERATOR_MODE = "operator"
|
||||
INSECURE_LOCAL_MODE = "insecure-local"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Session:
|
||||
session_id: str
|
||||
csrf: str
|
||||
expires_at: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionVerification:
|
||||
session: Session | None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
def access_token() -> str:
|
||||
return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")
|
||||
|
||||
|
||||
def mode() -> str:
|
||||
return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower()
|
||||
|
||||
|
||||
def configuration_error() -> str | None:
|
||||
configured_mode = mode()
|
||||
if configured_mode == INSECURE_LOCAL_MODE:
|
||||
return None
|
||||
if configured_mode != OPERATOR_MODE:
|
||||
return "unsupported authentication mode"
|
||||
operator_secret = access_token()
|
||||
signing_secret = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
|
||||
if len(operator_secret) < MIN_SECRET_LENGTH or len(signing_secret) < MIN_SECRET_LENGTH:
|
||||
return "operator mode requires two sufficiently long secrets"
|
||||
if hmac.compare_digest(operator_secret, signing_secret):
|
||||
return "operator and session secrets must be independent"
|
||||
return None
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return mode() == OPERATOR_MODE and configuration_error() is None
|
||||
|
||||
|
||||
def is_loopback_request(request: Request) -> bool:
|
||||
host = request.client.host if request.client else ""
|
||||
try:
|
||||
return ipaddress.ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return host == "localhost"
|
||||
|
||||
|
||||
def _secret() -> bytes:
|
||||
configured = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
|
||||
if configuration_error() is not None or mode() != OPERATOR_MODE:
|
||||
raise RuntimeError("Dashboard authentication is not configured")
|
||||
return configured.encode()
|
||||
|
||||
|
||||
def _encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def _decode(value: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
|
||||
|
||||
def _session_store(now: int | None = None) -> SessionStore:
|
||||
database = os.getenv(
|
||||
"STACKCHAIN_SESSION_DB",
|
||||
os.path.join(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"), "sessions.sqlite3"),
|
||||
)
|
||||
current = time.time if now is None else lambda: now
|
||||
return SessionStore(database, clock=current)
|
||||
|
||||
|
||||
def issue_session(
|
||||
now: int | None = None, *, device_label: str = "This device"
|
||||
) -> tuple[str, Session]:
|
||||
issued_at = int(time.time() if now is None else now)
|
||||
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
||||
session = Session(
|
||||
session_id=secrets.token_urlsafe(32),
|
||||
csrf=secrets.token_urlsafe(24),
|
||||
expires_at=issued_at + max(1, ttl),
|
||||
)
|
||||
payload = json.dumps(
|
||||
{"csrf": session.csrf, "exp": session.expires_at, "sid": session.session_id},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode()
|
||||
encoded = _encode(payload)
|
||||
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||
_session_store(now).activate(
|
||||
session.session_id, session.expires_at, device_label=device_label
|
||||
)
|
||||
return f"{encoded}.{signature}", session
|
||||
|
||||
|
||||
def verify_session_with_reason(
|
||||
value: str | None, now: int | None = None
|
||||
) -> SessionVerification:
|
||||
if not value or "." not in value or not enabled():
|
||||
return SessionVerification(None)
|
||||
encoded, supplied_signature = value.rsplit(".", 1)
|
||||
expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||
if not hmac.compare_digest(supplied_signature, expected):
|
||||
return SessionVerification(None)
|
||||
try:
|
||||
payload = json.loads(_decode(encoded))
|
||||
session = Session(
|
||||
session_id=payload["sid"],
|
||||
csrf=payload["csrf"],
|
||||
expires_at=int(payload["exp"]),
|
||||
)
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||
return SessionVerification(None)
|
||||
current = int(time.time() if now is None else now)
|
||||
if (
|
||||
session.expires_at <= current
|
||||
or not isinstance(session.session_id, str)
|
||||
or not session.session_id
|
||||
or not isinstance(session.csrf, str)
|
||||
or not session.csrf
|
||||
):
|
||||
return SessionVerification(None)
|
||||
if not _session_store(now).is_active(session.session_id, session.expires_at):
|
||||
return SessionVerification(None, "session_revoked")
|
||||
return SessionVerification(session)
|
||||
|
||||
|
||||
def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
||||
return verify_session_with_reason(value, now).session
|
||||
|
||||
|
||||
def revoke_session(session: Session) -> None:
|
||||
_session_store().revoke(session.session_id)
|
||||
|
||||
|
||||
async def revoke_all_sessions() -> None:
|
||||
await asyncio.to_thread(_session_store().revoke_all)
|
||||
|
||||
|
||||
async def active_devices(session: Session):
|
||||
return await asyncio.to_thread(_session_store().list_active, session.session_id)
|
||||
|
||||
|
||||
async def revoke_managed_session(management_id: str) -> bool:
|
||||
return await asyncio.to_thread(_session_store().revoke_managed, management_id)
|
||||
|
||||
|
||||
async def issue_step_up(session: Session, *, action: str, target: str) -> str:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().mint_step_up,
|
||||
session.session_id,
|
||||
action=action,
|
||||
target=target,
|
||||
ttl_seconds=STEP_UP_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
async def consume_step_up(
|
||||
grant: str, session: Session, *, action: str, target: str
|
||||
) -> bool:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().consume_step_up,
|
||||
grant,
|
||||
session.session_id,
|
||||
action=action,
|
||||
target=target,
|
||||
)
|
||||
|
||||
|
||||
async def request_session(request: Request) -> Session | None:
|
||||
return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))
|
||||
|
||||
|
||||
async def request_session_verification(request: Request) -> SessionVerification:
|
||||
return await asyncio.to_thread(
|
||||
verify_session_with_reason, request.cookies.get(SESSION_COOKIE)
|
||||
)
|
||||
|
||||
|
||||
def cookie_path(request: Request) -> str:
|
||||
root_path = request.scope.get("root_path", "").rstrip("/")
|
||||
return root_path or "/"
|
||||
|
||||
|
||||
def application_path(request: Request) -> str:
|
||||
path = request.url.path
|
||||
root_path = request.scope.get("root_path", "").rstrip("/")
|
||||
if root_path and (path == root_path or path.startswith(root_path + "/")):
|
||||
return path[len(root_path):] or "/"
|
||||
return path
|
||||
|
||||
|
||||
def same_origin(request: Request) -> bool:
|
||||
origin = request.headers.get("origin", "")
|
||||
expected = f"{request.url.scheme}://{request.url.netloc}"
|
||||
return bool(origin) and hmac.compare_digest(origin.rstrip("/"), expected)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Build the content-addressed browser shell served by the dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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_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)
|
||||
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_digest=digest,
|
||||
runtime_name=runtime_name,
|
||||
page_sources=sources,
|
||||
service_worker_source=worker,
|
||||
)
|
||||
+1585
-46
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,136 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reservation:
|
||||
state: str
|
||||
response: Any = None
|
||||
|
||||
|
||||
class IdempotencyLedgerBusy(RuntimeError):
|
||||
"""Raised when a durable completion cannot acquire the SQLite write lock."""
|
||||
|
||||
|
||||
class IdempotencyLedger:
|
||||
"""Small durable ledger for replaying successful Gitea mutations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
ttl_seconds: float,
|
||||
max_entries: int,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.max_entries = max_entries
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
self.clock = clock
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection.execute(
|
||||
f"PRAGMA busy_timeout = {max(1, int(self.lock_timeout_seconds * 1000))}"
|
||||
)
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS idempotency_operations (
|
||||
key TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending', 'completed')),
|
||||
response_json TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
completed_at REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idempotency_completed_at_idx "
|
||||
"ON idempotency_operations(completed_at)"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _fingerprint(value: tuple[Any, ...]) -> str:
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
def reserve(self, key: str, fingerprint: tuple[Any, ...]) -> Reservation:
|
||||
encoded = self._fingerprint(fingerprint)
|
||||
now = self.clock()
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"DELETE FROM idempotency_operations "
|
||||
"WHERE status = 'completed' AND completed_at <= ?",
|
||||
(now - self.ttl_seconds,),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT fingerprint, status, response_json, created_at FROM idempotency_operations "
|
||||
"WHERE key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row[0] != encoded:
|
||||
return Reservation("conflict")
|
||||
if row[1] == "completed":
|
||||
return Reservation("completed", json.loads(row[2]))
|
||||
if row[3] <= now - self.ttl_seconds:
|
||||
return Reservation("uncertain")
|
||||
return Reservation("pending")
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) FROM idempotency_operations "
|
||||
"WHERE status = 'completed' OR created_at > ?",
|
||||
(now - self.ttl_seconds,),
|
||||
).fetchone()[0]
|
||||
if count >= self.max_entries:
|
||||
completed = connection.execute(
|
||||
"SELECT key FROM idempotency_operations "
|
||||
"WHERE status = 'completed' ORDER BY completed_at, rowid LIMIT 1"
|
||||
).fetchone()
|
||||
if completed is None:
|
||||
return Reservation("busy")
|
||||
connection.execute(
|
||||
"DELETE FROM idempotency_operations WHERE key = ?",
|
||||
(completed[0],),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency_operations "
|
||||
"(key, fingerprint, status, created_at) VALUES (?, ?, 'pending', ?)",
|
||||
(key, encoded, now),
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
||||
return Reservation("busy")
|
||||
raise
|
||||
return Reservation("reserved")
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM idempotency_operations")
|
||||
|
||||
def complete(self, key: str, response: Any) -> None:
|
||||
encoded = json.dumps(response, separators=(",", ":"), sort_keys=True)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET status = 'completed', "
|
||||
"response_json = ?, completed_at = ? WHERE key = ?",
|
||||
(encoded, self.clock(), key),
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
||||
raise IdempotencyLedgerBusy from exc
|
||||
raise
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Durable, account-scoped Later deferrals."""
|
||||
|
||||
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,
|
||||
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)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS later_plans (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
records TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
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()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
if row is None:
|
||||
return {"revision": 0, "records": {}}
|
||||
return {"revision": int(row[0]), "records": json.loads(row[1])}
|
||||
|
||||
@staticmethod
|
||||
def _validate_wake_at(wake_at: str | None) -> str:
|
||||
if not wake_at:
|
||||
raise ValueError("wake_at is required for defer")
|
||||
try:
|
||||
datetime.fromisoformat(wake_at.replace("Z", "+00:00"))
|
||||
except ValueError as error:
|
||||
raise ValueError("wake_at must be an ISO timestamp") from error
|
||||
return wake_at
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, records FROM later_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
login: str,
|
||||
operation_id: str,
|
||||
action: str,
|
||||
item_id: str,
|
||||
*,
|
||||
wake_at: str | None = None,
|
||||
base_revision: int | None = None,
|
||||
) -> dict:
|
||||
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."""
|
||||
login = self._normalize_login(login)
|
||||
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)
|
||||
records = dict(snapshot["records"])
|
||||
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", "")
|
||||
action = operation.get("action", "")
|
||||
item_id = operation.get("item_id", "")
|
||||
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")
|
||||
wake_at = operation.get("wake_at")
|
||||
if action == "defer":
|
||||
wake_at = self._validate_wake_at(wake_at)
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
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
|
||||
changed = before != wake_at
|
||||
else:
|
||||
changed = item_id in records
|
||||
records.pop(item_id, None)
|
||||
revision += 1 if changed else 0
|
||||
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)
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
elif accepted:
|
||||
connection.execute(
|
||||
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
|
||||
(revision, serialized, login),
|
||||
)
|
||||
return {
|
||||
"revision": revision,
|
||||
"records": records,
|
||||
"accepted_operation_ids": accepted,
|
||||
"duplicate_operation_ids": duplicates,
|
||||
"rejected_operations": rejected,
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Durable throttling state for operator sign-in attempts."""
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import math
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class LoginAttemptStoreError(RuntimeError):
|
||||
"""Raised when sign-in throttling state cannot be accessed safely."""
|
||||
|
||||
|
||||
def client_source(peer_host: str, forwarded_for: str, trusted_proxy_cidrs: str) -> str:
|
||||
"""Resolve a canonical client IP without trusting arbitrary forwarding headers."""
|
||||
try:
|
||||
peer = ipaddress.ip_address(peer_host)
|
||||
trusted = [
|
||||
ipaddress.ip_network(value.strip())
|
||||
for value in trusted_proxy_cidrs.split(",")
|
||||
if value.strip()
|
||||
]
|
||||
except ValueError:
|
||||
return peer_host
|
||||
if not any(peer in network for network in trusted):
|
||||
return str(peer)
|
||||
try:
|
||||
forwarded = [
|
||||
ipaddress.ip_address(value.strip())
|
||||
for value in forwarded_for.split(",")
|
||||
if value.strip()
|
||||
]
|
||||
except ValueError:
|
||||
return str(peer)
|
||||
for address in reversed(forwarded):
|
||||
if not any(address in network for network in trusted):
|
||||
return str(address)
|
||||
return str(forwarded[0]) if forwarded else str(peer)
|
||||
|
||||
|
||||
class LoginAttemptStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
clock: Callable[[], float],
|
||||
max_failures: int,
|
||||
window_seconds: int,
|
||||
max_entries: int = 10_000,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self.max_failures = max(1, max_failures)
|
||||
self.window_seconds = max(1, window_seconds)
|
||||
self.max_entries = max(1, max_entries)
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
|
||||
@staticmethod
|
||||
def _digest(source: str) -> str:
|
||||
return hashlib.sha256(source.encode()).hexdigest()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
source_hash TEXT PRIMARY KEY,
|
||||
failures INTEGER NOT NULL,
|
||||
window_started_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def retry_after(self, source: str) -> int:
|
||||
now = self.clock()
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
|
||||
(self._digest(source),),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
if row is None or row[0] < self.max_failures:
|
||||
return 0
|
||||
remaining = row[1] + self.window_seconds - now
|
||||
return max(0, math.ceil(remaining))
|
||||
|
||||
def record_failure(self, source: str) -> None:
|
||||
now = self.clock()
|
||||
source_hash = self._digest(source)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"DELETE FROM login_attempts WHERE window_started_at + ? <= ?",
|
||||
(self.window_seconds, now),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
|
||||
(source_hash,),
|
||||
).fetchone()
|
||||
if row is None or row[1] + self.window_seconds <= now:
|
||||
connection.execute(
|
||||
"INSERT OR REPLACE INTO login_attempts VALUES (?, 1, ?)",
|
||||
(source_hash, now),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE login_attempts SET failures = failures + 1 WHERE source_hash = ?",
|
||||
(source_hash,),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM login_attempts
|
||||
WHERE source_hash NOT IN (
|
||||
SELECT source_hash FROM login_attempts
|
||||
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
|
||||
)
|
||||
""",
|
||||
(self.max_entries,),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def clear(self, source: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM login_attempts WHERE source_hash = ?",
|
||||
(self._digest(source),),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
+2581
-106
File diff suppressed because it is too large.
Load diff
@@ -17,6 +17,11 @@ class Repo(BaseModel):
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
class Milestone(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
|
||||
|
||||
class Issue(BaseModel):
|
||||
id: int
|
||||
number: int
|
||||
@@ -26,6 +31,8 @@ class Issue(BaseModel):
|
||||
assignees: list[str] = []
|
||||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
due_date: str | None = None
|
||||
milestone: Milestone | None = None
|
||||
url: str
|
||||
|
||||
|
||||
|
||||
+306
-52
@@ -2,11 +2,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import errno
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Protocol
|
||||
@@ -14,6 +19,10 @@ from urllib import error, request
|
||||
|
||||
|
||||
PRIORITY = {"P0": 0, "P1": 1, "P2": 2}
|
||||
RUNTIME_ENV_NAMES = (
|
||||
"HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "SHELL",
|
||||
"SSH_AUTH_SOCK", "TMPDIR", "USER",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -31,6 +40,7 @@ class CommandResult:
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -42,6 +52,10 @@ class EngineConfig:
|
||||
agent_command: str
|
||||
test_command: str
|
||||
base_branch: str = "main"
|
||||
agent_timeout_seconds: float = 1800.0
|
||||
test_timeout_seconds: float = 900.0
|
||||
termination_grace_seconds: float = 5.0
|
||||
agent_env_vars: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -52,6 +66,9 @@ class RunState:
|
||||
evidence: str = ""
|
||||
pr_number: int | None = None
|
||||
pr_url: str = ""
|
||||
schema_version: int = 1
|
||||
repo: str = ""
|
||||
head_sha: str = ""
|
||||
|
||||
|
||||
class Client(Protocol):
|
||||
@@ -63,7 +80,14 @@ class Client(Protocol):
|
||||
|
||||
|
||||
class Runner(Protocol):
|
||||
def run(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> CommandResult: ...
|
||||
def run(
|
||||
self,
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
termination_grace_seconds: float = 5.0,
|
||||
) -> CommandResult: ...
|
||||
|
||||
|
||||
class StateStore:
|
||||
@@ -77,9 +101,71 @@ class StateStore:
|
||||
|
||||
def save(self, state: RunState) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_name(self.path.name + ".tmp")
|
||||
tmp.write_text(json.dumps(asdict(state), indent=2) + "\n")
|
||||
tmp.replace(self.path)
|
||||
temporary_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=self.path.parent,
|
||||
prefix=self.path.name + ".",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as temporary:
|
||||
temporary_path = Path(temporary.name)
|
||||
temporary.write(json.dumps(asdict(state), indent=2) + "\n")
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
os.replace(temporary_path, self.path)
|
||||
directory = os.open(self.path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class RepositoryLease:
|
||||
"""Non-blocking, process-backed single-writer lease for one worktree."""
|
||||
|
||||
def __init__(self, repo_path: Path):
|
||||
self.path = Path(repo_path).resolve() / ".release-engine" / "run.lock"
|
||||
self._file = None
|
||||
|
||||
def __enter__(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_file = self.path.open("a+", encoding="utf-8")
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.EACCES, errno.EAGAIN):
|
||||
detail = ""
|
||||
try:
|
||||
lock_file.seek(0)
|
||||
metadata = json.loads(lock_file.read(512))
|
||||
owner_pid = int(metadata["pid"])
|
||||
acquired_at = float(metadata["acquired_at"])
|
||||
detail = f" (owner pid={owner_pid}, acquired_at={acquired_at:.3f})"
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
lock_file.close()
|
||||
raise RuntimeError(f"release run already active{detail}") from exc
|
||||
lock_file.close()
|
||||
raise
|
||||
self._file = lock_file
|
||||
lock_file.seek(0)
|
||||
lock_file.truncate()
|
||||
lock_file.write(json.dumps({"pid": os.getpid(), "acquired_at": time.time()}) + "\n")
|
||||
lock_file.flush()
|
||||
os.fsync(lock_file.fileno())
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback):
|
||||
if self._file is not None:
|
||||
fcntl.flock(self._file.fileno(), fcntl.LOCK_UN)
|
||||
self._file.close()
|
||||
self._file = None
|
||||
|
||||
|
||||
def _label_names(labels: Iterable[Any]) -> list[str]:
|
||||
@@ -113,20 +199,70 @@ def branch_name(agent: str, issue: Issue) -> str:
|
||||
return f"{actor}/{issue.number}-{slug or 'issue'}"
|
||||
|
||||
|
||||
def parse_command(command: str, name: str) -> list[str]:
|
||||
try:
|
||||
arguments = shlex.split(command)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"invalid {name}: {exc}") from exc
|
||||
if not arguments:
|
||||
raise ValueError(f"invalid {name}: command is empty")
|
||||
return arguments
|
||||
|
||||
|
||||
def subprocess_environment(
|
||||
allowed_names: Iterable[str] = (),
|
||||
overrides: dict[str, str] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Build an explicit child environment without inheriting parent secrets."""
|
||||
environment = {"PATH": os.environ.get("PATH", os.defpath)}
|
||||
for name in (*RUNTIME_ENV_NAMES, *allowed_names):
|
||||
if name in os.environ:
|
||||
environment[name] = os.environ[name]
|
||||
if overrides:
|
||||
environment.update(overrides)
|
||||
return environment
|
||||
|
||||
|
||||
def validate_agent_env_vars(names: Iterable[str]) -> None:
|
||||
for name in names:
|
||||
if (
|
||||
re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) is None
|
||||
or name == "GITEA_TOKEN"
|
||||
or name.startswith("RELEASE_")
|
||||
):
|
||||
raise ValueError(f"invalid agent environment variable: {name}")
|
||||
|
||||
|
||||
class ShellRunner:
|
||||
def run(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> CommandResult:
|
||||
merged_env = os.environ.copy()
|
||||
if env:
|
||||
merged_env.update(env)
|
||||
completed = subprocess.run(
|
||||
def run(
|
||||
self,
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
termination_grace_seconds: float = 5.0,
|
||||
) -> CommandResult:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=str(cwd),
|
||||
env=merged_env,
|
||||
shell=True,
|
||||
env=dict(env or {}),
|
||||
shell=False,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
return CommandResult(completed.returncode, completed.stdout, completed.stderr)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=timeout_seconds)
|
||||
return CommandResult(process.returncode, stdout, stderr)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=termination_grace_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
stdout, stderr = process.communicate()
|
||||
return CommandResult(process.returncode, stdout, stderr, timed_out=True)
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
@@ -197,8 +333,23 @@ class ReleaseEngine:
|
||||
self.store = StateStore(config.state_path)
|
||||
|
||||
def execute(self, issue_number: int | None = None, dry_run: bool = False) -> dict[str, Any]:
|
||||
with RepositoryLease(self.config.repo_path):
|
||||
return self._execute_locked(issue_number=issue_number, dry_run=dry_run)
|
||||
|
||||
def _execute_locked(self, issue_number: int | None = None, dry_run: bool = False) -> dict[str, Any]:
|
||||
deadlines = {
|
||||
"agent timeout seconds": self.config.agent_timeout_seconds,
|
||||
"test timeout seconds": self.config.test_timeout_seconds,
|
||||
"termination grace seconds": self.config.termination_grace_seconds,
|
||||
}
|
||||
for name, value in deadlines.items():
|
||||
if value <= 0:
|
||||
raise ValueError(f"{name} must be positive")
|
||||
validate_agent_env_vars(self.config.agent_env_vars)
|
||||
|
||||
issue = select_issue(self.client.list_open_issues(self.config.repo), self.config.agent, issue_number)
|
||||
branch = branch_name(self.config.agent, issue)
|
||||
head_sha = ""
|
||||
|
||||
existing_pr = self.client.find_open_pr(self.config.repo, branch)
|
||||
if existing_pr:
|
||||
@@ -220,50 +371,138 @@ class ReleaseEngine:
|
||||
"agent": self.config.agent,
|
||||
}
|
||||
|
||||
self.client.claim_issue(self.config.repo, issue.number, self.config.agent)
|
||||
verified = self.client.get_issue(self.config.repo, issue.number)
|
||||
if verified.assignee != self.config.agent:
|
||||
raise RuntimeError(
|
||||
f"claim verification failed: expected {self.config.agent}, got {verified.assignee}"
|
||||
agent_command = parse_command(self.config.agent_command, "agent command")
|
||||
test_command = parse_command(self.config.test_command, "test command")
|
||||
runtime_env = subprocess_environment()
|
||||
agent_env = subprocess_environment(
|
||||
self.config.agent_env_vars,
|
||||
{
|
||||
"RELEASE_ISSUE_NUMBER": str(issue.number),
|
||||
"RELEASE_ISSUE_TITLE": issue.title,
|
||||
"RELEASE_ISSUE_BODY": issue.body,
|
||||
"RELEASE_REPO": self.config.repo,
|
||||
"RELEASE_BRANCH": branch,
|
||||
},
|
||||
)
|
||||
checkpoint = self.store.load()
|
||||
resumable_statuses = {"agent_complete", "tests_passed", "pushed"}
|
||||
if checkpoint is not None and checkpoint.status in resumable_statuses:
|
||||
if (
|
||||
checkpoint.schema_version != 1
|
||||
or checkpoint.repo != self.config.repo
|
||||
or checkpoint.issue != issue.number
|
||||
or checkpoint.branch != branch
|
||||
or not checkpoint.head_sha
|
||||
):
|
||||
raise RuntimeError("release checkpoint does not match this run")
|
||||
branch_result = self.runner.run(
|
||||
["git", "checkout", branch], self.config.repo_path, runtime_env
|
||||
)
|
||||
self.store.save(RunState(issue.number, branch, "claimed"))
|
||||
if branch_result.returncode != 0:
|
||||
raise RuntimeError(f"checkpoint branch checkout failed: {branch_result.stderr.strip()}")
|
||||
head_result = self.runner.run(
|
||||
["git", "rev-parse", "HEAD"], self.config.repo_path, runtime_env
|
||||
)
|
||||
if head_result.returncode != 0 or head_result.stdout.strip() != checkpoint.head_sha:
|
||||
raise RuntimeError("release checkpoint commit does not match the local branch")
|
||||
status_result = self.runner.run(
|
||||
["git", "status", "--porcelain"], self.config.repo_path, runtime_env
|
||||
)
|
||||
if status_result.returncode != 0 or status_result.stdout.strip():
|
||||
raise RuntimeError("release checkpoint worktree is not clean")
|
||||
head_sha = checkpoint.head_sha
|
||||
evidence = checkpoint.evidence
|
||||
else:
|
||||
checkpoint = None
|
||||
evidence = ""
|
||||
self.client.claim_issue(self.config.repo, issue.number, self.config.agent)
|
||||
verified = self.client.get_issue(self.config.repo, issue.number)
|
||||
if verified.assignee != self.config.agent:
|
||||
raise RuntimeError(
|
||||
f"claim verification failed: expected {self.config.agent}, got {verified.assignee}"
|
||||
)
|
||||
self.store.save(RunState(issue.number, branch, "claimed"))
|
||||
|
||||
branch_result = self.runner.run(f"git checkout -B {shlex.quote(branch)}", self.config.repo_path)
|
||||
if branch_result.returncode != 0:
|
||||
raise RuntimeError(f"branch creation failed: {branch_result.stderr.strip()}")
|
||||
branch_result = self.runner.run(
|
||||
["git", "checkout", "-B", branch], self.config.repo_path, runtime_env
|
||||
)
|
||||
if branch_result.returncode != 0:
|
||||
raise RuntimeError(f"branch creation failed: {branch_result.stderr.strip()}")
|
||||
|
||||
context = {
|
||||
"issue_number": issue.number,
|
||||
"issue_title": issue.title,
|
||||
"issue_body": issue.body,
|
||||
"repo": self.config.repo,
|
||||
"branch": branch,
|
||||
}
|
||||
command = self.config.agent_command.format(**context)
|
||||
agent_result = self.runner.run(command, self.config.repo_path, {
|
||||
"RELEASE_ISSUE_NUMBER": str(issue.number),
|
||||
"RELEASE_ISSUE_TITLE": issue.title,
|
||||
"RELEASE_ISSUE_BODY": issue.body,
|
||||
"RELEASE_REPO": self.config.repo,
|
||||
"RELEASE_BRANCH": branch,
|
||||
})
|
||||
if agent_result.returncode != 0:
|
||||
self.store.save(RunState(issue.number, branch, "agent_failed", agent_result.stderr[-2000:]))
|
||||
raise RuntimeError(f"coding agent failed: {agent_result.stderr.strip()}")
|
||||
self.store.save(RunState(issue.number, branch, "agent_complete"))
|
||||
agent_result = self.runner.run(
|
||||
agent_command,
|
||||
self.config.repo_path,
|
||||
agent_env,
|
||||
timeout_seconds=self.config.agent_timeout_seconds,
|
||||
termination_grace_seconds=self.config.termination_grace_seconds,
|
||||
)
|
||||
if agent_result.timed_out:
|
||||
evidence = (agent_result.stdout + "\n" + agent_result.stderr).strip()[-2000:]
|
||||
self.store.save(RunState(issue.number, branch, "agent_timed_out", evidence))
|
||||
raise RuntimeError(f"coding agent timed out: {evidence}")
|
||||
if agent_result.returncode != 0:
|
||||
self.store.save(RunState(issue.number, branch, "agent_failed", agent_result.stderr[-2000:]))
|
||||
raise RuntimeError(f"coding agent failed: {agent_result.stderr.strip()}")
|
||||
head_result = self.runner.run(
|
||||
["git", "rev-parse", "HEAD"], self.config.repo_path, runtime_env
|
||||
)
|
||||
head_sha = head_result.stdout.strip()
|
||||
if head_result.returncode != 0 or not head_sha:
|
||||
raise RuntimeError("could not identify the release checkpoint commit")
|
||||
self.store.save(RunState(
|
||||
issue.number, branch, "agent_complete",
|
||||
repo=self.config.repo, head_sha=head_sha,
|
||||
))
|
||||
|
||||
test_result = self.runner.run(self.config.test_command, self.config.repo_path)
|
||||
evidence = (test_result.stdout + "\n" + test_result.stderr).strip()[-4000:]
|
||||
if test_result.returncode != 0:
|
||||
self.store.save(RunState(issue.number, branch, "tests_failed", evidence))
|
||||
raise RuntimeError(f"tests failed: {evidence}")
|
||||
self.store.save(RunState(issue.number, branch, "tests_passed", evidence))
|
||||
if checkpoint is None or checkpoint.status == "agent_complete":
|
||||
test_result = self.runner.run(
|
||||
test_command,
|
||||
self.config.repo_path,
|
||||
runtime_env,
|
||||
timeout_seconds=self.config.test_timeout_seconds,
|
||||
termination_grace_seconds=self.config.termination_grace_seconds,
|
||||
)
|
||||
evidence = (test_result.stdout + "\n" + test_result.stderr).strip()[-4000:]
|
||||
if test_result.timed_out:
|
||||
self.store.save(RunState(
|
||||
issue.number, branch, "tests_timed_out", evidence,
|
||||
repo=self.config.repo, head_sha=head_sha,
|
||||
))
|
||||
raise RuntimeError(f"tests timed out: {evidence}")
|
||||
if test_result.returncode != 0:
|
||||
self.store.save(RunState(
|
||||
issue.number, branch, "tests_failed", evidence,
|
||||
repo=self.config.repo, head_sha=head_sha,
|
||||
))
|
||||
raise RuntimeError(f"tests failed: {evidence}")
|
||||
self.store.save(RunState(
|
||||
issue.number, branch, "tests_passed", evidence,
|
||||
repo=self.config.repo, head_sha=head_sha,
|
||||
))
|
||||
|
||||
push_result = self.runner.run(f"git push -u origin {shlex.quote(branch)}", self.config.repo_path)
|
||||
if push_result.returncode != 0:
|
||||
self.store.save(RunState(issue.number, branch, "push_failed", push_result.stderr[-2000:]))
|
||||
raise RuntimeError(f"push failed: {push_result.stderr.strip()}")
|
||||
self.store.save(RunState(issue.number, branch, "pushed", evidence))
|
||||
if checkpoint is not None and checkpoint.status == "pushed":
|
||||
remote_result = self.runner.run(
|
||||
["git", "ls-remote", "--heads", "origin", branch],
|
||||
self.config.repo_path,
|
||||
runtime_env,
|
||||
)
|
||||
expected_remote = f"{checkpoint.head_sha}\trefs/heads/{branch}"
|
||||
if remote_result.returncode != 0 or remote_result.stdout.strip() != expected_remote:
|
||||
raise RuntimeError("release checkpoint commit does not match the remote branch")
|
||||
else:
|
||||
push_result = self.runner.run(
|
||||
["git", "push", "-u", "origin", branch], self.config.repo_path, runtime_env
|
||||
)
|
||||
if push_result.returncode != 0:
|
||||
self.store.save(RunState(
|
||||
issue.number, branch, "push_failed", push_result.stderr[-2000:],
|
||||
repo=self.config.repo, head_sha=head_sha,
|
||||
))
|
||||
raise RuntimeError(f"push failed: {push_result.stderr.strip()}")
|
||||
self.store.save(RunState(
|
||||
issue.number, branch, "pushed", evidence,
|
||||
repo=self.config.repo, head_sha=head_sha,
|
||||
))
|
||||
|
||||
pr_body = (
|
||||
f"Closes #{issue.number}\n\n"
|
||||
@@ -288,6 +527,8 @@ class ReleaseEngine:
|
||||
evidence,
|
||||
pr_number=pr["number"],
|
||||
pr_url=pr.get("html_url", ""),
|
||||
repo=self.config.repo,
|
||||
head_sha=head_sha,
|
||||
)
|
||||
self.store.save(final)
|
||||
return {
|
||||
@@ -310,6 +551,15 @@ def _parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--api", default=os.getenv("GITEA_API", "http://127.0.0.1:3000/api/v1"))
|
||||
parser.add_argument("--agent-command", default=os.getenv("RELEASE_AGENT_COMMAND", ""))
|
||||
parser.add_argument("--test-command", default=os.getenv("RELEASE_TEST_COMMAND", "python3 -m pytest tests/ -q"))
|
||||
parser.add_argument("--agent-timeout", type=float, default=float(os.getenv("RELEASE_AGENT_TIMEOUT", "1800")))
|
||||
parser.add_argument("--test-timeout", type=float, default=float(os.getenv("RELEASE_TEST_TIMEOUT", "900")))
|
||||
parser.add_argument("--termination-grace", type=float, default=float(os.getenv("RELEASE_TERMINATION_GRACE", "5")))
|
||||
parser.add_argument(
|
||||
"--agent-env",
|
||||
action="append",
|
||||
default=[name for name in os.getenv("RELEASE_AGENT_ENV", "").split(",") if name],
|
||||
help="parent environment variable to pass to the coding agent; repeat as needed",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -327,6 +577,10 @@ def main() -> int:
|
||||
state_path=args.state,
|
||||
agent_command=args.agent_command,
|
||||
test_command=args.test_command,
|
||||
agent_timeout_seconds=args.agent_timeout,
|
||||
test_timeout_seconds=args.test_timeout,
|
||||
termination_grace_seconds=args.termination_grace,
|
||||
agent_env_vars=tuple(args.agent_env),
|
||||
)
|
||||
result = ReleaseEngine(config, GiteaClient(args.api, token), ShellRunner()).execute(
|
||||
issue_number=args.issue,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
|
||||
SESSION_BODY_LIMIT = 16 * 1024
|
||||
API_MUTATION_BODY_LIMIT = 64 * 1024
|
||||
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
||||
|
||||
|
||||
def request_body_limit(method: str, path: str) -> int | None:
|
||||
"""Return the admission limit for request bodies that FastAPI will parse."""
|
||||
normalized_method = method.upper()
|
||||
if normalized_method == "POST" and path == "/api/v1/session":
|
||||
return SESSION_BODY_LIMIT
|
||||
if normalized_method in MUTATION_METHODS and path.startswith("/api/v1/"):
|
||||
return API_MUTATION_BODY_LIMIT
|
||||
return None
|
||||
|
||||
|
||||
class RequestBodyLimitMiddleware:
|
||||
"""Reject oversized API bodies before framework parsing or endpoint work."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
limit_for: Callable[[str, str], int | None] = request_body_limit,
|
||||
) -> None:
|
||||
self.app = app
|
||||
self.limit_for = limit_for
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
root_path = scope.get("root_path", "").rstrip("/")
|
||||
if root_path and (path == root_path or path.startswith(root_path + "/")):
|
||||
path = path[len(root_path) :] or "/"
|
||||
limit = self.limit_for(scope.get("method", "GET"), path)
|
||||
if limit is None:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
declared_length = self._content_length(scope)
|
||||
if declared_length is not None and declared_length > limit:
|
||||
await self._reject(send)
|
||||
return
|
||||
|
||||
messages: list[Message] = []
|
||||
received = 0
|
||||
while True:
|
||||
message = await receive()
|
||||
messages.append(message)
|
||||
if message["type"] != "http.request":
|
||||
break
|
||||
received += len(message.get("body", b""))
|
||||
if received > limit:
|
||||
await self._reject(send)
|
||||
return
|
||||
if not message.get("more_body", False):
|
||||
break
|
||||
|
||||
async def replay() -> Message:
|
||||
if messages:
|
||||
return messages.pop(0)
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
await self.app(scope, replay, send)
|
||||
|
||||
@staticmethod
|
||||
def _content_length(scope: Scope) -> int | None:
|
||||
for name, value in scope.get("headers", []):
|
||||
if name.lower() != b"content-length":
|
||||
continue
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return max(0, parsed)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _reject(send: Send) -> None:
|
||||
body = b'{"detail":"Request body too large"}'
|
||||
headers = [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(body)).encode("ascii")),
|
||||
(b"cache-control", b"no-store"),
|
||||
]
|
||||
await send({"type": "http.response.start", "status": 413, "headers": headers})
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Durable active-session registry used to revoke signed operator sessions."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class SessionStoreError(RuntimeError):
|
||||
"""Raised when session state cannot be read or changed safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveDevice:
|
||||
management_id: str
|
||||
device_label: str
|
||||
created_at: int
|
||||
expires_at: int
|
||||
current: bool
|
||||
|
||||
|
||||
class SessionStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
clock: Callable[[], float],
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
|
||||
@staticmethod
|
||||
def _digest(session_id: str) -> str:
|
||||
return hashlib.sha256(session_id.encode()).hexdigest()
|
||||
|
||||
def _connect(self, *, initialize: bool = False) -> sqlite3.Connection:
|
||||
try:
|
||||
if initialize:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(
|
||||
self.path, timeout=self.lock_timeout_seconds
|
||||
)
|
||||
else:
|
||||
connection = sqlite3.connect(
|
||||
f"{self.path.resolve().as_uri()}?mode=rw",
|
||||
timeout=self.lock_timeout_seconds,
|
||||
uri=True,
|
||||
)
|
||||
if initialize:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS active_sessions (
|
||||
session_hash TEXT PRIMARY KEY,
|
||||
expires_at INTEGER NOT NULL,
|
||||
management_id TEXT,
|
||||
device_label TEXT,
|
||||
created_at INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS step_up_grants (
|
||||
grant_hash TEXT PRIMARY KEY,
|
||||
session_hash TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS step_up_grants_session_hash "
|
||||
"ON step_up_grants(session_hash)"
|
||||
)
|
||||
columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(active_sessions)")
|
||||
}
|
||||
additions = {
|
||||
"management_id": "TEXT",
|
||||
"device_label": "TEXT",
|
||||
"created_at": "INTEGER",
|
||||
}
|
||||
for name, column_type in additions.items():
|
||||
if name not in columns:
|
||||
connection.execute(
|
||||
f"ALTER TABLE active_sessions ADD COLUMN {name} {column_type}"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE active_sessions SET management_id = lower(hex(randomblob(16))) "
|
||||
"WHERE management_id IS NULL"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE active_sessions SET device_label = 'Existing device' "
|
||||
"WHERE device_label IS NULL"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL",
|
||||
(int(self.clock()),),
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id "
|
||||
"ON active_sessions(management_id)"
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def activate(
|
||||
self, session_id: str, expires_at: int, *, device_label: str = "This device"
|
||||
) -> None:
|
||||
label = " ".join(str(device_label).split())[:64] or "This device"
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect(initialize=True) as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM active_sessions WHERE expires_at <= ?", (now,)
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO active_sessions("
|
||||
"session_hash, expires_at, management_id, device_label, created_at"
|
||||
") VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
self._digest(session_id),
|
||||
expires_at,
|
||||
secrets.token_urlsafe(18),
|
||||
label,
|
||||
now,
|
||||
),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def is_active(self, session_id: str, expires_at: int) -> bool:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT expires_at FROM active_sessions WHERE session_hash = ?",
|
||||
(self._digest(session_id),),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
return row is not None and row[0] == expires_at and expires_at > now
|
||||
|
||||
def revoke(self, session_id: str) -> None:
|
||||
try:
|
||||
with self._connect(initialize=True) as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash = ?",
|
||||
(self._digest(session_id),),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM active_sessions WHERE session_hash = ?",
|
||||
(self._digest(session_id),),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def list_active(self, current_session_id: str) -> list[ActiveDevice]:
|
||||
now = int(self.clock())
|
||||
current_hash = self._digest(current_session_id)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT management_id, device_label, created_at, expires_at, session_hash "
|
||||
"FROM active_sessions WHERE expires_at > ? "
|
||||
"ORDER BY expires_at DESC, created_at DESC",
|
||||
(now,),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
return [
|
||||
ActiveDevice(
|
||||
management_id=row[0],
|
||||
device_label=row[1],
|
||||
created_at=row[2],
|
||||
expires_at=row[3],
|
||||
current=secrets.compare_digest(row[4], current_hash),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def revoke_managed(self, management_id: str) -> bool:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash = ?", (row[0],)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all(self) -> None:
|
||||
try:
|
||||
with self._connect(initialize=True) as connection:
|
||||
connection.execute("DELETE FROM step_up_grants")
|
||||
connection.execute("DELETE FROM active_sessions")
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def mint_step_up(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
action: str,
|
||||
target: str,
|
||||
ttl_seconds: int,
|
||||
) -> str:
|
||||
now = int(self.clock())
|
||||
grant = secrets.token_urlsafe(32)
|
||||
session_hash = self._digest(session_id)
|
||||
try:
|
||||
with self._connect(initialize=True) as connection:
|
||||
active = connection.execute(
|
||||
"SELECT 1 FROM active_sessions "
|
||||
"WHERE session_hash = ? AND expires_at > ?",
|
||||
(session_hash, now),
|
||||
).fetchone()
|
||||
if active is None:
|
||||
raise SessionStoreError("Session is no longer active")
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE expires_at <= ?", (now,)
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO step_up_grants("
|
||||
"grant_hash, session_hash, action, target, expires_at"
|
||||
") VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
self._digest(grant),
|
||||
session_hash,
|
||||
action,
|
||||
target,
|
||||
now + max(1, ttl_seconds),
|
||||
),
|
||||
)
|
||||
except SessionStoreError:
|
||||
raise
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
return grant
|
||||
|
||||
def consume_step_up(
|
||||
self,
|
||||
grant: str,
|
||||
session_id: str,
|
||||
*,
|
||||
action: str,
|
||||
target: str,
|
||||
) -> bool:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE expires_at <= ?", (now,)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE grant_hash = ? "
|
||||
"AND session_hash = ? AND action = ? AND target = ? "
|
||||
"AND expires_at > ? AND EXISTS ("
|
||||
"SELECT 1 FROM active_sessions "
|
||||
"WHERE active_sessions.session_hash = step_up_grants.session_hash "
|
||||
"AND active_sessions.expires_at > ?)",
|
||||
(
|
||||
self._digest(grant),
|
||||
self._digest(session_id),
|
||||
action,
|
||||
target,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Durable, account-scoped ordered Today plans."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TodayPlanFull(ValueError):
|
||||
"""Raised when an add would exceed the bounded Today plan."""
|
||||
|
||||
|
||||
class TodayStore:
|
||||
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)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_plans (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
ids TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
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()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
if row is None:
|
||||
return {"revision": 0, "ids": []}
|
||||
return {"revision": int(row[0]), "ids": json.loads(row[1])}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
login: str,
|
||||
operation_id: str,
|
||||
action: str,
|
||||
item_id: str,
|
||||
*,
|
||||
direction: str | 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 {"add", "remove", "move"}:
|
||||
raise ValueError("unsupported Today action")
|
||||
if action == "move" and direction not in {"up", "down"}:
|
||||
raise ValueError("move direction must be up or down")
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
return snapshot
|
||||
|
||||
ids = list(snapshot["ids"])
|
||||
changed = False
|
||||
if action == "add":
|
||||
if item_id not in ids:
|
||||
if len(ids) >= self.limit:
|
||||
raise TodayPlanFull("Today is limited to five items")
|
||||
ids.append(item_id)
|
||||
changed = True
|
||||
elif action == "remove":
|
||||
if item_id in ids:
|
||||
ids.remove(item_id)
|
||||
changed = True
|
||||
else:
|
||||
try:
|
||||
index = ids.index(item_id)
|
||||
except ValueError:
|
||||
index = -1
|
||||
target = index - 1 if direction == "up" else index + 1
|
||||
if index >= 0 and 0 <= target < len(ids):
|
||||
ids[index], ids[target] = ids[target], ids[index]
|
||||
changed = True
|
||||
|
||||
revision = snapshot["revision"] + (1 if changed else 0)
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
|
||||
(login, revision, json.dumps(ids, separators=(",", ":"))),
|
||||
)
|
||||
elif changed:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
||||
(revision, json.dumps(ids, separators=(",", ":")), login),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
return {"revision": revision, "ids": ids}
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
"""Apply an ordered batch with one lock and receipt per operation."""
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
ids = list(snapshot["ids"])
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
rejected: list[dict[str, str]] = []
|
||||
|
||||
for operation in operations:
|
||||
operation_id = operation.get("operation_id", "")
|
||||
action = operation.get("action", "")
|
||||
item_id = operation.get("item_id", "")
|
||||
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"}:
|
||||
raise ValueError("unsupported Today action")
|
||||
if action == "move" and direction not in {"up", "down"}:
|
||||
raise ValueError("move direction must be up or down")
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
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:
|
||||
if len(ids) >= self.limit:
|
||||
rejected.append({"operation_id": operation_id, "reason": "today_full"})
|
||||
continue
|
||||
ids.append(item_id)
|
||||
changed = True
|
||||
elif action == "remove":
|
||||
if item_id in ids:
|
||||
ids.remove(item_id)
|
||||
changed = True
|
||||
else:
|
||||
try:
|
||||
index = ids.index(item_id)
|
||||
except ValueError:
|
||||
index = -1
|
||||
target = index - 1 if direction == "up" else index + 1
|
||||
if index >= 0 and 0 <= target < len(ids):
|
||||
ids[index], ids[target] = ids[target], ids[index]
|
||||
changed = True
|
||||
revision += 1 if changed else 0
|
||||
self._record_operation(connection, login, operation_id)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(ids, separators=(",", ":"))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
elif accepted:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
||||
(revision, serialized, login),
|
||||
)
|
||||
return {
|
||||
"revision": revision,
|
||||
"ids": ids,
|
||||
"accepted_operation_ids": accepted,
|
||||
"duplicate_operation_ids": duplicates,
|
||||
"rejected_operations": rejected,
|
||||
}
|
||||
+53
-4
@@ -1,12 +1,61 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
|
||||
router = APIRouter()
|
||||
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
|
||||
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
|
||||
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">
|
||||
<title>Sign in · Stackchain Dashboard</title>
|
||||
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
|
||||
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
|
||||
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main>
|
||||
<script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
class RevalidatingHTMLResponse(HTMLResponse):
|
||||
def __init__(self, content, status_code=200, headers=None, media_type=None, background=None):
|
||||
response_headers = dict(headers or {})
|
||||
response_headers["Cache-Control"] = "no-cache"
|
||||
super().__init__(content, status_code, response_headers, media_type, background)
|
||||
|
||||
|
||||
@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)
|
||||
async def login() -> str:
|
||||
return LOGIN_HTML
|
||||
|
||||
|
||||
@router.get("/manifest.webmanifest", response_class=FileResponse)
|
||||
async def web_app_manifest() -> FileResponse:
|
||||
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
|
||||
|
||||
|
||||
@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) -> Response:
|
||||
if digest != FRONTEND_BUILD.runtime_digest:
|
||||
raise HTTPException(status_code=404, detail="Runtime revision not found")
|
||||
return Response(
|
||||
FRONTEND_BUILD.runtime_bytes,
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
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:
|
||||
"""Return the HTML, CSS, and bootstrap sources that make up the dashboard."""
|
||||
return "\n".join(
|
||||
(
|
||||
(FRONTEND / "index.html").read_text(),
|
||||
(FRONTEND / "dashboard.css").read_text(),
|
||||
(FRONTEND / "dashboard.js").read_text(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def dashboard() -> str:
|
||||
# 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(),
|
||||
)
|
||||
)
|
||||
@@ -12,7 +12,7 @@ async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkey
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def empty_events():
|
||||
async def empty_events(user_data=None):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
@@ -23,7 +23,7 @@ async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkey
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
for path in ("/api/v1/context", "/api/v1/events"):
|
||||
for path in ("/api/v1/context", "/api/v1/events", "/api/v1/live"):
|
||||
response = await client.get(path)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -2,12 +2,11 @@ import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
DASHBOARD_HTML = Path(__file__).parent.parent / "frontend" / "index.html"
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
html = DASHBOARD_HTML.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
|
||||
|
||||
assert api_paths
|
||||
@@ -15,6 +14,10 @@ def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
urljoin("https://forge.alexanderwhitestone.com/dashboard/", path)
|
||||
for path in api_paths
|
||||
} == {
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/context",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/events",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/live",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_authored_operations():
|
||||
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_issue_comment_replays_one_upstream_result_for_concurrent_requests(monkeypatch):
|
||||
calls = 0
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
started.set()
|
||||
await release.wait()
|
||||
return {"id": 82, "body": body}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "issue-comment-185"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = asyncio.create_task(client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Ship it"}, headers=headers,
|
||||
))
|
||||
await started.wait()
|
||||
second = asyncio.create_task(client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Ship it"}, headers=headers,
|
||||
))
|
||||
await asyncio.sleep(0)
|
||||
release.set()
|
||||
responses = await asyncio.gather(first, second)
|
||||
|
||||
assert [response.status_code for response in responses] == [201, 201]
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_comment_rejects_changed_payload_for_same_key(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
calls.append(body)
|
||||
return {"id": len(calls), "body": body}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "pull-comment-185"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/comments",
|
||||
json={"body": "Ship it"}, headers=headers,
|
||||
)
|
||||
conflict = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/comments",
|
||||
json={"body": "Changed"}, headers=headers,
|
||||
)
|
||||
|
||||
assert created.status_code == 201
|
||||
assert conflict.status_code == 409
|
||||
assert calls == ["Ship it"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_reply_retry_recovers_after_caller_timeout(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def reply(_thread_id, body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
await asyncio.sleep(0.03)
|
||||
return {"id": 91, "body": body}
|
||||
|
||||
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "notification-reply-185"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
timed_out = await client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "Retry"}, headers=headers,
|
||||
)
|
||||
await asyncio.sleep(0.03)
|
||||
recovered = await client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "Retry"}, headers=headers,
|
||||
)
|
||||
|
||||
assert timed_out.status_code == 503
|
||||
assert recovered.status_code == 201
|
||||
assert recovered.json()["id"] == 91
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_authored_task_remains_joinable_after_reservation_age_threshold(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
calls = 0
|
||||
now = [1_000.0]
|
||||
release = asyncio.Event()
|
||||
|
||||
async def reply(_thread_id, body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
await release.wait()
|
||||
return {"id": 319, "body": body}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_idempotency_ledger",
|
||||
IdempotencyLedger(
|
||||
tmp_path / "live.sqlite3",
|
||||
ttl_seconds=1,
|
||||
max_entries=256,
|
||||
clock=lambda: now[0],
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "live-after-threshold-319"}
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
timed_out = await client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "Join me"}, headers=headers,
|
||||
)
|
||||
now[0] += 2
|
||||
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 1.0)
|
||||
joined = asyncio.create_task(client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "Join me"}, headers=headers,
|
||||
))
|
||||
await asyncio.sleep(0.02)
|
||||
assert not joined.done(), "a live local operation must be joined instead of rejected as stale"
|
||||
release.set()
|
||||
recovered = await joined
|
||||
|
||||
assert timed_out.status_code == 503
|
||||
assert recovered.status_code == 201
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_replays_same_key_and_rejects_changed_head(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def requested(_repository, _number):
|
||||
return True
|
||||
|
||||
async def submit(_repository, _number, head, decision, body):
|
||||
calls.append((head, decision, body))
|
||||
return {"id": 93, "state": "APPROVED"}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "review-185"}
|
||||
payload = {"expected_head_sha": "abc", "decision": "approve", "body": "Good"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review", json=payload, headers=headers,
|
||||
)
|
||||
replayed = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review", json=payload, headers=headers,
|
||||
)
|
||||
conflict = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={**payload, "expected_head_sha": "def"}, headers=headers,
|
||||
)
|
||||
|
||||
assert [created.status_code, replayed.status_code, conflict.status_code] == [201, 201, 409]
|
||||
assert calls == [("abc", "approve", "Good")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_completed_comment_replays_after_ledger_reconstruction(monkeypatch, tmp_path):
|
||||
calls = 0
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": 201, "body": body}
|
||||
|
||||
database = tmp_path / "actions.sqlite3"
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_idempotency_ledger",
|
||||
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "restart-comment-201"}
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Persist me"},
|
||||
headers=headers,
|
||||
)
|
||||
main._authored_action_operations.clear()
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_idempotency_ledger",
|
||||
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||
)
|
||||
replayed = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Persist me"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert [created.status_code, replayed.status_code] == [201, 201]
|
||||
assert replayed.json() == {"id": 201, "body": "Persist me"}
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orphaned_pending_comment_fails_closed_without_upstream_retry(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
calls = 0
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": 202, "body": body}
|
||||
|
||||
ledger = IdempotencyLedger(
|
||||
tmp_path / "orphan.sqlite3", ttl_seconds=600, max_entries=256
|
||||
)
|
||||
fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first")
|
||||
assert ledger.reserve("orphan-comment-201", fingerprint).state == "reserved"
|
||||
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Verify first"},
|
||||
headers={"Idempotency-Key": "orphan-comment-201"},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == (
|
||||
"This action may still be completing; verify its result before retrying"
|
||||
)
|
||||
assert calls == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_orphaned_comment_returns_non_retryable_uncertain_delivery(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
calls = 0
|
||||
now = [1_000.0]
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, _body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": 319}
|
||||
|
||||
ledger = IdempotencyLedger(
|
||||
tmp_path / "stale-orphan.sqlite3",
|
||||
ttl_seconds=10,
|
||||
max_entries=256,
|
||||
clock=lambda: now[0],
|
||||
)
|
||||
fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first")
|
||||
assert ledger.reserve("stale-orphan-319", fingerprint).state == "reserved"
|
||||
now[0] += 11
|
||||
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Verify first"},
|
||||
headers={"Idempotency-Key": "stale-orphan-319"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"] == {
|
||||
"code": "delivery_uncertain",
|
||||
"message": "Delivery could not be confirmed. Verify it was not posted before retrying.",
|
||||
}
|
||||
assert "Retry-After" not in response.headers
|
||||
assert calls == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_slow_ledger_reservation_does_not_block_health_requests(monkeypatch):
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
return {"id": 248, "body": body}
|
||||
|
||||
started = threading.Event()
|
||||
original_reserve = main._idempotency_ledger.reserve
|
||||
|
||||
def slow_reserve(key, fingerprint):
|
||||
started.set()
|
||||
time.sleep(0.2)
|
||||
return original_reserve(key, fingerprint)
|
||||
|
||||
monkeypatch.setattr(main._idempotency_ledger, "reserve", slow_reserve)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
authored = asyncio.create_task(client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/248/comments",
|
||||
json={"body": "Keep the dashboard responsive"},
|
||||
headers={"Idempotency-Key": "contention-248"},
|
||||
))
|
||||
assert await asyncio.to_thread(started.wait, 1)
|
||||
health = await client.get("/healthz")
|
||||
health_elapsed = asyncio.get_running_loop().time() - started_at
|
||||
authored_response = await authored
|
||||
|
||||
assert health.status_code == 200
|
||||
assert health_elapsed < 0.15
|
||||
assert authored_response.status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_locked_ledger_returns_bounded_retryable_response(monkeypatch, tmp_path):
|
||||
upstream_calls = 0
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
nonlocal upstream_calls
|
||||
upstream_calls += 1
|
||||
return {"id": 248, "body": body}
|
||||
|
||||
database = tmp_path / "contended.sqlite3"
|
||||
ledger = IdempotencyLedger(
|
||||
database,
|
||||
ttl_seconds=600,
|
||||
max_entries=256,
|
||||
lock_timeout_seconds=0.05,
|
||||
)
|
||||
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
blocker = sqlite3.connect(database)
|
||||
blocker.execute("BEGIN IMMEDIATE")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/248/comments",
|
||||
json={"body": "Retry without freezing"},
|
||||
headers={"Idempotency-Key": "locked-ledger-248"},
|
||||
)
|
||||
elapsed = asyncio.get_running_loop().time() - started_at
|
||||
finally:
|
||||
blocker.rollback()
|
||||
blocker.close()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["Retry-After"] == "1"
|
||||
assert response.json()["detail"] == "Authored action queue is busy; please retry"
|
||||
assert elapsed < 0.5
|
||||
assert upstream_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_slow_ledger_completion_does_not_block_health_requests(monkeypatch):
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
return {"id": 249, "body": body}
|
||||
|
||||
started = threading.Event()
|
||||
original_complete = main._idempotency_ledger.complete
|
||||
|
||||
def slow_complete(key, response):
|
||||
started.set()
|
||||
time.sleep(0.2)
|
||||
return original_complete(key, response)
|
||||
|
||||
monkeypatch.setattr(main._idempotency_ledger, "complete", slow_complete)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
authored = asyncio.create_task(client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/248/comments",
|
||||
json={"body": "Persist without freezing"},
|
||||
headers={"Idempotency-Key": "slow-completion-248"},
|
||||
))
|
||||
assert await asyncio.to_thread(started.wait, 1)
|
||||
health = await client.get("/healthz")
|
||||
health_elapsed = asyncio.get_running_loop().time() - started_at
|
||||
authored_response = await authored
|
||||
|
||||
assert health.status_code == 200
|
||||
assert health_elapsed < 0.15
|
||||
assert authored_response.status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_locked_completion_fails_closed_with_retry_guidance(monkeypatch, tmp_path):
|
||||
database = tmp_path / "completion-contention.sqlite3"
|
||||
ledger = IdempotencyLedger(
|
||||
database, ttl_seconds=600, max_entries=256, lock_timeout_seconds=0.05
|
||||
)
|
||||
blocker = None
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
nonlocal blocker
|
||||
blocker = sqlite3.connect(database)
|
||||
blocker.execute("BEGIN IMMEDIATE")
|
||||
return {"id": 250, "body": body}
|
||||
|
||||
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/248/comments",
|
||||
json={"body": "The upstream mutation may already exist"},
|
||||
headers={"Idempotency-Key": "locked-completion-248"},
|
||||
)
|
||||
finally:
|
||||
if blocker is not None:
|
||||
blocker.rollback()
|
||||
blocker.close()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["Retry-After"] == "5"
|
||||
assert response.json()["detail"] == (
|
||||
"This action may have completed; verify its result before retrying"
|
||||
)
|
||||
assert ledger.reserve(
|
||||
"locked-completion-248",
|
||||
("issue-comment", "stackchain/api", 248, "The upstream mutation may already exist"),
|
||||
).state == "pending"
|
||||
@@ -0,0 +1,302 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OUTBOX = Path(__file__).parents[1] / "frontend" / "authored-outbox.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_authored_outbox_quarantines_legacy_and_cross_account_messages():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify({{version:1,items:[
|
||||
{{id:'legacy',operationId:'legacy',kind:'issue-comment',repository:'o/r',number:1,body:'Old',status:'queued'}}
|
||||
]}})]]);
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const calls = [];
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy',
|
||||
fetchJson:async url => {{calls.push(url); return {{id:1}};}},
|
||||
}});
|
||||
const bound = outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:2,body:'Mine',operationId:'mine'}});
|
||||
(async()=>{{
|
||||
const wrong = await outbox.flush('alexander');
|
||||
const right = await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{bound,wrong,right,calls,remaining:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["bound"]["ownerLogin"] == "timmy"
|
||||
assert output["wrong"]["blocked"] == 2
|
||||
assert len(output["right"]["confirmed"]) == 1
|
||||
assert [item["id"] for item in output["remaining"]] == ["legacy"]
|
||||
assert len(output["calls"]) == 1
|
||||
|
||||
|
||||
def test_authored_outbox_persists_each_message_kind_and_flushes_sequentially():
|
||||
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 calls = [];
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', now:() => 1234,
|
||||
fetchJson:async (url, options) => {{
|
||||
calls.push({{url,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
return {{id:calls.length}};
|
||||
}},
|
||||
}});
|
||||
outbox.enqueue({{kind:'issue-comment',repository:'stackchain/api',number:7,body:'Issue note',operationId:'issue-op'}});
|
||||
outbox.enqueue({{kind:'pull-comment',repository:'stackchain/web',number:8,body:'PR note',operationId:'pull-op'}});
|
||||
outbox.enqueue({{kind:'update-reply',notificationId:9,body:'Update note',operationId:'update-op'}});
|
||||
const persisted = createAuthoredOutbox({{storage,fetchJson:outbox.fetchJson}}).list();
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persisted,calls,result,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["kind"] for item in output["persisted"]] == [
|
||||
"issue-comment", "pull-comment", "update-reply"
|
||||
]
|
||||
assert [item["operationId"] for item in output["persisted"]] == ["issue-op", "pull-op", "update-op"]
|
||||
assert output["calls"] == [
|
||||
{"url": "api/v1/repos/stackchain/api/issues/7/comments", "key": "issue-op", "body": {"body": "Issue note"}},
|
||||
{"url": "api/v1/repos/stackchain/web/pulls/8/comments", "key": "pull-op", "body": {"body": "PR note"}},
|
||||
{"url": "api/v1/notifications/9/reply", "key": "update-op", "body": {"body": "Update note"}},
|
||||
]
|
||||
assert len(output["result"]["confirmed"]) == 3
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
|
||||
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)}};
|
||||
let phase = 'permanent';
|
||||
const calls = [];
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage,
|
||||
getOwnerLogin:()=>'timmy',
|
||||
fetchJson:async (_url, options) => {{
|
||||
const body = JSON.parse(options.body).body; calls.push(body);
|
||||
if (body === 'Bad' && phase === 'permanent') {{ const e = new Error('Reply rejected'); e.status=422; throw e; }}
|
||||
if (body === 'Later' && phase === 'transient') {{ const e = new Error('Offline'); e.status=503; throw e; }}
|
||||
return {{id:calls.length}};
|
||||
}},
|
||||
}});
|
||||
const bad=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Bad',operationId:'a'}});
|
||||
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Good',operationId:'b'}});
|
||||
outbox.flush('timmy').then(async first => {{
|
||||
phase='transient';
|
||||
outbox.enqueue({{kind:'update-reply',notificationId:3,body:'Later',operationId:'c'}});
|
||||
const second=await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{first,second,calls,remaining:outbox.list(),badId:bad.id}}));
|
||||
}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == ["Bad", "Good", "Later"]
|
||||
assert len(output["first"]["confirmed"]) == 1
|
||||
assert output["remaining"][0]["id"] == output["badId"]
|
||||
assert output["remaining"][0]["status"] == "attention"
|
||||
assert output["remaining"][0]["error"] == "Reply rejected"
|
||||
assert output["remaining"][1]["status"] == "queued"
|
||||
|
||||
|
||||
def test_authored_outbox_requires_deliberate_rekey_after_uncertain_delivery():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); let sequence=0; const calls=[];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'verified-' + (++sequence),
|
||||
fetchJson:async (_url, options) => {{
|
||||
const key=options.headers['Idempotency-Key']; calls.push(key);
|
||||
if (key === 'uncertain-key') {{ const e=new Error('Verify it was not posted before retrying.'); e.status=422; e.code='delivery_uncertain'; throw e; }}
|
||||
return {{id:calls.length}};
|
||||
}},
|
||||
}});
|
||||
const uncertain=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Check',operationId:'uncertain-key'}});
|
||||
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Continue',operationId:'other-key'}});
|
||||
(async()=>{{
|
||||
const first=await outbox.flush('timmy');
|
||||
const attention=outbox.list()[0];
|
||||
const retried=await outbox.retry(uncertain.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{first,attention,retried,calls,remaining:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == ["uncertain-key", "other-key", "verified-1"]
|
||||
assert len(output["first"]["confirmed"]) == 1
|
||||
assert output["attention"]["status"] == "attention"
|
||||
assert output["attention"]["deliveryState"] == "uncertain"
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_retry_is_single_flight_and_edit_rotates_identity():
|
||||
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)}};
|
||||
let release; const gate=new Promise(resolve=>release=resolve); let calls=0; let sequence=0;
|
||||
const outbox=createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'new-' + (++sequence),
|
||||
fetchJson:async()=>{{calls++; await gate; return {{id:1}};}},
|
||||
}});
|
||||
const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Old',operationId:'old'}});
|
||||
const edited=outbox.update(item.id,{{body:'New'}});
|
||||
const flush=outbox.flush('timmy'); const retry=outbox.retry(item.id, 'timmy'); release();
|
||||
Promise.all([flush,retry]).then(results=>process.stdout.write(JSON.stringify({{edited,calls,results,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["edited"]["body"] == "New"
|
||||
assert output["edited"]["operationId"] == "new-1"
|
||||
assert output["calls"] == 1
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_deduplicates_repeated_queue_attempts_by_operation_id():
|
||||
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'}});
|
||||
const first=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
|
||||
const second=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
|
||||
process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == output["second"]
|
||||
assert len(output["items"]) == 1
|
||||
|
||||
|
||||
def test_authored_outbox_mirrors_to_background_sync_and_reconciles_worker_results():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const mirrors=[]; let requested=0;
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const backgroundSync = {{
|
||||
reconcile: async (items, lane) => mirrors.push({{items:items.map(item=>({{...item}})),lane}}),
|
||||
requestSync: async () => {{requested += 1;}},
|
||||
}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync}});
|
||||
const sent=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Sent',operationId:'sent'}});
|
||||
const failed=outbox.enqueue({{kind:'update-reply',notificationId:2,body:'Fix me',operationId:'failed'}});
|
||||
setTimeout(() => {{
|
||||
const reconciled=outbox.reconcileBackground([
|
||||
{{...sent,status:'sent'}},
|
||||
{{...failed,status:'attention',error:'Reply rejected'}},
|
||||
]);
|
||||
setTimeout(() => process.stdout.write(JSON.stringify({{mirrors,requested,reconciled}})), 0);
|
||||
}}, 0);
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["mirrors"][0]["lane"] == "authored"
|
||||
assert [item["operationId"] for item in output["mirrors"][-1]["items"]] == ["failed"]
|
||||
assert output["requested"] >= 1
|
||||
assert [(item["operationId"], item["status"]) for item in output["reconciled"]] == [
|
||||
("failed", "attention")
|
||||
]
|
||||
|
||||
|
||||
def test_authored_outbox_reconciles_uncertain_worker_delivery_state():
|
||||
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'}});
|
||||
const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:7,body:'Maybe',operationId:'maybe'}});
|
||||
const reconciled=outbox.reconcileBackground([{{...item,status:'attention',deliveryState:'uncertain',error:'Verify first'}}]);
|
||||
process.stdout.write(JSON.stringify(reconciled[0]));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["status"] == "attention"
|
||||
assert output["deliveryState"] == "uncertain"
|
||||
|
||||
|
||||
def test_authored_outbox_waits_for_durable_background_admission():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let release; const gate = new Promise(resolve => release = resolve); const events = [];
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
backgroundSync: {{
|
||||
reconcile: async (_items, lane) => {{ events.push('mirror:' + lane); await gate; events.push('committed'); }},
|
||||
requestSync: async () => events.push('registered'),
|
||||
}},
|
||||
}});
|
||||
const admission = outbox.enqueueDurably({{
|
||||
kind:'issue-comment',repository:'o/r',number:1,body:'Do not lose',operationId:'message-1'
|
||||
}}).then(result => {{ events.push('confirmed'); return result; }});
|
||||
Promise.resolve().then(async () => {{
|
||||
const pending = events.slice(); release(); const result = await admission;
|
||||
process.stdout.write(JSON.stringify({{pending,events,result,items:outbox.list()}}));
|
||||
}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["pending"] == ["mirror:authored"]
|
||||
assert output["events"] == ["mirror:authored", "committed", "registered", "confirmed"]
|
||||
assert output["result"]["durability"] == "background"
|
||||
assert output["items"][0]["operationId"] == "message-1"
|
||||
|
||||
|
||||
def test_authored_outbox_foreground_send_uses_atomic_background_delivery():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); let backgroundCalls=0; let directCalls=0;
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const backgroundSync = {{
|
||||
reconcile: async()=>{{}}, requestSync:async()=>{{}},
|
||||
send: async (item, owner) => {{backgroundCalls += 1; return {{message:{{id:3,owner}}}};}},
|
||||
}};
|
||||
const outbox=createAuthoredOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',backgroundSync,
|
||||
fetchJson:async()=>{{directCalls += 1; return {{id:4}};}},
|
||||
}});
|
||||
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Review',operationId:'once'}});
|
||||
(async()=>{{
|
||||
const result=await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{backgroundCalls,directCalls,result,remaining:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["backgroundCalls"] == 1
|
||||
assert output["directCalls"] == 0
|
||||
assert output["result"]["confirmed"] == [{"id": 3, "owner": "timmy"}]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/authored-outbox.js"></script>' in html
|
||||
assert "const authoredOutbox = createAuthoredOutbox({" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
||||
assert "flushAuthoredOutbox();" in html
|
||||
assert "await authoredOutbox.enqueueDurably" in html
|
||||
assert "Saving for background delivery…" in html
|
||||
assert "Saved for next launch; background delivery unavailable." in html
|
||||
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
|
||||
assert "authoredOutbox.discard(item.outbox_id)" in html
|
||||
assert "if (result?.queued)" in html
|
||||
assert "backgroundSync: backgroundIssueSync" in html
|
||||
assert "authoredOutbox.reconcileBackground(records)" in html
|
||||
@@ -0,0 +1,794 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
SYNC = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
completed = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, check=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_matching_issue_once_with_original_idempotency_key():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const item = {{
|
||||
id:'capture-1', operationId:'capture-1', ownerLogin:'timmy', status:'queued',
|
||||
repository:'stackchain/api', title:'Offline report', body:'Full context',
|
||||
labelIds:[3], milestoneId:4, dueDate:'2026-08-09', queuedAt:100,
|
||||
}};
|
||||
const state = {{item, completed:[], released:[], failed:[], calls:[]}};
|
||||
const store = {{
|
||||
claimNext: async owner => state.item && state.item.ownerLogin === owner ? {{...state.item}} : null,
|
||||
complete: async id => {{ state.completed.push(id); state.item = null; }},
|
||||
release: async id => state.released.push(id),
|
||||
fail: async (id, message) => state.failed.push({{id,message}}),
|
||||
}};
|
||||
const fetchJson = async (url, options = {{}}) => {{
|
||||
state.calls.push({{url,options}});
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
return {{repository:'stackchain/api',number:251,title:'Offline report'}};
|
||||
}};
|
||||
(async () => {{
|
||||
const sync = createBackgroundIssueSync({{store,fetchJson}});
|
||||
const result = await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{state,result}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["result"]["confirmed"][0]["number"] == 251
|
||||
assert output["state"]["completed"] == ["capture-1"]
|
||||
assert output["state"]["released"] == []
|
||||
assert output["state"]["failed"] == []
|
||||
assert output["state"]["calls"][0]["url"] == "api/v1/background-identity"
|
||||
mutation = output["state"]["calls"][1]
|
||||
assert mutation["url"] == "api/v1/repos/stackchain/api/issues"
|
||||
assert mutation["options"]["headers"]["Idempotency-Key"] == "capture-1"
|
||||
assert json.loads(mutation["options"]["body"]) == {
|
||||
"title": "Offline report",
|
||||
"body": "Full context",
|
||||
"label_ids": [3],
|
||||
"milestone_id": 4,
|
||||
"due_date": "2026-08-09T23:59:59Z",
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const queued = [
|
||||
{{id:'capture-1',operationId:'capture-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Secret title',body:'Secret body',labelIds:[]}},
|
||||
{{id:'reply-1',operationId:'reply-1',ownerLogin:'timmy',status:'queued',kind:'pull-comment',repository:'stackchain/web',number:8,body:'Secret reply'}},
|
||||
{{id:'bad-1',operationId:'bad-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Bad',body:'Secret failure',labelIds:[]}},
|
||||
];
|
||||
const store = {{
|
||||
claimNext: async () => queued.shift() || null,
|
||||
complete: async () => {{}}, release: async () => {{}}, fail: async () => {{}},
|
||||
countBlocked: async () => 0,
|
||||
}};
|
||||
let issueMutations = 0;
|
||||
const fetchJson = async (url) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
if (url.endsWith('/issues') && issueMutations++ === 0) return {{number:44,repository:'stackchain/api',title:'Secret title'}};
|
||||
if (url.includes('/pulls/8/comments')) return {{id:91,body:'Secret reply'}};
|
||||
const error = new Error('Sensitive validation detail'); error.status = 422; throw error;
|
||||
}};
|
||||
(async () => {{
|
||||
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["login"] == "timmy"
|
||||
assert output["receipts"] == [
|
||||
{
|
||||
"id": "capture-1",
|
||||
"status": "confirmed",
|
||||
"kind": "issue",
|
||||
"route": "#/my-work/issue/stackchain/api/44",
|
||||
},
|
||||
{
|
||||
"id": "reply-1",
|
||||
"status": "confirmed",
|
||||
"kind": "message",
|
||||
"route": "#/my-work/pull/stackchain/web/8",
|
||||
},
|
||||
{
|
||||
"id": "bad-1",
|
||||
"status": "attention",
|
||||
"kind": "issue",
|
||||
"route": "#/my-work/drafts",
|
||||
},
|
||||
]
|
||||
serialized = json.dumps(output["receipts"])
|
||||
assert "Secret" not in serialized
|
||||
assert "Sensitive" not in serialized
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("item", "expected_url"),
|
||||
[
|
||||
(
|
||||
{"kind": "issue-comment", "repository": "stackchain/api", "number": 7},
|
||||
"api/v1/repos/stackchain/api/issues/7/comments",
|
||||
),
|
||||
(
|
||||
{"kind": "pull-comment", "repository": "stackchain/web", "number": 8},
|
||||
"api/v1/repos/stackchain/web/pulls/8/comments",
|
||||
),
|
||||
(
|
||||
{"kind": "update-reply", "notificationId": 9},
|
||||
"api/v1/notifications/9/reply",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_closed_app_sync_delivers_each_authored_message_kind(item, expected_url):
|
||||
authored = {
|
||||
"id": "message-op",
|
||||
"operationId": "message-op",
|
||||
"ownerLogin": "timmy",
|
||||
"status": "queued",
|
||||
"body": "Ship this reply",
|
||||
**item,
|
||||
}
|
||||
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}});
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
return {{id:42}};
|
||||
}};
|
||||
(async () => {{
|
||||
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify({{calls,result}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
mutation = output["calls"][1]
|
||||
assert mutation["url"] == expected_url
|
||||
assert mutation["options"]["headers"]["Idempotency-Key"] == "message-op"
|
||||
assert json.loads(mutation["options"]["body"]) == {"body": "Ship this reply"}
|
||||
assert output["result"]["confirmed"] == [{"id": 42}]
|
||||
|
||||
|
||||
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
|
||||
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:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
|
||||
await store.reconcile([{{id:'message',kind:'issue-comment',ownerLogin:'timmy',status:'queued'}}], 'authored');
|
||||
await store.reconcile([], 'issue');
|
||||
process.stdout.write(JSON.stringify(await store.snapshot()));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == [
|
||||
{"id": "message", "kind": "issue-comment", "ownerLogin": "timmy", "status": "queued", "outboxLane": "authored"}
|
||||
]
|
||||
|
||||
|
||||
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const state={{opens:0,closes:0}};
|
||||
const indexedDB={{lastDb:null,open:()=>{{
|
||||
state.opens += 1;
|
||||
const request={{result:null,onsuccess:null,onerror:null,onupgradeneeded:null}};
|
||||
const db={{
|
||||
objectStoreNames:{{contains:()=>true}},
|
||||
close:()=>{{state.closes += 1;}},
|
||||
transaction:()=>{{
|
||||
const tx={{oncomplete:null,onerror:null,onabort:null,error:null,objectStore:()=>({{
|
||||
getAll:()=>{{const get={{onsuccess:null,onerror:null,result:[]}};queueMicrotask(()=>get.onsuccess?.());return get;}},
|
||||
}})}};
|
||||
setTimeout(()=>tx.oncomplete?.(),0);
|
||||
return tx;
|
||||
}},
|
||||
}};
|
||||
indexedDB.lastDb=db;request.result=db;queueMicrotask(()=>request.onsuccess?.());return request;
|
||||
}}}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{indexedDB}});
|
||||
await store.snapshot();
|
||||
indexedDB.lastDb.onversionchange?.();
|
||||
await store.snapshot();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"opens": 2, "closes": 1}
|
||||
|
||||
|
||||
def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox():
|
||||
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.setReceiptPreference('timmy', true);
|
||||
await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
|
||||
const result={{
|
||||
timmy:await store.getReceiptPreference('timmy'),
|
||||
alexander:await store.getReceiptPreference('alexander'),
|
||||
snapshot:await store.snapshot(),
|
||||
blocked:await store.countBlocked('alexander'),
|
||||
}};
|
||||
await store.setReceiptPreference('timmy', false);
|
||||
result.disabled=await store.getReceiptPreference('timmy');
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"timmy": True,
|
||||
"alexander": False,
|
||||
"snapshot": [
|
||||
{
|
||||
"id": "issue",
|
||||
"ownerLogin": "timmy",
|
||||
"status": "queued",
|
||||
"outboxLane": "issue",
|
||||
}
|
||||
],
|
||||
"blocked": 1,
|
||||
"disabled": False,
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_leaves_another_accounts_issue_queued():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const state = {{owners:[],mutations:0}};
|
||||
const store = {{
|
||||
claimNext: async owner => {{ state.owners.push(owner); return null; }},
|
||||
countBlocked: async owner => owner === 'alexander' ? 1 : 0,
|
||||
complete: async () => {{}},
|
||||
}};
|
||||
const fetchJson = async url => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'alexander'}};
|
||||
state.mutations += 1;
|
||||
}};
|
||||
(async () => {{
|
||||
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify({{state,result}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"] == {"owners": ["alexander"], "mutations": 0}
|
||||
assert output["result"]["confirmed"] == []
|
||||
assert output["result"]["blocked"] == 1
|
||||
|
||||
|
||||
def test_session_expiry_during_delivery_releases_claim_without_attention():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const queued = [
|
||||
{{id:'capture-auth',operationId:'stable-key',ownerLogin:'timmy',repository:'o/r',title:'Keep me',body:'',labelIds:[]}},
|
||||
{{id:'capture-later',operationId:'later-key',ownerLogin:'timmy',repository:'o/r',title:'Do not try yet',body:'',labelIds:[]}},
|
||||
];
|
||||
const state = {{released:[],failed:[],mutationKeys:[]}};
|
||||
const store = {{
|
||||
claimNext: async () => queued.shift() || null,
|
||||
release: async id => state.released.push(id),
|
||||
fail: async (id,message) => state.failed.push({{id,message}}),
|
||||
countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
state.mutationKeys.push(options.headers['Idempotency-Key']);
|
||||
const error = new Error('Authentication required'); error.status = 401; throw error;
|
||||
}};
|
||||
(async () => {{
|
||||
let error = null;
|
||||
try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }}
|
||||
catch (caught) {{ error = {{message:caught.message,status:caught.status}}; }}
|
||||
process.stdout.write(JSON.stringify({{state,error}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"state": {
|
||||
"released": ["capture-auth"],
|
||||
"failed": [],
|
||||
"mutationKeys": ["stable-key"],
|
||||
},
|
||||
"error": {"message": "Authentication required", "status": 401},
|
||||
}
|
||||
|
||||
|
||||
def test_transient_delivery_failure_releases_claim_and_requests_another_sync():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item = {{id:'capture-2',operationId:'capture-2',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}};
|
||||
const state = {{released:[],completed:[]}};
|
||||
const store = {{
|
||||
claimNext: async () => item ? (item = null, {{id:'capture-2',operationId:'capture-2',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}}) : null,
|
||||
release: async id => state.released.push(id),
|
||||
complete: async id => state.completed.push(id),
|
||||
}};
|
||||
const fetchJson = async url => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
const error = new Error('Gitea unavailable'); error.status = 503; throw error;
|
||||
}};
|
||||
(async () => {{
|
||||
let error = null;
|
||||
try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }}
|
||||
catch (caught) {{ error = caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{state,error}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["released"] == ["capture-2"]
|
||||
assert output["state"]["completed"] == []
|
||||
assert output["error"] == "Gitea unavailable"
|
||||
|
||||
|
||||
def test_stalled_delivery_times_out_releases_claim_and_retries_with_same_idempotency_key():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const item = {{id:'capture-timeout',operationId:'stable-operation',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}};
|
||||
const state = {{queued:true,released:[],completed:[],keys:[],attempts:0}};
|
||||
const store = {{
|
||||
claimNext: async () => state.queued ? (state.queued=false, {{...item}}) : null,
|
||||
complete: async id => state.completed.push(id),
|
||||
release: async id => {{state.released.push(id); state.queued=true;}},
|
||||
fail: async () => {{}}, countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
state.keys.push(options.headers['Idempotency-Key']);
|
||||
state.attempts += 1;
|
||||
if (state.attempts === 1) return new Promise(() => {{}});
|
||||
return {{repository:'o/r',number:9}};
|
||||
}};
|
||||
(async () => {{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson,requestTimeoutMs:10}});
|
||||
let firstError='';
|
||||
try {{ await sync.flush(); }} catch (error) {{ firstError=error.message; }}
|
||||
const second=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{state,firstError,second}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["firstError"] == "Background request timed out."
|
||||
assert output["state"]["released"] == ["capture-timeout"]
|
||||
assert output["state"]["completed"] == ["capture-timeout"]
|
||||
assert output["state"]["keys"] == ["stable-operation", "stable-operation"]
|
||||
assert output["second"]["confirmed"][0]["number"] == 9
|
||||
|
||||
|
||||
def test_purge_cancels_stalled_delivery_without_reclaiming_it():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const items=[
|
||||
{{id:'capture-purge-1',operationId:'capture-purge-1',ownerLogin:'timmy',repository:'o/r',title:'Private',body:'Draft',labelIds:[]}},
|
||||
{{id:'capture-purge-2',operationId:'capture-purge-2',ownerLogin:'timmy',repository:'o/r',title:'Private 2',body:'Draft',labelIds:[]}},
|
||||
];
|
||||
const state={{released:[],closed:0,mutations:0}};
|
||||
let mutationStarted;
|
||||
const started=new Promise(resolve => mutationStarted=resolve);
|
||||
const store={{
|
||||
claimBatch:async()=>items.map(item=>({{...item}})),
|
||||
complete:async()=>{{}},
|
||||
release:async id=>state.released.push(id),
|
||||
fail:async()=>{{}}, countBlocked:async()=>0, close:async()=>{{state.closed+=1;}},
|
||||
}};
|
||||
const fetchJson=async url=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
state.mutations+=1;mutationStarted();return new Promise(()=>{{}});
|
||||
}};
|
||||
(async()=>{{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson,maxConcurrency:1,requestTimeoutMs:1000}});
|
||||
sync.flush().catch(()=>{{}});
|
||||
await started;
|
||||
const outcome=await Promise.race([
|
||||
sync.purge().then(()=> 'purged'),
|
||||
new Promise(resolve=>setTimeout(()=>resolve('blocked'),80)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({{state,outcome}}));
|
||||
process.exit(0);
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["outcome"] == "purged"
|
||||
assert output["state"] == {
|
||||
"released": ["capture-purge-1", "capture-purge-2"],
|
||||
"closed": 1,
|
||||
"mutations": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_bounded_flush_delivers_healthy_records_after_transient_failure_without_same_run_retry():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const items = [
|
||||
{{id:'issue-1',operationId:'issue-1',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'One',body:'',labelIds:[]}},
|
||||
{{id:'message-1',operationId:'message-1',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:1,body:'Reply'}},
|
||||
{{id:'issue-bad',operationId:'issue-bad',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Bad',body:'',labelIds:[]}},
|
||||
{{id:'message-2',operationId:'message-2',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:2,body:'Reply'}},
|
||||
{{id:'issue-2',operationId:'issue-2',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Two',body:'',labelIds:[]}},
|
||||
];
|
||||
const state = {{active:0,maxActive:0,attempted:[],completed:[],released:[],batches:0}};
|
||||
const store = {{
|
||||
claimBatch: async (owner, limit) => items.slice(0, limit),
|
||||
complete: async id => state.completed.push(id),
|
||||
release: async id => state.released.push(id),
|
||||
fail: async () => {{}}, countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
const key = options.headers['Idempotency-Key'];
|
||||
state.attempted.push(key); state.active += 1;
|
||||
state.maxActive = Math.max(state.maxActive, state.active);
|
||||
await new Promise(resolve => setTimeout(resolve, key === 'issue-bad' ? 5 : 20));
|
||||
state.active -= 1;
|
||||
if (key === 'issue-bad') {{ const error=new Error('Temporary outage'); error.status=503; throw error; }}
|
||||
return {{id:key,number:7}};
|
||||
}};
|
||||
(async () => {{
|
||||
let error;
|
||||
try {{ await createBackgroundIssueSync({{
|
||||
store,fetchJson,maxConcurrency:3,batchSize:10,
|
||||
batch: async work => {{ state.batches += 1; return work(); }},
|
||||
}}).flush(); }}
|
||||
catch (caught) {{ error=caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{state,error}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["maxActive"] == 3
|
||||
assert output["state"]["batches"] == 1
|
||||
assert sorted(output["state"]["attempted"]) == [
|
||||
"issue-1", "issue-2", "issue-bad", "message-1", "message-2"
|
||||
]
|
||||
assert len(output["state"]["attempted"]) == len(set(output["state"]["attempted"]))
|
||||
assert sorted(output["state"]["completed"]) == [
|
||||
"issue-1", "issue-2", "message-1", "message-2"
|
||||
]
|
||||
assert output["state"]["released"] == ["issue-bad"]
|
||||
assert output["error"] == "Temporary outage"
|
||||
|
||||
|
||||
def test_bounded_flush_stops_admission_and_releases_unstarted_claims_on_auth_loss():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const items = Array.from({{length:5}}, (_, index) => ({{
|
||||
id:'item-'+index,operationId:'item-'+index,ownerLogin:'timmy',outboxLane:'issue',
|
||||
repository:'o/r',title:'Item',body:'',labelIds:[],
|
||||
}}));
|
||||
const state = {{attempted:[],released:[],completed:[]}};
|
||||
const store = {{
|
||||
claimBatch: async () => items,
|
||||
complete: async id => state.completed.push(id),
|
||||
release: async id => state.released.push(id), fail:async()=>{{}}, countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
const key=options.headers['Idempotency-Key']; state.attempted.push(key);
|
||||
if (key === 'item-0') {{ const error=new Error('Authentication required'); error.status=401; throw error; }}
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
return {{number:1}};
|
||||
}};
|
||||
(async()=>{{
|
||||
let error;
|
||||
try {{ await createBackgroundIssueSync({{store,fetchJson,maxConcurrency:2}}).flush(); }}
|
||||
catch (caught) {{ error={{message:caught.message,status:caught.status}}; }}
|
||||
process.stdout.write(JSON.stringify({{state,error}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["attempted"] == ["item-0", "item-1"]
|
||||
assert sorted(output["state"]["released"]) == ["item-0", "item-2", "item-3", "item-4"]
|
||||
assert output["state"]["completed"] == ["item-1"]
|
||||
assert output["error"] == {"message": "Authentication required", "status": 401}
|
||||
|
||||
|
||||
def test_issue_store_claims_one_finite_batch_fairly_across_outbox_lanes():
|
||||
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,now:()=>100,claimMs:5000}});
|
||||
await store.reconcile([
|
||||
{{id:'issue-1',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'issue-2',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'issue-3',ownerLogin:'timmy',status:'queued'}},
|
||||
], 'issue');
|
||||
await store.reconcile([
|
||||
{{id:'message-1',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'message-2',ownerLogin:'timmy',status:'queued'}},
|
||||
], 'authored');
|
||||
const claimed=await store.claimBatch('timmy', 4);
|
||||
process.stdout.write(JSON.stringify({{claimed,snapshot:await store.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["id"] for item in output["claimed"]] == [
|
||||
"issue-1", "message-1", "issue-2", "message-2"
|
||||
]
|
||||
assert all(item["status"] == "sending" for item in output["claimed"])
|
||||
assert all(item["claimUntil"] == 5100 for item in output["claimed"])
|
||||
assert next(item for item in output["snapshot"] if item["id"] == "issue-3")["status"] == "queued"
|
||||
|
||||
|
||||
def test_permanent_delivery_failure_marks_issue_for_foreground_attention():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let claimed = false;
|
||||
const state = {{failed:[],released:[]}};
|
||||
const store = {{
|
||||
claimNext: async () => claimed ? null : (claimed = true, {{id:'capture-3',operationId:'capture-3',ownerLogin:'timmy',repository:'o/r',title:'Invalid',body:'',labelIds:[]}}),
|
||||
fail: async (id, message) => state.failed.push({{id,message}}),
|
||||
release: async id => state.released.push(id),
|
||||
countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async url => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
const error = new Error('Title is invalid'); error.status = 422; throw error;
|
||||
}};
|
||||
(async () => {{
|
||||
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify({{state,result}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["failed"] == [
|
||||
{"id": "capture-3", "message": "Title is invalid"}
|
||||
]
|
||||
assert output["state"]["released"] == []
|
||||
assert output["result"]["attention"] == 1
|
||||
|
||||
|
||||
def test_uncertain_background_delivery_preserves_verification_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let claimed=false; const state={{failed:[]}};
|
||||
const store={{
|
||||
claimNext:async()=>claimed?null:(claimed=true,{{id:'maybe',operationId:'maybe',ownerLogin:'timmy',repository:'o/r',title:'Maybe',labelIds:[]}}),
|
||||
fail:async(id,message,deliveryState)=>state.failed.push({{id,message,deliveryState}}),
|
||||
release:async()=>{{}}, countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async url=>{{
|
||||
if(url==='api/v1/background-identity') return {{login:'timmy'}};
|
||||
const error=new Error('Verify first'); error.status=422; error.code='delivery_uncertain'; throw error;
|
||||
}};
|
||||
(async()=>{{
|
||||
await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["failed"] == [
|
||||
{"id": "maybe", "message": "Verify first", "deliveryState": "uncertain"}
|
||||
]
|
||||
|
||||
|
||||
def test_issue_store_atomically_grants_one_delivery_claim():
|
||||
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,now:()=>1000,claimMs:5000}});
|
||||
await store.reconcile([{{id:'same',operationId:'same',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Once',labelIds:[]}}]);
|
||||
const claims = await Promise.all([store.claimNext('timmy'),store.claimNext('timmy')]);
|
||||
process.stdout.write(JSON.stringify({{claims,records:[...records.values()]}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert sum(claim is not None for claim in output["claims"]) == 1
|
||||
assert output["records"][0]["status"] == "sending"
|
||||
assert output["records"][0]["claimUntil"] == 6000
|
||||
|
||||
|
||||
def test_foreground_and_worker_race_still_posts_one_issue():
|
||||
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;
|
||||
}};
|
||||
const item={{id:'race',operationId:'race',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Exactly once',body:'',labelIds:[]}};
|
||||
let mutations=0;
|
||||
const fetchJson=async url=>{{
|
||||
if(url==='api/v1/background-identity') return {{login:'timmy'}};
|
||||
mutations+=1; return {{number:77}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
await store.reconcile([item]);
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson}});
|
||||
const [foreground,worker]=await Promise.all([sync.send(item,'timmy'),sync.flush()]);
|
||||
process.stdout.write(JSON.stringify({{foreground,worker,mutations,remaining:[...records.values()]}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["mutations"] == 1
|
||||
assert len(output["remaining"]) == 1
|
||||
assert output["remaining"][0]["status"] == "sent"
|
||||
delivered = int(bool(output["foreground"].get("issue"))) + len(
|
||||
output["worker"]["confirmed"]
|
||||
)
|
||||
assert delivered == 1
|
||||
|
||||
|
||||
def test_foreground_send_upserts_before_claim_when_mirror_is_still_pending():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let record = null; const state={{upserts:0,mutations:0}};
|
||||
const store={{
|
||||
upsert:async item=>{{state.upserts+=1;record={{...item}};}},
|
||||
claim:async(id,owner)=>record?.id===id&&record?.ownerLogin===owner?{{...record}}:null,
|
||||
complete:async()=>{{record=null;}}, release:async()=>{{}}, fail:async()=>{{}},
|
||||
}};
|
||||
const fetchJson=async()=>{{state.mutations+=1;return {{number:88}};}};
|
||||
(async()=>{{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson}});
|
||||
const result=await sync.send({{id:'early',operationId:'early',ownerLogin:'timmy',repository:'o/r',title:'Fast',body:'',labelIds:[]}},'timmy');
|
||||
process.stdout.write(JSON.stringify({{state,result,record}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"] == {"upserts": 1, "mutations": 1}
|
||||
assert output["result"]["issue"]["number"] == 88
|
||||
assert output["record"] is None
|
||||
|
||||
|
||||
def test_completed_delivery_leaves_non_replayable_tombstone_for_next_page():
|
||||
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,now:()=>100}});
|
||||
await store.reconcile([{{id:'done',ownerLogin:'timmy',status:'queued'}}]);
|
||||
await store.claimNext('timmy');
|
||||
await store.complete('done');
|
||||
const replay=await store.claimNext('timmy');
|
||||
const snapshot=await store.snapshot();
|
||||
process.stdout.write(JSON.stringify({{replay,snapshot}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["replay"] is None
|
||||
assert output["snapshot"] == [
|
||||
{"id": "done", "ownerLogin": "timmy", "status": "sent", "outboxLane": "issue", "claimUntil": 0}
|
||||
]
|
||||
|
||||
|
||||
def test_user_edit_resets_worker_attention_item_for_retry():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map([['edit',{{id:'edit',ownerLogin:'timmy',status:'attention',title:'Bad',error:'Invalid'}}]]);
|
||||
let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}});
|
||||
await store.reconcile([{{id:'edit',ownerLogin:'timmy',status:'queued',title:'Fixed'}}]);
|
||||
const claimed=await store.claimNext('timmy');
|
||||
process.stdout.write(JSON.stringify(claimed));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["title"] == "Fixed"
|
||||
assert output["status"] == "sending"
|
||||
assert "error" not in output
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def user():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": 7, "login": "timmy", "email": "private@example.com"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/background-identity")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"login": "timmy"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/background-issue-sync.js"></script>' in html
|
||||
assert "const backgroundIssueStore = createIssueSyncStore();" in html
|
||||
assert "createBackgroundIssueSync({" in html
|
||||
assert "backgroundSync: backgroundIssueSync" in html
|
||||
assert "registration.sync.register('stackchain-issue-outbox-v1')" in html
|
||||
assert "backgroundIssueSync.snapshot().then(records => {" in html
|
||||
assert "issueOutbox.reconcileBackground(records);" in html
|
||||
assert "authoredOutbox.reconcileBackground(records);" in html
|
||||
assert "if ('indexedDB' in window)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="delivery-receipts" type="checkbox"' in html
|
||||
assert "deliveryReceipts.addEventListener('change', async () => {" in html
|
||||
assert "await Notification.requestPermission()" in html
|
||||
assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html
|
||||
assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html
|
||||
assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html
|
||||
assert "if (window.location.hash !== '#/my-work/drafts') return;" in html
|
||||
@@ -15,3 +15,14 @@ def test_ci_installs_declared_requirements_before_tests():
|
||||
install = text.index("pip install -r requirements.txt")
|
||||
tests = text.index("python3 -m pytest tests/ -q")
|
||||
assert install < tests
|
||||
|
||||
|
||||
def test_release_promotion_waits_for_tests_and_bundle():
|
||||
text = WORKFLOW.read_text()
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
|
||||
assert "needs: [lint, build-release]" in release
|
||||
assert "github.event_name == 'push'" in release
|
||||
assert "actions/download-artifact@v3" in release
|
||||
assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release
|
||||
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
|
||||
@@ -4,9 +4,13 @@ from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
COMMANDS = FRONTEND / "commands.js"
|
||||
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
||||
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
@@ -40,9 +44,253 @@ if (action !== 'refresh') throw new Error(`expected refresh, got ${{action}}`);
|
||||
|
||||
def test_command_script_resolves_inside_dashboard_subpath():
|
||||
parser = ScriptSourceParser()
|
||||
parser.feed((FRONTEND / "index.html").read_text())
|
||||
parser.feed(dashboard_bundle_text())
|
||||
command_source = next(source for source in parser.sources if source.endswith("commands.js"))
|
||||
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/", command_source
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
|
||||
|
||||
|
||||
def test_remote_command_search_ignores_stale_responses():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const pending = new Map();
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: query => new Promise(resolve => pending.set(query, resolve)),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setQuery('release');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get('release')([{{ title: 'Current result' }}]);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get('mobile')([{{ title: 'Stale result' }}]);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.filter(state => state.status === 'ready');
|
||||
if (ready.length !== 1) throw new Error(`expected one ready state, got ${{ready.length}}`);
|
||||
if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result') {{
|
||||
throw new Error(`stale response replaced current state: ${{JSON.stringify(ready)}}`);
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_remote_command_search_aborts_superseded_and_cleared_queries():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const signals = [];
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal) => {{
|
||||
signals.push(signal);
|
||||
return new Promise((resolve, reject) => signal.addEventListener('abort', () => {{
|
||||
const error = new Error('aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
}}));
|
||||
}},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setQuery('release');
|
||||
if (!signals[0].aborted) throw new Error('superseded request was not aborted');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setQuery('');
|
||||
if (!signals[1].aborted) throw new Error('cleared query request was not aborted');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (states.some(state => state.status === 'error')) throw new Error('abort rendered an error');
|
||||
if (states.at(-1).status !== 'idle') throw new Error('clear did not restore idle state');
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_preserves_partial_result_status():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: () => Promise.resolve({{ items:[{{ title:'Useful result' }}], partial:true }}),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.find(state => state.status === 'ready');
|
||||
if (!ready || ready.items.length !== 1 || ready.partial !== true) {{
|
||||
throw new Error('partial result metadata was lost: ' + JSON.stringify(states));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_command_selection_wraps_for_arrow_keys():
|
||||
script = f"""
|
||||
const commands = require({json.dumps(str(COMMANDS))});
|
||||
if (commands.nextSelection(-1, 'ArrowDown', 3) !== 0) throw new Error('should select first');
|
||||
if (commands.nextSelection(2, 'ArrowDown', 3) !== 0) throw new Error('should wrap down');
|
||||
if (commands.nextSelection(0, 'ArrowUp', 3) !== 2) throw new Error('should wrap up');
|
||||
if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys should not move');
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'role="combobox"' in html
|
||||
assert 'aria-controls="cmd-results"' in html
|
||||
assert 'role="listbox"' in html
|
||||
assert "fetch('api/v1/search?q='" in html
|
||||
assert "async function searchGlobalWork(query, signal)" in html
|
||||
assert "signal," in html
|
||||
assert "Some results are temporarily unavailable." in html
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/",
|
||||
"api/v1/search?q=mobile",
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile"
|
||||
|
||||
|
||||
def test_search_preview_ignores_stale_result_details():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const pending = new Map();
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)),
|
||||
claim: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }});
|
||||
preview.open({{ repository:'stackchain/api', number:2, kind:'issue' }});
|
||||
pending.get(2)({{ number:2, title:'Current' }});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get(1)({{ number:1, title:'Stale' }});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.filter(state => state.status === 'ready');
|
||||
if (ready.length !== 1 || ready[0].detail.number !== 2) {{
|
||||
throw new Error('stale detail replaced current preview: ' + JSON.stringify(ready));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_claim_is_single_flight_and_reports_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let claims = 0;
|
||||
let resolveClaim;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item),
|
||||
claim: () => {{ claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
|
||||
const detail = {{ repository:'stackchain/api', number:42, claimable:true }};
|
||||
const first = preview.claim(detail);
|
||||
const second = preview.claim(detail);
|
||||
if (claims !== 1 || first !== second) throw new Error('claim was not single-flight');
|
||||
resolveClaim({{ assignees:['timmy'] }});
|
||||
await first;
|
||||
if (!states.some(state => state.status === 'claimed')) throw new Error('claim confirmation missing');
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
run_item = html.split("function runCommandItem(item)", 1)[1].split(
|
||||
"function renderCommands", 1
|
||||
)[0]
|
||||
assert "searchPreview.open(item.result)" in run_item
|
||||
assert "window.location.assign" not in run_item
|
||||
remote_branch = run_item.split("} else {", 1)[1]
|
||||
assert "qs('#cmd-input').value = ''" not in remote_branch
|
||||
|
||||
|
||||
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
|
||||
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
|
||||
assert "openPreviewIssueInMyWork" in html
|
||||
|
||||
|
||||
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
|
||||
script = f"""
|
||||
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
|
||||
const listeners = new Map();
|
||||
const viewport = {{
|
||||
height: 430, offsetTop: 17,
|
||||
addEventListener: (name, fn) => listeners.set(name, fn),
|
||||
removeEventListener: (name, fn) => {{ if (listeners.get(name) === fn) listeners.delete(name); }},
|
||||
}};
|
||||
const values = new Map();
|
||||
const palette = {{ style: {{
|
||||
setProperty: (name, value) => values.set(name, value),
|
||||
removeProperty: name => values.delete(name),
|
||||
}} }};
|
||||
const results = {{ scrollTop: 73 }};
|
||||
const controller = createMobileSearchViewport({{
|
||||
palette, results, viewport, mediaQuery: {{ matches: true }},
|
||||
schedule: fn => fn(),
|
||||
}});
|
||||
controller.open();
|
||||
if (values.get('--search-viewport-top') !== '17px') throw new Error('viewport offset was not applied');
|
||||
if (values.get('--search-viewport-height') !== '430px') throw new Error('viewport height was not applied');
|
||||
if (listeners.size !== 2) throw new Error('viewport listeners were not attached once');
|
||||
controller.rememberScroll();
|
||||
results.scrollTop = 0;
|
||||
controller.restoreScroll();
|
||||
if (results.scrollTop !== 73) throw new Error('result scroll position was not restored');
|
||||
controller.open();
|
||||
if (listeners.size !== 2) throw new Error('duplicate viewport listeners were attached');
|
||||
controller.close();
|
||||
if (listeners.size !== 0 || values.size !== 0) throw new Error('viewport state leaked after close');
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_mobile_search_is_a_keyboard_safe_workspace_wired_to_preview_history():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'class="cmd-palette-header"' in html
|
||||
assert 'id="close-command-palette"' in html
|
||||
assert 'style="margin-top:8px;max-height:min(65vh,520px);overflow-y:auto;"' not in html
|
||||
assert 'src="static/mobile-search-viewport.js"' in html
|
||||
assert "height:var(--search-viewport-height,100dvh)" in css
|
||||
assert "top:var(--search-viewport-top,0px)" in css
|
||||
assert "#cmd-results { flex:1; min-height:0; overflow-y:auto;" in css
|
||||
assert "mobileSearchViewport.rememberScroll()" in html
|
||||
assert "mobileSearchViewport.restoreScroll()" in html
|
||||
assert "mobileSearchViewport.open()" in html
|
||||
assert "mobileSearchViewport.close()" in html
|
||||
@@ -63,3 +63,181 @@ const poller = createContextPoller({{
|
||||
"callsWhileHidden": 1,
|
||||
"callsAfterResume": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_context_poller_sends_revisions_and_merges_changed_sections():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const requested = [];
|
||||
const rendered = [];
|
||||
const responses = [
|
||||
{{ context: {{ user: {{ login: 'timmy' }} }}, events: [{{ id: 1 }}], notifications: [],
|
||||
sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
|
||||
revisions: {{ context: 1, events: 1, notifications: 1 }}, freshness: {{ age_seconds: 0 }} }},
|
||||
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
|
||||
revisions: {{ context: 1, events: 2, notifications: 1 }}, events: [{{ id: 2 }}],
|
||||
freshness: {{ age_seconds: 1 }} }},
|
||||
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
|
||||
revisions: {{ context: 1, events: 2, notifications: 1 }}, freshness: {{ age_seconds: 2 }} }},
|
||||
];
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: revisions => {{ requested.push({{ ...revisions }}); return Promise.resolve(responses.shift()); }},
|
||||
onSnapshot: (snapshot, changed) => rendered.push({{
|
||||
context: snapshot.context.user.login,
|
||||
event: snapshot.events[0].id,
|
||||
age: snapshot.freshness.age_seconds,
|
||||
changed,
|
||||
}}),
|
||||
onError: error => {{ throw error; }},
|
||||
setTimer: () => 1,
|
||||
clearTimer: () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
process.stdout.write(JSON.stringify({{ requested, rendered }}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"requested": [
|
||||
{},
|
||||
{"context": 1, "events": 1, "notifications": 1},
|
||||
{"context": 1, "events": 2, "notifications": 1},
|
||||
],
|
||||
"rendered": [
|
||||
{"context": "timmy", "event": 1, "age": 0,
|
||||
"changed": ["context", "events", "notifications"]},
|
||||
{"context": "timmy", "event": 2, "age": 1,
|
||||
"changed": ["events"]},
|
||||
{"context": "timmy", "event": 2, "age": 2, "changed": []},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
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))});
|
||||
const timers = [];
|
||||
const deadlineTimers = [];
|
||||
const errors = [];
|
||||
const snapshots = [];
|
||||
const signals = [];
|
||||
let calls = 0;
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: (revisions, options) => {{
|
||||
calls += 1;
|
||||
signals.push(options.signal);
|
||||
if (calls === 1) return new Promise(() => {{}});
|
||||
return Promise.resolve({{ context: {{ user: {{ login: 'timmy' }} }} }});
|
||||
}},
|
||||
onSnapshot: snapshot => snapshots.push(snapshot.context.user.login),
|
||||
onError: error => errors.push({{ name: error.name, message: error.message }}),
|
||||
setTimer: (callback, delay) => {{
|
||||
const timer = {{ callback, delay, cancelled: false }};
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
}},
|
||||
clearTimer: timer => {{ timer.cancelled = true; }},
|
||||
setDeadlineTimer: (callback, delay) => {{
|
||||
const timer = {{ callback, delay, cancelled: false }};
|
||||
deadlineTimers.push(timer);
|
||||
return timer;
|
||||
}},
|
||||
clearDeadlineTimer: timer => {{ timer.cancelled = true; }},
|
||||
intervalMs: 8000,
|
||||
timeoutMs: 25,
|
||||
}});
|
||||
|
||||
(async () => {{
|
||||
const first = poller.start();
|
||||
const deadline = deadlineTimers.find(timer => timer.delay === 25 && !timer.cancelled);
|
||||
deadline.callback();
|
||||
await first;
|
||||
const retry = timers.find(timer => timer.delay === 8000 && !timer.cancelled);
|
||||
retry.callback();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
firstAborted: signals[0].aborted,
|
||||
errors,
|
||||
snapshots,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": 2,
|
||||
"firstAborted": True,
|
||||
"errors": [
|
||||
{"name": "TimeoutError", "message": "Live update timed out after 25ms."}
|
||||
],
|
||||
"snapshots": ["timmy"],
|
||||
}
|
||||
|
||||
|
||||
def test_forced_refresh_retires_stale_generation_without_late_overwrite():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
let resolveFirst;
|
||||
let firstSignal;
|
||||
const rendered = [];
|
||||
const errors = [];
|
||||
let calls = 0;
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: (revisions, options) => {{
|
||||
calls += 1;
|
||||
if (calls === 1) {{
|
||||
firstSignal = options.signal;
|
||||
return new Promise(resolve => {{ resolveFirst = resolve; }});
|
||||
}}
|
||||
return Promise.resolve({{ context: {{ user: {{ login: 'fresh' }} }} }});
|
||||
}},
|
||||
onSnapshot: snapshot => rendered.push(snapshot.context.user.login),
|
||||
onError: error => errors.push(error.name),
|
||||
setTimer: () => 1,
|
||||
clearTimer: () => {{}},
|
||||
setDeadlineTimer: () => 1,
|
||||
clearDeadlineTimer: () => {{}},
|
||||
}});
|
||||
|
||||
(async () => {{
|
||||
const stale = poller.start();
|
||||
const fresh = poller.refresh({{ force: true }});
|
||||
await fresh;
|
||||
resolveFirst({{ context: {{ user: {{ login: 'stale' }} }} }});
|
||||
await stale;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
firstAborted: firstSignal.aborted,
|
||||
rendered,
|
||||
errors,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": 2,
|
||||
"firstAborted": True,
|
||||
"rendered": ["fresh"],
|
||||
"errors": [],
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,276 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
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 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; }},
|
||||
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
|
||||
}};
|
||||
const boundary = createSessionBoundary({{
|
||||
cookie: () => 'other=x; stackchain_csrf=csrf-proof; theme=dark',
|
||||
origin: 'https://forge.example',
|
||||
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 }});
|
||||
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' }} }});
|
||||
}},
|
||||
localStorage: storage,
|
||||
sessionStorage: storage,
|
||||
indexedDB: {{ deleteDatabase: name => {{
|
||||
state.deletedDatabases.push(name);
|
||||
const request = {{ onsuccess: null, onerror: null, onblocked: null, error: null }};
|
||||
setTimeout(() => {{
|
||||
if (state.failDeletion) {{ request.error = new Error('database blocked'); request.onerror?.(); }}
|
||||
else {{ state.deletionCompleted = true; request.onsuccess?.(); }}
|
||||
}}, 20);
|
||||
return request;
|
||||
}} }},
|
||||
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
|
||||
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: (message, ports = []) => {{ state.workerMessages.push(message); ports[0]?.postMessage({{ok:true}}); }} }} }}) }},
|
||||
MessageChannel: class {{ constructor() {{
|
||||
const first = {{ onmessage: null, postMessage: data => queueMicrotask(() => second.onmessage?.({{data}})) }};
|
||||
const second = {{ onmessage: null, postMessage: data => queueMicrotask(() => first.onmessage?.({{data}})) }};
|
||||
this.port1 = first; this.port2 = second;
|
||||
}} }},
|
||||
location: {{
|
||||
assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }},
|
||||
replace: value => state.replaced.push(value),
|
||||
}},
|
||||
onClearError: error => state.clearErrors.push(error.message),
|
||||
confirmAction: message => {{ state.confirmations.push(message); return true; }},
|
||||
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
|
||||
}});
|
||||
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_mutating_same_origin_fetch_receives_csrf_proof():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.fetch('/dashboard/api/v1/notifications/7/read', { method: 'PATCH' });
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
|
||||
|
||||
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responses = [
|
||||
{status:428, payload:{detail:{code:'step_up_required', action:'merge_pull', target:'stackchain/api#7'}}},
|
||||
{status:201, payload:{grant:'one-time-grant', expires_in:90}},
|
||||
{status:200, payload:{merged:true}},
|
||||
];
|
||||
const original = { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({expected_head_sha:'abc123'}) };
|
||||
const response = await boundary.fetch('/dashboard/api/v1/repos/stackchain/api/pulls/7/merge', original);
|
||||
state.finalStatus = response.status;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["finalStatus"] == 200
|
||||
assert result["prompts"] == [
|
||||
{"action": "merge_pull", "target": "stackchain/api#7"}
|
||||
]
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"/dashboard/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
"/dashboard/api/v1/fresh-authorization",
|
||||
"/dashboard/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
]
|
||||
assert json.loads(result["requests"][1]["body"]) == {
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "merge_pull",
|
||||
"target": "stackchain/api#7",
|
||||
}
|
||||
assert result["requests"][2]["headers"]["x-step-up-grant"] == "one-time-grant"
|
||||
assert result["requests"][2]["body"] == result["requests"][0]["body"]
|
||||
|
||||
|
||||
def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
await Promise.all([
|
||||
boundary.fetch('/dashboard/api/v1/live'),
|
||||
boundary.fetch('https://forge.example/dashboard/api/v1/context'),
|
||||
]);
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
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"] == []
|
||||
|
||||
|
||||
def test_remotely_revoked_response_clears_private_work_before_replacing_dashboard():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
state.responsePayload = {detail:'Authentication required', code:'session_revoked'};
|
||||
await boundary.fetch('/dashboard/api/v1/live');
|
||||
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["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_worker_revocation_message_clears_window_storage_before_login():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-revoked'}});
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
|
||||
|
||||
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
await boundary.fetch('https://untrusted.example/api/private');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["replaced"] == []
|
||||
|
||||
|
||||
def test_sign_out_clears_only_dashboard_private_device_state():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.signOut();
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
request = result["requests"][0]
|
||||
assert request["url"] == "/dashboard/api/v1/session"
|
||||
assert request["method"] == "DELETE"
|
||||
assert request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert "stackchain.private" in result["removed"]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
assert result["assignedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_sign_out_stays_on_page_and_reports_failed_private_outbox_deletion():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.failDeletion = true;
|
||||
try { await boundary.signOut(); } catch (error) { state.signOutError = error.message; }
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["assigned"] == ""
|
||||
assert result["clearErrors"] == ["Could not clear private queued work from this device."]
|
||||
assert result["signOutError"] == "Could not clear private queued work from this device."
|
||||
|
||||
|
||||
def test_sign_out_all_devices_requires_confirmation_and_uses_global_endpoint():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.signOutAllDevices();
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["confirmations"] == [
|
||||
"Sign out every device? You will need to sign in again everywhere."
|
||||
]
|
||||
request = result["requests"][0]
|
||||
assert request["url"] == "/dashboard/api/v1/sessions"
|
||||
assert request["method"] == "DELETE"
|
||||
assert request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
|
||||
def test_active_devices_can_be_loaded_and_one_remote_device_revoked():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {devices:[
|
||||
{management_id:'remote-id-123456789', device_label:'Pixel <script>', created_at:1000, expires_at:2000, current:false},
|
||||
{management_id:'current-id-12345678', device_label:'Work laptop', created_at:1100, expires_at:2100, current:true},
|
||||
]};
|
||||
const devices = await boundary.listActiveDevices();
|
||||
await boundary.revokeActiveDevice(devices[0]);
|
||||
state.devices = devices;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["devices"][0]["device_label"] == "Pixel <script>"
|
||||
assert result["requests"][0]["url"] == "/dashboard/api/v1/sessions"
|
||||
assert result["requests"][1]["url"] == (
|
||||
"/dashboard/api/v1/sessions/remote-id-123456789"
|
||||
)
|
||||
assert result["requests"][1]["method"] == "DELETE"
|
||||
assert result["requests"][1]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert result["confirmations"] == ["Sign out Pixel <script>?"]
|
||||
|
||||
|
||||
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.resumeQueuedWork();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["workerMessages"] == [{"type": "stackchain-resume-outbox"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/session.js"></script>' in html
|
||||
assert html.index('static/session.js') < html.index('static/markdown.js')
|
||||
assert '<button id="sign-out" type="button">Sign out & clear this device</button>' in html
|
||||
assert '<button id="sign-out-all" type="button">Sign out all devices</button>' in html
|
||||
assert '<button id="active-devices" type="button">Active devices</button>' in html
|
||||
assert 'id="active-devices-sheet"' in html
|
||||
assert 'aria-label="Active devices"' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
@@ -0,0 +1,200 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.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_draft_inbox_discovers_existing_formats_orders_activity_and_ignores_corruption():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-capture.v1', JSON.stringify({{repository:'stackchain/api',title:'Ship recovery',body:'Detailed plan',labelIds:[]}})],
|
||||
['stackchain.issue-comment.v1:stackchain/api#17', 'Please add a regression test'],
|
||||
['stackchain.pull-comment.v1:stackchain/web#9', 'Looks good on mobile'],
|
||||
['stackchain.update-reply.v1.44', 'I can take this'],
|
||||
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{'app.js':'Handle offline state'}},comments:[],summary:'Needs one fix',decision:'request_changes'}})],
|
||||
['stackchain.issue-content.v1:stackchain/api#18', '{{broken-json'],
|
||||
['stackchain.issue-comment.v1:stackchain/api#20:operation', 'not-a-draft'],
|
||||
]);
|
||||
const storage = {{
|
||||
get length() {{ return values.size; }},
|
||||
key: index => Array.from(values.keys())[index] || null,
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
let clock = 1000;
|
||||
const inbox = createDraftInbox({{storage, now:() => ++clock}});
|
||||
const first = inbox.list();
|
||||
values.set('stackchain.issue-comment.v1:stackchain/api#17', 'Newest comment text');
|
||||
const second = inbox.list();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
kinds:first.map(item => item.kind).sort(),
|
||||
newest:second[0],
|
||||
count:second.length,
|
||||
}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["kinds"] == ["issue-comment", "new-issue", "pull-comment", "review", "update-reply"]
|
||||
assert output["count"] == 5
|
||||
assert output["newest"]["kind"] == "issue-comment"
|
||||
assert output["newest"]["repository"] == "stackchain/api"
|
||||
assert output["newest"]["number"] == 17
|
||||
assert output["newest"]["preview"] == "Newest comment text"
|
||||
assert output["newest"]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 17}
|
||||
|
||||
|
||||
def test_draft_discard_removes_only_selected_content_and_operation_identity():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-comment.v1:stackchain/api#17', 'One'],
|
||||
['stackchain.issue-comment.v1:stackchain/api#17:operation', 'operation-one'],
|
||||
['stackchain.pull-comment.v1:stackchain/web#9', 'Two'],
|
||||
]);
|
||||
const storage = {{
|
||||
get length() {{ return values.size; }}, key:i => Array.from(values.keys())[i] || null,
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key),
|
||||
}};
|
||||
const inbox = createDraftInbox({{storage, now:() => 1000}});
|
||||
const selected = inbox.list().find(item => item.kind === 'issue-comment');
|
||||
inbox.discard(selected.id);
|
||||
process.stdout.write(JSON.stringify({{remaining:inbox.list(), keys:Array.from(values.keys()).sort()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["kind"] for item in output["remaining"]] == ["pull-comment"]
|
||||
assert "stackchain.issue-comment.v1:stackchain/api#17" not in output["keys"]
|
||||
assert "stackchain.issue-comment.v1:stackchain/api#17:operation" not in output["keys"]
|
||||
assert "stackchain.pull-comment.v1:stackchain/web#9" in output["keys"]
|
||||
|
||||
|
||||
def test_draft_inbox_recovers_issue_edit_and_review_content_without_exposing_empty_records():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-content.v1:stackchain/api#18', JSON.stringify({{title:'Clarify acceptance',body:'Describe mobile flow',expectedUpdatedAt:'2026-08-07T10:00:00Z'}})],
|
||||
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{}},comments:[],summary:'',decision:'approve'}})],
|
||||
['stackchain.pull-comment.v1:stackchain/web#10', ' '],
|
||||
]);
|
||||
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),removeItem:k=>values.delete(k)}};
|
||||
const drafts = createDraftInbox({{storage,now:()=>42}}).list();
|
||||
process.stdout.write(JSON.stringify(drafts));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert len(output) == 1
|
||||
assert output[0]["kind"] == "issue-edit"
|
||||
assert output[0]["preview"] == "Clarify acceptance — Describe mobile flow"
|
||||
assert output[0]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 18}
|
||||
|
||||
|
||||
def test_draft_inbox_expands_issue_outbox_items_with_actionable_states():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:1,items:[
|
||||
{{id:'queued-1',repository:'stackchain/api',title:'Offline capture',body:'Context',status:'queued',queuedAt:100}},
|
||||
{{id:'attention-2',repository:'stackchain/web',title:'Fix labels',body:'Details',status:'attention',error:'Unknown label',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),removeItem:k=>values.delete(k)}};
|
||||
const drafts = createDraftInbox({{storage,now:()=>300}}).list();
|
||||
process.stdout.write(JSON.stringify(drafts));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["outbox_id"] for item in output] == ["attention-2", "queued-1"]
|
||||
assert [item["status"] for item in output] == ["attention", "queued"]
|
||||
assert output[0]["label"] == "Needs attention"
|
||||
assert output[1]["label"] == "Queued issue"
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_uncertain_delivery_for_explicit_user_verification():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'uncertain',kind:'issue-comment',repository:'o/r',number:7,body:'Possibly posted',ownerLogin:'timmy',status:'attention',deliveryState:'uncertain',error:'Verify it was not posted before retrying.',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 drafts = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list();
|
||||
process.stdout.write(JSON.stringify(drafts[0]));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["delivery_state"] == "uncertain"
|
||||
assert output["label"] == "Verify delivery"
|
||||
|
||||
|
||||
def test_draft_inbox_marks_mismatched_and_legacy_outbox_content_copy_only():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'other',repository:'o/r',title:'Other issue',body:'Private context',ownerLogin:'alexander',status:'queued'}},
|
||||
{{id:'legacy',repository:'o/r',title:'Legacy issue',body:'Old context',status:'queued'}}
|
||||
]}})],
|
||||
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'mine',kind:'issue-comment',repository:'o/r',number:2,body:'My comment',ownerLogin:'timmy',status:'queued'}}
|
||||
]}})],
|
||||
]);
|
||||
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 drafts = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list();
|
||||
process.stdout.write(JSON.stringify(drafts));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
by_id = {item["outbox_id"]: item for item in output}
|
||||
assert by_id["other"]["quarantined"] is True
|
||||
assert by_id["other"]["ownership"] == "Queued by alexander — current account is timmy"
|
||||
assert by_id["other"]["copy_text"] == "Other issue\n\nPrivate context"
|
||||
assert by_id["legacy"]["ownership"] == "Queued by an unknown account — current account is timmy"
|
||||
assert by_id["mine"]["quarantined"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/drafts.js"></script>' in html
|
||||
assert 'data-work-filter="draft"' in html
|
||||
assert 'data-work-count="draft"' in html
|
||||
assert 'class="draft-resume"' in html
|
||||
assert 'class="draft-discard"' in html
|
||||
assert 'Discard this unfinished draft?' in html
|
||||
assert '.draft-actions button { min-height:44px;' in html
|
||||
assert 'createDraftInbox({ storage: localStorage' in html
|
||||
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
|
||||
assert "Verified not posted — retry" in html
|
||||
assert "payload.detail?.message" in html
|
||||
assert "error.code = payload.detail?.code" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot():
|
||||
html = await dashboard()
|
||||
|
||||
assert "let confirmedOwnerLogin = '';" in html
|
||||
assert "let activeFlushLogin = '';" in html
|
||||
assert "getOwnerLogin: () => confirmedOwnerLogin" in html
|
||||
assert "getCurrentLogin: () => activeFlushLogin" in html
|
||||
assert "const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&" in html
|
||||
assert "!contextFreshness?.degraded && !contextFreshness?.revalidating;" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';" in html
|
||||
assert "issueOutbox.flush(activeFlushLogin)" in html
|
||||
assert "authoredOutbox.flush(activeFlushLogin)" in html
|
||||
assert "activeFlushLogin = '';" in html
|
||||
assert 'class="draft-copy"' in html
|
||||
assert "navigator.clipboard.writeText(item.copy_text)" in html
|
||||
assert "item.quarantined" in html
|
||||
@@ -6,33 +6,34 @@ import pytest
|
||||
|
||||
from src import main
|
||||
from src import gitea_proxy
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
DASHBOARD = Path("frontend/index.html")
|
||||
|
||||
|
||||
def test_dashboard_has_realtime_gitea_event_stream_widget():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "Gitea event stream" in html
|
||||
assert "id=\"gitea-events\"" in html
|
||||
assert "function paintEventStream" in html
|
||||
assert "setInterval(() => { if (!document.hidden) loadEventStream(); }, 5000)" in html
|
||||
assert "if (!document.hidden) loadEventStream();" in html
|
||||
assert "fetch('api/v1/live'" in html
|
||||
assert "paintEventStream(snapshot.events)" in html
|
||||
assert "loadEventStream" not in html
|
||||
assert "event.actor?.login" in html
|
||||
assert "event.repo?.full_name" in html
|
||||
|
||||
|
||||
def test_event_stream_reports_when_activity_was_refreshed():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'id="gitea-events-status"' in html
|
||||
assert "setEventStreamStatus('Updating…')" in html
|
||||
assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html
|
||||
|
||||
|
||||
def test_event_stream_reports_refresh_failure_without_erasing_visible_events():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "setEventStreamStatus('Update failed · showing last activity')" in html
|
||||
assert "qs('#gitea-events').innerHTML = '<div class=\"muted\">Event stream unavailable.</div>'" not in html
|
||||
@@ -121,6 +122,21 @@ async def test_activity_events_fetches_feed_for_authenticated_user(monkeypatch):
|
||||
assert paths == ["users/timmy/activities/feeds?limit=20"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_reuses_supplied_authenticated_user(monkeypatch):
|
||||
async def unexpected_current_user():
|
||||
raise AssertionError("current user must not be fetched twice")
|
||||
|
||||
async def fake_fetch(path):
|
||||
assert path == "users/timmy/activities/feeds?limit=20"
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", unexpected_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
assert await gitea_proxy.activity_events({"login": "timmy"}) == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_skips_malformed_feed_entries(monkeypatch):
|
||||
async def fake_current_user():
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import gzip
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
html = await dashboard()
|
||||
bundle = await runtime_bundle(build.runtime_digest)
|
||||
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
|
||||
@@ -0,0 +1,282 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_page_preserves_upstream_total_without_loading_other_pages():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "125"},
|
||||
json=[{
|
||||
"id": 42,
|
||||
"unread": True,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Retry failed deploy",
|
||||
"type": "Issue",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
},
|
||||
}],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.notification_page(page=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/notifications?status-types=unread&limit=50&page=2"
|
||||
]
|
||||
assert result["page"] == 2
|
||||
assert result["total"] == 125
|
||||
assert result["has_more"] is True
|
||||
assert [item["id"] for item in result["items"]] == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notifications_are_normalized_for_mobile_handoff():
|
||||
result = gitea_proxy._normalize_notifications(
|
||||
[
|
||||
{
|
||||
"id": 42,
|
||||
"unread": True,
|
||||
"updated_at": "2026-08-06T12:30:00Z",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Retry failed deploy",
|
||||
"type": "Issue",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
},
|
||||
{"id": 43, "repository": None, "subject": None},
|
||||
{
|
||||
"id": 44,
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"subject": {"title": "Unsafe", "html_url": "javascript:alert(1)"},
|
||||
},
|
||||
"malformed",
|
||||
]
|
||||
)
|
||||
assert result == [
|
||||
{
|
||||
"id": 42,
|
||||
"unread": True,
|
||||
"updated_at": "2026-08-06T12:30:00Z",
|
||||
"repository": "stackchain/api",
|
||||
"number": 7,
|
||||
"title": "Retry failed deploy",
|
||||
"subject_type": "Issue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"subject_url": "https://forge.example/stackchain/api/issues/7",
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"unread": False,
|
||||
"updated_at": "",
|
||||
"repository": "",
|
||||
"number": None,
|
||||
"title": "Untitled update",
|
||||
"subject_type": "Update",
|
||||
"state": "",
|
||||
"url": "",
|
||||
"subject_url": "",
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"unread": False,
|
||||
"updated_at": "",
|
||||
"repository": "stackchain/web",
|
||||
"number": None,
|
||||
"title": "Unsafe",
|
||||
"subject_type": "Update",
|
||||
"state": "",
|
||||
"url": "",
|
||||
"subject_url": "",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_collection_rejects_non_list_payload():
|
||||
with pytest.raises(ValueError, match="notification response was not a list"):
|
||||
gitea_proxy._normalize_notifications({"message": "unexpected"})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_reader():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
if request.url.path.endswith("/notifications/threads/42"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Retry failed deploy",
|
||||
"type": "Issue",
|
||||
"state": "open",
|
||||
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
|
||||
"latest_comment_url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
})
|
||||
if request.url.path.endswith("/issues/7"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 7, "state": "open", "assignees": [],
|
||||
"body": "Deploy fails after **three** retries.",
|
||||
})
|
||||
if request.url.path.endswith("/issues/comments/9"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 9,
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"user": {"login": "alexander"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
})
|
||||
if request.url.path.endswith("/issues/7/comments"):
|
||||
return httpx.Response(200, json=[{
|
||||
"id": 9,
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"user": {"login": "alexander"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
}], headers={"X-Total-Count": "1"})
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.notification_detail(42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
|
||||
]
|
||||
assert result == {
|
||||
"id": 42,
|
||||
"repository": "stackchain/api",
|
||||
"title": "Retry failed deploy",
|
||||
"subject_type": "Issue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"subject_body": "Deploy fails after **three** retries.",
|
||||
"latest_comment": {
|
||||
"author": "alexander",
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
"issue": {"number": 7, "assignees": [], "claimable": True},
|
||||
"conversation": {
|
||||
"comments": [{
|
||||
"id": 9,
|
||||
"author": "alexander",
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
}],
|
||||
"page": 1,
|
||||
"older_page": None,
|
||||
"total": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_never_follows_foreign_api_urls():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Suspicious update",
|
||||
"url": "https://evil.example/api/v1/user",
|
||||
"latest_comment_url": "https://evil.example/api/v1/admin/users",
|
||||
"html_url": "javascript:alert(1)",
|
||||
},
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.notification_detail(42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42"
|
||||
]
|
||||
assert result["url"] == ""
|
||||
assert result["subject_body"] == ""
|
||||
assert result["latest_comment"]["body"] == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("subject_kind", ["issues", "pulls"])
|
||||
async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kind):
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append((request.method, str(request.url), request.content))
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Pull" if subject_kind == "pulls" else "Issue",
|
||||
"url": (
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/"
|
||||
f"{subject_kind}/7"
|
||||
),
|
||||
},
|
||||
})
|
||||
return httpx.Response(201, json={
|
||||
"id": 91,
|
||||
"user": {"login": "timmy"},
|
||||
"body": "Please retry the worker.",
|
||||
"created_at": "2026-08-07T19:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.reply_to_notification(42, "Please retry the worker.")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
(
|
||||
"GET",
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
|
||||
b"",
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
b'{"body":"Please retry the worker."}',
|
||||
),
|
||||
]
|
||||
assert result == {
|
||||
"id": 91,
|
||||
"author": "timmy",
|
||||
"body": "Please retry the worker.",
|
||||
"created_at": "2026-08-07T19:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_transport_is_reused_across_requests_and_closed():
|
||||
client_ids = []
|
||||
|
||||
async def handler(request):
|
||||
client_ids.append(id(gitea_proxy._client))
|
||||
return httpx.Response(200, json={"path": request.url.path})
|
||||
|
||||
client = gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
first = await gitea_proxy.fetch("user")
|
||||
second = await gitea_proxy.fetch("user/repos")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert first == {"path": "/api/v1/user"}
|
||||
assert second == {"path": "/api/v1/user/repos"}
|
||||
assert client_ids == [id(client), id(client)]
|
||||
assert client.is_closed
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_transport_coalesces_identical_concurrent_gets():
|
||||
calls = 0
|
||||
release = asyncio.Event()
|
||||
|
||||
async def handler(request):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
await release.wait()
|
||||
return httpx.Response(200, json={"path": request.url.path})
|
||||
|
||||
gitea_proxy.start_client(
|
||||
transport=httpx.MockTransport(handler),
|
||||
max_concurrency=4,
|
||||
admission_timeout=0.1,
|
||||
)
|
||||
try:
|
||||
requests = [asyncio.create_task(gitea_proxy.fetch("user")) for _ in range(20)]
|
||||
await asyncio.sleep(0)
|
||||
release.set()
|
||||
results = await asyncio.gather(*requests)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert calls == 1
|
||||
assert results == [{"path": "/api/v1/user"}] * 20
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancelling_one_coalesced_waiter_keeps_the_shared_get_running():
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def handler(request):
|
||||
started.set()
|
||||
await release.wait()
|
||||
return httpx.Response(200, json={"path": request.url.path})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
owner = asyncio.create_task(gitea_proxy.fetch("user"))
|
||||
try:
|
||||
await started.wait()
|
||||
waiter = asyncio.create_task(gitea_proxy.fetch("user"))
|
||||
await asyncio.sleep(0)
|
||||
waiter.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await waiter
|
||||
release.set()
|
||||
assert await owner == {"path": "/api/v1/user"}
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.gather(owner, return_exceptions=True)
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_transport_rejects_reads_beyond_bounded_capacity():
|
||||
active = 0
|
||||
peak = 0
|
||||
saturated = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def handler(request):
|
||||
nonlocal active, peak
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
if active == 2:
|
||||
saturated.set()
|
||||
try:
|
||||
await release.wait()
|
||||
return httpx.Response(200, json={"path": request.url.path})
|
||||
finally:
|
||||
active -= 1
|
||||
|
||||
gitea_proxy.start_client(
|
||||
transport=httpx.MockTransport(handler),
|
||||
max_concurrency=3,
|
||||
admission_timeout=0.02,
|
||||
)
|
||||
first = asyncio.create_task(gitea_proxy.fetch("first"))
|
||||
second = asyncio.create_task(gitea_proxy.fetch("second"))
|
||||
try:
|
||||
await asyncio.wait_for(saturated.wait(), timeout=0.2)
|
||||
with pytest.raises(gitea_proxy.GiteaOverloadedError):
|
||||
await gitea_proxy.fetch("third")
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.gather(first, second)
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert peak == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_transport_reserves_one_slot_for_mutations_and_bounds_them():
|
||||
active = 0
|
||||
peak = 0
|
||||
reads_started = asyncio.Event()
|
||||
mutation_started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def handler(request):
|
||||
nonlocal active, peak
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
if active == 2:
|
||||
reads_started.set()
|
||||
if request.method == "POST":
|
||||
mutation_started.set()
|
||||
try:
|
||||
await release.wait()
|
||||
return httpx.Response(200, json={"method": request.method})
|
||||
finally:
|
||||
active -= 1
|
||||
|
||||
client = gitea_proxy.start_client(
|
||||
transport=httpx.MockTransport(handler),
|
||||
max_concurrency=3,
|
||||
admission_timeout=0.02,
|
||||
)
|
||||
reads = [
|
||||
asyncio.create_task(gitea_proxy.fetch("first")),
|
||||
asyncio.create_task(gitea_proxy.fetch("second")),
|
||||
]
|
||||
mutation = None
|
||||
try:
|
||||
await asyncio.wait_for(reads_started.wait(), timeout=0.2)
|
||||
mutation = asyncio.create_task(client.post("/api/v1/mutate", json={"value": 1}))
|
||||
await asyncio.wait_for(mutation_started.wait(), timeout=0.2)
|
||||
with pytest.raises(gitea_proxy.GiteaOverloadedError):
|
||||
await asyncio.wait_for(
|
||||
client.post("/api/v1/mutate", json={"value": 1}), timeout=0.1
|
||||
)
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.gather(*reads, *(task for task in [mutation] if task is not None))
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert peak == 3
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_transport_reads_bulkhead_configuration_from_environment(monkeypatch):
|
||||
monkeypatch.setenv("GITEA_MAX_CONCURRENCY", "12")
|
||||
monkeypatch.setenv("GITEA_ADMISSION_TIMEOUT_SECONDS", "0.75")
|
||||
|
||||
client = gitea_proxy.start_client(
|
||||
transport=httpx.MockTransport(lambda request: httpx.Response(200, json={}))
|
||||
)
|
||||
try:
|
||||
assert client.max_concurrency == 12
|
||||
assert client.admission_timeout == 0.75
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_streaming_reads_share_the_same_bounded_read_capacity():
|
||||
active = 0
|
||||
saturated = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def handler(request):
|
||||
nonlocal active
|
||||
active += 1
|
||||
if active == 2:
|
||||
saturated.set()
|
||||
try:
|
||||
await release.wait()
|
||||
return httpx.Response(200, content=b"diff")
|
||||
finally:
|
||||
active -= 1
|
||||
|
||||
gitea_proxy.start_client(
|
||||
transport=httpx.MockTransport(handler),
|
||||
max_concurrency=3,
|
||||
admission_timeout=0.02,
|
||||
)
|
||||
streams = [
|
||||
asyncio.create_task(gitea_proxy.fetch_text("first", 100)),
|
||||
asyncio.create_task(gitea_proxy.fetch_text("second", 100)),
|
||||
]
|
||||
try:
|
||||
await asyncio.wait_for(saturated.wait(), timeout=0.2)
|
||||
with pytest.raises(gitea_proxy.GiteaOverloadedError):
|
||||
await asyncio.wait_for(gitea_proxy.fetch("third"), timeout=0.1)
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.gather(*streams)
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_application_lifespan_opens_and_closes_gitea_transport(monkeypatch):
|
||||
calls = []
|
||||
|
||||
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)
|
||||
|
||||
async with main.app.router.lifespan_context(main.app):
|
||||
assert calls == ["start"]
|
||||
|
||||
assert calls == ["start", "stop"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_application_shutdown_finishes_snapshot_before_closing_transport(monkeypatch):
|
||||
calls = []
|
||||
started = asyncio.Event()
|
||||
|
||||
async def active_snapshot():
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
calls.append("snapshot cancelled")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
|
||||
async def stop_client():
|
||||
assert main._live_snapshot_task is not None
|
||||
state = "done" if main._live_snapshot_task.done() else "active"
|
||||
calls.append(f"stop ({state})")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
|
||||
|
||||
try:
|
||||
async with main.app.router.lifespan_context(main.app):
|
||||
main._live_snapshot_task = asyncio.create_task(active_snapshot())
|
||||
await started.wait()
|
||||
finally:
|
||||
task = main._live_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
main._live_snapshot_task = None
|
||||
|
||||
assert calls == ["start", "snapshot cancelled", "stop (done)"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_application_shutdown_cancels_available_work_scan_before_transport(monkeypatch):
|
||||
calls = []
|
||||
started = asyncio.Event()
|
||||
|
||||
async def active_scan():
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
calls.append("available scan cancelled")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
|
||||
async def stop_client():
|
||||
task = main._available_issue_snapshot_task
|
||||
calls.append(f"stop ({'done' if task is not None and task.done() else 'active'})")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
|
||||
|
||||
try:
|
||||
async with main.app.router.lifespan_context(main.app):
|
||||
main._available_issue_snapshot_task = asyncio.create_task(active_scan())
|
||||
await started.wait()
|
||||
finally:
|
||||
task = main._available_issue_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
main._available_issue_snapshot_task = None
|
||||
|
||||
assert calls == ["start", "available scan cancelled", "stop (done)"]
|
||||
+511
-33
@@ -1,26 +1,386 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_available_issue_snapshot():
|
||||
main._available_issue_snapshot_task = None
|
||||
main._available_issue_snapshot_value = None
|
||||
main._available_issue_snapshot_created_at = None
|
||||
main._available_issue_snapshot_retry_at = None
|
||||
yield
|
||||
task = main._available_issue_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
main._available_issue_snapshot_task = None
|
||||
main._available_issue_snapshot_value = None
|
||||
main._available_issue_snapshot_created_at = None
|
||||
main._available_issue_snapshot_retry_at = None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_page_preserves_total_and_reason_without_loading_other_pages():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "84"},
|
||||
json=[{
|
||||
"id": 51,
|
||||
"number": 51,
|
||||
"title": "Older review",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/pulls/51",
|
||||
}],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.work_page("review", page=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&review_requested=true&type=pulls&limit=50&page=2"
|
||||
]
|
||||
assert result["page"] == 2
|
||||
assert result["total"] == 84
|
||||
assert result["has_more"] is False
|
||||
assert result["items"][0]["work_reasons"] == ["review_requested"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_priority():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "4"},
|
||||
json=[
|
||||
{"id": 1, "number": 1, "title": "Ordinary", "state": "open",
|
||||
"updated_at": "2026-08-07T12:00:00Z", "assignees": None,
|
||||
"pull_request": None,
|
||||
"labels": [], "repository": {"full_name": "stackchain/api"}},
|
||||
{"id": 2, "number": 2, "title": "Claimed", "state": "open",
|
||||
"assignees": [{"login": "alex"}], "repository": {"full_name": "stackchain/api"}},
|
||||
{"id": 3, "number": 3, "title": "A pull", "state": "open",
|
||||
"assignees": [], "pull_request": {"merged": False},
|
||||
"repository": {"full_name": "stackchain/api"}},
|
||||
{"id": 4, "number": 4, "title": "Critical", "state": "open",
|
||||
"updated_at": "2026-08-07T10:00:00Z", "assignees": [],
|
||||
"pull_request": None,
|
||||
"labels": [{"name": "critical"}],
|
||||
"repository": {"full_name": "stackchain/web"}},
|
||||
],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.available_issue_page(page=1)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&type=issues&limit=50&page=1"
|
||||
]
|
||||
assert [item["title"] for item in result["items"]] == ["Critical", "Ordinary"]
|
||||
assert result == {
|
||||
"items": result["items"], "page": 1, "total": 2, "has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_page_ranks_all_upstream_pages_before_logical_pagination():
|
||||
requests = []
|
||||
|
||||
def issue(number, *, assigned=True, critical=False, updated_at="2026-08-07T12:00:00Z"):
|
||||
return {
|
||||
"id": number, "number": number, "title": f"Issue {number}", "state": "open",
|
||||
"updated_at": updated_at, "assignees": [{"login": "alex"}] if assigned else None,
|
||||
"pull_request": None,
|
||||
"labels": [{"name": "critical"}] if critical else [],
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
}
|
||||
|
||||
first_page = [issue(number) for number in range(1, 51)]
|
||||
first_page[0] = issue(1, assigned=False, updated_at="2026-08-07T13:00:00Z")
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
page = int(request.url.params["page"])
|
||||
payload = first_page if page == 1 else [
|
||||
issue(51, assigned=False, critical=True, updated_at="2026-08-07T10:00:00Z"),
|
||||
issue(52, assigned=False, updated_at="2026-08-07T11:00:00Z"),
|
||||
]
|
||||
return httpx.Response(200, headers={"X-Total-Count": "52"}, json=payload)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.available_issue_page(page=1, limit=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 2
|
||||
assert [item["number"] for item in result["items"]] == [51, 1]
|
||||
assert result == {
|
||||
"items": result["items"], "page": 1, "total": 3, "has_more": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_snapshot_loads_known_remaining_pages_concurrently():
|
||||
active = 0
|
||||
peak_active = 0
|
||||
remaining_started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def upstream(request):
|
||||
nonlocal active, peak_active
|
||||
page = int(request.url.params["page"])
|
||||
if page == 1:
|
||||
return httpx.Response(
|
||||
200, headers={"X-Total-Count": "200"},
|
||||
json=[{
|
||||
"id": number, "number": number, "title": f"Issue {number}",
|
||||
"state": "open", "assignees": [], "pull_request": None,
|
||||
"labels": [], "repository": {"full_name": "stackchain/api"},
|
||||
} for number in range(1, 51)],
|
||||
)
|
||||
active += 1
|
||||
peak_active = max(peak_active, active)
|
||||
if peak_active == 3:
|
||||
remaining_started.set()
|
||||
await release.wait()
|
||||
active -= 1
|
||||
start = (page - 1) * 50 + 1
|
||||
return httpx.Response(200, headers={"X-Total-Count": "200"}, json=[{
|
||||
"id": number, "number": number, "title": f"Issue {number}",
|
||||
"state": "open", "assignees": [], "pull_request": None,
|
||||
"labels": [], "repository": {"full_name": "stackchain/api"},
|
||||
} for number in range(start, start + 50)])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
task = asyncio.create_task(gitea_proxy.available_issue_snapshot())
|
||||
try:
|
||||
await asyncio.wait_for(remaining_started.wait(), timeout=1)
|
||||
assert peak_active == 3
|
||||
release.set()
|
||||
result = await asyncio.wait_for(task, timeout=1)
|
||||
finally:
|
||||
release.set()
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(result) == 200
|
||||
assert peak_active == 3
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def available():
|
||||
calls.append(True)
|
||||
return [{"number": number} for number in range(1, 52)]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"items": [{"number": number} for number in range(1, 51)],
|
||||
"page": 1, "total": 51, "has_more": True,
|
||||
}
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_coalesces_cold_scan_and_reuses_it_for_pages(monkeypatch):
|
||||
calls = 0
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def available():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
started.set()
|
||||
await release.wait()
|
||||
return [{"number": number} for number in range(1, 64)]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = asyncio.create_task(client.get("/api/v1/available-issues?page=1"))
|
||||
second = asyncio.create_task(client.get("/api/v1/available-issues?page=2"))
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
release.set()
|
||||
first_response, second_response = await asyncio.gather(first, second)
|
||||
repeated_response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert calls == 1
|
||||
assert len(first_response.json()["items"]) == 50
|
||||
assert len(second_response.json()["items"]) == 13
|
||||
assert second_response.json()["total"] == 63
|
||||
assert second_response.json()["has_more"] is False
|
||||
assert repeated_response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_retains_last_snapshot_on_refresh_failure(monkeypatch):
|
||||
async def unavailable():
|
||||
raise httpx.ConnectError("offline")
|
||||
|
||||
main._available_issue_snapshot_value = [{"number": 7}]
|
||||
main._available_issue_snapshot_created_at = 0.0
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", unavailable)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [{"number": 7}], "page": 1, "total": 1,
|
||||
"has_more": False, "stale": True, "revalidating": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_serves_expired_snapshot_while_one_refresh_runs(monkeypatch):
|
||||
calls = 0
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def refresh():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
started.set()
|
||||
await release.wait()
|
||||
return [{"number": 8}]
|
||||
|
||||
main._available_issue_snapshot_value = [{"number": 7}]
|
||||
main._available_issue_snapshot_created_at = 0.0
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", refresh)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first, second = await asyncio.gather(
|
||||
client.get("/api/v1/available-issues?page=1"),
|
||||
client.get("/api/v1/available-issues?page=1"),
|
||||
)
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
|
||||
assert calls == 1
|
||||
assert first.json() == second.json() == {
|
||||
"items": [{"number": 7}], "page": 1, "total": 1,
|
||||
"has_more": False, "stale": True, "revalidating": True,
|
||||
}
|
||||
release.set()
|
||||
await asyncio.wait_for(main._available_issue_snapshot_task, timeout=1)
|
||||
assert main._available_issue_snapshot_value == [{"number": 8}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_backs_off_after_background_refresh_failure(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def unavailable():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise httpx.ConnectError("offline")
|
||||
|
||||
main._available_issue_snapshot_value = [{"number": 7}]
|
||||
main._available_issue_snapshot_created_at = 0.0
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", unavailable)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = await client.get("/api/v1/available-issues?page=1")
|
||||
await asyncio.gather(main._available_issue_snapshot_task, return_exceptions=True)
|
||||
second = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert calls == 1
|
||||
assert first.json()["revalidating"] is True
|
||||
assert second.json() == {
|
||||
"items": [{"number": 7}], "page": 1, "total": 1,
|
||||
"has_more": False, "stale": True, "refresh_failed": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_confirmed_claim_is_removed_from_retained_available_snapshot(monkeypatch):
|
||||
main._available_issue_snapshot_value = [
|
||||
{"number": 7, "repository": "stackchain/api"},
|
||||
{"number": 8, "repository": "stackchain/web"},
|
||||
]
|
||||
main._available_issue_snapshot_created_at = 10**12
|
||||
|
||||
async def claim(repository, number):
|
||||
return {"repository": repository, "number": number, "assignees": ["timmy"]}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
claimed = await client.patch("/api/v1/repos/stackchain/api/issues/7/claim")
|
||||
available = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert claimed.status_code == 200
|
||||
assert available.json() == {
|
||||
"items": [{"number": 8, "repository": "stackchain/web"}],
|
||||
"page": 1, "total": 1, "has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
assert page == 1
|
||||
totals = {"issue": 84, "pull": 61, "review": 73}
|
||||
return {
|
||||
"items": [], "page": 1, "total": totals[stream],
|
||||
"has_more": True, "stream": stream,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
assigned_issues = await gitea_proxy.issues()
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
assert assigned_issues.pagination == {
|
||||
"issue": {"page": 1, "total": 84, "has_more": True}
|
||||
}
|
||||
assert pulls.pagination == {
|
||||
"pull": {"page": 1, "total": 61, "has_more": True},
|
||||
"review": {"page": 1, "total": 73, "has_more": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_collections_include_supported_review_request_search(monkeypatch):
|
||||
requested_paths = []
|
||||
requested_streams = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested_paths.append(path)
|
||||
return []
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
requested_streams.append((stream, page, limit))
|
||||
return {"stream": stream, "items": [], "page": page, "total": 0, "has_more": False}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
assert await gitea_proxy.issues() == []
|
||||
assert await gitea_proxy.pull_requests() == []
|
||||
assert requested_paths == [
|
||||
"repos/issues/search?state=open&assigned=true&type=issues&limit=50",
|
||||
"repos/issues/search?state=open&assigned=true&type=pulls&limit=50",
|
||||
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
|
||||
assert requested_streams == [
|
||||
("issue", 1, 50),
|
||||
("pull", 1, 50),
|
||||
("review", 1, 50),
|
||||
]
|
||||
|
||||
|
||||
@@ -39,12 +399,16 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
|
||||
"repository": {"full_name": "stackchain/mobile"},
|
||||
}
|
||||
|
||||
async def fake_fetch(path):
|
||||
if "assigned=true" in path:
|
||||
return [assigned]
|
||||
return [assigned.copy(), review_only]
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
items = [assigned] if stream == "pull" else [assigned.copy(), review_only]
|
||||
reason = "assigned_to_me" if stream == "pull" else "review_requested"
|
||||
return {
|
||||
"stream": stream,
|
||||
"items": [{**item, "work_reasons": [reason]} for item in items],
|
||||
"page": page, "total": len(items), "has_more": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
@@ -54,26 +418,98 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_requested_review_guard_matches_repository_number_and_reason(monkeypatch):
|
||||
async def pulls():
|
||||
return [
|
||||
{
|
||||
"number": 7,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"work_reasons": ["review_requested"],
|
||||
},
|
||||
{
|
||||
"number": 8,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"work_reasons": ["assigned_to_me"],
|
||||
},
|
||||
]
|
||||
async def test_pull_request_searches_start_concurrently(monkeypatch):
|
||||
started = [asyncio.Event(), asyncio.Event()]
|
||||
release = asyncio.Event()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "pull_requests", pulls)
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
index = 0 if stream == "pull" else 1
|
||||
started[index].set()
|
||||
await release.wait()
|
||||
return {"stream": stream, "items": [], "page": page, "total": 0, "has_more": False}
|
||||
|
||||
assert await gitea_proxy.is_requested_review("stackchain/api", 7) is True
|
||||
assert await gitea_proxy.is_requested_review("stackchain/api", 8) is False
|
||||
assert await gitea_proxy.is_requested_review("stackchain/private", 7) is False
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
task = asyncio.create_task(gitea_proxy.pull_requests())
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*(event.wait() for event in started)), timeout=1
|
||||
)
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
assert await task == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_requested_review_guard_uses_direct_pull_and_current_user(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested.append(path)
|
||||
if path == "user":
|
||||
return {"login": "timmy"}
|
||||
assert path == "repos/stackchain/api/pulls/77"
|
||||
return {
|
||||
"state": "open",
|
||||
"requested_reviewers": [{"login": "timmy"}],
|
||||
}
|
||||
|
||||
async def reject_search(*args, **kwargs):
|
||||
raise AssertionError("authorization must not scan review search pages")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", reject_search)
|
||||
|
||||
assert await gitea_proxy.is_requested_review("stackchain/api", 77) is True
|
||||
assert sorted(requested) == ["repos/stackchain/api/pulls/77", "user"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_guard_uses_direct_target_and_current_user(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested.append(path)
|
||||
if path == "user":
|
||||
return {"login": "timmy"}
|
||||
assert path == "repos/stackchain/api/issues/77"
|
||||
return {
|
||||
"state": "open",
|
||||
"assignees": [{"login": "timmy"}],
|
||||
}
|
||||
|
||||
async def reject_search(*args, **kwargs):
|
||||
raise AssertionError("authorization must not scan work search pages")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", reject_search)
|
||||
|
||||
assert await gitea_proxy.is_assigned_issue("stackchain/api", 77) is True
|
||||
assert sorted(requested) == ["repos/stackchain/api/issues/77", "user"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_guard_uses_direct_target_and_current_user(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested.append(path)
|
||||
if path == "user":
|
||||
return {"login": "timmy"}
|
||||
assert path == "repos/stackchain/api/pulls/77"
|
||||
return {
|
||||
"state": "open",
|
||||
"assignees": [{"login": "timmy"}],
|
||||
}
|
||||
|
||||
async def reject_search(*args, **kwargs):
|
||||
raise AssertionError("authorization must not scan work search pages")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", reject_search)
|
||||
|
||||
assert await gitea_proxy.is_assigned_pull("stackchain/api", 77) is True
|
||||
assert sorted(requested) == ["repos/stackchain/api/pulls/77", "user"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -94,6 +530,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"repository": {"full_name": "stackchain/mobile"},
|
||||
"updated_at": "2026-08-06T12:00:00Z",
|
||||
"due_date": "2026-08-09T23:59:59Z",
|
||||
"html_url": "https://forge.example/stackchain/mobile/issues/7",
|
||||
}]
|
||||
|
||||
@@ -121,6 +558,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
|
||||
|
||||
assert payload["issues"][0]["repository"] == "stackchain/mobile"
|
||||
assert payload["issues"][0]["updated_at"] == "2026-08-06T12:00:00Z"
|
||||
assert payload["issues"][0]["due_date"] == "2026-08-09T23:59:59Z"
|
||||
assert payload["pull_requests"][0]["repository"] == "stackchain/api"
|
||||
assert payload["pull_requests"][0]["updated_at"] == "2026-08-06T11:00:00Z"
|
||||
assert payload["pull_requests"][0]["labels"] == ["priority-high"]
|
||||
@@ -175,6 +613,7 @@ async def test_pull_review_detail_combines_pr_files_status_and_reviews(monkeypat
|
||||
"repos/stackchain/api/pulls/7/reviews",
|
||||
]
|
||||
assert detail["author"] == "alex"
|
||||
assert detail["head_sha"] == "abc123"
|
||||
assert detail["ci_state"] == "success"
|
||||
assert detail["files"][0]["filename"] == "src/api.py"
|
||||
assert detail["reviews"][0]["state"] == "APPROVED"
|
||||
@@ -182,6 +621,45 @@ async def test_pull_review_detail_combines_pr_files_status_and_reviews(monkeypat
|
||||
assert len(detail["reviews"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_review_detail_fetches_independent_head_resources_concurrently(monkeypatch):
|
||||
started = {name: asyncio.Event() for name in ("files", "status", "reviews", "diff")}
|
||||
release = asyncio.Event()
|
||||
|
||||
async def wait_for_release(name, result):
|
||||
started[name].set()
|
||||
await release.wait()
|
||||
return result
|
||||
|
||||
async def fake_fetch(path):
|
||||
if path.endswith("/pulls/7"):
|
||||
return {"head": {"sha": "abc123"}, "user": {"login": "alex"}}
|
||||
if path.endswith("/files"):
|
||||
return await wait_for_release("files", [])
|
||||
if path.endswith("/reviews"):
|
||||
return await wait_for_release("reviews", [])
|
||||
return await wait_for_release("status", {"state": "success"})
|
||||
|
||||
async def fake_fetch_text(path, max_bytes):
|
||||
return await wait_for_release("diff", ("", False))
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch_text", fake_fetch_text)
|
||||
|
||||
detail_task = asyncio.create_task(
|
||||
gitea_proxy.pull_review_detail("stackchain/api", 7)
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*(event.wait() for event in started.values())), timeout=1
|
||||
)
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
detail = await detail_task
|
||||
assert detail["head_sha"] == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_review_detail_attaches_bounded_per_file_diff_previews(monkeypatch):
|
||||
async def fake_fetch(path):
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def search(query, limit):
|
||||
requested.append((query, limit))
|
||||
return {
|
||||
"items": [{
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Repair mobile queue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}],
|
||||
"partial": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/search?q=mobile&limit=7")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert requested == [("mobile", 7)]
|
||||
assert response.json() == {"query": "mobile", "items": [{
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Repair mobile queue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}], "partial": False}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_returns_normalized_action_context(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
requested.append((repository, kind, number))
|
||||
return {
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Repair mobile queue",
|
||||
"body": "Restore the queue before release.",
|
||||
"state": "open",
|
||||
"author": "alex",
|
||||
"labels": ["P1"],
|
||||
"assignees": [],
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
"assigned_to_me": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview?kind=issue"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert requested == [("stackchain/api", "issue", 42)]
|
||||
assert response.json()["claimable"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(dict(request.url.params))
|
||||
kind = request.url.params["type"]
|
||||
if kind == "issues":
|
||||
return httpx.Response(200, json=[{
|
||||
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
}, {
|
||||
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
}, {
|
||||
"id": 5, "number": 43, "title": "Unsafe", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "javascript:alert(1)",
|
||||
}])
|
||||
return httpx.Response(200, json=[{
|
||||
"id": 8, "number": 9, "title": "Improve search", "state": "closed",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
results = await gitea_proxy.global_search("mobile queue", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert {request["type"] for request in requests} == {"issues", "pulls"}
|
||||
assert all(request["q"] == "mobile queue" and request["limit"] == "7" for request in requests)
|
||||
assert results == {
|
||||
"items": [{
|
||||
"kind": "issue", "repository": "stackchain/api", "number": 42,
|
||||
"title": "Repair queue", "state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}, {
|
||||
"kind": "pull", "repository": "stackchain/web", "number": 9,
|
||||
"title": "Improve search", "state": "closed",
|
||||
"url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}],
|
||||
"partial": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_returns_healthy_stream_when_other_stream_fails():
|
||||
async def handler(request):
|
||||
if request.url.params["type"] == "issues":
|
||||
return httpx.Response(503, json={"message": "upstream details must stay private"})
|
||||
return httpx.Response(200, json=[{
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.global_search("search", 10)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"items": [{
|
||||
"kind": "pull",
|
||||
"repository": "stackchain/web",
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}],
|
||||
"partial": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_caps_combined_results_to_requested_limit():
|
||||
async def handler(request):
|
||||
kind = request.url.params["type"]
|
||||
items = [{
|
||||
"number": number,
|
||||
"title": f"{kind} {number}",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": f"https://forge.example/stackchain/web/{kind}/{number}",
|
||||
} for number in (1, 2)]
|
||||
return httpx.Response(200, json=items)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.global_search("search", 2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(result["items"]) == 2
|
||||
assert result["partial"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_propagates_cancellation_to_stop_obsolete_work():
|
||||
async def handler(request):
|
||||
if request.url.params["type"] == "issues":
|
||||
raise asyncio.CancelledError()
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await gitea_proxy.global_search("obsolete", 10)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_issues():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 42,
|
||||
"title": "Repair queue",
|
||||
"body": "Keep mobile operators moving.",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
"user": {"login": "alex"},
|
||||
"labels": [{"name": "P1"}, None],
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/api", "issue", 42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview == {
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Repair queue",
|
||||
"body": "Keep mobile operators moving.",
|
||||
"state": "open",
|
||||
"author": "alex",
|
||||
"labels": ["P1"],
|
||||
"assignees": [],
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
"assigned_to_me": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
"pull_request": {"merged": False},
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/web", "issue", 9)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview["kind"] == "pull"
|
||||
assert preview["claimable"] is False
|
||||
@@ -0,0 +1,91 @@
|
||||
import sqlite3
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
def test_completed_result_replays_after_ledger_reconstruction(tmp_path):
|
||||
database = tmp_path / "idempotency.sqlite3"
|
||||
first = IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
|
||||
|
||||
reservation = first.reserve("create-201", ("issue-create", "stackchain/api", "Ship it"))
|
||||
assert reservation.state == "reserved"
|
||||
first.complete("create-201", {"number": 42, "title": "Ship it"})
|
||||
|
||||
reconstructed = IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
|
||||
replay = reconstructed.reserve(
|
||||
"create-201", ("issue-create", "stackchain/api", "Ship it")
|
||||
)
|
||||
|
||||
assert replay.state == "completed"
|
||||
assert replay.response == {"number": 42, "title": "Ship it"}
|
||||
assert sqlite3.connect(database).execute(
|
||||
"SELECT status FROM idempotency_operations WHERE key = ?", ("create-201",)
|
||||
).fetchone() == ("completed",)
|
||||
|
||||
|
||||
def test_reservation_expires_completed_rows_and_enforces_capacity(tmp_path):
|
||||
now = [1_000.0]
|
||||
ledger = IdempotencyLedger(
|
||||
tmp_path / "bounded.sqlite3",
|
||||
ttl_seconds=10,
|
||||
max_entries=2,
|
||||
clock=lambda: now[0],
|
||||
)
|
||||
assert ledger.reserve("old", ("comment", "old")).state == "reserved"
|
||||
ledger.complete("old", {"id": 1})
|
||||
now[0] += 11
|
||||
|
||||
assert ledger.reserve("new-1", ("comment", "new-1")).state == "reserved"
|
||||
ledger.complete("new-1", {"id": 2})
|
||||
assert ledger.reserve("new-2", ("comment", "new-2")).state == "reserved"
|
||||
ledger.complete("new-2", {"id": 3})
|
||||
replacement = ledger.reserve("new-3", ("comment", "new-3"))
|
||||
|
||||
assert replacement.state == "reserved"
|
||||
with sqlite3.connect(ledger.path) as connection:
|
||||
keys = connection.execute(
|
||||
"SELECT key FROM idempotency_operations ORDER BY key"
|
||||
).fetchall()
|
||||
indexes = connection.execute(
|
||||
"PRAGMA index_list(idempotency_operations)"
|
||||
).fetchall()
|
||||
assert keys == [("new-2",), ("new-3",)]
|
||||
assert any(index[1] == "idempotency_completed_at_idx" for index in indexes)
|
||||
|
||||
|
||||
def test_independent_connections_atomically_reserve_one_operation(tmp_path):
|
||||
database = tmp_path / "workers.sqlite3"
|
||||
ledgers = [
|
||||
IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
|
||||
for _ in range(2)
|
||||
]
|
||||
barrier = Barrier(2)
|
||||
|
||||
def reserve(ledger):
|
||||
barrier.wait()
|
||||
return ledger.reserve("worker-key-201", ("review", "stackchain/api", 7)).state
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
states = list(executor.map(reserve, ledgers))
|
||||
|
||||
assert sorted(states) == ["pending", "reserved"]
|
||||
|
||||
|
||||
def test_stale_pending_reservation_becomes_uncertain_without_consuming_capacity(tmp_path):
|
||||
now = [1_000.0]
|
||||
ledger = IdempotencyLedger(
|
||||
tmp_path / "orphaned.sqlite3",
|
||||
ttl_seconds=10,
|
||||
max_entries=1,
|
||||
clock=lambda: now[0],
|
||||
)
|
||||
fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first")
|
||||
|
||||
assert ledger.reserve("orphan", fingerprint).state == "reserved"
|
||||
now[0] += 11
|
||||
|
||||
assert ledger.reserve("orphan", fingerprint).state == "uncertain"
|
||||
assert ledger.reserve("unrelated", ("issue-comment", "stackchain/api", 8, "Continue")).state == "reserved"
|
||||
assert ledger.reserve("orphan", ("issue-comment", "stackchain/api", 7, "Changed")).state == "conflict"
|
||||
@@ -0,0 +1,212 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
INSTALL_APP = Path(__file__).resolve().parents[1] / "frontend" / "install-app.js"
|
||||
|
||||
|
||||
def run_install_scenario(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createInstallApp = require({json.dumps(str(INSTALL_APP))});
|
||||
class FakeTarget {{
|
||||
constructor() {{ this.listeners = {{}}; }}
|
||||
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
|
||||
dispatch(name, event = {{}}) {{ for (const callback of this.listeners[name] || []) callback(event); }}
|
||||
}}
|
||||
class FakeButton extends FakeTarget {{
|
||||
constructor() {{ super(); this.disabled = false; }}
|
||||
click() {{ this.dispatch('click', {{currentTarget:this}}); }}
|
||||
}}
|
||||
(async () => {{
|
||||
{scenario}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_native_install_prompt_is_revealed_by_eligibility_and_invoked_once():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const install = new FakeButton();
|
||||
const dismiss = new FakeButton();
|
||||
const guidance = {hidden:true};
|
||||
const status = {textContent:''};
|
||||
let promptCalls = 0;
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:install, dismissButton:dismiss,
|
||||
guidance, status, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
windowTarget.dispatch('beforeinstallprompt', {
|
||||
preventDefault(){},
|
||||
prompt:async () => { promptCalls += 1; },
|
||||
userChoice:Promise.resolve({outcome:'accepted'}),
|
||||
});
|
||||
const shown = !card.hidden;
|
||||
install.click();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
install.click();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({shown, promptCalls, hidden:card.hidden, status:status.textContent}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"shown": True,
|
||||
"promptCalls": 1,
|
||||
"hidden": True,
|
||||
"status": "Stackchain was added to your device.",
|
||||
}
|
||||
|
||||
|
||||
def test_declined_native_install_hides_the_spent_choice_and_announces_it():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const install = new FakeButton();
|
||||
const status = {textContent:''};
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:install, dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
windowTarget.dispatch('beforeinstallprompt', {
|
||||
preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'dismissed'}),
|
||||
});
|
||||
install.click();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({hidden:card.hidden, disabled:install.disabled, status:status.textContent}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"hidden": True,
|
||||
"disabled": False,
|
||||
"status": "Installation was not completed.",
|
||||
}
|
||||
|
||||
|
||||
def test_ios_safari_gets_manual_add_to_home_screen_guidance():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const install = new FakeButton(); install.hidden = false;
|
||||
const dismiss = new FakeButton();
|
||||
const guidance = {hidden:true};
|
||||
const status = {textContent:''};
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:install, dismissButton:dismiss,
|
||||
guidance, status, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>true,
|
||||
});
|
||||
app.start();
|
||||
process.stdout.write(JSON.stringify({cardHidden:card.hidden, installHidden:install.hidden, guidanceHidden:guidance.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"cardHidden": False,
|
||||
"installHidden": True,
|
||||
"guidanceHidden": False,
|
||||
}
|
||||
|
||||
|
||||
def test_standalone_launch_never_promotes_installation():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>true, isIosSafari:()=>true,
|
||||
});
|
||||
app.start();
|
||||
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
|
||||
process.stdout.write(JSON.stringify({hidden:card.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"hidden": True}
|
||||
|
||||
|
||||
def test_dismissal_is_remembered_and_suppresses_later_promotion():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key => values.get(key) || null, setItem:(key, value) => values.set(key, value)};
|
||||
const firstWindow = new FakeTarget();
|
||||
const firstCard = {hidden:true};
|
||||
const firstDismiss = new FakeButton();
|
||||
const first = createInstallApp({
|
||||
window:firstWindow, card:firstCard, installButton:new FakeButton(), dismissButton:firstDismiss,
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage,
|
||||
isStandalone:()=>false, isIosSafari:()=>true,
|
||||
});
|
||||
first.start();
|
||||
firstDismiss.click();
|
||||
const secondCard = {hidden:true};
|
||||
const second = createInstallApp({
|
||||
window:new FakeTarget(), card:secondCard, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage,
|
||||
isStandalone:()=>false, isIosSafari:()=>true,
|
||||
});
|
||||
second.start();
|
||||
process.stdout.write(JSON.stringify({firstHidden:firstCard.hidden, remembered:values.size, secondHidden:secondCard.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True}
|
||||
|
||||
|
||||
def test_appinstalled_hides_an_visible_native_promotion():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
|
||||
const shown = !card.hidden;
|
||||
windowTarget.dispatch('appinstalled');
|
||||
process.stdout.write(JSON.stringify({shown, hidden:card.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"shown": True, "hidden": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_touch_safe_install_promotion():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="install-app-card"' in html
|
||||
assert 'id="install-app"' in html
|
||||
assert 'id="dismiss-install-app"' in html
|
||||
assert 'id="install-app-guidance"' in html
|
||||
assert 'Share, then choose <strong>Add to Home Screen</strong>' in html
|
||||
assert '.install-app-card button { min-height:44px;' in html
|
||||
assert '<script src="static/install-app.js"></script>' in html
|
||||
assert 'createInstallApp({' in html
|
||||
assert "window.matchMedia('(display-mode: standalone)').matches" in html
|
||||
assert "navigator.standalone === true" in html
|
||||
assert "isIosSafari" in html
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,406 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.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_issue_outbox_binds_items_and_only_flushes_for_the_matching_account():
|
||||
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 confirmedLogin = 'timmy';
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:() => confirmedLogin, createOperationId:() => 'bound-op',
|
||||
fetchJson:async url => {{ calls.push(url); return {{number:1}}; }},
|
||||
}});
|
||||
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bound',body:'Context'}});
|
||||
confirmedLogin = 'alexander';
|
||||
(async () => {{
|
||||
const mismatch = await outbox.flush('alexander');
|
||||
const unknown = await outbox.retry(queued.id, '');
|
||||
const matched = await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{queued,mismatch,unknown,matched,calls}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["ownerLogin"] == "timmy"
|
||||
assert output["mismatch"]["blocked"] == 1
|
||||
assert output["unknown"]["blocked"] == 1
|
||||
assert len(output["matched"]["confirmed"]) == 1
|
||||
assert len(output["calls"]) == 1
|
||||
|
||||
|
||||
def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
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),
|
||||
}};
|
||||
let sequence = 0;
|
||||
const outbox = createIssueOutbox({{storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}});
|
||||
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One',labelIds:[3]}});
|
||||
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two',milestoneId:4,dueDate:'2026-08-09'}});
|
||||
const reloaded = createIssueOutbox({{storage}}).list();
|
||||
process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.get('stackchain.issue-outbox.v1'))}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [(item["repository"], item["title"]) for item in output["items"]] == [
|
||||
("stackchain/api", "First"),
|
||||
("stackchain/web", "Second"),
|
||||
]
|
||||
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
|
||||
|
||||
|
||||
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
|
||||
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),removeItem:k=>values.delete(k)}};
|
||||
let sequence = 0;
|
||||
const calls = [];
|
||||
const outbox = createIssueOutbox({{
|
||||
storage,
|
||||
getOwnerLogin:()=>'timmy',
|
||||
createOperationId:() => 'stable-' + (++sequence),
|
||||
fetchJson:async (url, options) => {{
|
||||
calls.push({{url, key:options.headers['Idempotency-Key']}});
|
||||
if (calls.length === 2) {{ const error = new Error('upstream unavailable'); error.status = 503; throw error; }}
|
||||
return {{repository:'stackchain/api',number:41,title:'First'}};
|
||||
}},
|
||||
}});
|
||||
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One'}});
|
||||
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two'}});
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["result"]["confirmed"][0]["number"] == 41
|
||||
assert output["calls"] == [
|
||||
{"url": "api/v1/repos/stackchain/api/issues", "key": "stable-1"},
|
||||
{"url": "api/v1/repos/stackchain/web/issues", "key": "stable-2"},
|
||||
]
|
||||
assert len(output["remaining"]) == 1
|
||||
assert output["remaining"][0]["title"] == "Second"
|
||||
assert output["remaining"][0]["status"] == "queued"
|
||||
|
||||
|
||||
def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry():
|
||||
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),removeItem:k=>values.delete(k)}};
|
||||
let invalid = true;
|
||||
let operationSequence = 0;
|
||||
const calls = [];
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'edit-' + (++operationSequence),
|
||||
fetchJson:async (_url, options) => {{
|
||||
calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }}
|
||||
return {{repository:'stackchain/api',number:42,title:'Fixed title'}};
|
||||
}},
|
||||
}});
|
||||
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bad',body:'Context'}});
|
||||
outbox.flush('timmy').then(async () => {{
|
||||
const attention = outbox.list()[0];
|
||||
outbox.update(queued.id, {{...attention,title:'Fixed title'}});
|
||||
invalid = false;
|
||||
const result = await outbox.retry(queued.id, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{attention,result,calls,remaining:outbox.list()}}));
|
||||
}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["attention"]["status"] == "attention"
|
||||
assert output["attention"]["error"] == "Title is invalid"
|
||||
assert [call["key"] for call in output["calls"]] == ["edit-1", "edit-2"]
|
||||
assert output["calls"][1]["body"]["title"] == "Fixed title"
|
||||
assert output["result"]["confirmed"][0]["number"] == 42
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_issue_outbox_rekeys_only_after_user_retries_uncertain_delivery():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); let sequence=0; const calls=[];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'operation-' + (++sequence),
|
||||
fetchJson:async (_url, options) => {{
|
||||
const key=options.headers['Idempotency-Key']; calls.push(key);
|
||||
if (key === 'operation-1') {{ const e=new Error('Verify it was not posted before retrying.'); e.status=422; e.code='delivery_uncertain'; throw e; }}
|
||||
return {{number:319}};
|
||||
}},
|
||||
}});
|
||||
const item=outbox.enqueue({{repository:'o/r',title:'Possibly sent',body:'Check first'}});
|
||||
(async()=>{{
|
||||
await outbox.flush('timmy'); const attention=outbox.list()[0];
|
||||
await outbox.retry(item.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{attention,calls,remaining:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["attention"]["status"] == "attention"
|
||||
assert output["attention"]["deliveryState"] == "uncertain"
|
||||
assert output["calls"] == ["operation-1", "operation-2"]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_issue_outbox_rotates_operation_id_only_when_delivery_payload_changes():
|
||||
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:() => 'payload-' + (++sequence),
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
repository:'stackchain/api',title:'Plan',body:'Context',labelIds:[1],milestoneId:2,dueDate:'2026-08-09'
|
||||
}});
|
||||
const unchanged = outbox.update(queued.id, {{...queued}});
|
||||
const fields = [
|
||||
['repository', 'stackchain/web'], ['title', 'Revised'], ['body', 'More context'],
|
||||
['labelIds', [1, 3]], ['milestoneId', 4], ['dueDate', '2026-08-10'],
|
||||
];
|
||||
const edits = [];
|
||||
for (const [field, value] of fields) {{
|
||||
const current = outbox.list()[0];
|
||||
edits.push(outbox.update(queued.id, {{...current, [field]:value}}));
|
||||
}}
|
||||
process.stdout.write(JSON.stringify({{queued,unchanged,edits,final:outbox.list()[0]}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["unchanged"]["operationId"] == output["queued"]["operationId"] == "payload-1"
|
||||
assert [item["operationId"] for item in output["edits"]] == [
|
||||
"payload-2", "payload-3", "payload-4", "payload-5", "payload-6", "payload-7"
|
||||
]
|
||||
assert output["final"]["milestoneId"] == 4
|
||||
assert output["final"]["dueDate"] == "2026-08-10"
|
||||
|
||||
|
||||
def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap():
|
||||
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),removeItem:k=>values.delete(k)}};
|
||||
let calls = 0;
|
||||
let release;
|
||||
const gate = new Promise(resolve => {{ release = resolve; }});
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'single-flight',
|
||||
fetchJson:async () => {{ calls += 1; await gate; return {{repository:'stackchain/api',number:43,title:'Once'}}; }},
|
||||
}});
|
||||
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Once',body:'Context'}});
|
||||
const reconnect = outbox.flush('timmy');
|
||||
const sendNow = outbox.retry(queued.id, 'timmy');
|
||||
release();
|
||||
Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.stringify({{calls,results,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == 1
|
||||
assert output["remaining"] == []
|
||||
assert sum(len(result["confirmed"]) for result in output["results"]) >= 1
|
||||
|
||||
|
||||
def test_issue_outbox_mirrors_queue_and_registers_background_sync():
|
||||
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 state = {{snapshots:[],syncs:0}};
|
||||
const backgroundSync = {{
|
||||
reconcile: async items => state.snapshots.push(items.map(item => ({{...item}}))),
|
||||
requestSync: async () => {{ state.syncs += 1; }},
|
||||
}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'background-1',
|
||||
}});
|
||||
outbox.enqueue({{repository:'stackchain/api',title:'Close the app',body:'Still deliver'}});
|
||||
setTimeout(() => process.stdout.write(JSON.stringify(state)), 0);
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["snapshots"][0][0]["operationId"] == "background-1"
|
||||
assert output["snapshots"][0][0]["ownerLogin"] == "timmy"
|
||||
assert output["syncs"] == 1
|
||||
|
||||
|
||||
def test_issue_outbox_confirms_durable_admission_only_after_mirror_and_sync():
|
||||
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 releaseMirror; let releaseSync;
|
||||
const mirrorGate = new Promise(resolve => releaseMirror = resolve);
|
||||
const syncGate = new Promise(resolve => releaseSync = resolve);
|
||||
const events = [];
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'durable-1',
|
||||
backgroundSync: {{
|
||||
reconcile: async items => {{ events.push('mirror-start'); await mirrorGate; events.push('mirror-committed'); }},
|
||||
requestSync: async () => {{ events.push('sync-start'); await syncGate; events.push('sync-registered'); }},
|
||||
}},
|
||||
}});
|
||||
const admission = outbox.enqueueDurably({{repository:'stackchain/api',title:'Keep this',body:'Draft'}})
|
||||
.then(result => {{ events.push('confirmed'); return result; }});
|
||||
Promise.resolve().then(async () => {{
|
||||
const beforeMirror = events.slice();
|
||||
releaseMirror(); await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const beforeSync = events.slice();
|
||||
releaseSync();
|
||||
const result = await admission;
|
||||
process.stdout.write(JSON.stringify({{beforeMirror,beforeSync,events,result,items:outbox.list()}}));
|
||||
}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["beforeMirror"] == ["mirror-start"]
|
||||
assert output["beforeSync"] == ["mirror-start", "mirror-committed", "sync-start"]
|
||||
assert output["events"][-1] == "confirmed"
|
||||
assert output["result"]["background"] is True
|
||||
assert output["result"]["item"]["id"] == "durable-1"
|
||||
assert output["items"][0]["title"] == "Keep this"
|
||||
|
||||
|
||||
def test_issue_outbox_reports_degraded_admission_without_losing_foreground_item():
|
||||
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:()=> 'foreground-1',
|
||||
backgroundSync: {{
|
||||
reconcile: async () => {{}},
|
||||
requestSync: async () => {{ throw new Error('Background Sync unavailable'); }},
|
||||
}},
|
||||
}});
|
||||
outbox.enqueueDurably({{repository:'stackchain/api',title:'Retain me',body:'Draft'}}).then(result =>
|
||||
process.stdout.write(JSON.stringify({{
|
||||
background:result.background, durability:result.durability,
|
||||
error:result.error.message, item:result.item, persisted:outbox.list()
|
||||
}}))
|
||||
);
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["background"] is False
|
||||
assert output["durability"] == "foreground-only"
|
||||
assert output["error"] == "Background Sync unavailable"
|
||||
assert output["persisted"] == [output["item"]]
|
||||
|
||||
|
||||
def test_foreground_delivery_uses_same_atomic_background_claim():
|
||||
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 state = {{backgroundCalls:0,directCalls:0}};
|
||||
const backgroundSync = {{
|
||||
reconcile: async () => {{}}, requestSync: async () => {{}},
|
||||
send: async (item, login) => {{ state.backgroundCalls += 1; return {{issue:{{number:9}}, claimed:item.id + ':' + login}}; }},
|
||||
}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'shared-claim',
|
||||
fetchJson:async () => {{ state.directCalls += 1; return {{number:10}}; }},
|
||||
}});
|
||||
outbox.enqueue({{repository:'o/r',title:'One owner'}});
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{state,result,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"] == {"backgroundCalls": 1, "directCalls": 0}
|
||||
assert output["result"]["confirmed"][0]["number"] == 9
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_foreground_background_delivery_surfaces_permanent_failure_immediately():
|
||||
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 error=new Error('Title is invalid');error.status=422;
|
||||
const backgroundSync={{reconcile:async()=>{{}},requestSync:async()=>{{}},send:async()=>({{attention:true,error}})}};
|
||||
const outbox=createIssueOutbox({{storage,backgroundSync,getOwnerLogin:()=>'timmy',createOperationId:()=>'invalid'}});
|
||||
outbox.enqueue({{repository:'o/r',title:'Bad'}});
|
||||
outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result,items:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["items"][0]["status"] == "attention"
|
||||
assert output["items"][0]["error"] == "Title is invalid"
|
||||
assert output["result"]["confirmed"] == []
|
||||
|
||||
|
||||
def test_page_reconciles_worker_success_and_attention_into_visible_outbox():
|
||||
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:2,items:[
|
||||
{{id:'sent',operationId:'sent',ownerLogin:'timmy',status:'queued',title:'Done'}},
|
||||
{{id:'bad',operationId:'bad',ownerLogin:'timmy',status:'queued',title:'Fix me'}},
|
||||
]}}));
|
||||
const outbox=createIssueOutbox({{storage}});
|
||||
outbox.reconcileBackground([
|
||||
{{id:'sent',status:'sent'}},
|
||||
{{id:'bad',status:'attention',error:'Title is invalid'}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(outbox.list()));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == [
|
||||
{
|
||||
"id": "bad",
|
||||
"operationId": "bad",
|
||||
"ownerLogin": "timmy",
|
||||
"status": "attention",
|
||||
"title": "Fix me",
|
||||
"error": "Title is invalid",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@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 "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
|
||||
assert "issueOutbox.retry(queued.id, activeFlushLogin)" in html
|
||||
assert "navigator.onLine" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
||||
assert "flushIssueOutbox();" in html
|
||||
assert 'class="draft-send"' in html
|
||||
assert 'class="draft-edit"' in html
|
||||
assert 'Queued for sync' in html
|
||||
assert 'Needs attention' in html
|
||||
assert "issueOutbox.discard(item.outbox_id)" in html
|
||||
assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.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_exact_local_time_is_validated_and_converted_to_an_instant():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const picker = createLaterPicker({{
|
||||
now: () => new Date(2026, 7, 9, 14, 0, 0),
|
||||
}});
|
||||
const valid = picker.parse('2026-08-09T16:30');
|
||||
const past = picker.parse('2026-08-09T13:59');
|
||||
const invalid = picker.parse('2026-02-30T12:00');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
valid: {{ok:valid.ok, local:[valid.value.getFullYear(), valid.value.getMonth() + 1, valid.value.getDate(), valid.value.getHours(), valid.value.getMinutes()]}},
|
||||
past, invalid,
|
||||
}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["valid"] == {"ok": True, "local": [2026, 8, 9, 16, 30]}
|
||||
assert output["past"] == {
|
||||
"ok": False,
|
||||
"message": "Choose a future date and time.",
|
||||
}
|
||||
assert output["invalid"] == {
|
||||
"ok": False,
|
||||
"message": "Choose a valid local date and time.",
|
||||
}
|
||||
|
||||
|
||||
def test_picker_uses_history_for_cancel_and_confirms_only_valid_future_time():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const listeners = {{}};
|
||||
const states = [];
|
||||
const confirmed = [];
|
||||
let focused = 0;
|
||||
const history = {{
|
||||
state: {{workRoute:'issue'}},
|
||||
pushState(state) {{ this.state = state; states.push(['push', state.laterPicker]); }},
|
||||
back() {{ states.push(['back']); const previous = {{workRoute:'issue'}}; this.state = previous; listeners.popstate({{state:previous}}); }},
|
||||
}};
|
||||
const picker = createLaterPicker({{
|
||||
now: () => new Date(2026, 7, 9, 14, 0, 0), history,
|
||||
eventTarget: {{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
|
||||
onState: state => states.push([state.open, state.message || '']),
|
||||
onConfirm: (item, value, context) => {{ confirmed.push([item.number, value.toISOString(), context]); return true; }},
|
||||
}});
|
||||
picker.start();
|
||||
picker.open({{number:17}}, {{focus:() => {{ focused += 1; }}}}, 'detail');
|
||||
const invalid = picker.submit('2026-08-09T13:00');
|
||||
const valid = picker.submit('2026-08-09T16:30');
|
||||
picker.submit('2026-08-09T17:30');
|
||||
process.stdout.write(JSON.stringify({{invalid, valid, confirmed, focused, current:picker.current(), states}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["invalid"] is False
|
||||
assert output["valid"] is True
|
||||
assert output["confirmed"] == [[17, "2026-08-09T16:30:00.000Z", "detail"]]
|
||||
assert output["focused"] == 1
|
||||
assert output["current"] is False
|
||||
assert [False, "Choose a future date and time."] not in output["states"]
|
||||
assert [True, "Choose a future date and time."] in output["states"]
|
||||
assert ["back"] in output["states"]
|
||||
|
||||
|
||||
def test_picker_runs_detail_cleanup_only_after_its_history_entry_closes():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const listeners = {{}};
|
||||
const order = [];
|
||||
const history = {{
|
||||
state: {{workRoute:'issue'}},
|
||||
pushState(state) {{ this.state = state; }},
|
||||
back() {{
|
||||
order.push('picker-back');
|
||||
this.state = {{workRoute:'issue'}};
|
||||
listeners.popstate({{state:this.state}});
|
||||
}},
|
||||
}};
|
||||
const picker = createLaterPicker({{
|
||||
now:() => new Date(2026, 7, 9, 14, 0), history,
|
||||
eventTarget:{{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
|
||||
onConfirm:() => () => order.push('detail-close'),
|
||||
}});
|
||||
picker.start();
|
||||
picker.open({{number:17}}, null, 'detail');
|
||||
const saved = picker.submit('2026-08-09T16:30');
|
||||
process.stdout.write(JSON.stringify({{saved, order}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"saved": True, "order": ["picker-back", "detail-close"]}
|
||||
|
||||
|
||||
def test_picker_ignores_a_second_submit_while_browser_back_is_pending():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const listeners = {{}};
|
||||
let popstate;
|
||||
let confirmations = 0;
|
||||
const history = {{
|
||||
state:null,
|
||||
pushState(state) {{ this.state = state; }},
|
||||
back() {{ popstate = () => {{ this.state = {{}}; listeners.popstate({{state:this.state}}); }}; }},
|
||||
}};
|
||||
const picker = createLaterPicker({{
|
||||
now:() => new Date(2026, 7, 9, 14, 0), history,
|
||||
eventTarget:{{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
|
||||
onConfirm:() => {{ confirmations += 1; return true; }},
|
||||
}});
|
||||
picker.start();
|
||||
picker.open({{number:17}});
|
||||
const first = picker.submit('2026-08-09T16:30');
|
||||
const second = picker.submit('2026-08-09T16:30');
|
||||
popstate();
|
||||
process.stdout.write(JSON.stringify({{first, second, confirmations}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"first": True, "second": False, "confirmations": 1}
|
||||
@@ -0,0 +1,277 @@
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.later_store import LaterStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transient_later_store_failure_tells_clients_when_to_retry(monkeypatch):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
class BusyStore:
|
||||
def apply(self, *_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("database is busy")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_later_store", lambda: BusyStore())
|
||||
payload = main.LaterOperation(
|
||||
operation_id="retry-me",
|
||||
action="defer",
|
||||
item_id="issue:r:1:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.update_later_plan(payload)
|
||||
|
||||
assert raised.value.status_code == 503
|
||||
assert raised.value.headers == {"Retry-After": "1"}
|
||||
|
||||
|
||||
def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path)
|
||||
|
||||
deferred = store.apply(
|
||||
"Timmy",
|
||||
"op-1",
|
||||
"defer",
|
||||
"issue:stackchain/dashboard:363:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
duplicate = store.apply(
|
||||
"timmy",
|
||||
"op-1",
|
||||
"defer",
|
||||
"issue:stackchain/dashboard:363:",
|
||||
wake_at="2026-08-11T09:00:00.000Z",
|
||||
)
|
||||
|
||||
assert duplicate == deferred == {
|
||||
"revision": 1,
|
||||
"records": {
|
||||
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
|
||||
},
|
||||
}
|
||||
assert LaterStore(path).get("timmy") == deferred
|
||||
assert store.get("alexander") == {"revision": 0, "records": {}}
|
||||
|
||||
assert store.apply(
|
||||
"timmy", "op-2", "restore", "issue:stackchain/dashboard:363:"
|
||||
) == {"revision": 2, "records": {}}
|
||||
|
||||
|
||||
def test_batch_applies_in_one_ordered_idempotent_unit(tmp_path):
|
||||
store = LaterStore(tmp_path / "later.sqlite3")
|
||||
operations = [
|
||||
{"operation_id": "first", "action": "defer", "item_id": "issue:r:1:", "wake_at": "2026-08-10T09:00:00.000Z"},
|
||||
{"operation_id": "second", "action": "restore", "item_id": "issue:r:1:"},
|
||||
]
|
||||
|
||||
result = store.apply_batch("timmy", operations)
|
||||
assert result == {
|
||||
"revision": 2,
|
||||
"records": {},
|
||||
"accepted_operation_ids": ["first", "second"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [],
|
||||
}
|
||||
replay = store.apply_batch("timmy", operations)
|
||||
assert replay["revision"] == 2
|
||||
assert replay["accepted_operation_ids"] == []
|
||||
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")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LATER_DB", str(tmp_path / "later.sqlite3"))
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
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"}
|
||||
)
|
||||
forbidden = await client.patch(
|
||||
"/api/v1/later",
|
||||
json={
|
||||
"operation_id": "mobile-1",
|
||||
"action": "defer",
|
||||
"item_id": "issue:stackchain/dashboard:363:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
},
|
||||
)
|
||||
changed = await client.patch(
|
||||
"/api/v1/later",
|
||||
json={
|
||||
"operations": [
|
||||
{"operation_id": "mobile-1", "action": "defer", "item_id": "issue:stackchain/dashboard:363:", "wake_at": "2026-08-10T09:00:00.000Z"},
|
||||
{"operation_id": "mobile-2", "action": "defer", "item_id": "issue:stackchain/dashboard:365:", "wake_at": "2026-08-11T09:00:00.000Z"},
|
||||
],
|
||||
},
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"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
|
||||
assert changed.status_code == 200
|
||||
assert changed.json() == {
|
||||
"revision": 2,
|
||||
"records": {
|
||||
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
|
||||
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
|
||||
},
|
||||
"accepted_operation_ids": ["mobile-1", "mobile-2"],
|
||||
"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",
|
||||
}}
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
@@ -0,0 +1,351 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
LATER_SYNC = Path(__file__).parents[1] / "frontend" / "later-sync.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||
COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(
|
||||
subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
)
|
||||
|
||||
|
||||
def test_two_tabs_cannot_erase_each_others_later_edits_or_double_drain():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const createCoordinator=require({json.dumps(str(COORDINATOR))});
|
||||
const values=new Map();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 channels=[];const channelFactory=()=>{{const channel={{onmessage:null,postMessage:data=>channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data}})),close(){{}}}};channels.push(channel);return channel}};
|
||||
const held=new Set();const locks={{request:async(name,_options,work)=>{{if(held.has(name))return work(null);held.add(name);try{{return await work({{name}})}}finally{{held.delete(name)}}}}}};
|
||||
let sequence=0,releaseFirst;const delivered=[];
|
||||
const make=tab=>createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=>tab+'-'+(++sequence),coordinator:createCoordinator({{storage,locks,channelFactory,tabId:tab}}),fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};const operation=JSON.parse(options.body).operations[0];delivered.push(operation);if(delivered.length===1)await new Promise(resolve=>releaseFirst=resolve);return {{revision:delivered.length,records:{{[operation.item_id]:operation.wake_at}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
|
||||
const first=make('first'),second=make('second');first.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{const draining=first.flush();while(!releaseFirst)await Promise.resolve();second.enqueue('defer','issue:r:2:','2026-08-11T09:00:00.000Z');const competing=second.flush();releaseFirst();await Promise.all([draining,competing]);await first.flush();process.stdout.write(JSON.stringify({{delivered,pending:first.pending(),keys:[...values.keys()]}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert sorted(operation["operation_id"] for operation in result["delivered"]) == ["first-1", "second-2"]
|
||||
assert len(result["delivered"]) == 2
|
||||
assert result["pending"] == []
|
||||
assert not any(".operation." in key for key in result["keys"])
|
||||
|
||||
|
||||
def test_concurrent_later_records_for_one_item_deliver_only_the_newest_intent():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();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 prefix='stackchain.later-sync.v1.timmy.operation.';
|
||||
values.set(prefix+'old',JSON.stringify({{queued_at:100,operation:{{operation_id:'old',action:'defer',item_id:'issue:r:2:',wake_at:'2026-08-10T09:00:00.000Z'}}}}));
|
||||
values.set(prefix+'new',JSON.stringify({{queued_at:101,operation:{{operation_id:'new',action:'restore',item_id:'issue:r:2:',wake_at:null}}}}));
|
||||
const delivered=[];const sync=createLaterSync({{storage,getLogin:()=> 'timmy',fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};delivered.push(...JSON.parse(options.body).operations);return {{revision:1,records:{{}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{delivered,pending:sync.pending(),keys:[...values.keys()]}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert [operation["operation_id"] for operation in result["delivered"]] == ["new"]
|
||||
assert result["pending"] == []
|
||||
assert not any(".operation." in key for key in result["keys"])
|
||||
|
||||
|
||||
def test_offline_deferral_replays_once_and_adopts_server_records():
|
||||
script = f"""
|
||||
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
||||
const values = new Map();
|
||||
const requests = [];
|
||||
let remote = {{revision:1,records:{{'issue:r:9:':'2026-08-11T09:00:00.000Z'}}}};
|
||||
const sync = createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy', createOperationId:()=> 'offline-op',
|
||||
fetchJson:async (url,options={{}})=>{{
|
||||
requests.push({{url,body:options.body&&JSON.parse(options.body)}});
|
||||
if (!options.method) return remote;
|
||||
const operation=JSON.parse(options.body).operations[0];
|
||||
remote={{revision:2,records:{{...remote.records,[operation.item_id]:operation.wake_at}}}};
|
||||
return remote;
|
||||
}},
|
||||
onRemoteRecords:records=>{{globalThis.records=records}},
|
||||
onStatus:status=>{{globalThis.status=status}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{await sync.flush();await sync.flush();process.stdout.write(JSON.stringify({{
|
||||
requests,records:globalThis.records,status:globalThis.status,pending:sync.pending()
|
||||
}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"api/v1/later",
|
||||
"api/v1/later",
|
||||
]
|
||||
assert result["requests"][0]["body"] == {"operations": [{
|
||||
"operation_id": "offline-op",
|
||||
"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))});
|
||||
const values=new Map();const requests=[];let sequence=0;
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
|
||||
fetchJson:async(_url,options={{}})=>{{const body=JSON.parse(options.body);requests.push(body);return {{revision:2,records:{{}},accepted_operation_ids:body.operations.map(x=>x.operation_id),duplicate_operation_ids:[],rejected_operations:[]}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');
|
||||
sync.enqueue('restore','issue:r:2:');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
result=run_node(script)
|
||||
assert len(result["requests"]) == 1
|
||||
assert [item["operation_id"] for item in result["requests"][0]["operations"]] == ["op-1", "op-2"]
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_latest_offline_intent_wins_and_failed_delivery_stays_pending():
|
||||
script = f"""
|
||||
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); let sequence=0;
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
|
||||
fetchJson:async()=>{{throw new Error('offline')}},onRemoteRecords:()=>{{}},onStatus:s=>{{globalThis.status=s}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
sync.enqueue('restore','issue:r:2:');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}}));}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"pending": [
|
||||
{
|
||||
"operation_id": "op-2",
|
||||
"action": "restore",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": None,
|
||||
"base_revision": 0,
|
||||
}
|
||||
],
|
||||
"status": "retrying",
|
||||
}
|
||||
|
||||
|
||||
def test_retry_is_single_flight_and_does_not_cross_account_boundary():
|
||||
script = f"""
|
||||
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const timers=[]; const requests=[]; let login='timmy';
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=>login,createOperationId:()=> 'stable-later-op',
|
||||
setTimer:(callback,delay)=>{{timers.push({{callback,delay}});return timers.length}},clearTimer:()=>{{}},
|
||||
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');throw new Error('offline')}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{
|
||||
const first=sync.flush(); const same=sync.flush(); await Promise.all([first,same]);
|
||||
const scheduledBeforeSwitch=timers.length;
|
||||
login='alexander'; await timers[0].callback();
|
||||
process.stdout.write(JSON.stringify({{scheduledBeforeSwitch,timers:timers.length,requests,pendingForAlexander:sync.pending()}}));
|
||||
}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"scheduledBeforeSwitch": 1,
|
||||
"timers": 1,
|
||||
"requests": ["PATCH"],
|
||||
"pendingForAlexander": [],
|
||||
}
|
||||
|
||||
|
||||
def test_change_queued_during_delivery_is_drained_before_flush_settles():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const actions=[]; let sequence=0; let releaseFirst;
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
|
||||
fetchJson:async (_url,options={{}})=>{{
|
||||
if (!options.method) return {{revision:0,records:{{}}}};
|
||||
const operation=JSON.parse(options.body).operations[0]; actions.push(operation.action);
|
||||
if (operation.action==='defer') await new Promise(resolve=>releaseFirst=resolve);
|
||||
return {{revision:actions.length,records:operation.action==='defer'?{{[operation.item_id]:operation.wake_at}}:{{}}}};
|
||||
}},onRemoteRecords:r=>{{globalThis.records=r}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{const flushing=sync.flush();while(!releaseFirst) await Promise.resolve();
|
||||
sync.enqueue('restore','issue:r:2:');releaseFirst();await flushing;
|
||||
process.stdout.write(JSON.stringify({{actions,pending:sync.pending(),records:globalThis.records}}));}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"actions": ["defer", "restore"],
|
||||
"pending": [],
|
||||
"records": {},
|
||||
}
|
||||
|
||||
|
||||
def test_tabs_reject_an_older_snapshot_after_a_newer_revision():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
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 listeners=[];
|
||||
const createChannel=()=>({{addEventListener:(_n,h)=>listeners.push(h),postMessage:data=>listeners.forEach(h=>h({{data}}))}});
|
||||
let resolveOld,resolveNew; const oldHistory=[],newHistory=[];
|
||||
const oldTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveOld=r),onRemoteRecords:r=>oldHistory.push(r),onStatus:()=>{{}}}});
|
||||
const newTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveNew=r),onRemoteRecords:r=>newHistory.push(r),onStatus:()=>{{}}}});
|
||||
(async()=>{{const oldFlush=oldTab.flush();const newFlush=newTab.flush();await Promise.resolve();
|
||||
resolveNew({{revision:2,records:{{new:'2026-08-11T09:00:00.000Z'}}}});await newFlush;
|
||||
resolveOld({{revision:1,records:{{old:'2026-08-10T09:00:00.000Z'}}}});await oldFlush;
|
||||
process.stdout.write(JSON.stringify({{oldHistory,newHistory}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["oldHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
|
||||
assert result["newHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
|
||||
|
||||
|
||||
def test_existing_browser_records_migrate_once_and_lifecycle_replays():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const handlers={{}}; let sequence=0; const requests=[];
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'migration-'+(++sequence),
|
||||
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:options.method?1:0,records:{{}}}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
const records={{'issue:r:1:':'2026-08-10T09:00:00.000Z'}};
|
||||
const first=sync.migrate(records),second=sync.migrate(records);
|
||||
sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:{{hidden:false,addEventListener:()=>{{}}}}}});
|
||||
(async()=>{{await handlers.online();process.stdout.write(JSON.stringify({{first,second,requests,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"first": True,
|
||||
"second": False,
|
||||
"requests": ["PATCH"],
|
||||
"pending": [],
|
||||
}
|
||||
|
||||
|
||||
def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
|
||||
script = f"""
|
||||
const createLaterWork=require({json.dumps(str(LATER_WORK))});
|
||||
const values=new Map(); const changes=[]; const expired=[];
|
||||
let clock=new Date('2026-08-08T12:00:00Z');
|
||||
const item={{kind:'issue',repository:'stackchain/api',number:17}};
|
||||
const work=createLaterWork({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
|
||||
onChange:(action,id,wakeAt)=>changes.push([action,id,wakeAt]),
|
||||
onExpire:ids=>expired.push(ids),
|
||||
}});
|
||||
work.defer(item,new Date('2026-08-08T16:00:00Z'));
|
||||
work.restore(item);
|
||||
work.adopt({{'issue:stackchain/api:17:':'2026-08-09T09:00:00.000Z'}});
|
||||
const remote=work.partition([item]);
|
||||
clock=new Date('2026-08-09T09:00:01Z');
|
||||
const awake=work.partition([item]);
|
||||
process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:awake.active}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["changes"] == [
|
||||
["defer", "issue:stackchain/api:17:", "2026-08-08T16:00:00.000Z"],
|
||||
["restore", "issue:stackchain/api:17:", None],
|
||||
]
|
||||
assert result["remote"][0]["deferred_until"] == "2026-08-09T09:00:00.000Z"
|
||||
assert result["awake"] == [{"kind": "issue", "repository": "stackchain/api", "number": 17}]
|
||||
assert result["expired"] == [["issue:stackchain/api:17:"]]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/later-sync.js"></script>' in html
|
||||
assert 'id="later-sync-status"' in html
|
||||
assert "const laterSync = createLaterSync({" in html
|
||||
assert "onRemoteRecords: records =>" in html
|
||||
assert "onChange: (action, itemId, wakeAt) =>" in html
|
||||
assert "onExpire: ids =>" in html
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt)" in html
|
||||
assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
|
||||
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-v56" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -0,0 +1,642 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_live_snapshot_task():
|
||||
main._live_snapshot_task = None
|
||||
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_failure_count = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_section_retry_at = {
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_snapshot_refreshing_sections = set()
|
||||
main._live_section_revisions = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
yield
|
||||
main._live_snapshot_task = None
|
||||
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_failure_count = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_section_retry_at = {
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_snapshot_refreshing_sections = set()
|
||||
main._live_section_revisions = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
|
||||
|
||||
def payload(response):
|
||||
return json.loads(response.body)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(monkeypatch):
|
||||
calls = {"user": 0}
|
||||
|
||||
async def user():
|
||||
calls["user"] += 1
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(authenticated_user):
|
||||
assert authenticated_user["login"] == "timmy"
|
||||
return [{"type": "push"}]
|
||||
|
||||
async def updates():
|
||||
return {
|
||||
"items": [{"id": 42, "title": "Mentioned you"}],
|
||||
"page": 1,
|
||||
"total": 125,
|
||||
"has_more": True,
|
||||
}
|
||||
|
||||
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", events)
|
||||
monkeypatch.setattr(main, "notifications", updates)
|
||||
|
||||
response = await main.live_snapshot()
|
||||
result = payload(response)
|
||||
|
||||
assert calls["user"] == 1
|
||||
assert result["context"]["user"]["login"] == "timmy"
|
||||
assert result["events"] == [{"type": "push"}]
|
||||
assert result["notifications"] == [{"id": 42, "title": "Mentioned you"}]
|
||||
assert result["notification_pagination"] == {
|
||||
"page": 1, "total": 125, "has_more": True
|
||||
}
|
||||
assert result["sections"] == {
|
||||
"context": "fresh", "events": "fresh", "notifications": "fresh"
|
||||
}
|
||||
assert set(result["revisions"]) == {"context", "events", "notifications"}
|
||||
assert len(set(result["revisions"].values())) == 1
|
||||
assert result["revisions"]["context"].endswith(".1")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(_authenticated_user):
|
||||
return [{"type": "push"}]
|
||||
|
||||
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", events)
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
first = payload(await main.live_snapshot())
|
||||
second = payload(await main.live_snapshot(
|
||||
context_revision=first["revisions"]["context"],
|
||||
events_revision=first["revisions"]["events"],
|
||||
notifications_revision=first["revisions"]["notifications"],
|
||||
))
|
||||
|
||||
assert "context" not in second
|
||||
assert "events" not in second
|
||||
assert "notifications" not in second
|
||||
assert "notification_pagination" not in second
|
||||
assert second["sections"] == first["sections"]
|
||||
assert second["revisions"] == first["revisions"]
|
||||
assert "freshness" in second
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_token_from_another_worker_never_suppresses_content(monkeypatch):
|
||||
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)
|
||||
monkeypatch.setattr(main, "_live_revision_generation", "worker-a")
|
||||
|
||||
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
|
||||
}
|
||||
monkeypatch.setattr(main, "_live_revision_generation", "worker-b")
|
||||
|
||||
worker_b = payload(await main.live_snapshot(context_revision=worker_a_token))
|
||||
|
||||
assert worker_a_token == "worker-a.1"
|
||||
assert worker_b["revisions"]["context"] == "worker-b.1"
|
||||
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():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def failing_events(authenticated_user):
|
||||
raise ConnectionError("secret upstream detail")
|
||||
|
||||
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", failing_events)
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
result = payload(await main.live_snapshot())
|
||||
|
||||
assert result["context"]["user"]["login"] == "timmy"
|
||||
assert result["events"] is None
|
||||
assert result["sections"] == {
|
||||
"context": "fresh",
|
||||
"events": "temporarily unavailable",
|
||||
"notifications": "fresh",
|
||||
}
|
||||
assert "secret" not in json.dumps(result)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_keeps_work_and_activity_when_notifications_fail(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(_authenticated_user):
|
||||
return [{"type": "push"}]
|
||||
|
||||
async def failing_updates():
|
||||
raise ConnectionError("private notification failure")
|
||||
|
||||
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", events)
|
||||
monkeypatch.setattr(main, "notifications", failing_updates)
|
||||
|
||||
result = payload(await main.live_snapshot())
|
||||
|
||||
assert result["context"]["user"]["login"] == "timmy"
|
||||
assert result["events"] == [{"type": "push"}]
|
||||
assert result["notifications"] is None
|
||||
assert result["sections"]["notifications"] == "temporarily unavailable"
|
||||
assert "private" not in json.dumps(result)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def failing_repos():
|
||||
raise ConnectionError("work unavailable")
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(authenticated_user):
|
||||
return [{"type": "push"}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", failing_repos)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", events)
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
result = payload(await main.live_snapshot())
|
||||
|
||||
assert result["context"] is None
|
||||
assert result["events"] == [{"type": "push"}]
|
||||
assert result["sections"] == {
|
||||
"context": "temporarily unavailable",
|
||||
"events": "fresh",
|
||||
"notifications": "fresh",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_reuses_completed_snapshot_inside_freshness_window(monkeypatch):
|
||||
user_calls = 0
|
||||
release = asyncio.Event()
|
||||
|
||||
async def user():
|
||||
nonlocal user_calls
|
||||
user_calls += 1
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def blocked_repos():
|
||||
await release.wait()
|
||||
return []
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(authenticated_user):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", blocked_repos)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", events)
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
first = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
second = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
assert user_calls == 1
|
||||
|
||||
release.set()
|
||||
await asyncio.gather(first, second)
|
||||
await main.live_snapshot()
|
||||
|
||||
assert user_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_snapshot_returns_immediately_while_one_refresh_revalidates(monkeypatch):
|
||||
now = 100.0
|
||||
refresh_started = asyncio.Event()
|
||||
release_refresh = asyncio.Event()
|
||||
builds = 0
|
||||
|
||||
async def snapshot(_sections=None):
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds == 2:
|
||||
refresh_started.set()
|
||||
await release_refresh.wait()
|
||||
return {
|
||||
"context": {"generation": builds},
|
||||
"events": [],
|
||||
"notifications": [],
|
||||
"sections": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||
|
||||
first = payload(await main.live_snapshot())
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
stale_request = asyncio.create_task(main.live_snapshot())
|
||||
await refresh_started.wait()
|
||||
await asyncio.sleep(0)
|
||||
returned_immediately = stale_request.done()
|
||||
release_refresh.set()
|
||||
stale = payload(await stale_request)
|
||||
|
||||
assert returned_immediately is True
|
||||
assert first["context"]["generation"] == 1
|
||||
assert stale["context"]["generation"] == 1
|
||||
assert stale["freshness"]["stale"] is True
|
||||
assert stale["freshness"]["revalidating"] is True
|
||||
assert builds == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_revalidation_enters_cooldown_and_keeps_last_snapshot(monkeypatch):
|
||||
now = 100.0
|
||||
builds = 0
|
||||
|
||||
async def snapshot(_sections=None):
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds > 1:
|
||||
raise ConnectionError("private upstream failure")
|
||||
return {
|
||||
"context": {"generation": 1},
|
||||
"events": [{"id": 1}],
|
||||
"notifications": [],
|
||||
"sections": {
|
||||
"context": "fresh",
|
||||
"events": "fresh",
|
||||
"notifications": "fresh",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", 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)
|
||||
degraded = payload(await main.live_snapshot())
|
||||
|
||||
assert stale["freshness"]["revalidating"] is True
|
||||
assert degraded["context"] == {"generation": 1}
|
||||
assert degraded["freshness"]["stale"] is True
|
||||
assert degraded["freshness"]["degraded"] is True
|
||||
assert degraded["freshness"]["last_refresh_failed"] is True
|
||||
assert degraded["freshness"]["revalidating"] is False
|
||||
assert degraded["freshness"]["retry_in_seconds"] > 0
|
||||
assert builds == 2
|
||||
assert "private" not in json.dumps(degraded)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_partial_refresh_updates_fresh_sections_and_retains_failed_sections(monkeypatch):
|
||||
now = 100.0
|
||||
builds = 0
|
||||
|
||||
async def snapshot(_sections=None):
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds == 1:
|
||||
return {
|
||||
"context": {"generation": 1},
|
||||
"events": [{"id": "last-known"}],
|
||||
"notifications": [{"id": 7}],
|
||||
"notification_pagination": {"page": 1, "total": 1, "has_more": False},
|
||||
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
|
||||
}
|
||||
return {
|
||||
"context": {"generation": 2},
|
||||
"events": None,
|
||||
"notifications": [{"id": 8}],
|
||||
"notification_pagination": {"page": 1, "total": 1, "has_more": False},
|
||||
"sections": {
|
||||
"context": "fresh",
|
||||
"events": "temporarily unavailable",
|
||||
"notifications": "fresh",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", 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)
|
||||
result = payload(await main.live_snapshot())
|
||||
|
||||
assert result["context"] == {"generation": 2}
|
||||
assert result["events"] == [{"id": "last-known"}]
|
||||
assert result["notifications"] == [{"id": 8}]
|
||||
assert result["sections"] == {
|
||||
"context": "fresh",
|
||||
"events": "stale",
|
||||
"notifications": "fresh",
|
||||
}
|
||||
assert result["freshness"]["degraded"] is True
|
||||
assert result["freshness"]["revalidating"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_cooldown_does_not_stop_due_work_and_activity_refreshes(monkeypatch):
|
||||
now = 100.0
|
||||
calls = {"context": 0, "events": 0, "notifications": 0}
|
||||
event_refresh_times = []
|
||||
notification_outage = False
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def work():
|
||||
calls["context"] += 1
|
||||
return []
|
||||
|
||||
async def empty_work():
|
||||
return []
|
||||
|
||||
async def events(_authenticated_user):
|
||||
calls["events"] += 1
|
||||
event_refresh_times.append(now)
|
||||
return [{"generation": calls["events"]}]
|
||||
|
||||
async def updates():
|
||||
calls["notifications"] += 1
|
||||
if notification_outage:
|
||||
raise ConnectionError("notifications unavailable")
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", work)
|
||||
monkeypatch.setattr(main, "issues", empty_work)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_work)
|
||||
monkeypatch.setattr(main, "activity_events", events)
|
||||
monkeypatch.setattr(main, "notifications", updates)
|
||||
|
||||
await main.live_snapshot()
|
||||
notification_outage = True
|
||||
|
||||
for elapsed in (
|
||||
main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1,
|
||||
main.LIVE_SNAPSHOT_RETRY_BASE_SECONDS,
|
||||
):
|
||||
now += elapsed
|
||||
await main.live_snapshot()
|
||||
assert main._live_snapshot_task is not None
|
||||
await main._live_snapshot_task
|
||||
|
||||
# Notification backoff is now 10 seconds. The healthy sections become due
|
||||
# one second before that cooldown expires and must refresh independently.
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
response = payload(await main.live_snapshot())
|
||||
assert main._live_snapshot_task is not None
|
||||
await main._live_snapshot_task
|
||||
refreshed = payload(await main.live_snapshot())
|
||||
|
||||
assert calls == {"context": 3, "events": 3, "notifications": 3}
|
||||
assert event_refresh_times == [100.0, 109.0, 123.0]
|
||||
assert response["freshness"]["sections"]["context"]["revalidating"] is True
|
||||
assert refreshed["events"] == [{"generation": 3}]
|
||||
assert refreshed["sections"]["notifications"] == "stale"
|
||||
assert refreshed["freshness"]["sections"]["notifications"]["retry_in_seconds"] == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state(monkeypatch):
|
||||
now = 100.0
|
||||
builds = 0
|
||||
retry_started = asyncio.Event()
|
||||
release_retry = asyncio.Event()
|
||||
|
||||
async def snapshot(_sections=None):
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds == 1:
|
||||
return {
|
||||
"context": {"generation": 1}, "events": [], "notifications": [],
|
||||
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
|
||||
}
|
||||
if builds == 2:
|
||||
return {
|
||||
"context": None, "events": [], "notifications": [],
|
||||
"sections": {
|
||||
"context": "temporarily unavailable",
|
||||
"events": "fresh",
|
||||
"notifications": "fresh",
|
||||
},
|
||||
}
|
||||
retry_started.set()
|
||||
await release_retry.wait()
|
||||
return {
|
||||
"context": {"generation": 3}, "events": [], "notifications": [],
|
||||
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", 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)
|
||||
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()
|
||||
assert builds == 3
|
||||
assert payload(await first)["freshness"]["revalidating"] is True
|
||||
assert payload(await second)["freshness"]["revalidating"] is True
|
||||
|
||||
release_retry.set()
|
||||
refresh_task = main._live_snapshot_task
|
||||
assert refresh_task is not None
|
||||
await refresh_task
|
||||
recovered = payload(await main.live_snapshot())
|
||||
|
||||
assert recovered["context"] == {"generation": 3}
|
||||
assert recovered["freshness"]["degraded"] is False
|
||||
assert recovered["freshness"]["last_refresh_failed"] is False
|
||||
assert recovered["freshness"]["retry_in_seconds"] == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancelling_one_waiter_does_not_cancel_the_shared_snapshot(monkeypatch):
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
snapshot_calls = 0
|
||||
|
||||
async def blocked_snapshot(_sections=None):
|
||||
nonlocal snapshot_calls
|
||||
snapshot_calls += 1
|
||||
started.set()
|
||||
await release.wait()
|
||||
return {
|
||||
"context": {},
|
||||
"events": [],
|
||||
"notifications": [],
|
||||
"sections": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", blocked_snapshot)
|
||||
|
||||
disconnected = asyncio.create_task(main.live_snapshot())
|
||||
await started.wait()
|
||||
survivor = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
disconnected.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await disconnected
|
||||
|
||||
assert main._live_snapshot_task is not None
|
||||
assert not main._live_snapshot_task.done()
|
||||
|
||||
release.set()
|
||||
response = await survivor
|
||||
|
||||
assert response.status_code == 200
|
||||
assert snapshot_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shared_snapshot_deadline_cancels_upstream_work_for_all_waiters(monkeypatch):
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def snapshot_that_exceeds_deadline(_sections=None):
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot_that_exceeds_deadline)
|
||||
monkeypatch.setattr(main, "CONTEXT_TIMEOUT_SECONDS", 0.01)
|
||||
|
||||
first = asyncio.create_task(main.live_snapshot())
|
||||
await started.wait()
|
||||
second = asyncio.create_task(main.live_snapshot())
|
||||
|
||||
try:
|
||||
responses = await asyncio.gather(first, second)
|
||||
await asyncio.wait_for(cancelled.wait(), timeout=0.1)
|
||||
|
||||
assert [response.status_code for response in responses] == [503, 503]
|
||||
assert [payload(response)["error"] for response in responses] == [
|
||||
"Gitea live snapshot timed out after 0.01s",
|
||||
"Gitea live snapshot timed out after 0.01s",
|
||||
]
|
||||
finally:
|
||||
task = main._live_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
@@ -0,0 +1,74 @@
|
||||
import sqlite3
|
||||
|
||||
from src.login_attempt_store import LoginAttemptStore, client_source
|
||||
|
||||
|
||||
def test_forwarded_client_is_used_only_for_explicitly_trusted_proxies():
|
||||
trusted = "127.0.0.0/8, 10.0.0.0/8"
|
||||
|
||||
assert client_source("198.51.100.4", "203.0.113.7", trusted) == "198.51.100.4"
|
||||
assert (
|
||||
client_source("127.0.0.1", "203.0.113.7, 10.1.2.3", trusted)
|
||||
== "203.0.113.7"
|
||||
)
|
||||
assert client_source("127.0.0.1", "not-an-ip", trusted) == "127.0.0.1"
|
||||
|
||||
|
||||
def test_failure_budget_is_shared_across_store_instances_and_expires(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "login-attempts.sqlite3"
|
||||
first = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
second = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
assert first.retry_after("203.0.113.7") == 0
|
||||
first.record_failure("203.0.113.7")
|
||||
second.record_failure("203.0.113.7")
|
||||
first.record_failure("203.0.113.7")
|
||||
|
||||
assert second.retry_after("203.0.113.7") == 60
|
||||
now[0] = 1_060.0
|
||||
assert first.retry_after("203.0.113.7") == 0
|
||||
|
||||
|
||||
def test_recording_a_failure_prunes_expired_source_records(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
store.record_failure("203.0.113.1")
|
||||
store.record_failure("203.0.113.2")
|
||||
|
||||
now[0] = 1_060.0
|
||||
store.record_failure("203.0.113.3")
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: 1_000.0,
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
max_entries=2,
|
||||
)
|
||||
|
||||
for address in ("203.0.113.1", "203.0.113.2", "203.0.113.3"):
|
||||
store.record_failure(address)
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
|
||||
@@ -0,0 +1,298 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import login
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOGIN_JS = ROOT / "frontend" / "login.js"
|
||||
|
||||
|
||||
def test_expired_session_reason_explains_preserved_private_work():
|
||||
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-expired');
|
||||
const expired = status.textContent;
|
||||
controller.showReason('https://evil.example/redirect');
|
||||
process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert state["expired"] == (
|
||||
"Your session expired. Private drafts remain on this device. "
|
||||
"Sign in to continue."
|
||||
)
|
||||
assert state["ignored"] == state["expired"]
|
||||
|
||||
|
||||
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const state = {{cleared:false, snapshots:[]}};
|
||||
const status = {{textContent:''}};
|
||||
const button = {{disabled:false}};
|
||||
const controller = createLoginController({{
|
||||
form:{{reset:()=>{{}}}}, status, button,
|
||||
fetchImpl:async()=>new Response('{{}}',{{status:200}}),
|
||||
location:{{replace:()=>{{}}}},
|
||||
clearPrivateDeviceData:async()=>{{
|
||||
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
state.cleared=true;
|
||||
}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const pending=controller.showReason('session-revoked');
|
||||
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
|
||||
await pending;
|
||||
state.final={{status:status.textContent,disabled:button.disabled,cleared:state.cleared}};
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert all(snapshot["disabled"] for snapshot in state["snapshots"])
|
||||
assert state["final"] == {
|
||||
"status": "This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.",
|
||||
"disabled": False,
|
||||
"cleared": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_loads_private_data_purger_before_controller():
|
||||
html = await login()
|
||||
|
||||
assert '<script src="static/private-device-data.js"></script>' in html
|
||||
assert html.index('static/private-device-data.js') < html.index('static/login.js')
|
||||
|
||||
|
||||
def test_revoked_session_does_not_claim_success_when_purger_is_unavailable():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const status={{textContent:''}};
|
||||
const button={{disabled:false}};
|
||||
const controller=createLoginController({{
|
||||
form:{{reset:()=>{{}}}},status,button,
|
||||
fetchImpl:async()=>new Response('{{}}',{{status:200}}),location:{{replace:()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await controller.showReason('session-revoked');
|
||||
process.stdout.write(JSON.stringify({{status:status.textContent,disabled:button.disabled}}));
|
||||
}})();
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"status": "This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this site’s data before signing in.",
|
||||
"disabled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_rate_limited_login_disables_submit_and_counts_down():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const state = {{ reset: 0, interval: null }};
|
||||
const status = {{ textContent: '' }};
|
||||
const button = {{ disabled: false }};
|
||||
const form = {{ reset: () => state.reset += 1 }};
|
||||
const controller = createLoginController({{
|
||||
form, status, button,
|
||||
fetchImpl: async () => new Response(JSON.stringify({{ detail: 'Too many sign-in attempts' }}), {{ status: 429, headers: {{ 'Retry-After': '2' }} }}),
|
||||
location: {{ replace: () => {{}} }},
|
||||
setIntervalImpl: callback => {{ state.interval = callback; return 1; }},
|
||||
clearIntervalImpl: () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
await controller.submit('never-store-this-token');
|
||||
state.initial = {{ disabled: button.disabled, status: status.textContent, reset: state.reset }};
|
||||
state.interval();
|
||||
state.afterTick = {{ disabled: button.disabled, status: status.textContent }};
|
||||
state.interval();
|
||||
state.finished = {{ disabled: button.disabled, status: status.textContent }};
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert state["initial"] == {
|
||||
"disabled": True,
|
||||
"status": "Too many attempts. Try again in 2 seconds.",
|
||||
"reset": 1,
|
||||
}
|
||||
assert state["afterTick"]["disabled"] is True
|
||||
assert state["finished"] == {
|
||||
"disabled": False,
|
||||
"status": "You can try signing in again.",
|
||||
}
|
||||
|
||||
|
||||
def test_successful_login_resumes_valid_shared_capture_continuation():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const state = {{ replaced: null }};
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: value => state.replaced = value }},
|
||||
continuation: './?title=Production+crash&url=https%3A%2F%2Fexample.com',
|
||||
}});
|
||||
(async () => {{
|
||||
await controller.submit('operator-token');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout)["replaced"] == (
|
||||
"./?title=Production+crash&url=https%3A%2F%2Fexample.com"
|
||||
)
|
||||
|
||||
|
||||
def test_login_sends_a_bounded_device_label_with_the_access_token():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
let request = null;
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
|
||||
fetchImpl: async (url, options) => {{ request = {{url, body: JSON.parse(options.body)}}; return new Response('{{}}', {{ status: 200 }}); }},
|
||||
location: {{ replace: () => {{}} }},
|
||||
}});
|
||||
(async () => {{
|
||||
await controller.submit('operator-token', 'Timmy’s Pixel');
|
||||
process.stdout.write(JSON.stringify(request));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"url": "api/v1/session",
|
||||
"body": {"access_token": "operator-token", "device_label": "Timmy’s Pixel"},
|
||||
}
|
||||
|
||||
|
||||
def test_shared_capture_login_explains_why_sign_in_is_required():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const status = {{ textContent: '' }};
|
||||
createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: () => {{}} }}, continuation: './?title=Shared',
|
||||
}});
|
||||
process.stdout.write(status.textContent);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert result.stdout == "Sign in to continue your shared capture."
|
||||
|
||||
|
||||
def test_login_rejects_untrusted_or_non_share_continuations():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
async function destination(continuation) {{
|
||||
let replaced = null;
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: value => replaced = value }}, continuation,
|
||||
}});
|
||||
await controller.submit('operator-token');
|
||||
return replaced;
|
||||
}}
|
||||
(async () => {{
|
||||
process.stdout.write(JSON.stringify({{
|
||||
crossOrigin: await destination('https://evil.example/steal'),
|
||||
otherRoute: await destination('./settings?title=Shared'),
|
||||
extraField: await destination('./?title=Shared&next=https%3A%2F%2Fevil.example'),
|
||||
prototypeField: await destination('./?toString=Shared'),
|
||||
oversized: await destination('./?title=' + 'x'.repeat(201)),
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"crossOrigin": "./",
|
||||
"otherRoute": "./",
|
||||
"extraField": "./",
|
||||
"prototypeField": "./",
|
||||
"oversized": "./",
|
||||
}
|
||||
|
||||
|
||||
def test_login_bootstrap_continues_shared_capture_after_submit():
|
||||
harness = f"""
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const state = {{ replaced: null, submit: null }};
|
||||
const elements = {{
|
||||
'sign-in': {{ reset: () => {{}}, addEventListener: (_name, handler) => state.submit = handler }},
|
||||
'status': {{ textContent: '' }},
|
||||
'submit-sign-in': {{ disabled: false }},
|
||||
}};
|
||||
const context = {{
|
||||
URLSearchParams, Response, setInterval, clearInterval,
|
||||
FormData: function () {{ return {{ get: () => 'operator-token' }}; }},
|
||||
fetch: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
document: {{ getElementById: id => elements[id] }},
|
||||
window: {{ location: {{
|
||||
search: '?continue=.%2F%3Ftitle%3DShared%2Blink%26url%3Dhttps%253A%252F%252Fexample.com',
|
||||
replace: value => state.replaced = value,
|
||||
}} }},
|
||||
}};
|
||||
context.fetch.bind = Function.prototype.bind;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(LOGIN_JS))}, 'utf8'), context);
|
||||
state.submit({{ preventDefault: () => {{}} }});
|
||||
setTimeout(() => process.stdout.write(JSON.stringify({{
|
||||
replaced: state.replaced, status: elements.status.textContent,
|
||||
}})), 0);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"replaced": "./?title=Shared+link&url=https%3A%2F%2Fexample.com",
|
||||
"status": "Signing in…",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_page_loads_rate_limit_controller():
|
||||
html = await login()
|
||||
|
||||
assert '<script src="static/login.js"></script>' in html
|
||||
assert "main{box-sizing:border-box" in html
|
||||
assert '<p id="status" role="status" aria-live="polite"></p>' in html
|
||||
assert 'name="device_label"' in html
|
||||
assert 'maxlength="64"' in html
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
@@ -9,6 +10,19 @@ FRONTEND = Path(__file__).parent.parent / "frontend"
|
||||
RENDERER = FRONTEND / "markdown.js"
|
||||
|
||||
|
||||
def render_markdown(payload):
|
||||
script = (
|
||||
f"const render = require({json.dumps(str(RENDERER))});"
|
||||
f"process.stdout.write(render({json.dumps(payload)}));"
|
||||
)
|
||||
return subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -50,3 +64,77 @@ def test_markdown_script_resolves_inside_dashboard_subpath():
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/", markdown_source
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/static/markdown.js"
|
||||
|
||||
|
||||
def test_markdown_renderer_preserves_mobile_reading_structure():
|
||||
rendered = render_markdown(
|
||||
"## Plan\n\n> Ship this\n\n- [x] Tested\n- [ ] Released\n\n"
|
||||
"```python\nprint('ready')\n```"
|
||||
)
|
||||
|
||||
assert rendered == (
|
||||
"<h2>Plan</h2><blockquote><p>Ship this</p></blockquote>"
|
||||
'<ul class="task-list"><li class="task-list-item">'
|
||||
'<input type="checkbox" disabled checked> Tested</li>'
|
||||
'<li class="task-list-item"><input type="checkbox" disabled> Released</li></ul>'
|
||||
'<pre><code class="language-python">print('ready')</code></pre>'
|
||||
)
|
||||
|
||||
|
||||
def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert():
|
||||
rendered = render_markdown(
|
||||
"[Forge](https://forge.example/work?q=1&safe=yes) "
|
||||
"[relative](/git/org/repo/issues/1) "
|
||||
"[attack](javascript:alert(1)) <img src=x onerror=alert(2)>"
|
||||
)
|
||||
|
||||
assert (
|
||||
'<a href="https://forge.example/work?q=1&safe=yes" target="_blank" '
|
||||
'rel="noopener noreferrer">Forge</a>' in rendered
|
||||
)
|
||||
assert (
|
||||
'<a href="/git/org/repo/issues/1" target="_blank" '
|
||||
'rel="noopener noreferrer">relative</a>' in rendered
|
||||
)
|
||||
assert "javascript:" not in rendered
|
||||
assert "<img" not in rendered
|
||||
assert "<img src=x onerror=alert(2)>" in rendered
|
||||
|
||||
|
||||
def test_all_read_only_work_bodies_use_the_shared_markdown_renderer():
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
expected_paths = (
|
||||
"renderMarkdown(comment.body || 'No comment body provided.')",
|
||||
"renderMarkdown(detail.subject_body || 'No subject context was provided.')",
|
||||
"renderMarkdown(detail.body || 'No description provided.')",
|
||||
"renderMarkdown(item.body || 'No description provided.')",
|
||||
"renderMarkdown(review.body)",
|
||||
"renderMarkdown(confirmed.body || 'No description provided.')",
|
||||
)
|
||||
for path in expected_paths:
|
||||
assert path in dashboard
|
||||
|
||||
assert dashboard.count("renderMarkdown(detail.body || 'No description provided.')") == 4
|
||||
|
||||
|
||||
def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
worker = (FRONTEND / "service-worker.js").read_text()
|
||||
|
||||
for body_id in (
|
||||
"issue-sheet-body",
|
||||
"pull-sheet-body",
|
||||
"review-sheet-body",
|
||||
"update-subject-body",
|
||||
"search-preview-body",
|
||||
):
|
||||
body_tag = re.search(rf'<div\b[^>]*\bid="{body_id}"[^>]*>', html)
|
||||
assert body_tag
|
||||
assert "markdown-content" in body_tag.group(0)
|
||||
|
||||
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-v56" in worker
|
||||
@@ -0,0 +1,38 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HTML = ROOT / "frontend" / "index.html"
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||
CSS = ROOT / "frontend" / "dashboard.css"
|
||||
WORKER = ROOT / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def test_all_conversation_composers_are_wired_to_keyboard_safe_panels():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert '<script src="static/mobile-composer-viewport.js"></script>' in html
|
||||
assert "createMobileComposerViewport({" in dashboard
|
||||
for panel, workspace, composer, submit, status in [
|
||||
("#issue-sheet .issue-sheet-panel", ".issue-comment-composer", "#issue-comment", "#send-issue-comment", "#issue-comment-status"),
|
||||
("#pull-sheet .pull-sheet-panel", ".pull-comment-composer", "#pull-comment", "#send-pull-comment", "#pull-comment-status"),
|
||||
("#update-sheet .update-sheet-panel", ".update-reply", "#update-reply", "#send-update-reply", "#update-reply-status"),
|
||||
]:
|
||||
for selector in (panel, workspace, composer, submit, status):
|
||||
assert repr(selector) in dashboard
|
||||
assert "height:var(--composer-viewport-height,100dvh)" in css
|
||||
assert "top:var(--composer-viewport-top,0px)" in css
|
||||
assert ".composer-keyboard-active" in css
|
||||
|
||||
|
||||
def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
html = HTML.read_text()
|
||||
worker = WORKER.read_text()
|
||||
local_assets = set(re.findall(r'(?:src|href)="((?:static/|manifest\.webmanifest)[^"?#]*)', html))
|
||||
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-v56" in worker
|
||||
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-composer-viewport.js"
|
||||
|
||||
|
||||
def run_controller_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
class Target {{
|
||||
constructor() {{ this.listeners = {{}}; }}
|
||||
addEventListener(name, handler) {{ (this.listeners[name] ||= new Set()).add(handler); }}
|
||||
removeEventListener(name, handler) {{ this.listeners[name]?.delete(handler); }}
|
||||
dispatch(name, event = {{}}) {{ for (const handler of this.listeners[name] || []) handler(event); }}
|
||||
count(name) {{ return this.listeners[name]?.size || 0; }}
|
||||
}}
|
||||
function element() {{
|
||||
const target = new Target();
|
||||
target.style = {{
|
||||
values: {{}},
|
||||
setProperty(name, value) {{ this.values[name] = value; }},
|
||||
removeProperty(name) {{ delete this.values[name]; }},
|
||||
}};
|
||||
target.scrollTop = 0;
|
||||
target.reveals = [];
|
||||
target.scrollIntoView = options => target.reveals.push(options);
|
||||
target.contains = candidate => candidate === target || candidate?.workspace === target;
|
||||
return target;
|
||||
}}
|
||||
const viewport = new Target();
|
||||
viewport.offsetTop = 12;
|
||||
viewport.height = 360;
|
||||
const mediaQuery = {{matches:true}};
|
||||
const panel = element();
|
||||
const workspace = element();
|
||||
const composer = element(); composer.workspace = workspace;
|
||||
const submit = element(); submit.workspace = workspace;
|
||||
const status = element(); status.workspace = workspace;
|
||||
const document = {{activeElement:null}};
|
||||
const scheduled = [];
|
||||
const controller = createController({{
|
||||
entries:[{{panel, workspace, composer, submit, status}}], viewport, mediaQuery, document,
|
||||
schedule: callback => scheduled.push(callback),
|
||||
}});
|
||||
function flush() {{ while (scheduled.length) scheduled.shift()(); }}
|
||||
{scenario}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_mobile_composer_uses_visual_viewport_and_reveals_delivery_controls():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
controller.start();
|
||||
panel.scrollTop = 240;
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
viewport.offsetTop = 20;
|
||||
viewport.height = 300;
|
||||
viewport.dispatch('resize');
|
||||
flush();
|
||||
process.stdout.write(JSON.stringify({
|
||||
geometry: panel.style.values,
|
||||
resizeListeners: viewport.count('resize'),
|
||||
scrollListeners: viewport.count('scroll'),
|
||||
revealCount: workspace.reveals.length,
|
||||
revealOptions: workspace.reveals.at(-1),
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"geometry": {
|
||||
"--composer-viewport-top": "20px",
|
||||
"--composer-viewport-height": "300px",
|
||||
},
|
||||
"resizeListeners": 1,
|
||||
"scrollListeners": 1,
|
||||
"revealCount": 2,
|
||||
"revealOptions": {"block": "end", "inline": "nearest"},
|
||||
}
|
||||
|
||||
|
||||
def test_blur_restores_scroll_and_close_clears_geometry_without_duplicate_listeners():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
controller.start();
|
||||
controller.start();
|
||||
panel.scrollTop = 240;
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
composer.dispatch('focus');
|
||||
document.activeElement = null;
|
||||
workspace.dispatch('focusout');
|
||||
flush();
|
||||
const afterBlur = {scrollTop:panel.scrollTop, geometry:{...panel.style.values}, resize:viewport.count('resize')};
|
||||
panel.scrollTop = 99;
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
controller.close(panel);
|
||||
flush();
|
||||
process.stdout.write(JSON.stringify({
|
||||
afterBlur,
|
||||
afterClose:{scrollTop:panel.scrollTop, geometry:panel.style.values, resize:viewport.count('resize'), scroll:viewport.count('scroll')},
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"afterBlur": {"scrollTop": 240, "geometry": {}, "resize": 0},
|
||||
"afterClose": {"scrollTop": 99, "geometry": {}, "resize": 0, "scroll": 0},
|
||||
}
|
||||
|
||||
|
||||
def test_desktop_and_missing_visual_viewport_are_noop():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
mediaQuery.matches = false;
|
||||
controller.start();
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
process.stdout.write(JSON.stringify({geometry:panel.style.values, resize:viewport.count('resize'), reveals:workspace.reveals.length}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"geometry": {}, "resize": 0, "reveals": 0}
|
||||
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MOBILE_LAUNCH = Path(__file__).resolve().parents[1] / "frontend" / "mobile-launch.js"
|
||||
|
||||
|
||||
def test_mobile_launch_selects_priority_queue_without_overriding_saved_choice():
|
||||
script = f"""
|
||||
const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
today: mobileLaunch.chooseFilter({{saved:null, today:2, attention:4}}),
|
||||
attention: mobileLaunch.chooseFilter({{saved:null, today:0, attention:4}}),
|
||||
all: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0}}),
|
||||
saved: mobileLaunch.chooseFilter({{saved:'review', today:2, attention:4}}),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"today": "today",
|
||||
"attention": "attention",
|
||||
"all": "all",
|
||||
"saved": "review",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_disclosure_closes_on_escape_and_restores_launcher_focus():
|
||||
script = f"""
|
||||
const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))});
|
||||
const listeners = {{}};
|
||||
const disclosure = {{open:true, addEventListener(name, fn) {{ listeners[name] = fn; }}}};
|
||||
const launcher = {{focuses:0, focus() {{ this.focuses += 1; }}}};
|
||||
const controller = mobileLaunch.createDisclosure({{disclosure, launcher}});
|
||||
controller.start();
|
||||
let prevented = false;
|
||||
listeners.keydown({{key:'Escape', preventDefault() {{ prevented = true; }}}});
|
||||
process.stdout.write(JSON.stringify({{open:disclosure.open, focuses:launcher.focuses, prevented}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"open": False, "focuses": 1, "prevented": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_launch_progressively_discloses_secondary_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="app-brand"' in html
|
||||
assert 'class="app-live-status"' in html
|
||||
assert 'class="app-menu"' in html
|
||||
assert 'id="app-menu-toggle"' in html
|
||||
assert 'class="work-settings"' in html
|
||||
assert 'id="work-settings-toggle"' in html
|
||||
assert 'id="active-work-queue"' in html
|
||||
assert '<script src="static/mobile-launch.js"></script>' in html
|
||||
service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "BASE + 'static/mobile-launch.js'" in service_worker
|
||||
assert "mobileLaunch.chooseFilter" in html
|
||||
assert "mobileLaunch.createDisclosure" in html
|
||||
assert "@media (max-width: 600px)" in html
|
||||
assert "header { min-height:56px; max-height:64px;" in html
|
||||
assert ".work-settings:not([open]) > .work-settings-panel { display:none; }" in html
|
||||
assert ".app-menu:not([open]) > .app-menu-panel { display:none; }" in html
|
||||
assert ".my-work-actions button { min-height:44px;" in html
|
||||
@@ -0,0 +1,123 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
|
||||
|
||||
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
class FakeElement {{
|
||||
constructor() {{
|
||||
this.listeners = {{}};
|
||||
this.attributes = {{}};
|
||||
this.classList = {{ values:new Set(), contains:value => this.classList.values.has(value) }};
|
||||
this.hidden = false;
|
||||
this.focuses = 0;
|
||||
}}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
click() {{ this.listeners.click({{currentTarget:this}}); }}
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
focus() {{ this.focuses += 1; }}
|
||||
}}
|
||||
const nav = new FakeElement();
|
||||
const buttons = Object.fromEntries(['work','find','new','search','drafts'].map(name => [name, new FakeElement()]));
|
||||
const overlay = new FakeElement();
|
||||
const calls = [];
|
||||
let observerCallback;
|
||||
const dock = createDock({{
|
||||
nav, buttons, overlays:[overlay],
|
||||
actions: Object.fromEntries(Object.keys(buttons).map(name => [name, () => calls.push(name)])),
|
||||
observe(callback) {{ observerCallback = callback; return {{disconnect() {{}}}}; }},
|
||||
}});
|
||||
dock.start();
|
||||
buttons.work.click();
|
||||
buttons.find.click();
|
||||
overlay.classList.values.add('open'); observerCallback();
|
||||
const hiddenWhileOpen = nav.hidden;
|
||||
overlay.classList.values.delete('open'); observerCallback();
|
||||
buttons.drafts.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden,
|
||||
findFocuses:buttons.find.focuses,
|
||||
current:Object.fromEntries(Object.entries(buttons).map(([name, button]) => [name, button.attributes['aria-current'] || null])),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": ["work", "find", "drafts"],
|
||||
"hiddenWhileOpen": True,
|
||||
"hiddenAfterClose": False,
|
||||
"findFocuses": 1,
|
||||
"current": {
|
||||
"work": None,
|
||||
"find": None,
|
||||
"new": None,
|
||||
"search": None,
|
||||
"drafts": "page",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
|
||||
const badge = {{ textContent:'', hidden:true }};
|
||||
const dock = createDock({{ nav:{{}}, buttons:{{work}}, attentionBadge:badge }});
|
||||
dock.updateAttention(3);
|
||||
const pending = {{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
dock.updateAttention(0);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
pending,
|
||||
empty:{{ count:badge.textContent, hidden:badge.hidden, 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) == {
|
||||
"pending": {"count": "3", "hidden": False, "label": "Work, 3 items need attention"},
|
||||
"empty": {"count": "0", "hidden": True, "label": "Work"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">' in html
|
||||
assert html.count('class="mobile-task-action" data-mobile-task=') == 5
|
||||
for task in ("work", "find", "new", "search", "drafts"):
|
||||
assert f'data-mobile-task="{task}"' in html
|
||||
assert 'id="mobile-draft-count"' in html
|
||||
assert 'data-work-filter="attention"' in html
|
||||
assert 'id="mobile-attention-count"' in html
|
||||
assert '.mobile-task-dock { display:none;' in html
|
||||
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
|
||||
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
|
||||
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 "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
|
||||
+3834
-3
File diff suppressed because it is too large.
Load diff
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user