Compare commits
No commits in common. "main" and "timmy/1270-persist-week-task-times" have entirely different histories.
main
...
timmy/1270
|
|
@ -7,15 +7,12 @@ on:
|
|||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.11" }
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pip install -r requirements-audit.txt
|
||||
|
|
@ -26,7 +23,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build deterministic release bundle
|
||||
run: |
|
||||
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
|
||||
|
|
@ -36,7 +33,7 @@ jobs:
|
|||
--commit "$GITHUB_SHA" \
|
||||
--source-date-epoch "$SOURCE_DATE_EPOCH"
|
||||
- name: Upload tested release bundle
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist/
|
||||
|
|
@ -47,11 +44,11 @@ jobs:
|
|||
env:
|
||||
STACKCHAIN_RUN_RELEASE_E2E: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.11" }
|
||||
- name: Download assembled release bundle
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist
|
||||
|
|
@ -60,7 +57,7 @@ jobs:
|
|||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e -q
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -69,9 +66,9 @@ jobs:
|
|||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download tested release bundle
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist
|
||||
|
|
|
|||
100
README.md
100
README.md
|
|
@ -18,20 +18,7 @@ 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. For authored
|
||||
pulls, up to eight non-overlapping one-line reviewer suggestions across bounded UTF-8
|
||||
files can be staged during the mobile feedback pass, reviewed in a path-grouped manifest,
|
||||
and committed as one atomic branch update. The batch preserves every original blob
|
||||
identity, applies same-file replacements from the bottom up, rejects overlapping or stale
|
||||
lines, and verifies the advanced pull head and every final file before reporting success.
|
||||
The server uses Gitea's multi-file contents mutation so a cross-file implementation-and-test
|
||||
fix cannot be partially committed.
|
||||
After an exact merged commit fails release checks, its mobile release receipt can load the
|
||||
failed job evidence and prepare a draft rollback pull request. The server revalidates the
|
||||
operator's participation and push access, reverses only bounded UTF-8 files whose current
|
||||
default-branch content still matches the failed merge, and creates one atomic commit on a
|
||||
stable operator-owned branch. Conflicts, renames, binary files, and oversized changes fail
|
||||
closed; retrying converges on the same draft PR, which opens in the existing review workspace.
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
Assigned-issue, assigned-pull-request, and unread-update conversation composers accept an ordered bundle of up to five PNG, JPEG, or WebP photos. Repeated camera captures append to the bundle, the gallery picker accepts multiple images, and each composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary. Before sending, the selected conversation photo can use the same touch editor as New issue evidence to crop, privacy-redact, highlight, or add an arrow; Apply replaces only that flattened derivative while preserving its caption and bundle position, and Cancel leaves the original unchanged.
|
||||
For online delivery, every photo uploads before the comment is posted to the exact conversation target, producing one ordered Markdown comment or reply; validation or upload failures keep the typed text and removable preview available for retry. Offline photo conversations admit every image Blob to IndexedDB before confirmation, keep only bounded metadata in localStorage, and checkpoint each upload separately so reconnect resumes at the first unconfirmed photo without duplicating an upload, comment, reply, or reply-and-read transition. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
|
||||
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
|
||||
|
|
@ -120,61 +107,11 @@ device can reopen the exact Search with one tap while stale writes surface a con
|
|||
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
|
||||
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
||||
`.stackchain-state/saved-searches.sqlite3` path.
|
||||
|
||||
The mobile **Customize routine order** control is also portable across authenticated devices. Reordering
|
||||
remains immediate when offline and is marked **Sync pending** until connectivity returns. Rapid taps are
|
||||
coalesced into one latest-order write; transient network or server failures retry automatically with bounded
|
||||
backoff, and a clean phone refreshes the account order when it returns to the foreground. The complete
|
||||
routine order is stored as an encrypted, revisioned collection scoped to the confirmed Gitea login; a
|
||||
concurrent edit shows explicit **Keep this device** and **Use other device** actions instead of silently
|
||||
losing either order. Account changes discard stale responses and retry work. Resetting publishes the
|
||||
canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable.
|
||||
Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path.
|
||||
|
||||
Mobile **Recent work** is also portable across signed-in devices. Opening an issue, pull request,
|
||||
review, Filed item, or update records its canonical detail route locally before navigation and marks
|
||||
the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks
|
||||
merge the server list without duplicate routes, while each confirmed account remains bounded to its
|
||||
five most recent items. A separate **Pin** action keeps up to 20 frequently revisited items above
|
||||
Recent work even after that five-item window advances; **Unpin** removes only the pin, and both actions
|
||||
apply offline-first before account-scoped synchronization. Open and Pin/Unpin remain separate touch and
|
||||
keyboard targets. Titles, repositories, routes, and pins are encrypted at rest with the shared
|
||||
private-state key; stale responses from a prior account are discarded. Set
|
||||
`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.
|
||||
|
||||
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
|
||||
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
|
||||
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
|
||||
after the account-scoped collection confirms the same mutation. This completes the read → watch → revisit flow without
|
||||
changing ownership, review assignment, or scheduling work.
|
||||
Following is a read-first, account-scoped collection: it is encrypted at rest, revisioned, bounded to
|
||||
50 typed items, and synchronized across signed-in devices. Opening a row reuses the correct issue or
|
||||
pull-request Search Preview; confirmed **Stop watching** removes an open item. When watched work
|
||||
closes or merges, the sequential review exposes **Stop watching & next** so the completed item can be
|
||||
retired without leaving the preview; the next captured change opens immediately, and retiring the
|
||||
final item completes the Following phase. Changed cards explain whether work closed, reopened, changed title, or
|
||||
received other activity. During review, conversation messages newer than the prior reviewed revision are highlighted,
|
||||
and the revision is acknowledged only after both detail and conversation context load; a failed conversation load keeps
|
||||
that exact change unseen for Retry or **Keep for later & next**. For open work that still needs thought, **Keep for later & next**
|
||||
restores only the loaded revision to the unseen queue and continues the captured pass without changing
|
||||
Gitea state, ownership, planning, or watch status. Failed or unconfirmed Gitea mutations leave the collection
|
||||
and current review position unchanged. Following counts never influence the recommended Work queue. Set
|
||||
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
|
||||
|
||||
Each device can explicitly opt in to **Notify me when Following changes**. Stackchain polls that
|
||||
channel independently, validates the device session immediately before delivery, and checkpoints
|
||||
the exact unseen revision set so unchanged work stays silent across workers. Lock-screen payloads
|
||||
contain only a bounded count and the `#/my-work/following` route—never repository names, titles,
|
||||
bodies, or comments. Disabling Following alerts leaves Updates, deadlines, and start-day reminders
|
||||
unchanged.
|
||||
|
||||
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
|
||||
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, Filed,
|
||||
and unseen Following activity, then opens the highest-priority non-empty review queue. Following refreshes only when
|
||||
Prepare Today is opened; selecting that phase starts its changed-first sequential review directly. Starting the pass
|
||||
saves a confirmed-account, local-day checkpoint: finishing Agenda, Updates, the final Filed review, or the final
|
||||
Following revision returns to a focused handoff using fresh queue counts, while reopening the queue sheet resumes the
|
||||
next live phase. **Finish for now** removes only that local
|
||||
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, and
|
||||
Filed work and opens the highest-priority non-empty review queue. Starting it saves a confirmed-account, local-day
|
||||
checkpoint: finishing Agenda, Updates, or the final Filed review returns to a focused handoff using fresh queue
|
||||
counts, while reopening the queue sheet resumes the next live phase. **Finish for now** removes only that local
|
||||
checkpoint. Once urgent review is clear the pass continues the existing Today plan, or opens Find Work when Today
|
||||
is empty; the briefing and checkpoint never change Gitea state.
|
||||
Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the
|
||||
|
|
@ -204,7 +141,7 @@ concurrently changed plan is reported without overwriting newer work. Rescheduli
|
|||
Week Ahead uses a bounded account-scoped FIFO on the device: multiple offline moves survive reload,
|
||||
deliver oldest-first with stable operation IDs, and rebase each later move on the confirmed plan revisions
|
||||
without concurrent requests. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. Cross-device **Continue here** handoff transfers the bounded per-item timing ledger—not only the active item—so the receiving phone keeps the complete recap and optional time-log durations; legacy single-item sessions migrate automatically. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
After wrap-up, **Share day summary** opens a private review of the exact worked-on and tomorrow selections. Every row is opt-in adjustable, actual time is excluded by default, and an optional bounded note is previewed before the native share sheet or clipboard fallback. Canceling or closing keeps the account-scoped device draft; successful sharing or **Discard draft** removes it.
|
||||
|
||||
A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. When work is blocked, **Post blocker & move on** requires a future return time, admits the comment before any planning change, defers the item to Later, and continues the existing Today session. An admitted planning transition remains checkpointed for retry, so a storage or Today-removal failure cannot post the blocker twice. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable.
|
||||
|
|
@ -321,7 +258,7 @@ each envelope to its operation key and field purpose so rows and fields cannot b
|
|||
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
||||
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
||||
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
||||
likewise migrate on first read. Synchronized Saved Search collections, mobile Recent work, and completed Filed review
|
||||
likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
|
||||
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
|
||||
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
|
||||
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
|
||||
|
|
@ -444,8 +381,7 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
|||
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
|
||||
# three individual alerts followed by one private digest that opens Updates. A later
|
||||
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent. Opt-in
|
||||
# Following alerts use generic copy and deep-link to the changed-first Following review.
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent.
|
||||
# Browser push services must resolve exclusively to public IP addresses. Stackchain
|
||||
# validates endpoints at enrollment and again before delivery, rejects redirects,
|
||||
# and removes legacy subscriptions that resolve to private or reserved networks.
|
||||
|
|
@ -872,23 +808,3 @@ 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).
|
||||
|
||||
## Human Gates inbox
|
||||
|
||||
Authenticated release producers use `POST /api/v1/human-gates/intake` with an
|
||||
`Idempotency-Key` and an immutable `"candidate_hash"`; durable account-bound
|
||||
SQLite storage is configured by `STACKCHAIN_HUMAN_GATE_DB`. Account isolation
|
||||
binds each queue to the upstream principal ID and login, including its offline
|
||||
browser cache. Review decisions carry `expected_revision`, a stable idempotency
|
||||
key across network retries and mobile app restarts, and return durable receipts.
|
||||
An interrupted decision response is restored as **Decision outcome unknown**;
|
||||
the operator verifies the exact persisted operation instead of creating a second
|
||||
decision. The client clears that operation only after recovering a receipt. If producer evidence
|
||||
changes after a release or hold, the update reopens the exact hash for a new
|
||||
revision-checked decision instead of silently retaining the old outcome.
|
||||
New hashes mark older pending candidates `superseded` without removing their audit
|
||||
history. See [`docs/human-gates.md`](docs/human-gates.md) for the complete
|
||||
producer body, decision API, and **Telegram coalescing contract**. Lock-screen
|
||||
and Telegram notifications expose count and route only, using
|
||||
`#/my-work/human-gates`; they never expose project, title, candidate hash,
|
||||
artifacts, checks, provenance, reasons, or receipts.
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
# Human Gates producer and notification contract
|
||||
|
||||
Human Gates is an account-bound release-candidate inbox. The canonical mobile route is `#/my-work/human-gates`. Reads may use the last account-scoped browser cache, but Release/Hold decisions require a live authenticated identity and an online server round trip.
|
||||
|
||||
A cold open of the canonical route confirms the account and opens Human Gates from the core browser runtime while optional Today and Planning bundles continue hydrating or recovering. The full workspace adopts that controller and fixed review snapshot without a second queue request or duplicate decision handlers.
|
||||
|
||||
## Producer intake
|
||||
|
||||
Authenticated producers submit `POST /api/v1/human-gates/intake` with a unique `Idempotency-Key` header and JSON such as:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "release-bot",
|
||||
"project": "stackchain/dashboard",
|
||||
"candidate_hash": "abc123",
|
||||
"title": "Dashboard candidate",
|
||||
"priority": 7,
|
||||
"artifacts": [{"name": "manifest", "url": "https://forge.example/artifacts/manifest.json"}],
|
||||
"links": [{"label": "change", "url": "https://forge.example/pulls/1415"}],
|
||||
"checks": [{"name": "browser", "state": "success", "required": true}],
|
||||
"score": {"value": 92, "provenance": "release-evaluator/v2"},
|
||||
"provenance": {"producer": "release-bot", "run_id": "run-9"}
|
||||
}
|
||||
```
|
||||
|
||||
The immutable identity is authenticated account + `source` + `project` + `candidate_hash`. Retrying the same key and body returns the same gate. Reusing a key for different facts, or redefining an existing candidate hash, returns 409. A newer hash from the same source/project atomically marks older pending candidates `superseded`; detail history remains available for audit. Configure durable storage with `STACKCHAIN_HUMAN_GATE_DB` (default: `$STACKCHAIN_STATE_DIR/human-gates.sqlite3`).
|
||||
|
||||
Consumers list `GET /api/v1/human-gates`, inspect `GET /api/v1/human-gates/{id}`, and submit `POST /api/v1/human-gates/{id}/decision` with a new `Idempotency-Key`, `expected_revision`, and either `release` or `hold`. Hold requires a reason. Release requires all three checklist confirmations; if any required check is not successful it also requires an explicit override reason. Durable receipts are available at `GET /api/v1/human-gate-receipts/{receipt_id}`. All endpoints are authenticated, account-bound, and `Cache-Control: no-store`.
|
||||
|
||||
Before a mobile client submits Release or Hold, it stores one account-, gate-, and revision-bound pending operation with the exact payload and idempotency key. A transport interruption changes the decision tray to **Decision outcome unknown** and blocks replacement decisions. **Verify decision** replays that exact operation; server-side idempotency returns the original receipt if the decision committed, or executes it once if the first request never arrived. The pending operation and saved review progress are removed only after a receipt is confirmed. Reloading or restarting the installed app restores this verification flow even when the committed gate is no longer present in the live pending queue. Pending decision data never crosses the immutable account cache boundary.
|
||||
|
||||
## Privacy-safe notification contract
|
||||
|
||||
Web Push is opt-in per authenticated device under **My Work → Settings → Notify me when release decisions are waiting**. The preference is stored with that device's push subscription; revoked sessions are removed before delivery. The poller honors the device's routine-alert quiet hours, coalesces unchanged pending counts, and routes a notification tap to `#/my-work/human-gates`.
|
||||
|
||||
Web Push, Telegram, and other lock-screen adapters MUST expose **count and route only**:
|
||||
|
||||
```json
|
||||
{
|
||||
"pending_count": 3,
|
||||
"route": "#/my-work/human-gates"
|
||||
}
|
||||
```
|
||||
|
||||
The notification text may say “3 Human Gates pending” and provide the route. It MUST NOT include project names, candidate hash values, titles, artifact/link URLs, checks, scores, provenance, decision history, superseded candidate facts, reasons, or receipts. Multiple intake/supersession events before delivery replace the pending notification with the latest count rather than emitting one message per candidate. A transition to zero clears the outstanding notification; it does not send candidate detail. Producers and adapters must fetch current state after authenticated route open rather than treating notification delivery as an action authorization.
|
||||
|
|
@ -23,20 +23,3 @@
|
|||
- API contracts + versioning
|
||||
- independent deployability
|
||||
- observability: metrics, traces, structured logs
|
||||
|
||||
# Disk capacity incident
|
||||
|
||||
Root filesystem usage at or above 85% is an operations incident.
|
||||
|
||||
1. Capture the initial state with `df -h / /var/lib/gitea`.
|
||||
2. Inspect directory sizes and active processes before cleanup. Reclaim only
|
||||
disposable caches and abandoned temporary environments. Responders must not
|
||||
delete Gitea data, repositories, databases, secrets, or active release
|
||||
artifacts.
|
||||
3. Re-run `df -h / /var/lib/gitea`; usage must be below 85%.
|
||||
4. Confirm the release path with `systemctl is-active gitea act_runner`, then run
|
||||
the repository test suite.
|
||||
|
||||
Record the before and after usage in the incident ticket. If safe cleanup cannot
|
||||
restore capacity below 85%, keep the incident open and escalate storage
|
||||
expansion rather than removing durable data.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
|
||||
const reactionLabels = {
|
||||
'+1': 'Thumbs up', '-1': 'Thumbs down', laugh: 'Laugh', hooray: 'Hooray',
|
||||
confused: 'Confused', heart: 'Heart', rocket: 'Rocket', eyes: 'Eyes',
|
||||
};
|
||||
const encodedRepository = repository => String(repository || '').split('/')
|
||||
.map(encodeURIComponent).join('/');
|
||||
|
||||
|
|
@ -18,12 +14,6 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
|
|||
'/comments/' + encodeURIComponent(commentId);
|
||||
}
|
||||
|
||||
function reactionPath(context, commentId, content = '') {
|
||||
return pathFor(context, commentId) + '/reactions' +
|
||||
(content ? '/' + encodeURIComponent(content) : '');
|
||||
}
|
||||
|
||||
const pendingReactions = new Set();
|
||||
const controller = {
|
||||
isOwned(comment) {
|
||||
const login = String(getLogin() || '').trim();
|
||||
|
|
@ -51,98 +41,15 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
|
|||
pager.remove(commentId);
|
||||
return pager.snapshot();
|
||||
},
|
||||
async loadReactions(context, commentId) {
|
||||
return fetchJson(reactionPath(context, commentId), {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
async setReaction(context, commentId, content, active) {
|
||||
return fetchJson(reactionPath(context, commentId, content), {
|
||||
method: 'PUT',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ active: Boolean(active) }),
|
||||
});
|
||||
},
|
||||
reactionHtml(state) {
|
||||
const current = new Map((state?.reactions || []).map(item => [item.content, item]));
|
||||
return Object.entries(reactionLabels).map(([content, label]) => {
|
||||
const item = current.get(content) || {};
|
||||
const count = Number.isInteger(item.count) && item.count > 0 ? item.count : 0;
|
||||
const selected = item.selected === true;
|
||||
return '<button type="button" data-comment-reaction="' + content + '" ' +
|
||||
'aria-pressed="' + selected + '" aria-label="' + label + ' ' + count + '">' +
|
||||
label + ' ' + count + '</button>';
|
||||
}).join('') + '<button type="button" data-comment-reactions-close>Cancel</button>';
|
||||
},
|
||||
actionHtml(comment) {
|
||||
const owned = controller.isOwned(comment) ?
|
||||
return controller.isOwned(comment) ?
|
||||
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
|
||||
'<button type="button" data-comment-action="edit">Edit</button>' +
|
||||
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
|
||||
return owned + '<div class="comment-reactions" aria-label="Comment reactions">' +
|
||||
'<button type="button" data-comment-reactions-open aria-expanded="false" ' +
|
||||
'aria-label="React to this comment">React</button>' +
|
||||
'<div class="comment-reaction-menu" data-comment-reaction-menu hidden></div></div>';
|
||||
},
|
||||
wire({ root, getSurface, isOffline, escapeHtml }) {
|
||||
root.addEventListener('click', async event => {
|
||||
const actionButton = event.target.closest('[data-comment-action]');
|
||||
const reactionClose = actionButton ? null : event.target.closest('[data-comment-reactions-close]');
|
||||
const reactionButton = actionButton ? null : event.target.closest('[data-comment-reaction]');
|
||||
const reactionTrigger = actionButton ? null : event.target.closest('[data-comment-reactions-open]');
|
||||
if (reactionClose) {
|
||||
const card = reactionClose.closest('.issue-comment');
|
||||
const menu = card?.querySelector('[data-comment-reaction-menu]');
|
||||
const trigger = card?.querySelector('[data-comment-reactions-open]');
|
||||
if (menu && trigger) {
|
||||
menu.hidden = true;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (reactionButton || reactionTrigger) {
|
||||
const control = reactionButton || reactionTrigger;
|
||||
const card = control.closest('.issue-comment');
|
||||
const commentId = Number(card?.dataset.commentId);
|
||||
const surface = getSurface();
|
||||
const menu = card?.querySelector('[data-comment-reaction-menu]');
|
||||
const trigger = card?.querySelector('[data-comment-reactions-open]');
|
||||
if (!commentId || !menu || !trigger) return;
|
||||
if (isOffline()) {
|
||||
surface.status.textContent = 'Reconnect to react.';
|
||||
return;
|
||||
}
|
||||
if (pendingReactions.has(commentId)) return;
|
||||
pendingReactions.add(commentId);
|
||||
control.disabled = true;
|
||||
try {
|
||||
if (reactionButton) {
|
||||
const content = reactionButton.dataset.commentReaction;
|
||||
const active = reactionButton.getAttribute('aria-pressed') !== 'true';
|
||||
const state = await controller.setReaction(surface.context, commentId, content, active);
|
||||
menu.innerHTML = controller.reactionHtml(state);
|
||||
menu.hidden = true;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.focus();
|
||||
surface.status.textContent = 'Reaction updated.';
|
||||
} else {
|
||||
surface.status.textContent = 'Loading reactions…';
|
||||
const state = await controller.loadReactions(surface.context, commentId);
|
||||
menu.innerHTML = controller.reactionHtml(state);
|
||||
menu.hidden = false;
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
surface.status.textContent = '';
|
||||
}
|
||||
} catch (error) {
|
||||
surface.status.textContent = error.message || 'Reactions are unavailable. Please retry.';
|
||||
} finally {
|
||||
pendingReactions.delete(commentId);
|
||||
control.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const button = actionButton;
|
||||
const button = event.target.closest('[data-comment-action]');
|
||||
if (!button) return;
|
||||
const card = button.closest('.issue-comment');
|
||||
const commentId = Number(card?.dataset.commentId);
|
||||
|
|
|
|||
|
|
@ -44,14 +44,9 @@ function createContextPoller({
|
|||
const freshness = snapshot && snapshot.freshness;
|
||||
const sections = freshness && freshness.sections;
|
||||
const sectionValues = sections && Object.values(sections);
|
||||
const degradedRetrySeconds = (sectionValues || [])
|
||||
.filter(section => section.degraded)
|
||||
.map(section => Number(section.retry_in_seconds ?? freshness.retry_in_seconds))
|
||||
.filter(seconds => Number.isFinite(seconds) && seconds > 0);
|
||||
const failedSectionDelay = degradedRetrySeconds.length ?
|
||||
Math.min(...degradedRetrySeconds) * 1000 : null;
|
||||
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
|
||||
return failedSectionDelay || intervalMs;
|
||||
const retrySeconds = Number(freshness.retry_in_seconds);
|
||||
if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000;
|
||||
}
|
||||
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
|
||||
const healthyDeadlines = (sectionValues || [])
|
||||
|
|
@ -60,10 +55,10 @@ function createContextPoller({
|
|||
.filter(seconds => Number.isFinite(seconds));
|
||||
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
|
||||
const earliestDeadline = Math.min(...healthyDeadlines);
|
||||
const healthyDelay = earliestDeadline <= 0 &&
|
||||
sectionValues.some(section => !section.degraded && section.revalidating) ?
|
||||
intervalMs : Math.max(0, earliestDeadline) * 1000;
|
||||
return failedSectionDelay === null ? healthyDelay : Math.min(healthyDelay, failedSectionDelay);
|
||||
if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) {
|
||||
return intervalMs;
|
||||
}
|
||||
return Math.max(0, earliestDeadline) * 1000;
|
||||
}
|
||||
return intervalMs;
|
||||
}
|
||||
|
|
@ -126,7 +121,7 @@ function createContextPoller({
|
|||
|
||||
let request;
|
||||
try {
|
||||
request = fetchContext(options.full ? {} : { ...revisions }, { signal: controller.signal });
|
||||
request = fetchContext({ ...revisions }, { signal: controller.signal });
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
|
|
@ -171,47 +166,9 @@ function createContextPoller({
|
|||
return refresh({ force: true });
|
||||
}
|
||||
|
||||
function adopt(snapshot) {
|
||||
if (
|
||||
stopped || activeRequest || retainedSnapshot || !snapshot ||
|
||||
typeof snapshot !== 'object' ||
|
||||
!Object.prototype.hasOwnProperty.call(snapshot, 'context')
|
||||
) return false;
|
||||
retainedSnapshot = { ...snapshot };
|
||||
revisions = { ...(snapshot.revisions || {}) };
|
||||
failureStreak = 0;
|
||||
lastSuccessAt = Date.now();
|
||||
nextDelayMs = snapshotDelay(snapshot);
|
||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||
section => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||
);
|
||||
onSnapshot(retainedSnapshot, changedSections);
|
||||
schedule(nextDelayMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function adoptPending(snapshotPromise) {
|
||||
if (!snapshotPromise || typeof snapshotPromise.then !== 'function') return false;
|
||||
let deadline = null;
|
||||
const timeout = new Promise(resolve => {
|
||||
deadline = setDeadlineTimer(() => resolve(null), timeoutMs);
|
||||
});
|
||||
try {
|
||||
const snapshot = await Promise.race([
|
||||
Promise.resolve(snapshotPromise).catch(() => null),
|
||||
timeout,
|
||||
]);
|
||||
return adopt(snapshot);
|
||||
} finally {
|
||||
clearDeadlineTimer(deadline);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: refresh,
|
||||
refresh,
|
||||
adopt,
|
||||
adoptPending,
|
||||
setVisible,
|
||||
getState() {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
return function createConversationPhotoDrafts({ store, lanes, onChange = () => {} }) {
|
||||
return function createConversationPhotoDrafts({ store, lanes }) {
|
||||
const states = {};
|
||||
Object.entries(lanes || {}).forEach(([kind, lane]) => {
|
||||
states[kind] = { ...lane, target:null, generation:0, restoring:false, pending:Promise.resolve() };
|
||||
|
|
@ -24,11 +24,7 @@
|
|||
const attachments = await current.controller.serialize();
|
||||
const save = current.pending.then(() => store.save(target, attachments));
|
||||
current.pending = save.catch(() => null);
|
||||
try {
|
||||
const saved = await save;
|
||||
onChange();
|
||||
return saved;
|
||||
}
|
||||
try { return await save; }
|
||||
catch (error) { current.onError?.(error); throw error; }
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +67,6 @@
|
|||
const target = { ...current.target };
|
||||
await current.pending;
|
||||
await store.remove(target);
|
||||
onChange();
|
||||
current.restoring = true;
|
||||
try { current.controller.clear(); }
|
||||
finally {
|
||||
|
|
|
|||
|
|
@ -33,11 +33,10 @@
|
|||
}
|
||||
return async (operation, key, value) => {
|
||||
const db = await database();
|
||||
const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
|
||||
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
|
||||
const records = transaction.objectStore(storeName);
|
||||
if (operation === 'put') return requestResult(records.put(value));
|
||||
if (operation === 'delete') return requestResult(records.delete(key));
|
||||
if (operation === 'list') return requestResult(records.getAll());
|
||||
return requestResult(records.get(key));
|
||||
};
|
||||
}
|
||||
|
|
@ -98,10 +97,7 @@
|
|||
const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target);
|
||||
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
|
||||
if (!list.length) { await transact('delete', id); return null; }
|
||||
await transact('put', id, {
|
||||
id, version:1, ownerLogin, scope:recordScope, ...normalized,
|
||||
updatedAt:Date.now(), attachments:list,
|
||||
});
|
||||
await transact('put', id, { id, version:1, ownerLogin, scope:recordScope, ...normalized, attachments:list });
|
||||
return list;
|
||||
}
|
||||
|
||||
|
|
@ -126,38 +122,6 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
async function list() {
|
||||
if (!transact || draftScope !== 'conversation') return [];
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) return [];
|
||||
const records = await transact('list');
|
||||
return (Array.isArray(records) ? records : []).flatMap(record => {
|
||||
if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
|
||||
(record.scope && record.scope !== 'conversation') ||
|
||||
!Array.isArray(record.attachments) || !record.attachments.length) return [];
|
||||
const updatedAt = Number(record.updatedAt || 0);
|
||||
if (record.kind === 'update') {
|
||||
const notificationId = Number(record.notificationId || 0);
|
||||
if (!Number.isInteger(notificationId) || notificationId < 1) return [];
|
||||
return [{
|
||||
id:record.id, kind:'photo-reply', label:'Update photos', title:'Update #' + notificationId,
|
||||
photo_count:record.attachments.length, updated_at:updatedAt,
|
||||
route:{ kind:'update', notification_id:notificationId }, photo_store:'conversation',
|
||||
}];
|
||||
}
|
||||
const repository = String(record.repository || '');
|
||||
const number = Number(record.number || 0);
|
||||
if (!['issue', 'pull'].includes(record.kind) || !repository ||
|
||||
!Number.isInteger(number) || number < 1) return [];
|
||||
const label = record.kind === 'issue' ? 'Issue photos' : 'PR photos';
|
||||
return [{
|
||||
id:record.id, kind:'photo-reply', label, repository, number,
|
||||
title:repository + '#' + number, photo_count:record.attachments.length, updated_at:updatedAt,
|
||||
route:{ kind:record.kind, repository, number }, photo_store:'conversation',
|
||||
}];
|
||||
}).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
return { save, load, remove, list };
|
||||
return { save, load, remove };
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -81,37 +81,4 @@ function createConversationPager({ loadPage }) {
|
|||
};
|
||||
}
|
||||
|
||||
function renderConversationComment(comment, controller, escapeHtml, formatTime, renderMarkdown) {
|
||||
const actions = controller?.actionHtml?.(comment) || '';
|
||||
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
|
||||
escapeHtml(comment.author || 'Unknown author') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') +
|
||||
'</div>' + actions + '<div class="issue-sheet-content markdown-content">' +
|
||||
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
|
||||
}
|
||||
|
||||
function createConversationRenderers({qs,renderComment,updateReadPosition,getSelectedUpdate}) {
|
||||
function status(kind, comments, total) {
|
||||
qs('#' + kind + '-conversation-status').textContent = comments.length ?
|
||||
comments.length + ' of ' + Math.max(total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
||||
}
|
||||
function paint(kind, state, controller) {
|
||||
const comments = state?.comments || [];
|
||||
qs('#' + kind + '-comments').innerHTML = comments.length ? comments.map(comment =>
|
||||
kind === 'pull' ? '<div class="pull-comment-card">' + renderComment(comment,controller) + '</div>' :
|
||||
renderComment(comment,controller)).join('') : '<div class="muted">No comments yet.</div>';
|
||||
qs('#load-older-' + kind + '-comments').hidden = !Number.isInteger(state?.older_page);
|
||||
status(kind,comments,state?.total);
|
||||
if (kind === 'update') {
|
||||
const newest = qs('#update-comments .issue-comment:last-child');
|
||||
updateReadPosition.ready(String(getSelectedUpdate()?.notification_id || ''),newest);
|
||||
}
|
||||
}
|
||||
return {
|
||||
paintIssueConversation:(state,controller)=>paint('issue',state,controller),
|
||||
paintPullConversation:(state,controller)=>paint('pull',state,controller),
|
||||
paintUpdateConversation:(state,controller)=>paint('update',state,controller),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationPager;
|
||||
|
|
|
|||
|
|
@ -1,203 +0,0 @@
|
|||
function createController({request, storage, login, createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now())}) {
|
||||
const storageKey = 'stackchain.create-pull.v1.' + String(login || '').toLowerCase();
|
||||
let operationId = createId();
|
||||
let current = {
|
||||
repository:'', branches:[], head:'', base:'', expected_head_sha:'',
|
||||
title:'', body:'', draft:true,
|
||||
};
|
||||
try {
|
||||
const saved = JSON.parse(storage?.getItem?.(storageKey) || 'null');
|
||||
if (saved && typeof saved === 'object') {
|
||||
for (const field of ['repository', 'head', 'base', 'title', 'body']) {
|
||||
if (typeof saved[field] === 'string') current[field] = saved[field];
|
||||
}
|
||||
if (typeof saved.draft === 'boolean') current.draft = saved.draft;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
function snapshot() {
|
||||
return JSON.parse(JSON.stringify(current));
|
||||
}
|
||||
|
||||
function persist() {
|
||||
storage?.setItem?.(storageKey, JSON.stringify({
|
||||
repository:current.repository, head:current.head, base:current.base,
|
||||
title:current.title, body:current.body, draft:current.draft,
|
||||
}));
|
||||
}
|
||||
|
||||
async function selectRepository(repository) {
|
||||
const options = await request('api/v1/repos/' + repository + '/pull-creation-options');
|
||||
const branches = Array.isArray(options?.branches) ? options.branches : [];
|
||||
const base = branches.some(branch => branch.name === options.default_branch) ? options.default_branch : (branches[0]?.name || '');
|
||||
const headBranch = branches.find(branch => branch.name !== base) || branches[0] || {};
|
||||
current = {
|
||||
...current, repository, branches, base,
|
||||
head:headBranch.name || '', expected_head_sha:headBranch.sha || '',
|
||||
};
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function update(values) {
|
||||
current = {...current, ...values};
|
||||
if (Object.prototype.hasOwnProperty.call(values, 'head')) {
|
||||
current.expected_head_sha = current.branches.find(branch => branch.name === values.head)?.sha || '';
|
||||
}
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const result = await request('api/v1/repos/' + current.repository + '/pulls', {
|
||||
method:'POST',
|
||||
headers:{'Idempotency-Key':operationId},
|
||||
body:{
|
||||
head:current.head, base:current.base, title:current.title, body:current.body,
|
||||
draft:current.draft, expected_head_sha:current.expected_head_sha,
|
||||
},
|
||||
});
|
||||
operationId = createId();
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
state:snapshot,
|
||||
selectRepository,
|
||||
update,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
|
||||
function createBinding({controller, render, status, onCreated}) {
|
||||
return {
|
||||
async repositoryChanged(repository) {
|
||||
status('Loading branches…');
|
||||
try {
|
||||
const state = await controller.selectRepository(repository);
|
||||
render(state);
|
||||
status(state.branches.length ? 'Choose the source and base branches.' : 'No branches are available.');
|
||||
return state;
|
||||
} catch (error) {
|
||||
status(error.message || 'Branches could not be loaded.');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
changed(values) {
|
||||
const state = controller.update(values);
|
||||
render(state);
|
||||
return state;
|
||||
},
|
||||
async submit() {
|
||||
status('Creating pull request…');
|
||||
try {
|
||||
const result = await controller.submit();
|
||||
status(result.existing ? `Pull request #${result.number} is already open.` : `Pull request #${result.number} created.`);
|
||||
onCreated(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
status(error.message || 'The pull request could not be created. Your draft is safe.');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function bindDashboard() {
|
||||
const qs = selector => document.querySelector(selector);
|
||||
const switchButton = qs('#switch-to-create-pull');
|
||||
switchButton.disabled = true;
|
||||
const identityResponse = await fetch('api/v1/background-identity').catch(()=>null);
|
||||
const identity = await identityResponse?.json().catch(()=>({}));
|
||||
if (!identityResponse?.ok || !identity?.login) {
|
||||
switchButton.title = 'Account identity is unavailable.';
|
||||
return null;
|
||||
}
|
||||
const login = identity.login;
|
||||
const root = qs('#create-pull-sheet');
|
||||
const repository = qs('#create-pull-repository');
|
||||
const head = qs('#create-pull-head');
|
||||
const base = qs('#create-pull-base');
|
||||
const title = qs('#create-pull-title');
|
||||
const body = qs('#create-pull-body');
|
||||
const submit = qs('#submit-create-pull');
|
||||
const request = async (url, options) => {
|
||||
const response = await fetch(url, options ? {
|
||||
method:options.method,
|
||||
headers:{'Content-Type':'application/json', ...options.headers},
|
||||
body:JSON.stringify(options.body),
|
||||
} : undefined);
|
||||
const payload = await response.json().catch(()=>({}));
|
||||
if (!response.ok) throw new Error(payload.error || payload.detail || 'Request failed.');
|
||||
return payload;
|
||||
};
|
||||
const controller = createController({request, storage:localStorage, login});
|
||||
const setOptions = (select, branches) => {
|
||||
select.replaceChildren(...branches.map(branch => {
|
||||
const option = document.createElement('option');
|
||||
option.value = option.textContent = branch.name;
|
||||
return option;
|
||||
}));
|
||||
};
|
||||
const render = state => {
|
||||
setOptions(head, state.branches);
|
||||
setOptions(base, state.branches);
|
||||
head.value = state.head;
|
||||
base.value = state.base;
|
||||
head.disabled = base.disabled = !state.branches.length;
|
||||
qs('#create-pull-head-receipt').textContent = state.expected_head_sha ? 'Source at ' + state.expected_head_sha.slice(0, 12) : '';
|
||||
submit.disabled = !(state.repository && state.head && state.base && state.head !== state.base && title.value.trim());
|
||||
};
|
||||
const binding = createBinding({
|
||||
controller, render,
|
||||
status:message=>{ qs('#create-pull-status').textContent = message; },
|
||||
onCreated:result=>{
|
||||
root.hidden = true;
|
||||
qs('main').inert = false;
|
||||
location.hash = '#/my-work/pull/' + result.repository + '/' + result.number;
|
||||
location.reload();
|
||||
},
|
||||
});
|
||||
const update = () => binding.changed({
|
||||
head:head.value, base:base.value, title:title.value, body:body.value,
|
||||
draft:document.querySelector('input[name="create-pull-mode"]:checked')?.value !== 'ready',
|
||||
});
|
||||
repository.addEventListener('change', () => binding.repositoryChanged(repository.value).catch(()=>{}));
|
||||
[head,base,title,body].forEach(field => field.addEventListener('input', update));
|
||||
document.querySelectorAll('input[name="create-pull-mode"]').forEach(field => field.addEventListener('change', () => {
|
||||
update();
|
||||
submit.textContent = field.value === 'ready' && field.checked ? 'Create ready pull request' : 'Create draft pull request';
|
||||
}));
|
||||
qs('#create-pull-form').addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
update();
|
||||
binding.submit().catch(()=>{});
|
||||
});
|
||||
const close = () => {
|
||||
root.hidden = true;
|
||||
qs('main').inert = false;
|
||||
qs('#new-issue').focus();
|
||||
};
|
||||
qs('#cancel-create-pull').addEventListener('click', close);
|
||||
switchButton.addEventListener('click', () => {
|
||||
qs('#cancel-new-issue').click();
|
||||
repository.replaceChildren(...Array.from(qs('#create-issue-repository').options).map(source => {
|
||||
const option = document.createElement('option');
|
||||
option.value = source.value;
|
||||
option.textContent = source.textContent;
|
||||
return option;
|
||||
}));
|
||||
root.hidden = false;
|
||||
qs('main').inert = true;
|
||||
qs('#cancel-create-pull').focus();
|
||||
});
|
||||
root.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(); }
|
||||
});
|
||||
switchButton.disabled = false;
|
||||
return binding;
|
||||
}
|
||||
|
||||
const cp = {createController, createBinding, b:bindDashboard};
|
||||
if (typeof module !== 'undefined') module.exports = cp;
|
||||
else cp.b();
|
||||
|
|
@ -35,20 +35,7 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
|||
.release-watchlist-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:10px; border:1px solid #334155; border-radius:10px; }
|
||||
.release-watchlist-item > div { display:grid; min-width:0; gap:3px; overflow-wrap:anywhere; }
|
||||
.release-watchlist-item > button { width:auto; min-width:88px; }
|
||||
.release-watchlist-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:min(240px,46vw); }
|
||||
.release-watchlist-actions > button { min-height:44px; width:100%; }
|
||||
.release-branch-cleanup-status { color:#cbd5e1; }
|
||||
.release-failure-summary { display:grid; gap:8px; min-width:0; margin-top:8px; padding-top:8px; border-top:1px solid #334155; overflow-x:hidden; }
|
||||
.release-failure-summary > button { min-height:44px; }
|
||||
.release-failure-recovery { display:grid; gap:8px; min-width:0; padding:10px; border:1px solid #7f1d1d; border-radius:8px; background:#180f17; overflow-x:hidden; }
|
||||
.release-failure-recovery pre { box-sizing:border-box; max-width:100%; max-height:32dvh; margin:0; padding:10px; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; background:#07101d; }
|
||||
.release-failure-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:0; }
|
||||
.release-failure-actions > button, .release-failure-actions > a { min-height:44px; width:100%; }
|
||||
@media (max-width:359px) {
|
||||
.release-receipt-actions, .release-watchlist-actions, .release-failure-actions { grid-template-columns:1fr; }
|
||||
.release-watchlist-item { grid-template-columns:1fr; }
|
||||
.release-watchlist-actions { min-width:0; width:100%; }
|
||||
}
|
||||
@media (max-width:359px) { .release-receipt-actions { grid-template-columns:1fr; } }
|
||||
.issue-filing-receipt-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.issue-filing-receipt-panel h2, .issue-filing-receipt-panel h3 { margin:.25rem 0; }
|
||||
.issue-filing-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
|
|
@ -146,12 +133,6 @@ button:hover { filter: brightness(1.15); }
|
|||
.issue-comment { min-width:0; overflow-wrap:anywhere; }
|
||||
.comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; }
|
||||
.comment-owned-actions button { min-height:44px; min-width:72px; }
|
||||
.comment-reactions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:8px 0; min-width:0; }
|
||||
.comment-reactions > button { min-height:44px; min-width:72px; }
|
||||
.comment-reaction-menu { display:flex; gap:8px; flex-wrap:wrap; width:100%; min-width:0; }
|
||||
.comment-reaction-menu[hidden] { display:none; }
|
||||
.comment-reaction-menu button { min-height:44px; min-width:88px; flex:1 1 104px; overflow-wrap:anywhere; }
|
||||
.comment-reaction-menu button[aria-pressed="true"] { border-color:#4ade80; background:#123c2b; color:#dcfce7; }
|
||||
.comment-edit-textarea { box-sizing:border-box; display:block; width:100%; max-width:100%; min-height:132px; resize:vertical; overflow-wrap:anywhere; }
|
||||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||
|
|
@ -260,13 +241,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.offline-status[hidden] { display:none; }
|
||||
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||
.app-badge-setting { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.app-badge-control { min-height:44px; }
|
||||
.push-update-control { min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.push-quiet-hours-times { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; width:100%; }
|
||||
.push-quiet-hours-times label { min-width:0; }
|
||||
.offline-work-controls .push-quiet-hours-times input { box-sizing:border-box; width:100%; min-width:0; height:44px; }
|
||||
.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; }
|
||||
|
|
@ -280,15 +256,6 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||||
.event:last-child { border-bottom: 0; }
|
||||
.my-work { grid-column: 1 / -1; }
|
||||
.progressive-work-detail { position:fixed; inset:0; z-index:120; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.progressive-work-detail[hidden] { display:none; }
|
||||
.progressive-work-detail-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:calc(18px + env(safe-area-inset-top)) 18px calc(18px + env(safe-area-inset-bottom)); border:1px solid #55d6be; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.progressive-work-detail-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.progressive-work-detail-panel h2, .progressive-work-detail-panel p { margin-top:0; }
|
||||
.progressive-work-detail-panel button, .progressive-work-detail-panel .button-link { box-sizing:border-box; min-height:44px; }
|
||||
.progressive-work-detail-panel .button-link { display:flex; align-items:center; justify-content:center; width:100%; }
|
||||
@media(max-width:320px) { .progressive-work-detail-panel { width:100%; padding-inline:14px; } }
|
||||
|
||||
.plan-today-sheet { position:fixed; inset:0; z-index:75; display:flex; justify-content:flex-end; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
|
||||
.plan-today-sheet[hidden] { display:none; }
|
||||
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
|
|
@ -777,21 +744,6 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.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; }
|
||||
.pull-feedback-code-actions, .pull-feedback-file-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.pull-feedback-code-actions button, .pull-feedback-file-actions button { min-height:44px; }
|
||||
.pull-feedback-suggestion-preview { min-width:0; display:grid; gap:10px; margin:10px 0; padding:10px; border:1px solid #31577f; border-radius:10px; background:#07101e; }
|
||||
.pull-feedback-suggestion-preview[hidden] { display:none; }
|
||||
.pull-feedback-suggestion-preview h5 { margin:0; }
|
||||
.pull-feedback-suggestion-change { display:grid; gap:8px; min-width:0; }
|
||||
.pull-feedback-suggestion-change > div { min-width:0; }
|
||||
.pull-feedback-suggestion-change pre { box-sizing:border-box; max-width:100%; max-height:160px; white-space:pre-wrap; overflow-wrap:anywhere; word-break:break-word; }
|
||||
.pull-feedback-suggestion-preview input { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.pull-feedback-batch-review { min-width:0; display:grid; gap:10px; margin:10px 0; padding:10px; border:1px solid #3b82f6; border-radius:10px; background:#07101e; }
|
||||
.pull-feedback-batch-review[hidden] { display:none; }
|
||||
.pull-feedback-batch-review h5 { margin:0; }
|
||||
.pull-feedback-batch-review ul { min-width:0; margin:0; padding-left:20px; overflow-wrap:anywhere; }
|
||||
.pull-feedback-batch-review input { box-sizing:border-box; width:100%; min-width:0; min-height:44px; }
|
||||
#review-pull-feedback-batch { position:sticky; bottom:calc(8px + env(safe-area-inset-bottom)); z-index:4; width:100%; min-height:44px; }
|
||||
.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; }
|
||||
|
|
@ -838,17 +790,6 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.ci-check-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
|
||||
.ci-check-copy strong, .ci-check-copy span { min-width:0; overflow-wrap:anywhere; }
|
||||
.ci-check-link { min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid #60a5fa; border-radius:8px; white-space:nowrap; }
|
||||
.ci-check-actions { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:6px; min-height:44px; }
|
||||
.ci-check-inspect { min-height:44px; white-space:nowrap; }
|
||||
.ci-check-recovery { max-width:100%; min-width:0; margin:0 8px 8px; padding:10px; border:1px solid #dc2626; border-radius:8px; background:#101b2e; }
|
||||
.ci-check-recovery[hidden] { display:none; }
|
||||
.ci-check-recovery h3 { margin:0 0 6px; overflow-wrap:anywhere; }
|
||||
.ci-check-log { max-width:100%; max-height:42vh; margin:8px 0; padding:10px; overflow-x:auto; overflow-y:auto; border:1px solid #2a496e; border-radius:6px; background:#07101d; color:#dbeafe; font-size:.78rem; white-space:pre; }
|
||||
.ci-check-recovery-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-top:6px; background:#101b2e; }
|
||||
.ci-check-recovery-actions button { min-height:44px; }
|
||||
.pull-branch-update { max-width:100%; min-width:0; margin:10px 0; padding:12px; border:1px solid #60a5fa; border-radius:10px; overflow-wrap:anywhere; }
|
||||
.pull-branch-update h3 { margin:0 0 6px; }
|
||||
.pull-branch-update button { width:100%; min-height:44px; }
|
||||
.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; }
|
||||
|
|
@ -888,11 +829,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.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; }
|
||||
.current-work-pin { display:none; }
|
||||
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav { display:none; }
|
||||
@media (max-width:600px) {
|
||||
[data-current-work-pin] { display:inline-flex; align-items:center; justify-content:center; min-width:64px; min-height:44px; padding-inline:12px; }
|
||||
[data-current-work-pin="unpin"] { border-color:#60a5fa; background:#17365a; color:#fff; }
|
||||
.issue-sheet-panel, .pull-sheet-panel, .update-sheet-panel, .review-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); }
|
||||
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav {
|
||||
position:sticky; top:env(safe-area-inset-top); z-index:6;
|
||||
|
|
@ -1015,20 +953,12 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.filed-claim-actions button { min-width:0; min-height:44px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions #watch-issue-detail, .pull-sheet-actions #watch-pull-detail { min-width:0; min-height:44px; }
|
||||
.detail-watch-status:not(:empty) { margin-top:8px; overflow-wrap:anywhere; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-handoff, .pull-ownership, .pull-review-request, .pull-lifecycle { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div, .pull-review-request > div, .pull-lifecycle > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.pull-lifecycle { border-color:#b45353; }
|
||||
.pull-lifecycle button { min-height:44px; border-color:#ef4444; background:#5f1d24; color:#fff; }
|
||||
.pull-close-receipt { margin-top:14px; padding:12px; border:1px solid #4ade80; border-radius:12px; background:#10291e; }
|
||||
.pull-close-receipt h3 { margin-top:0; }
|
||||
.pull-close-receipt button { width:100%; min-height:44px; border-color:#4ade80; }
|
||||
.issue-handoff select, .pull-ownership select, .pull-review-request select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff, .pull-ownership { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select, .pull-ownership select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff select, .issue-handoff button { min-height:44px; }
|
||||
.pull-ownership select, .pull-ownership button { min-height:44px; }
|
||||
.pull-review-request select, .pull-review-request button { min-height:44px; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
|
|
@ -1106,14 +1036,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.search-preview-conversation { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-conversation h2 { margin:0; font-size:1rem; }
|
||||
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||
.search-preview-comment.new-since-review { padding-left:10px; border-left:3px solid var(--accent); background:#10233a; }
|
||||
.search-preview-conversation button { min-height:44px; width:100%; }
|
||||
.search-preview-review { display:grid; gap:8px; min-width:0; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-review[hidden] { display:none; }
|
||||
.search-preview-review h2 { margin:0; font-size:1rem; }
|
||||
.search-preview-review button { min-height:44px; width:100%; }
|
||||
.search-preview-file { min-width:0; margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; overflow:hidden; }
|
||||
.search-preview-file > strong, .search-preview-file > .small { display:block; overflow-wrap:anywhere; }
|
||||
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||
.search-preview-reply[hidden] { display:none; }
|
||||
.search-preview-reply h2 { margin:0; font-size:1rem; }
|
||||
|
|
@ -1125,13 +1048,10 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.search-preview-reply .conversation-photo-actions { max-width:100%; }
|
||||
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
#keep-following-status { display:block; min-height:20px; overflow-wrap:anywhere; }
|
||||
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
|
||||
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.search-preview-primary-actions.following-disposition-mode { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
@media (max-width:420px) {
|
||||
.search-preview-primary-actions { grid-template-columns:1fr; }
|
||||
.search-preview-primary-actions.following-disposition-mode { grid-template-columns:1fr; }
|
||||
}
|
||||
@media (max-width:600px) {
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-top:max(12px,env(safe-area-inset-top)); }
|
||||
|
|
@ -1155,25 +1075,12 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
padding-bottom:calc(6px + env(safe-area-inset-bottom));
|
||||
background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
|
||||
}
|
||||
#search-preview-overview, #search-preview-conversation, #search-preview-review,
|
||||
#search-preview-overview, #search-preview-conversation,
|
||||
#search-preview-reply-workspace, #search-preview-actions { scroll-margin-top:72px; }
|
||||
#search-preview-actions { position:static; }
|
||||
}
|
||||
@media (min-width:601px) { .mobile-search-preview-nav { display:none; } }
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.following-sheet { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto 0 0 auto; padding:0; border:1px solid #2a496e; color:var(--text); background:#102641; }
|
||||
.following-sheet::backdrop { background:rgba(3,9,18,.74); }
|
||||
.following-panel { box-sizing:border-box; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
||||
.following-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.following-panel h2, .following-panel p { margin:0; }
|
||||
.following-panel header button, #retry-following { min-height:44px; }
|
||||
.following-review-action { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.following-list { display:grid; gap:8px; min-width:0; max-height:70dvh; overflow:auto; }
|
||||
.following-card { box-sizing:border-box; display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; min-width:0; min-height:52px; padding:10px 12px; text-align:left; }
|
||||
.following-card span:first-child { min-width:0; display:grid; gap:3px; }
|
||||
.following-card em { color:var(--accent); font-size:.75rem; font-style:normal; font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.following-card.has-unseen-change { border-color:var(--accent); }
|
||||
.following-card strong, .following-card small { overflow-wrap:anywhere; }
|
||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||
.markdown-content > :first-child { margin-top:0; }
|
||||
.markdown-content > :last-child { margin-bottom:0; }
|
||||
|
|
@ -1215,12 +1122,6 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.voice-conversation-review textarea { box-sizing:border-box; width:100%; min-height:96px; resize:vertical; }
|
||||
.create-issue-capture-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.create-issue-capture-actions[hidden] { display:none; }
|
||||
.create-issue-sheet.progressive-capture .mobile-create-issue-nav,
|
||||
.create-issue-sheet.progressive-capture .create-issue-attachment,
|
||||
.create-issue-sheet.progressive-capture .create-issue-filing,
|
||||
.create-issue-sheet.progressive-capture #switch-to-create-pull,
|
||||
.create-issue-sheet.progressive-capture .today-capture-interruption,
|
||||
.create-issue-sheet.progressive-capture .shared-content-conflict { display:none; }
|
||||
.create-issue-filing { display:grid; gap:12px; min-width:0; }
|
||||
.create-issue-filing[hidden] { display:none; }
|
||||
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
||||
|
|
@ -1330,42 +1231,6 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
|
||||
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
|
||||
.pull-review-tools button { min-height:44px; }
|
||||
.pull-reviewer-summary { margin:10px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
|
||||
.pull-reviewer-summary h3 { margin:0 0 6px; font-size:14px; }
|
||||
.pull-reviewer-status { padding:8px 0; border-top:1px solid #203a5c; overflow-wrap:anywhere; }
|
||||
.pull-reviewer-status:first-child { border-top:0; }
|
||||
.pull-reviewer-status-heading { display:flex; justify-content:space-between; gap:10px; }
|
||||
.pull-reviewer-status-heading span { text-align:right; }
|
||||
.cancel-review-request { min-height:44px; width:100%; margin-top:8px; }
|
||||
.pull-review-feedback { margin-top:8px; border:1px solid #2a496e; border-radius:8px; padding:0 8px 8px; }
|
||||
.pull-review-feedback > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
|
||||
.pull-review-feedback-summary { margin:4px 0 10px; white-space:pre-wrap; }
|
||||
.pull-review-feedback-file { margin-top:8px; padding-top:8px; border-top:1px solid #203a5c; }
|
||||
.pull-review-feedback-file h4 { margin:0 0 4px; overflow-wrap:anywhere; }
|
||||
.pull-review-feedback-file ul { margin:0; padding-left:20px; }
|
||||
.pull-review-feedback-file li { margin:6px 0; }
|
||||
.pull-review-feedback-file small { display:block; color:#93c5fd; }
|
||||
.pull-review-feedback-file button { min-height:44px; width:100%; margin-top:4px; }
|
||||
.address-review-feedback, [data-load-review-feedback] { min-height:44px; width:100%; margin-top:8px; }
|
||||
.pull-feedback-pass { margin-top:10px; padding:10px; border:1px solid #3b82f6; border-radius:10px; overflow-wrap:anywhere; }
|
||||
.pull-feedback-pass-heading { display:flex; align-items:start; justify-content:space-between; gap:8px; }
|
||||
.pull-feedback-pass-heading h4 { margin:2px 0 8px; }
|
||||
.pull-feedback-pass p { white-space:pre-wrap; }
|
||||
.pull-feedback-pass textarea { width:100%; min-height:88px; box-sizing:border-box; }
|
||||
#make-pull-feedback-fix { width:100%; margin-top:8px; border-color:#4ade80; }
|
||||
.pull-feedback-file-editor { display:grid; min-width:0; gap:8px; margin-top:10px; padding:10px; border:1px solid #4ade80; border-radius:10px; overflow-x:hidden; }
|
||||
.pull-feedback-file-editor[hidden] { display:none; }
|
||||
.pull-feedback-file-editor h5 { margin:0; overflow-wrap:anywhere; }
|
||||
#pull-feedback-file-content { min-height:36dvh; resize:vertical; font:13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size:2; white-space:pre; overflow:auto; }
|
||||
#pull-feedback-commit-message { box-sizing:border-box; width:100%; min-width:0; }
|
||||
.pull-feedback-file-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:0; }
|
||||
@media (max-width:359px) { .pull-feedback-file-actions { grid-template-columns:1fr; } }
|
||||
.pull-feedback-dispositions { display:grid; grid-template-columns:1fr; gap:6px; margin:10px 0; padding:8px; }
|
||||
.pull-feedback-dispositions button[aria-pressed="true"] { border-color:#60a5fa; background:#173b63; }
|
||||
.pull-feedback-navigation { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:8px; }
|
||||
.pull-feedback-pass button { min-height:44px; }
|
||||
#finish-pull-feedback, #request-feedback-review { width:100%; margin-top:8px; }
|
||||
#request-updated-pull-review { min-height:44px; width:100%; margin-top:8px; }
|
||||
.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; }
|
||||
.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
|
||||
.pull-review summary h2 { margin:0; font-size:16px; }
|
||||
|
|
@ -1499,8 +1364,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.composer-keyboard-active .review-inline-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-queue-sheet { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.following-sheet { width:100%; max-width:none; border:0; border-radius:18px 18px 0 0; }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
|
||||
.mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-first-task::backdrop { background:rgba(3,9,18,.78); }
|
||||
|
|
@ -1512,32 +1376,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.mobile-first-task-actions button:disabled { opacity:.55; }
|
||||
|
||||
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
|
||||
.mobile-queue-panel { box-sizing:border-box; max-height:100dvh; overflow-y:auto; overscroll-behavior:contain; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.mobile-queue-panel h2 { margin:0; }
|
||||
.mobile-queue-next { display:grid; gap:6px; margin-top:12px; padding:12px; border:1px solid #60a5fa; border-radius:14px; background:#122f50; }
|
||||
.mobile-queue-next p, .mobile-queue-group h3 { margin:0; }
|
||||
.mobile-queue-next button { min-height:48px; width:100%; text-align:center; font-weight:800; }
|
||||
.mobile-queue-priority { margin-top:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
|
||||
.mobile-queue-priority > summary { min-height:44px; display:flex; align-items:center; padding:0 12px; cursor:pointer; font-weight:700; }
|
||||
.mobile-queue-priority > p { margin:0; padding:0 12px 10px; }
|
||||
.mobile-queue-priority-list { display:grid; gap:6px; padding:0 8px; }
|
||||
.mobile-queue-priority-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; min-width:0; padding:6px 4px; border-top:1px solid #233f61; }
|
||||
.mobile-queue-priority-row > span:first-child { min-width:0; overflow-wrap:anywhere; font-weight:700; }
|
||||
.mobile-queue-priority-controls { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:4px; }
|
||||
.mobile-queue-priority-controls button { min-height:44px; min-width:64px; padding:6px 8px; }
|
||||
.mobile-queue-priority-footer { display:grid; gap:6px; padding:10px 12px 12px; }
|
||||
.mobile-queue-priority-footer button { min-height:44px; }
|
||||
.mobile-queue-priority-conflict { margin:0 8px 10px; padding:10px; border:1px solid #f59e0b; border-radius:10px; background:#3b2808; }
|
||||
.mobile-queue-priority-conflict p { margin:0 0 8px; }
|
||||
.mobile-queue-priority-conflict button { min-height:44px; margin:4px 4px 0 0; }
|
||||
.mobile-queue-group { margin-top:16px; }
|
||||
.mobile-queue-group h3 { font-size:1rem; }
|
||||
.mobile-queue-all { margin-top:16px; }
|
||||
.mobile-queue-all summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
.mobile-queue-list [data-unavailable="true"] { border-color:#f59e0b; }
|
||||
.mobile-queue-list [data-unavailable="true"]::after { content:'Sync unavailable · open to retry'; color:#fbbf24; font-size:.75rem; }
|
||||
.mobile-queue-list [data-progressive-loading="true"]::after { content:'Still loading · available after workspace starts'; }
|
||||
.mobile-delivery-recovery { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-delivery-recovery::backdrop { background:rgba(3,9,18,.78); }
|
||||
.mobile-delivery-recovery-panel { box-sizing:border-box; display:grid; gap:12px; width:100%; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
||||
|
|
@ -1549,16 +1390,12 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); }
|
||||
.mobile-start-day h3, .mobile-start-day p { margin:0; }
|
||||
.mobile-start-day p + p { margin-top:4px; }
|
||||
.mobile-start-day-action { width:100%; min-height:48px; text-align:center; }
|
||||
.mobile-start-day-finish { min-height:44px; width:100%; background:transparent; }
|
||||
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
|
||||
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||
.mobile-queue-list small { color:var(--muted); }
|
||||
.mobile-recent-work-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; min-width:0; }
|
||||
.mobile-queue-list .mobile-recent-work-row button { min-height:44px; width:auto; }
|
||||
.mobile-queue-list .mobile-recent-work-row [data-recent-work-route] { min-width:0; width:100%; min-height:56px; overflow-wrap:anywhere; }
|
||||
.mobile-queue-list .mobile-recent-work-row [data-recent-work-pin] { min-width:64px; justify-content:center; padding-inline:12px; }
|
||||
.mobile-pinned-work-toggle { min-height:44px; width:100%; margin-top:8px; }
|
||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
|
||||
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
||||
|
|
@ -1629,23 +1466,3 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
transition: transform 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
.create-pull-sheet{position:fixed;inset:0;z-index:75;background:rgba(3,7,18,.72);display:grid;place-items:end center;padding:16px}
|
||||
.create-pull-sheet[hidden]{display:none}
|
||||
.create-pull-panel{width:min(100%,680px);max-height:calc(100dvh - 32px);overflow:auto;background:var(--panel);border:1px solid var(--border);border-radius:20px;padding:20px;padding-bottom:max(20px,env(safe-area-inset-bottom))}
|
||||
.create-pull-header{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
.create-pull-header h3{margin:2px 0 0}
|
||||
.create-pull-panel form,.create-pull-panel label{display:grid;gap:6px}
|
||||
.create-pull-panel form{gap:14px}
|
||||
.create-pull-branches{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.create-pull-mode{display:flex;gap:18px;border:1px solid var(--border);border-radius:12px;padding:10px 12px}
|
||||
.create-pull-mode label{display:flex;align-items:center;gap:7px}
|
||||
.create-pull-panel button,.create-pull-panel select,.create-pull-panel input{min-height:44px}
|
||||
@media(max-width:600px){.create-pull-sheet{padding:0}.create-pull-panel{width:100%;max-height:100dvh;border-radius:18px 18px 0 0}.create-pull-branches{grid-template-columns:1fr}}
|
||||
|
||||
.human-gates{position:fixed;inset:0;z-index:72;background:var(--bg);overflow:auto;padding:18px max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}
|
||||
.human-gates[hidden]{display:none}.human-gates-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;max-width:760px;margin:0 auto 14px}.human-gates-header h3{margin:0}.human-gates-list,.human-gate-detail-host{display:grid;gap:10px;max-width:760px;margin:0 auto 14px}.human-gate-card{display:grid;grid-template-columns:1fr auto;text-align:left;gap:6px 12px;min-height:58px;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--panel)}.human-gate-card span{grid-column:1/-1;color:var(--muted)}.human-gate-detail{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.human-gate-detail h3,.human-gate-detail h4,.human-gate-detail p{margin:0}.human-gate-detail label{display:grid;gap:6px}.human-gate-detail label:has(input[type=checkbox]){grid-template-columns:auto 1fr;align-items:center}.human-gate-detail textarea{min-height:78px}.human-gate-decision-tray{display:grid;gap:10px}.human-gate-decision-state{display:grid;gap:4px}.human-gate-decision-state [data-gate-error]{color:var(--danger,#fb7185)}.human-gate-decision-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.human-gate-decision-actions button{min-height:44px}.human-gates-zero{display:grid;gap:6px;text-align:center;padding:32px 16px;border:1px dashed var(--border);border-radius:16px}.human-gates-launcher span{display:inline-grid;place-items:center;min-width:22px;border-radius:999px;background:var(--accent);color:#06101f}
|
||||
.human-gate-views{display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:760px;margin:0 auto 14px}.human-gate-views button{min-height:44px}.human-gate-views button[aria-pressed="true"]{border-color:var(--accent);background:rgba(96,165,250,.14)}.human-gate-history-card time{font-size:.78rem;color:var(--muted)}.human-gate-history-more{min-height:44px;width:100%;padding:max(10px,env(safe-area-inset-bottom)) 12px}.human-gate-state{font-weight:700}.human-gate-state-released{color:#86efac}.human-gate-state-held{color:#fbbf24}.human-gate-state-superseded{color:#cbd5e1}.human-gate-receipt{padding-bottom:16px}
|
||||
.human-gate-decision-recovery{border-color:#f59e0b}.human-gate-decision-recovery .human-gate-decision-state span{color:var(--muted)}.human-gate-decision-recovery button{min-height:44px;min-width:140px}
|
||||
@media(max-width:600px){.human-gate-detail{padding-bottom:calc(124px + env(safe-area-inset-bottom))}.human-gate-decision-tray{position:sticky;bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)));z-index:4;margin:0 -16px calc(-124px - env(safe-area-inset-bottom));padding:12px 16px;padding-bottom:max(16px,env(safe-area-inset-bottom));border-top:1px solid var(--border);background:rgba(11,21,38,.97);box-shadow:0 -12px 24px rgba(0,0,0,.32);backdrop-filter:blur(10px)}}
|
||||
@media(min-width:761px){.human-gates{inset:8% max(8%,80px);border:1px solid var(--border);border-radius:20px;box-shadow:0 24px 80px rgba(0,0,0,.4)}}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,292 +0,0 @@
|
|||
(function (root, factory) {
|
||||
const exports = factory();
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = exports.createFollowing;
|
||||
module.exports.attachFollowing = exports.attachFollowing;
|
||||
module.exports.planningDisposition = exports.planningDisposition;
|
||||
} else {
|
||||
root.createFollowing = exports.createFollowing;
|
||||
root.attachFollowing = exports.attachFollowing;
|
||||
root.followingPlanningDisposition = exports.planningDisposition;
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
function planningDisposition(detail, controls) {
|
||||
const active = detail?.following === true && detail.kind === 'issue' && detail.state === 'open' &&
|
||||
Boolean(detail.claimable || detail.assigned_to_me);
|
||||
const result = active ? {active:true,today:'Add to Today & next',later:'Later & next',week:'Week Ahead & next'} : {active:false};
|
||||
if (!controls && globalThis.document) {
|
||||
const query = selector => globalThis.document.querySelector(selector);
|
||||
controls = {root:query('.search-preview-primary-actions'),claim:query('#claim-search-result'),
|
||||
today:query('#queue-search-result'),later:query('#defer-search-result'),week:query('#plan-search-result'),
|
||||
start:query('#start-search-result'),watch:query('#watch-search-result')};
|
||||
}
|
||||
controls?.root?.classList.toggle('following-disposition-mode', active);
|
||||
if (active && controls) {
|
||||
controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true;
|
||||
controls.today.textContent = result.today;
|
||||
controls.later.textContent = result.later;
|
||||
controls.week.textContent = result.week;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createFollowing(options) {
|
||||
let generation = 0;
|
||||
let snapshot = {revision:0, items:[]};
|
||||
let review = null;
|
||||
|
||||
function sameItem(left, right) {
|
||||
return (left?.kind || 'issue') === (right?.kind || 'issue') &&
|
||||
left?.repository === right?.repository && Number(left?.number) === Number(right?.number);
|
||||
}
|
||||
|
||||
function publish(status, error) {
|
||||
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
|
||||
state.degraded = snapshot.degraded === true;
|
||||
state.refreshFailures = Number(snapshot.refreshFailures) || 0;
|
||||
if (review?.acknowledged.size) state.reviewSummary = {
|
||||
reviewed:review.acknowledged.size,
|
||||
remaining:snapshot.items.filter(item => item.has_unseen_change === true).length,
|
||||
};
|
||||
if (error) state.error = error;
|
||||
options.render?.(state);
|
||||
if (status === 'ready') options.onCount?.(
|
||||
snapshot.items.filter(item => item.has_unseen_change === true).length);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const requestGeneration = ++generation;
|
||||
publish('loading');
|
||||
try {
|
||||
const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}});
|
||||
if (requestGeneration !== generation) return snapshot;
|
||||
snapshot = {
|
||||
revision:Number(result?.revision) || 0,
|
||||
items:Array.isArray(result?.items) ? result.items.slice(0, 50)
|
||||
.map(item => ({...item, kind:item.kind === 'pull' ? 'pull' : 'issue'})) : [],
|
||||
degraded:result?.degraded === true,
|
||||
refreshFailures:Number(result?.refresh_failures) || 0,
|
||||
};
|
||||
publish('ready');
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (requestGeneration === generation) publish('error', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledge(item) {
|
||||
const current = snapshot.items.find(candidate => sameItem(candidate, item) &&
|
||||
candidate.updated_at === item?.updated_at);
|
||||
if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
|
||||
await options.onAcknowledge(current);
|
||||
current.has_unseen_change = false;
|
||||
review?.acknowledged.add(current.kind + ':' + current.repository + '#' + current.number + '@' + current.updated_at);
|
||||
publish('ready');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function keepForLater(item) {
|
||||
const exact = candidate => sameItem(candidate, item) && candidate.updated_at === item?.updated_at;
|
||||
const current = snapshot.items.find(exact);
|
||||
const index = review?.active ? review.items.findIndex(exact) : -1;
|
||||
if (!current || index < 0 || typeof options.onKeep !== 'function') return null;
|
||||
await options.onKeep(current);
|
||||
current.has_unseen_change = true;
|
||||
review.acknowledged.delete(current.kind + ':' + current.repository + '#' +
|
||||
current.number + '@' + current.updated_at);
|
||||
review.items.splice(index, 1);
|
||||
const next = review.items[index] || null;
|
||||
if (!next) review.active = false;
|
||||
publish('ready');
|
||||
if (next) await open(snapshot.items.findIndex(candidate => sameItem(candidate, next)));
|
||||
return next;
|
||||
}
|
||||
|
||||
async function open(index) {
|
||||
const item = snapshot.items[Number(index)];
|
||||
if (!item) return false;
|
||||
await options.onOpen?.({...item, following:true});
|
||||
await acknowledge(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function startReview() {
|
||||
const items = snapshot.items
|
||||
.filter(item => item.has_unseen_change === true)
|
||||
.map(item => ({...item, following:true}));
|
||||
if (!items.length) return false;
|
||||
review = {items, more:false, active:true, acknowledged:new Set()};
|
||||
await open(snapshot.items.indexOf(snapshot.items.find(item => sameItem(item, items[0]))));
|
||||
return true;
|
||||
}
|
||||
|
||||
function finishReview() {
|
||||
const completed = Boolean(review?.completed || (review?.active &&
|
||||
!snapshot.items.some(item => item.has_unseen_change === true)));
|
||||
if (review) review.active = false;
|
||||
if (completed && !review.completed) {
|
||||
review.completed = true;
|
||||
options.onReviewComplete?.();
|
||||
}
|
||||
publish('ready');
|
||||
return completed;
|
||||
}
|
||||
|
||||
function retire(item) {
|
||||
const same = candidate => sameItem(candidate, item);
|
||||
const index = review?.active ? review.items.findIndex(same) : -1;
|
||||
snapshot.items = snapshot.items.filter(candidate => !same(candidate));
|
||||
if (index < 0) {
|
||||
publish('ready');
|
||||
return null;
|
||||
}
|
||||
review.items.splice(index, 1);
|
||||
const next = review.items[index] || null;
|
||||
if (!next) {
|
||||
review.active = false;
|
||||
if (!review.completed) {
|
||||
review.completed = true;
|
||||
options.onReviewComplete?.();
|
||||
}
|
||||
}
|
||||
publish('ready');
|
||||
return next ? {...next} : null;
|
||||
}
|
||||
|
||||
return {
|
||||
load, open, startReview, previewLoaded:acknowledge, keepForLater, finishReview, retire,
|
||||
session:() => review?.active ? {items:[...review.items], more:false} : null,
|
||||
items:() => snapshot.items.map(item => ({...item})),
|
||||
count:() => snapshot.items.length,
|
||||
};
|
||||
}
|
||||
|
||||
function attachFollowing(onOpen, hooks = {}) {
|
||||
const document = globalThis.document;
|
||||
const query = selector => document.querySelector(selector);
|
||||
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''})[character]);
|
||||
const formatTime = value => new Date(value).toLocaleString();
|
||||
const fetchJson = async (url, options) => {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
|
||||
return payload;
|
||||
};
|
||||
const changeRevision = (item, action) => {
|
||||
const [owner, repo] = item.repository.split('/');
|
||||
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
|
||||
encodeURIComponent(repo) + '/issues/' + item.number + '/' + action +
|
||||
'?kind=' + encodeURIComponent(item.kind), {
|
||||
method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
|
||||
body:JSON.stringify({updated_at:item.updated_at}),
|
||||
});
|
||||
};
|
||||
let feature;
|
||||
let activeReviewItem = null;
|
||||
const show = () => query('#following-sheet').open || query('#following-sheet').showModal();
|
||||
function render(state) {
|
||||
hooks.onStatus?.(state.status);
|
||||
const list = query('#following-list');
|
||||
const status = query('#following-status');
|
||||
const reviewButton = query('#review-following');
|
||||
query('#retry-following').hidden = state.status !== 'error';
|
||||
if (state.status === 'loading') return void (status.textContent = 'Loading watched items…');
|
||||
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
|
||||
const unseen = state.items.filter(item => item.has_unseen_change === true).length;
|
||||
reviewButton.hidden = unseen === 0;
|
||||
reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
|
||||
status.textContent = (state.reviewSummary
|
||||
? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. '
|
||||
: '') + (state.degraded ? 'Some watched items could not be refreshed. Showing last known details. ' : '') + (state.items.length
|
||||
? state.items.length + (state.items.length === 1 ? ' watched item.' : ' watched items.')
|
||||
: 'No watched items yet. Watch an issue or pull request from Search to keep it here.');
|
||||
list.innerHTML = state.items.map((item, index) =>
|
||||
'<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
|
||||
'" type="button" data-following-index="' + index + '"><span>' +
|
||||
(item.has_unseen_change ? '<em>' + escapeHtml(item.change_summary || 'New activity') + '</em>' : '') + '<strong>' +
|
||||
escapeHtml(item.title) + '</strong><small>' + escapeHtml(
|
||||
(item.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + item.repository + ' #' + item.number +
|
||||
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
|
||||
'</small></span><span aria-hidden="true">›</span></button>').join('');
|
||||
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
|
||||
query('#following-sheet').close();
|
||||
feature.open(Number(button.dataset.followingIndex)).catch(() => {});
|
||||
}));
|
||||
}
|
||||
feature = createFollowing({
|
||||
fetchJson, render,
|
||||
onCount:count => {
|
||||
const value = query('[data-mobile-queue-count="following"]');
|
||||
value.textContent = count;
|
||||
value.closest('button').setAttribute('aria-label', 'Following, ' + count +
|
||||
(count === 1 ? ' unseen change' : ' unseen changes'));
|
||||
hooks.onCount?.(count, feature.items());
|
||||
},
|
||||
onOpen:item => {
|
||||
activeReviewItem = item;
|
||||
return onOpen(item);
|
||||
},
|
||||
onReviewComplete:hooks.onReviewComplete,
|
||||
onAcknowledge:item => changeRevision(item, 'seen'),
|
||||
onKeep:item => changeRevision(item, 'keep'),
|
||||
});
|
||||
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
|
||||
query('#review-following').addEventListener('click', () => {
|
||||
query('#following-sheet').close();
|
||||
feature.startReview().catch(() => {
|
||||
show();
|
||||
});
|
||||
});
|
||||
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
|
||||
const keepButton = query('#keep-following-for-later');
|
||||
keepButton.addEventListener('click', async () => {
|
||||
const status = query('#keep-following-status');
|
||||
keepButton.disabled = true;
|
||||
status.textContent = 'Keeping this change for later…';
|
||||
try {
|
||||
if (!await feature.keepForLater(activeReviewItem)) query('#close-search-preview').click();
|
||||
status.textContent = 'Kept for later.';
|
||||
} catch (_) {
|
||||
status.textContent = 'Could not keep this change for later. Retry when ready.';
|
||||
keepButton.disabled = false;
|
||||
}
|
||||
});
|
||||
return {
|
||||
load:feature.load,
|
||||
review:feature.startReview,
|
||||
async route() {
|
||||
await feature.load().then(feature.startReview).catch(() => false) || show();
|
||||
},
|
||||
open() {
|
||||
show();
|
||||
feature.load().catch(() => {});
|
||||
return 'opened-following';
|
||||
},
|
||||
session:feature.session,
|
||||
previewLoaded:feature.previewLoaded,
|
||||
keepForLater:feature.keepForLater,
|
||||
retire:feature.retire,
|
||||
preview(state) {
|
||||
const disposition = planningDisposition(state?.detail || state?.item);
|
||||
keepButton.hidden = state?.item?.following !== true || disposition.active;
|
||||
keepButton.disabled = false;
|
||||
if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = '';
|
||||
},
|
||||
returnToFollowing() {
|
||||
const completed = feature.finishReview();
|
||||
if (completed) return 'completed-following';
|
||||
show();
|
||||
globalThis.requestAnimationFrame?.(() => {
|
||||
const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following');
|
||||
target?.focus();
|
||||
});
|
||||
return 'returned-following';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {createFollowing, attachFollowing, planningDisposition};
|
||||
});
|
||||
|
|
@ -1,510 +0,0 @@
|
|||
function createHumanGates(options = {}) {
|
||||
const storage = options.storage || window.localStorage;
|
||||
const getLogin = options.getLogin || (() => '');
|
||||
const getAccountKey = options.getAccountKey || getLogin;
|
||||
const isOnline = options.isOnline || (() => navigator.onLine);
|
||||
const location = options.location || window.location;
|
||||
const fetchJson = options.fetchJson;
|
||||
const nodes = options.nodes || {};
|
||||
let queue = { pending_count: 0, items: [] };
|
||||
let reviewSnapshot = [];
|
||||
let reviewIndex = -1;
|
||||
let loadedAccountKey = '';
|
||||
let loadEpoch = 0;
|
||||
const decisionKeys = new Map();
|
||||
let decisionFlight = null;
|
||||
let openFlight = null;
|
||||
let onChange = options.onChange;
|
||||
let historyItems = [];
|
||||
let historyNextCursor = null;
|
||||
let historyLoadedMore = false;
|
||||
let historyLoadError = '';
|
||||
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
})[character]);
|
||||
const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase();
|
||||
const progressKey = item => 'stackchain.human-gate-review.v1:' +
|
||||
String(getAccountKey() || '').trim().toLowerCase() + ':' + item.id + ':' + item.revision;
|
||||
const decisionStorageKey = () => 'stackchain.human-gate-decision.v1:' +
|
||||
String(getAccountKey() || '').trim().toLowerCase();
|
||||
const setText = (node, value) => { if (node) node.textContent = value; };
|
||||
const setHtml = (node, value) => { if (node) node.innerHTML = value; };
|
||||
const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state);
|
||||
|
||||
function validSnapshot(value) {
|
||||
return value && Number.isInteger(value.pending_count) && Array.isArray(value.items) ? value : null;
|
||||
}
|
||||
|
||||
function restore() {
|
||||
if (!getLogin()) return null;
|
||||
try { return validSnapshot(JSON.parse(storage.getItem(cacheKey()) || 'null')); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
function save(value) {
|
||||
if (!getLogin()) return;
|
||||
try { storage.setItem(cacheKey(), JSON.stringify(value)); } catch (_) {}
|
||||
}
|
||||
|
||||
function restoreProgress(item) {
|
||||
if (!getLogin() || !item) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(progressKey(item)) || 'null');
|
||||
if (!value || value.gate_id !== item.id || value.revision !== item.revision) return null;
|
||||
return value;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function saveProgress(values = {}) {
|
||||
const item = current();
|
||||
if (!getLogin() || !item) return false;
|
||||
const checklist = values.checklist || {};
|
||||
const progress = {
|
||||
gate_id:item.id, revision:item.revision,
|
||||
checklist:Object.fromEntries(['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].map(key => [key, checklist[key] === true])),
|
||||
reason:String(values.reason || ''), override_reason:String(values.override_reason || ''),
|
||||
};
|
||||
try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
function restorePendingDecision(item = null) {
|
||||
if (!getLogin()) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(decisionStorageKey()) || 'null');
|
||||
if (!value || typeof value !== 'object' || !value.idempotency_key || !value.operation ||
|
||||
!value.payload || !value.gate_id || !Number.isInteger(value.revision)) return null;
|
||||
if (item && (value.gate_id !== item.id || value.revision !== item.revision)) return null;
|
||||
return value;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function savePendingDecision(value) {
|
||||
storage.setItem(decisionStorageKey(), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clearPendingDecision(item) {
|
||||
if (!restorePendingDecision(item)) return;
|
||||
try { storage.removeItem?.(decisionStorageKey()); } catch (_) {}
|
||||
}
|
||||
|
||||
function valuesFromDetail() {
|
||||
const checklist = Object.fromEntries(Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || []).map(input => [input.dataset.gateChecklist, input.checked]));
|
||||
return {
|
||||
checklist,
|
||||
reason:nodes.detail?.querySelector?.('[data-gate-reason]')?.value || '',
|
||||
override_reason:nodes.detail?.querySelector?.('[data-gate-override]')?.value || '',
|
||||
};
|
||||
}
|
||||
|
||||
function updateReadiness(values = valuesFromDetail()) {
|
||||
const completed = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed']
|
||||
.filter(key => values.checklist?.[key] === true).length;
|
||||
setText(nodes.detail?.querySelector?.('[data-gate-readiness]'), completed + ' of 3 confirmations complete');
|
||||
}
|
||||
|
||||
function captureProgress() {
|
||||
if (!nodes.detail?.querySelectorAll) return false;
|
||||
const values = valuesFromDetail();
|
||||
updateReadiness(values);
|
||||
return saveProgress(values);
|
||||
}
|
||||
|
||||
nodes.detail?.addEventListener?.('input', captureProgress);
|
||||
nodes.detail?.addEventListener?.('change', captureProgress);
|
||||
function render() {
|
||||
setText(nodes.count, String(queue.pending_count));
|
||||
if (!queue.pending_count) {
|
||||
setHtml(nodes.list, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>No release candidates need your decision.</span></div>');
|
||||
setText(nodes.status, 'Human Gates inbox zero.');
|
||||
return;
|
||||
}
|
||||
setText(nodes.status, queue.pending_count + (queue.pending_count === 1 ? ' gate pending.' : ' gates pending.'));
|
||||
setHtml(nodes.list, queue.items.map(item =>
|
||||
'<button class="human-gate-card" type="button" data-human-gate-id="' + escape(item.id) + '">' +
|
||||
'<strong>' + escape(item.title) + '</strong><code>' + escape(item.candidate_hash) + '</code>' +
|
||||
'<span>Priority ' + escape(item.priority ?? 0) + '</span></button>'
|
||||
).join(''));
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
nodes.pendingTab?.setAttribute?.('aria-pressed', view === 'pending' ? 'true' : 'false');
|
||||
nodes.historyTab?.setAttribute?.('aria-pressed', view === 'history' ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function renderHistory(preserveDetail = false) {
|
||||
setView('history');
|
||||
if (historyNextCursor) {
|
||||
setText(nodes.status, historyItems.length + ' Human Gate decisions loaded. Older decisions are available.');
|
||||
} else if (historyLoadedMore) {
|
||||
setText(nodes.status, 'All ' + historyItems.length + ' Human Gate decisions loaded.');
|
||||
} else {
|
||||
setText(nodes.status, historyItems.length + (historyItems.length === 1 ? ' past Human Gate decision.' : ' past Human Gate decisions.'));
|
||||
}
|
||||
if (!historyItems.length) {
|
||||
setHtml(nodes.list, '<div class="human-gates-zero"><strong>No decision history</strong><span>Released and held candidates will appear here.</span></div>');
|
||||
setHtml(nodes.detail, '');
|
||||
return;
|
||||
}
|
||||
setHtml(nodes.list, historyItems.map(item =>
|
||||
'<button class="human-gate-card human-gate-history-card" type="button" data-human-gate-history-id="' + escape(item.id) + '">' +
|
||||
'<strong>' + escape(item.title) + '</strong><span class="human-gate-state human-gate-state-' + escape(item.state) + '">' +
|
||||
escape(item.state.charAt(0).toUpperCase() + item.state.slice(1)) + '</span>' +
|
||||
'<code>' + escape(item.candidate_hash) + '</code><time>' + escape(new Date(Number(item.updated_at) * 1000).toLocaleString()) + '</time></button>'
|
||||
).join('') + (historyNextCursor ? '<button class="human-gate-history-more" type="button" data-human-gate-history-more>' +
|
||||
(historyLoadError ? 'Retry older decisions' : 'Load older decisions') + '</button>' : ''));
|
||||
Array.from(nodes.list?.querySelectorAll?.('[data-human-gate-history-id]') || []).forEach(card => {
|
||||
card.addEventListener('click', () => selectHistory(card.dataset.humanGateHistoryId).catch(error => {
|
||||
setText(nodes.status, error.message || 'Human Gate history is unavailable.');
|
||||
}));
|
||||
});
|
||||
nodes.list?.querySelector?.('[data-human-gate-history-more]')?.addEventListener?.('click', () => {
|
||||
loadMoreHistory().catch(error => setText(nodes.status, error.message || 'Older Human Gate decisions are unavailable.'));
|
||||
});
|
||||
if (!preserveDetail) setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Decision history</strong><span>Open a candidate to review its durable receipt.</span></div>');
|
||||
}
|
||||
|
||||
async function showHistory() {
|
||||
if (!isOnline()) throw new Error('Human Gate history requires an online connection.');
|
||||
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
|
||||
const result = validSnapshot(await fetchJson('api/v1/human-gates?state=history&limit=20'));
|
||||
if (!result) throw new Error('Human Gate history response is invalid.');
|
||||
historyItems = result.items;
|
||||
historyNextCursor = result.next_cursor || null;
|
||||
historyLoadedMore = false;
|
||||
historyLoadError = '';
|
||||
renderHistory();
|
||||
return JSON.parse(JSON.stringify(historyItems));
|
||||
}
|
||||
|
||||
async function loadMoreHistory() {
|
||||
if (!historyNextCursor) return JSON.parse(JSON.stringify(historyItems));
|
||||
if (!isOnline()) throw new Error('Human Gate history requires an online connection.');
|
||||
const cursor = historyNextCursor;
|
||||
let result;
|
||||
try {
|
||||
result = validSnapshot(await fetchJson(
|
||||
'api/v1/human-gates?state=history&limit=20&cursor=' + encodeURIComponent(cursor)
|
||||
));
|
||||
if (!result) throw new Error('Human Gate history response is invalid.');
|
||||
} catch (error) {
|
||||
historyLoadError = error?.message || 'Older Human Gate decisions are unavailable.';
|
||||
renderHistory(true);
|
||||
setText(nodes.status, historyLoadError + ' Loaded decisions are still available.');
|
||||
throw error;
|
||||
}
|
||||
const known = new Set(historyItems.map(item => item.id));
|
||||
historyItems.push(...result.items.filter(item => !known.has(item.id)));
|
||||
historyNextCursor = result.next_cursor || null;
|
||||
historyLoadedMore = true;
|
||||
historyLoadError = '';
|
||||
renderHistory(true);
|
||||
return JSON.parse(JSON.stringify(historyItems));
|
||||
}
|
||||
|
||||
async function selectHistory(gateId) {
|
||||
const summary = historyItems.find(item => item.id === gateId);
|
||||
if (!summary) throw new Error('Gate is not in the current history.');
|
||||
const detail = await fetchJson('api/v1/human-gates/' + encodeURIComponent(gateId));
|
||||
if (!detail || detail.id !== gateId) throw new Error('Gate history detail is invalid.');
|
||||
let receipt = null;
|
||||
if (detail.receipt_id) receipt = await fetchJson('api/v1/human-gate-receipts/' + encodeURIComponent(detail.receipt_id));
|
||||
const checklist = receipt?.checklist || {};
|
||||
const confirmations = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed']
|
||||
.filter(key => checklist[key] === true).map(key => '<li>' + escape(key.replaceAll('_', ' ')) + '</li>').join('');
|
||||
setHtml(nodes.detail,
|
||||
'<article class="human-gate-detail human-gate-receipt"><p class="small">' + escape(detail.state.toUpperCase()) + '</p>' +
|
||||
'<h3>' + escape(detail.title) + '</h3><p>Project <strong>' + escape(detail.project) + '</strong></p>' +
|
||||
'<p>Exact candidate <code>' + escape(detail.candidate_hash) + '</code></p>' +
|
||||
(receipt ? '<p>Decision receipt <code>' + escape(receipt.receipt_id) + '</code></p>' +
|
||||
'<p>Decided ' + escape(new Date(Number(receipt.decided_at) * 1000).toLocaleString()) + '</p>' +
|
||||
(receipt.reason ? '<h4>Reason</h4><p>' + escape(receipt.reason) + '</p>' : '') +
|
||||
(receipt.override_reason ? '<h4>Override</h4><p>' + escape(receipt.override_reason) + '</p>' : '') +
|
||||
(confirmations ? '<h4>Confirmed</h4><ul>' + confirmations + '</ul>' : '') :
|
||||
'<p>No decision receipt exists because this candidate was superseded.</p>') + '</article>');
|
||||
return {detail, receipt};
|
||||
}
|
||||
|
||||
function showPending() {
|
||||
setView('pending');
|
||||
render();
|
||||
renderDetail(current());
|
||||
return JSON.parse(JSON.stringify(queue));
|
||||
}
|
||||
|
||||
function renderDetail(item) {
|
||||
if (!item) {
|
||||
setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>Fixed review snapshot complete.</span></div>');
|
||||
return;
|
||||
}
|
||||
const progress = restoreProgress(item) || {checklist:{}, reason:'', override_reason:''};
|
||||
const pendingDecision = restorePendingDecision(item);
|
||||
const checked = key => progress.checklist?.[key] === true ? ' checked' : '';
|
||||
const checks = (item.checks || []).map(check =>
|
||||
'<li class="gate-check gate-check-' + escape(check.state) + '"><strong>' + escape(check.name) + '</strong> · ' + escape(check.state) + (check.required ? ' · required' : '') + '</li>'
|
||||
).join('');
|
||||
const artifacts = (item.artifacts || []).map(artifact => '<li><a href="' + escape(artifact.url) + '" target="_blank" rel="noreferrer noopener">' + escape(artifact.name) + '</a></li>').join('');
|
||||
const links = (item.links || []).map(link => '<li><a href="' + escape(link.url) + '" target="_blank" rel="noreferrer noopener">' + escape(link.label) + '</a></li>').join('');
|
||||
const provenance = Object.entries(item.provenance || {}).map(([key, value]) => '<li><strong>' + escape(key) + '</strong> · ' + escape(value) + '</li>').join('');
|
||||
const history = (item.history || []).map(event => '<li><strong>' + escape(event.action) + '</strong> · ' + escape(event.at) + '</li>').join('');
|
||||
setHtml(nodes.detail,
|
||||
'<article class="human-gate-detail"><h3>' + escape(item.title) + '</h3>' +
|
||||
'<p>Project <strong>' + escape(item.project) + '</strong></p>' +
|
||||
'<p>Exact candidate <code>' + escape(item.candidate_hash) + '</code></p>' +
|
||||
'<p>Score ' + escape(item.score?.value ?? 'not supplied') + ' · ' + escape(item.score?.provenance || '') + '</p>' +
|
||||
'<h4>Artifacts</h4><ul>' + artifacts + '</ul><h4>Links</h4><ul>' + links + '</ul>' +
|
||||
'<h4>Checks</h4><ul>' + checks + '</ul><h4>Provenance</h4><ul>' + provenance + '</ul>' +
|
||||
'<h4>History</h4><ul>' + history + '</ul>' +
|
||||
'<label><input type="checkbox" data-gate-checklist="exact_hash"' + checked('exact_hash') + '> Exact hash reviewed</label>' +
|
||||
'<label><input type="checkbox" data-gate-checklist="artifacts_reviewed"' + checked('artifacts_reviewed') + '> Artifacts reviewed</label>' +
|
||||
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"' + checked('provenance_reviewed') + '> Provenance reviewed</label>' +
|
||||
'<label>Hold reason<textarea data-gate-reason>' + escape(progress.reason) + '</textarea></label>' +
|
||||
'<label>Override reason<textarea data-gate-override>' + escape(progress.override_reason) + '</textarea></label>' +
|
||||
'<div class="human-gate-decision-tray' + (pendingDecision ? ' human-gate-decision-recovery' : '') + '"><div class="human-gate-decision-state">' +
|
||||
(pendingDecision ? '<strong>Decision outcome unknown</strong><span role="status">The previous ' + escape(pendingDecision.decision) +
|
||||
' response was interrupted. Verify it before making another decision.</span>' :
|
||||
'<strong data-gate-readiness>0 of 3 confirmations complete</strong><span data-gate-error role="alert" hidden></span>') + '</div>' +
|
||||
'<div class="human-gate-decision-actions">' + (pendingDecision ?
|
||||
'<button type="button" data-gate-recover>Verify decision</button>' :
|
||||
'<button type="button" data-gate-decision="hold">Hold & next</button><button type="button" data-gate-decision="release">Release & next</button>') +
|
||||
'</div></div></article>'
|
||||
);
|
||||
updateReadiness();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const epoch = ++loadEpoch;
|
||||
const accountKey = String(getAccountKey() || '').trim().toLowerCase();
|
||||
if (accountKey !== loadedAccountKey) {
|
||||
loadedAccountKey = accountKey;
|
||||
queue = { pending_count: 0, items: [] };
|
||||
reviewSnapshot = [];
|
||||
reviewIndex = -1;
|
||||
render();
|
||||
}
|
||||
const cached = restore();
|
||||
if (cached) {
|
||||
queue = cached;
|
||||
render();
|
||||
publish({available:true, authoritative:false, cached:true});
|
||||
}
|
||||
try {
|
||||
const live = validSnapshot(await fetchJson('api/v1/human-gates'));
|
||||
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
|
||||
if (!live) throw new Error('Human Gates response is invalid.');
|
||||
queue = { pending_count: live.pending_count, items: live.items.slice() };
|
||||
const interrupted = restorePendingDecision();
|
||||
if (interrupted && interrupted.item?.id === interrupted.gate_id && interrupted.item.revision === interrupted.revision) {
|
||||
const recoveryItem = {...interrupted.item, decision_recovery:true};
|
||||
const existingIndex = queue.items.findIndex(candidate => candidate.id === interrupted.gate_id);
|
||||
if (existingIndex >= 0) queue.items[existingIndex] = recoveryItem;
|
||||
else {
|
||||
queue.items.unshift(recoveryItem);
|
||||
queue.pending_count += 1;
|
||||
}
|
||||
}
|
||||
save(queue);
|
||||
render();
|
||||
publish({available:true, authoritative:true});
|
||||
return queue;
|
||||
} catch (error) {
|
||||
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
|
||||
if (!cached) {
|
||||
publish({available:false, authoritative:false});
|
||||
throw error;
|
||||
}
|
||||
setText(nodes.status, 'Offline cached gate list · reconnect before deciding.');
|
||||
return queue;
|
||||
}
|
||||
}
|
||||
|
||||
function reviewNext() {
|
||||
if (reviewIndex < 0) {
|
||||
reviewSnapshot = queue.items.slice();
|
||||
reviewIndex = 0;
|
||||
}
|
||||
const item = reviewSnapshot[reviewIndex] || null;
|
||||
renderDetail(item);
|
||||
if (item) {
|
||||
const index = reviewIndex;
|
||||
fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id)).then(detail => {
|
||||
if (!detail || detail.id !== item.id) throw new Error('Gate detail is invalid.');
|
||||
if (reviewIndex !== index || reviewSnapshot[index]?.id !== item.id) return;
|
||||
reviewSnapshot[index] = detail;
|
||||
renderDetail(detail);
|
||||
}).catch(() => { setText(nodes.status, 'Gate detail is unavailable. Retry while online.'); });
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function select(gateId) {
|
||||
if (reviewIndex < 0) reviewSnapshot = queue.items.slice();
|
||||
const index = reviewSnapshot.findIndex(item => item.id === gateId);
|
||||
if (index < 0) throw new Error('Gate is not in the current review snapshot.');
|
||||
reviewIndex = index;
|
||||
return reviewNext();
|
||||
}
|
||||
|
||||
function current() { return reviewIndex < 0 ? null : (reviewSnapshot[reviewIndex] || null); }
|
||||
function idempotencyKey(item, decision, payload) {
|
||||
const operation = item.id + ':' + item.revision + ':' + decision + ':' + JSON.stringify(payload);
|
||||
const pending = restorePendingDecision(item);
|
||||
if (pending?.operation === operation) return { operation, key: pending.idempotency_key };
|
||||
if (decisionKeys.has(operation)) return { operation, key: decisionKeys.get(operation) };
|
||||
const nonce = globalThis.crypto?.randomUUID?.() || (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2));
|
||||
const key = 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce;
|
||||
decisionKeys.set(operation, key);
|
||||
return { operation, key };
|
||||
}
|
||||
|
||||
function completeDecision(item, decision, operation, receipt) {
|
||||
decisionKeys.delete(operation);
|
||||
clearPendingDecision(item);
|
||||
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
|
||||
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
|
||||
queue.pending_count = Math.max(0, queue.pending_count - 1);
|
||||
save(queue); render();
|
||||
publish({available:true, authoritative:true, decision:true});
|
||||
reviewIndex += 1;
|
||||
const next = current();
|
||||
if (next) reviewNext(); else renderDetail(null);
|
||||
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
|
||||
return { receipt, next };
|
||||
}
|
||||
|
||||
async function postDecision(item, pending) {
|
||||
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pending.idempotency_key },
|
||||
body: JSON.stringify(pending.payload),
|
||||
});
|
||||
return completeDecision(item, pending.decision, pending.operation, receipt);
|
||||
}
|
||||
|
||||
function decisionError(message, selector) {
|
||||
const error = new Error(message);
|
||||
error.targetSelector = selector;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function decideAndNext(decision, values = {}) {
|
||||
const item = current();
|
||||
if (!item) throw new Error('No gate is selected.');
|
||||
if (!isOnline()) throw new Error('Human Gate decisions require an online connection.');
|
||||
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
|
||||
const checklist = values.checklist || {};
|
||||
const complete = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].every(key => checklist[key] === true);
|
||||
if (decision === 'release' && !complete) {
|
||||
const firstMissing = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].find(key => checklist[key] !== true);
|
||||
throw decisionError('Complete the release checklist before deciding.', '[data-gate-checklist="' + firstMissing + '"]');
|
||||
}
|
||||
const unmet = (item.checks || []).filter(check => check.required && check.state !== 'success');
|
||||
if (decision === 'release' && unmet.length && !String(values.override_reason || '').trim()) {
|
||||
const names = unmet.map(check => String(check.name || 'Unnamed check')).join(', ');
|
||||
throw decisionError('An explicit override reason is required for unmet required checks: ' + names + '.', '[data-gate-override]');
|
||||
}
|
||||
if (decision === 'hold' && !String(values.reason || '').trim()) {
|
||||
throw decisionError('A hold reason is required.', '[data-gate-reason]');
|
||||
}
|
||||
const payload = {
|
||||
expected_revision: item.revision, decision,
|
||||
reason: String(values.reason || '').trim(),
|
||||
override_reason: String(values.override_reason || '').trim(), checklist,
|
||||
};
|
||||
if (decisionFlight) return decisionFlight;
|
||||
const operation = (async () => {
|
||||
const decisionKey = idempotencyKey(item, decision, payload);
|
||||
const pending = {
|
||||
gate_id:item.id, revision:item.revision, decision, payload,
|
||||
operation:decisionKey.operation, idempotency_key:decisionKey.key,
|
||||
item:JSON.parse(JSON.stringify(item)),
|
||||
};
|
||||
savePendingDecision(pending);
|
||||
try {
|
||||
return await postDecision(item, pending);
|
||||
} catch (error) {
|
||||
renderDetail(item);
|
||||
setText(nodes.status, 'Decision outcome unknown. Verify the interrupted decision while online.');
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
decisionFlight = operation;
|
||||
try {
|
||||
return await operation;
|
||||
} finally {
|
||||
if (decisionFlight === operation) decisionFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverDecision() {
|
||||
const item = current();
|
||||
if (!item) throw new Error('No gate is selected.');
|
||||
if (!isOnline()) throw new Error('Decision verification requires an online connection.');
|
||||
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
|
||||
const pending = restorePendingDecision(item);
|
||||
if (!pending) throw new Error('No interrupted decision exists for this gate revision.');
|
||||
if (decisionFlight) return decisionFlight;
|
||||
const operation = postDecision(item, pending);
|
||||
decisionFlight = operation;
|
||||
try { return await operation; }
|
||||
finally { if (decisionFlight === operation) decisionFlight = null; }
|
||||
}
|
||||
|
||||
async function submitDecision(decision) {
|
||||
const values = valuesFromDetail();
|
||||
updateReadiness(values);
|
||||
const errorNode = nodes.detail?.querySelector?.('[data-gate-error]');
|
||||
if (errorNode) { errorNode.textContent = ''; errorNode.hidden = true; }
|
||||
const buttons = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-decision]') || []);
|
||||
buttons.forEach(button => { button.disabled = true; });
|
||||
try {
|
||||
return await decideAndNext(decision, values);
|
||||
} catch (error) {
|
||||
if (errorNode) {
|
||||
errorNode.textContent = error?.message || 'The decision could not be saved.';
|
||||
errorNode.hidden = false;
|
||||
}
|
||||
let target = error?.targetSelector && nodes.detail?.querySelector?.(error.targetSelector);
|
||||
if (!target && error?.targetSelector?.startsWith('[data-gate-checklist=')) {
|
||||
const key = error.targetSelector.match(/"([^"]+)"/)?.[1];
|
||||
target = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || [])
|
||||
.find(input => input.dataset.gateChecklist === key);
|
||||
}
|
||||
target?.scrollIntoView?.({block:'center', behavior:'smooth'});
|
||||
target?.focus?.({preventScroll:true});
|
||||
throw error;
|
||||
} finally {
|
||||
buttons.forEach(button => { button.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
location.hash = '#/my-work/human-gates';
|
||||
if (nodes.panel) nodes.panel.hidden = false;
|
||||
if (openFlight) return openFlight;
|
||||
const operation = (async () => {
|
||||
setView('pending');
|
||||
await load();
|
||||
reviewSnapshot = queue.items.slice();
|
||||
reviewIndex = 0;
|
||||
return reviewNext();
|
||||
})();
|
||||
openFlight = operation;
|
||||
const clearFlight = () => {
|
||||
if (openFlight === operation) openFlight = null;
|
||||
};
|
||||
operation.then(clearFlight, clearFlight);
|
||||
return operation;
|
||||
}
|
||||
|
||||
return {
|
||||
load, open, reviewNext, select, showHistory, loadMoreHistory, selectHistory, showPending,
|
||||
decideAndNext, recoverDecision, submitDecision, current, saveProgress,
|
||||
setOnChange(callback) { onChange = callback; },
|
||||
restoreCached: restore,
|
||||
snapshot: () => JSON.parse(JSON.stringify(queue)),
|
||||
route: () => location.hash,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createHumanGates;
|
||||
if (typeof window !== 'undefined') window.createHumanGates = createHumanGates;
|
||||
|
|
@ -133,10 +133,6 @@
|
|||
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step" id="device-setup-app-badge-step">
|
||||
<div><strong>Show unread Updates on the app icon</strong><p class="small" id="device-setup-app-badge-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-app-badge" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
|
||||
<div class="device-setup-deadline-controls">
|
||||
|
|
@ -187,20 +183,7 @@
|
|||
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
|
||||
<button class="find-work-action" id="find-work" type="button">Find work</button>
|
||||
<button class="new-issue" id="new-issue" type="button">New issue</button>
|
||||
<button class="human-gates-launcher" id="open-human-gates" type="button">Review next <span id="human-gates-count">0</span></button>
|
||||
</div>
|
||||
<section id="human-gates" class="human-gates" aria-labelledby="human-gates-heading" hidden>
|
||||
<div class="human-gates-header">
|
||||
<div><h3 id="human-gates-heading">Human Gates</h3><p id="human-gates-status" class="small" role="status" aria-live="polite"></p></div>
|
||||
<button id="close-human-gates" type="button">Close</button>
|
||||
</div>
|
||||
<div class="human-gate-views" role="group" aria-label="Human Gate view">
|
||||
<button id="human-gates-pending" type="button" aria-pressed="true">Pending</button>
|
||||
<button id="human-gates-history" type="button" aria-pressed="false">History</button>
|
||||
</div>
|
||||
<div id="human-gates-list" class="human-gates-list"></div>
|
||||
<div id="human-gate-detail" class="human-gate-detail-host"></div>
|
||||
</section>
|
||||
<details class="work-settings">
|
||||
<summary id="work-settings-toggle">Queue & settings · <span id="active-work-queue">All</span></summary>
|
||||
<div class="work-settings-panel">
|
||||
|
|
@ -210,7 +193,6 @@
|
|||
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">0</span></button>
|
||||
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
|
||||
<button class="work-filter" data-work-filter="filed" aria-pressed="false">Filed <span data-work-count="filed">0</span></button>
|
||||
<button class="work-filter" data-work-filter="authored" aria-pressed="false">My PRs <span data-work-count="authored">0</span></button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||
|
|
@ -228,23 +210,9 @@
|
|||
<div class="offline-work-controls">
|
||||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<span class="app-badge-setting" id="app-badge-setting">
|
||||
<label class="app-badge-control" for="app-badge-control"><input id="app-badge-control" type="checkbox" /> Show review attention on the app icon</label>
|
||||
<span class="small" id="app-badge-status" role="status" aria-live="polite"></span>
|
||||
</span>
|
||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
|
||||
<label class="push-update-control" for="push-following"><input id="push-following" type="checkbox" /> Notify me when Following changes</label>
|
||||
<span class="small" id="push-following-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-human-gates"><input id="push-human-gates" type="checkbox" /> Notify me when release decisions are waiting</label>
|
||||
<span class="small" id="push-human-gates-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-quiet-hours"><input id="push-quiet-hours" type="checkbox" /> Pause routine alerts on a schedule</label>
|
||||
<div class="push-quiet-hours-times">
|
||||
<label for="push-quiet-start">From <input id="push-quiet-start" type="time" value="22:00" /></label>
|
||||
<label for="push-quiet-end">Until <input id="push-quiet-end" type="time" value="07:00" /></label>
|
||||
</div>
|
||||
<span class="small" id="push-quiet-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
|
||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||
|
|
@ -390,19 +358,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="progressive-work-detail" id="progressive-work-detail" role="dialog" aria-modal="true" aria-labelledby="progressive-work-detail-title" hidden>
|
||||
<div class="progressive-work-detail-panel">
|
||||
<header>
|
||||
<div><p class="small muted">My Work</p><h2 id="progressive-work-detail-title">Work item</h2></div>
|
||||
<button id="close-progressive-work-detail" type="button">Close</button>
|
||||
</header>
|
||||
<p class="small" id="progressive-work-detail-meta"></p>
|
||||
<p id="progressive-work-detail-reason"></p>
|
||||
<p class="muted">The full workspace is still loading. You can read this assignment now or open it in Gitea.</p>
|
||||
<a class="button-link" id="open-progressive-work-gitea" href="" hidden>Open in Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<dialog class="release-receipt-sheet" id="release-receipt-sheet" aria-labelledby="release-receipt-title">
|
||||
<section class="release-receipt-panel">
|
||||
<header><div><p class="small muted">Exact merge evidence</p><h2 id="release-receipt-title">Release watchlist</h2></div><button id="close-release-receipt" type="button">Close</button></header>
|
||||
|
|
@ -814,7 +769,7 @@
|
|||
</section>
|
||||
</div>
|
||||
|
||||
<div id="cmd-palette" role="dialog" aria-modal="true" aria-label="Command palette">
|
||||
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
||||
<div class="cmd-palette-header">
|
||||
<strong>Search work</strong>
|
||||
<div class="cmd-palette-header-actions">
|
||||
|
|
@ -921,7 +876,6 @@
|
|||
<nav class="mobile-search-preview-nav" aria-label="Search preview sections">
|
||||
<button type="button" data-search-preview-section="overview">Overview</button>
|
||||
<button type="button" data-search-preview-section="conversation">Conversation</button>
|
||||
<button type="button" data-search-preview-section="changes" hidden>Changes</button>
|
||||
<button type="button" data-search-preview-section="reply">Reply</button>
|
||||
<button type="button" data-search-preview-section="actions">Act</button>
|
||||
</nav>
|
||||
|
|
@ -935,15 +889,8 @@
|
|||
<div id="search-preview-comments"></div>
|
||||
<div id="search-preview-conversation-status" class="small" aria-live="polite"></div>
|
||||
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
|
||||
<button class="conversation-actions-retry" id="retry-search-preview-comment-actions" type="button" hidden>Retry comment tools</button>
|
||||
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
|
||||
</section>
|
||||
<section id="search-preview-review" class="search-preview-review" aria-labelledby="search-preview-review-title" hidden>
|
||||
<h2 id="search-preview-review-title">Changes</h2>
|
||||
<div id="search-preview-review-status" class="small" aria-live="polite"></div>
|
||||
<div id="search-preview-files"></div>
|
||||
<button id="retry-search-preview-review" type="button" hidden>Retry changes</button>
|
||||
</section>
|
||||
<section class="search-preview-reply" id="search-preview-reply-workspace" aria-labelledby="search-preview-reply-title" hidden>
|
||||
<h2 id="search-preview-reply-title">Reply</h2>
|
||||
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
|
|
@ -1002,10 +949,8 @@
|
|||
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
||||
<button id="plan-search-result" type="button" hidden>Plan ahead</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
<button id="watch-search-result" type="button" hidden>Watch issue</button>
|
||||
<button id="keep-following-for-later" type="button" hidden>Keep for later & next</button>
|
||||
|
||||
</div>
|
||||
<span id="keep-following-status" class="small" role="status" aria-live="polite"></span>
|
||||
<button id="share-search-result" type="button" disabled>Share result</button>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
|
|
@ -1053,7 +998,6 @@
|
|||
<div class="small" id="issue-sheet-key"></div>
|
||||
<h3 id="issue-sheet-title">Assigned issue</h3>
|
||||
</div>
|
||||
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
|
||||
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||||
</div>
|
||||
<nav class="mobile-issue-detail-nav" aria-label="Issue sections">
|
||||
|
|
@ -1229,13 +1173,11 @@
|
|||
</section>
|
||||
<div class="issue-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="watch-issue-detail" type="button">Watch issue</button>
|
||||
<button id="release-issue" type="button">Release assignment</button>
|
||||
<button id="close-issue" type="button">Close issue</button>
|
||||
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<div id="issue-watch-status" class="detail-watch-status small" aria-live="polite"></div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
|
|
@ -1317,7 +1259,6 @@
|
|||
<section class="create-issue-panel">
|
||||
<div class="create-issue-header">
|
||||
<h3 id="create-issue-heading">Capture work</h3>
|
||||
<button id="switch-to-create-pull" type="button">Pull request</button>
|
||||
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||
</div>
|
||||
<aside class="today-capture-interruption" id="today-capture-interruption" role="status" hidden>
|
||||
|
|
@ -1554,7 +1495,6 @@
|
|||
<div class="small" id="update-sheet-key"></div>
|
||||
<h3 id="update-sheet-title">Unread update</h3>
|
||||
</div>
|
||||
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
|
||||
</div>
|
||||
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
||||
<nav class="mobile-update-detail-nav" aria-label="Update sections">
|
||||
|
|
@ -1678,7 +1618,6 @@
|
|||
<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 class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
|
||||
<button id="close-pull-sheet" type="button">Close</button>
|
||||
</div>
|
||||
<nav class="mobile-detail-nav mobile-pull-detail-nav" aria-label="Pull request sections">
|
||||
|
|
@ -1691,16 +1630,6 @@
|
|||
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
|
||||
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
|
||||
<div class="pull-sheet-content markdown-content" id="pull-sheet-body"></div>
|
||||
<button id="edit-pull-content" type="button" hidden>Edit review context</button>
|
||||
<form class="issue-edit-form" id="pull-edit-form" hidden>
|
||||
<label>Title<input id="pull-edit-title" maxlength="255" required /></label>
|
||||
<label>Description<textarea id="pull-edit-body" maxlength="10000"></textarea></label>
|
||||
<div class="issue-edit-actions">
|
||||
<button id="save-pull-content" type="submit">Save context</button>
|
||||
<button id="cancel-pull-edit" type="button">Cancel</button>
|
||||
</div>
|
||||
<div id="pull-edit-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</form>
|
||||
</div>
|
||||
<section id="pull-conversation" tabindex="-1">
|
||||
<h2>Full conversation</h2><div id="pull-comments"></div>
|
||||
|
|
@ -1761,111 +1690,15 @@
|
|||
<div id="pull-review-status" class="small" aria-live="polite">Expand to load changed files and merge readiness.</div>
|
||||
<button class="pull-retry" id="pull-review-retry" type="button" hidden>Retry review data</button>
|
||||
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Review data not loaded</span></div>
|
||||
<section class="pull-branch-update" id="pull-branch-update" aria-labelledby="pull-branch-update-heading" hidden>
|
||||
<h3 id="pull-branch-update-heading">Bring branch up to date</h3>
|
||||
<p class="small" id="pull-branch-update-status" role="status" aria-live="assertive"></p>
|
||||
<button id="update-pull-branch" type="button">Update branch</button>
|
||||
</section>
|
||||
<section class="pull-reviewer-summary" aria-labelledby="pull-reviewer-heading">
|
||||
<h3 id="pull-reviewer-heading">Reviewer status</h3>
|
||||
<div id="pull-reviewer-status" class="small" aria-live="polite">Reviewer status not loaded.</div>
|
||||
<div id="pull-reviewer-statuses"></div>
|
||||
<button id="request-updated-pull-review" type="button" hidden>Request updated review</button>
|
||||
<section class="pull-feedback-pass" id="pull-feedback-pass" aria-labelledby="pull-feedback-heading" hidden>
|
||||
<div class="pull-feedback-pass-heading">
|
||||
<div><span class="small" id="pull-feedback-progress"></span><h4 id="pull-feedback-heading">Address review feedback</h4></div>
|
||||
<button id="close-pull-feedback" type="button">Close</button>
|
||||
</div>
|
||||
<p class="small" id="pull-feedback-location"></p>
|
||||
<p id="pull-feedback-body"></p>
|
||||
<div class="small" id="pull-feedback-context" aria-live="polite"></div>
|
||||
<div class="pull-feedback-code-actions">
|
||||
<button id="review-pull-feedback-suggestion" type="button" hidden>Review suggested change</button>
|
||||
<button id="make-pull-feedback-fix" type="button" hidden>Edit full file</button>
|
||||
</div>
|
||||
<section class="pull-feedback-suggestion-preview" id="pull-feedback-suggestion-preview" aria-labelledby="pull-feedback-suggestion-heading" hidden>
|
||||
<h5 id="pull-feedback-suggestion-heading">Suggested change</h5>
|
||||
<div class="pull-feedback-suggestion-change">
|
||||
<div><span class="small">Current line</span><pre id="pull-feedback-suggestion-before"></pre></div>
|
||||
<div><span class="small">Suggested replacement</span><pre id="pull-feedback-suggestion-after"></pre></div>
|
||||
</div>
|
||||
<label for="pull-feedback-suggestion-message">Commit message</label>
|
||||
<input id="pull-feedback-suggestion-message" maxlength="120" value="fix: apply review suggestion">
|
||||
<div class="pull-feedback-file-actions">
|
||||
<button id="cancel-pull-feedback-suggestion" type="button" hidden>Cancel suggestion</button>
|
||||
<button id="stage-pull-feedback-suggestion" type="button" hidden>Add to fix batch</button>
|
||||
<button id="apply-pull-feedback-suggestion" type="button" hidden>Commit suggested change</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pull-feedback-batch-review" id="pull-feedback-batch-review" aria-labelledby="pull-feedback-batch-heading" hidden>
|
||||
<h5 id="pull-feedback-batch-heading">Review fixes</h5>
|
||||
<ul id="pull-feedback-batch-items"></ul>
|
||||
<label for="pull-feedback-batch-message">Commit message</label>
|
||||
<input id="pull-feedback-batch-message" maxlength="120" value="fix: apply review suggestions">
|
||||
<div class="pull-feedback-file-actions">
|
||||
<button id="cancel-pull-feedback-batch" type="button" hidden>Keep reviewing</button>
|
||||
<button id="commit-pull-feedback-batch" type="button" hidden>Commit all fixes</button>
|
||||
</div>
|
||||
</section>
|
||||
<button id="review-pull-feedback-batch" type="button" hidden>Review fixes (0)</button>
|
||||
<section class="pull-feedback-file-editor" id="pull-feedback-file-editor" hidden>
|
||||
<h5>Edit <span id="pull-feedback-file-path"></span></h5>
|
||||
<label for="pull-feedback-file-content">File content</label>
|
||||
<textarea id="pull-feedback-file-content" spellcheck="false" maxlength="131072"></textarea>
|
||||
<label for="pull-feedback-commit-message">Commit message</label>
|
||||
<input id="pull-feedback-commit-message" maxlength="120" placeholder="fix: address review feedback">
|
||||
<div class="pull-feedback-file-actions">
|
||||
<button id="cancel-pull-feedback-fix" type="button" hidden>Cancel change</button>
|
||||
<button id="commit-pull-feedback-fix" type="button" hidden>Commit to pull branch</button>
|
||||
</div>
|
||||
</section>
|
||||
<fieldset class="pull-feedback-dispositions">
|
||||
<legend>Disposition</legend>
|
||||
<button type="button" data-feedback-disposition="addressed">Addressed</button>
|
||||
<button type="button" data-feedback-disposition="discussion">Needs discussion</button>
|
||||
<button type="button" data-feedback-disposition="skipped">Skip for now</button>
|
||||
</fieldset>
|
||||
<label for="pull-feedback-note">Response note (optional)</label>
|
||||
<textarea id="pull-feedback-note" maxlength="1000" placeholder="What changed or needs discussion?"></textarea>
|
||||
<div class="pull-feedback-navigation">
|
||||
<button id="previous-pull-feedback" type="button">Previous</button>
|
||||
<button id="next-pull-feedback" type="button">Next</button>
|
||||
</div>
|
||||
<button id="finish-pull-feedback" type="button" disabled>Post feedback response</button>
|
||||
<button id="request-feedback-review" type="button" hidden>Request updated review from this reviewer</button>
|
||||
<div id="pull-feedback-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
</section>
|
||||
<details class="ci-checks" id="pull-checks">
|
||||
<summary><span>Checks</span><span class="small" id="pull-checks-summary">Not loaded</span></summary>
|
||||
<button class="ci-check-refresh" id="refresh-pull-checks" type="button">Refresh checks</button>
|
||||
<div class="ci-check-list" id="pull-check-list"></div>
|
||||
<section class="ci-check-recovery" id="pull-check-recovery" aria-labelledby="pull-check-recovery-title" hidden>
|
||||
<h3 id="pull-check-recovery-title" tabindex="-1">Failed check</h3>
|
||||
<div id="pull-check-recovery-status" class="small" role="status" aria-live="assertive"></div>
|
||||
<pre class="ci-check-log" id="pull-check-log" tabindex="0"></pre>
|
||||
<div class="ci-check-recovery-actions">
|
||||
<button id="retry-pull-check" type="button" disabled>Retry failed job</button>
|
||||
<button id="close-pull-check-recovery" type="button">Close details</button>
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
|
||||
<div id="pull-files"></div>
|
||||
<button id="merge-pull" type="button" disabled>Merge</button>
|
||||
</details>
|
||||
<details class="pull-review-request" id="pull-review-request">
|
||||
<summary>Request review</summary>
|
||||
<div>
|
||||
<button id="publish-draft-pull" type="button" hidden>Ready for review</button>
|
||||
<div id="publish-draft-pull-status" class="small" aria-live="assertive"></div>
|
||||
<button id="load-pull-reviewers" type="button">Choose reviewer</button>
|
||||
<label for="pull-review-recipient" class="small">Eligible repository reviewer</label>
|
||||
<select id="pull-review-recipient" disabled><option value="">Select a teammate</option></select>
|
||||
<button id="confirm-pull-review" type="button" disabled>Request review</button>
|
||||
<div id="pull-review-request-status" class="small" aria-live="assertive">Load teammates to request a review.</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="pull-ownership" id="pull-ownership">
|
||||
<summary>Ownership</summary>
|
||||
<div>
|
||||
|
|
@ -1876,28 +1709,12 @@
|
|||
<div id="pull-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="pull-lifecycle" id="pull-lifecycle" hidden>
|
||||
<summary>Close pull request</summary>
|
||||
<div>
|
||||
<p class="small">Close an obsolete pull request without deleting its branch or discussion.</p>
|
||||
<button id="close-authored-pull" type="button" hidden>Close pull request</button>
|
||||
<div id="pull-lifecycle-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</div>
|
||||
</details>
|
||||
<section class="pull-close-receipt" id="pull-close-receipt" aria-labelledby="pull-close-receipt-title" hidden>
|
||||
<h3 id="pull-close-receipt-title" tabindex="-1">Pull request closed.</h3>
|
||||
<p class="small">This pull request was removed from active work. Reopen it if the close was accidental.</p>
|
||||
<button id="reopen-authored-pull" type="button">Reopen pull request</button>
|
||||
<div id="pull-reopen-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="pull-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="watch-pull-detail" type="button">Watch pull request</button>
|
||||
<button id="release-pull" type="button">Release assignment</button>
|
||||
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<div id="pull-watch-status" class="detail-watch-status small" aria-live="polite"></div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
|
|
@ -1915,7 +1732,6 @@
|
|||
<div class="small" id="review-sheet-key"></div>
|
||||
<h3 id="review-sheet-title">Pull request review</h3>
|
||||
</div>
|
||||
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
|
||||
<button class="review-action" id="close-review-sheet">Close</button>
|
||||
</div>
|
||||
<nav class="mobile-review-detail-nav" aria-label="Review sections">
|
||||
|
|
@ -2179,77 +1995,25 @@
|
|||
<div>
|
||||
<h3 id="mobile-start-day-heading">Prepare Today</h3>
|
||||
<p id="mobile-start-day-summary" class="small" aria-live="polite">Reviewing urgent queues…</p>
|
||||
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, Filed, and Following</p>
|
||||
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, and Filed</p>
|
||||
</div>
|
||||
<button class="mobile-start-day-action" id="mobile-start-day-action" type="button">Prepare Today</button>
|
||||
<button class="mobile-start-day-finish" id="finish-mobile-start-day" type="button" hidden>Finish for now</button>
|
||||
</section>
|
||||
<section class="mobile-queue-next" aria-labelledby="mobile-queue-next-heading">
|
||||
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
|
||||
<button id="mobile-queue-next-action" type="button">Find Work</button>
|
||||
</section>
|
||||
<p id="mobile-recent-work-status" role="status" aria-live="polite" class="small"></p>
|
||||
<section class="mobile-queue-group" id="mobile-pinned-work" aria-labelledby="mobile-pinned-work-heading" hidden>
|
||||
<h3 id="mobile-pinned-work-heading">Pinned work</h3>
|
||||
<div class="mobile-queue-list" id="mobile-pinned-work-list"></div>
|
||||
<button class="mobile-pinned-work-toggle" id="mobile-pinned-work-toggle" type="button" aria-controls="mobile-pinned-work-list" aria-expanded="false" hidden>Show all</button>
|
||||
</section>
|
||||
<section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden>
|
||||
<h3 id="mobile-recent-work-heading">Recent work</h3>
|
||||
<div class="mobile-queue-list" id="mobile-recent-work-list"></div>
|
||||
</section>
|
||||
<details class="mobile-queue-priority" id="mobile-queue-priority">
|
||||
<summary>Customize routine order</summary>
|
||||
<p class="small muted">Delivery and Human Gates always stay first. Move the routine queues to match how you work.</p>
|
||||
<div id="mobile-queue-priority-list" class="mobile-queue-priority-list"></div>
|
||||
<div class="mobile-queue-priority-footer">
|
||||
<button id="reset-mobile-queue-priority" type="button">Reset order</button>
|
||||
<span id="mobile-queue-priority-status" role="status" aria-live="polite" class="small"></span>
|
||||
</div>
|
||||
<div id="mobile-queue-priority-conflict" class="mobile-queue-priority-conflict" hidden>
|
||||
<p class="small">This routine was changed on another device. Choose which complete order to keep.</p>
|
||||
<button id="keep-local-mobile-queue-priority" type="button">Keep this device</button>
|
||||
<button id="use-remote-mobile-queue-priority" type="button">Use other device</button>
|
||||
</div>
|
||||
</details>
|
||||
<section class="mobile-queue-group" aria-labelledby="mobile-queue-active-heading" hidden>
|
||||
<h3 id="mobile-queue-active-heading">Active now</h3>
|
||||
<div class="mobile-queue-list" id="mobile-queue-active-list"></div>
|
||||
</section>
|
||||
<section class="mobile-queue-group" aria-labelledby="mobile-queue-planning-heading">
|
||||
<h3 id="mobile-queue-planning-heading">Plan & organize</h3>
|
||||
<div class="mobile-queue-list" id="mobile-queue-planning-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<button data-mobile-queue="tomorrow" type="button"><span><strong>Tomorrow</strong><small id="mobile-tomorrow-summary" aria-live="polite">Nothing planned</small></span></button>
|
||||
<button data-mobile-queue="week" type="button"><span><strong>Week Ahead</strong><small id="mobile-week-summary" aria-live="polite">Nothing planned</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
<details class="mobile-queue-all">
|
||||
<summary>All queues</summary>
|
||||
<div class="mobile-queue-list" id="mobile-queue-all-list">
|
||||
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
||||
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
|
||||
<button data-mobile-queue="gate" type="button"><span><strong>Human Gates</strong><small>Release decisions</small></span><span data-mobile-queue-count="gate">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="following" type="button" aria-label="Following, 0 unseen changes"><span><strong>Following</strong><small>Issues and pull requests you watch</small></span><span data-mobile-queue-count="following">0</span></button>
|
||||
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
||||
<button data-mobile-queue="authored" type="button"><span><strong>My PRs</strong><small>Pull requests you authored</small></span><span data-mobile-queue-count="authored">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
|
||||
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog class="following-sheet" id="following-sheet" aria-labelledby="following-title">
|
||||
<section class="following-panel">
|
||||
<header><div><p class="small muted">Read without taking ownership</p><h2 id="following-title">Following</h2></div><button id="close-following" type="button">Close</button></header>
|
||||
<p id="following-status" class="small" role="status" aria-live="polite"></p>
|
||||
<button id="review-following" class="following-review-action" type="button" hidden>Review new activity</button>
|
||||
<div id="following-list" class="following-list"></div>
|
||||
<button id="retry-following" type="button" hidden>Retry</button>
|
||||
<div class="mobile-queue-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<button data-mobile-queue="tomorrow" type="button"><span><strong>Tomorrow</strong><small id="mobile-tomorrow-summary" aria-live="polite">Nothing planned</small></span></button>
|
||||
<button data-mobile-queue="week" type="button"><span><strong>Week Ahead</strong><small id="mobile-week-summary" aria-live="polite">Nothing planned</small></span></button>
|
||||
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
||||
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
|
||||
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
|
|
@ -2291,34 +2055,6 @@
|
|||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues: no active queues; no upcoming deadlines"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0 active</span></span><span class="mobile-task-deadline" id="mobile-deadline-count" hidden>0 due</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="create-pull-sheet" id="create-pull-sheet" role="dialog" aria-modal="true" aria-labelledby="create-pull-heading" hidden>
|
||||
<section class="create-pull-panel">
|
||||
<header class="create-pull-header">
|
||||
<div><span class="eyebrow">New</span><h3 id="create-pull-heading">Pull request</h3></div>
|
||||
<button id="cancel-create-pull" type="button">Cancel</button>
|
||||
</header>
|
||||
<p class="small">Open a pull request from a branch already pushed to Gitea.</p>
|
||||
<form id="create-pull-form">
|
||||
<label for="create-pull-repository">Repository
|
||||
<select id="create-pull-repository" required><option value="">Choose repository</option></select>
|
||||
</label>
|
||||
<div class="create-pull-branches">
|
||||
<label for="create-pull-head">Source branch<select id="create-pull-head" required disabled></select></label>
|
||||
<label for="create-pull-base">Base branch<select id="create-pull-base" required disabled></select></label>
|
||||
</div>
|
||||
<output id="create-pull-head-receipt" class="small" aria-live="polite"></output>
|
||||
<label for="create-pull-title">Title<input id="create-pull-title" maxlength="255" required autocomplete="off" /></label>
|
||||
<label for="create-pull-body">Context <span class="small">Optional · Markdown</span><textarea id="create-pull-body" maxlength="10000" rows="5"></textarea></label>
|
||||
<fieldset class="create-pull-mode"><legend>Start as</legend>
|
||||
<label><input type="radio" name="create-pull-mode" value="draft" checked /> Draft</label>
|
||||
<label><input type="radio" name="create-pull-mode" value="ready" /> Ready for review</label>
|
||||
</fieldset>
|
||||
<p id="create-pull-status" role="status" aria-live="polite"></p>
|
||||
<button id="submit-create-pull" type="submit" disabled>Create draft pull request</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/private-data-registry.js"></script>
|
||||
|
|
@ -2332,11 +2068,9 @@
|
|||
<script src="static/commands.js"></script>
|
||||
<script src="static/saved-searches.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/following.js"></script>
|
||||
<script src="static/search-reply-draft-store.js"></script>
|
||||
<script src="static/conversation-reply-draft-store.js"></script>
|
||||
<script src="static/conversation-photo-drafts.js"></script>
|
||||
<script src="static/photo-draft-inbox.js"></script>
|
||||
<script src="static/search-defer.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
|
|
@ -2356,10 +2090,6 @@
|
|||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/progressive-live-snapshot.js"></script>
|
||||
<script src="static/progressive-my-work.js"></script>
|
||||
<script src="static/progressive-mobile-dock.js"></script>
|
||||
<script src="static/progressive-capture.js"></script>
|
||||
<script src="static/agenda-replan.js"></script>
|
||||
<script src="static/agenda-calendar.js"></script>
|
||||
<script src="static/protect-today.js"></script>
|
||||
|
|
@ -2412,7 +2142,6 @@
|
|||
<script src="static/voice-issue-capture.js"></script>
|
||||
<script src="static/voice-conversation-capture.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/create-pull-sheet.js"></script>
|
||||
<script src="static/mobile-create-issue-nav.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
<script src="static/assign-and-start.js"></script>
|
||||
|
|
@ -2430,8 +2159,6 @@
|
|||
<script src="static/mobile-pull-refresh.js"></script>
|
||||
<script src="static/mobile-first-task.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-recent-work.js"></script>
|
||||
<script src="static/mobile-queue-priority.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/mobile-delivery-recovery.js"></script>
|
||||
<script src="static/mobile-start-day.js"></script>
|
||||
|
|
@ -2446,13 +2173,11 @@
|
|||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/mobile-insights.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/mobile-app-badge.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/private-device-data.js"></script>
|
||||
<script src="static/device-storage.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
<script src="static/mobile-search-modal.js"></script>
|
||||
<script src="static/mobile-composer-viewport.js"></script>
|
||||
<script src="static/mention-composer.js"></script>
|
||||
<script src="static/push-notifications.js"></script>
|
||||
|
|
@ -2464,8 +2189,6 @@
|
|||
<script src="static/mobile-plan-today-nav.js"></script>
|
||||
<script src="static/mobile-find-work-nav.js"></script>
|
||||
<script src="static/workspace-bootstrap.js"></script>
|
||||
<script src="static/human-gates.js"></script>
|
||||
<script src="static/progressive-human-gates.js"></script>
|
||||
<script src="static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@
|
|||
return;
|
||||
}
|
||||
if (reason === 'session-idle') {
|
||||
status.textContent = 'Stackchain locked. Your drafts and queued work are still on this device. Sign in to resume.';
|
||||
status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.';
|
||||
return;
|
||||
}
|
||||
if (reason !== 'session-revoked') return;
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
(function(root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileAppBadge = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileAppBadge({
|
||||
control,
|
||||
status,
|
||||
container,
|
||||
navigator,
|
||||
storage,
|
||||
serviceWorker,
|
||||
}) {
|
||||
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
|
||||
let enabled = false;
|
||||
const confirmedCounts = {updates:0, following:0, 'human-gates':0};
|
||||
let renderedCount = null;
|
||||
|
||||
|
||||
function available() {
|
||||
return typeof navigator?.setAppBadge === 'function'
|
||||
&& typeof navigator?.clearAppBadge === 'function';
|
||||
}
|
||||
|
||||
async function syncPreference() {
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({type:'stackchain-app-badge-preference', enabled});
|
||||
return Boolean(registration?.active);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCount(channel, count) {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({type:'stackchain-app-badge-count', channel, count});
|
||||
} catch (_error) { /* A later refresh can retry. */ }
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const confirmedCount = Math.min(
|
||||
9999, confirmedCounts.updates + confirmedCounts.following + confirmedCounts['human-gates']
|
||||
);
|
||||
if (!enabled || !available() || renderedCount === confirmedCount) return true;
|
||||
try {
|
||||
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
|
||||
else await navigator.clearAppBadge();
|
||||
renderedCount = confirmedCount;
|
||||
if (status) status.textContent = 'App icon badge is on and up to date.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) status.textContent = 'Could not update the app icon badge. Stackchain still has the confirmed count.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function change() {
|
||||
enabled = Boolean(control?.checked);
|
||||
if (enabled) storage?.setItem(ENABLED_KEY, 'true');
|
||||
else storage?.removeItem(ENABLED_KEY);
|
||||
await syncPreference();
|
||||
if (enabled) {
|
||||
if (confirmedCounts.updates > 0) await syncCount('updates', confirmedCounts.updates);
|
||||
if (confirmedCounts.following > 0) await syncCount('following', confirmedCounts.following);
|
||||
if (confirmedCounts['human-gates'] > 0) await syncCount('human-gates', confirmedCounts['human-gates']);
|
||||
}
|
||||
if (!enabled && available()) {
|
||||
confirmedCounts.updates = 0;
|
||||
confirmedCounts.following = 0;
|
||||
confirmedCounts['human-gates'] = 0;
|
||||
try {
|
||||
await navigator.clearAppBadge();
|
||||
renderedCount = null;
|
||||
if (status) status.textContent = 'App icon badge is off.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) status.textContent = 'App icon badge is off, but the browser could not clear the old count.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return render();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!available()) {
|
||||
if (container) container.hidden = true;
|
||||
if (control) control.disabled = true;
|
||||
if (status) status.textContent = 'App icon badges are unavailable in this browser.';
|
||||
return false;
|
||||
}
|
||||
enabled = storage?.getItem(ENABLED_KEY) === 'true';
|
||||
if (enabled) void syncPreference();
|
||||
if (control) {
|
||||
control.checked = enabled;
|
||||
control.addEventListener('change', change);
|
||||
}
|
||||
if (status) status.textContent = enabled ? 'App icon badge is on.' : 'App icon badge is off.';
|
||||
return true;
|
||||
}
|
||||
|
||||
function readiness() {
|
||||
if (!available()) return {state:'unavailable', detail:'App icon badges are unavailable in this browser.'};
|
||||
return enabled
|
||||
? {state:'complete', detail:'The app icon shows review attention.'}
|
||||
: {state:'incomplete', detail:'Show review attention without opening Stackchain.'};
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
if (!available()) return false;
|
||||
if (control) control.checked = true;
|
||||
await change();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reconcile(channel, count, authoritative = false) {
|
||||
if (!Object.hasOwn(confirmedCounts, channel)
|
||||
|| authoritative !== true || !Number.isSafeInteger(count) || count < 0 || count > 9999) return false;
|
||||
confirmedCounts[channel] = count;
|
||||
await syncCount(channel, count);
|
||||
return render();
|
||||
}
|
||||
|
||||
return {start, change, reconcile, readiness, enable};
|
||||
});
|
||||
|
|
@ -9,9 +9,8 @@
|
|||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
|
||||
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
|
||||
['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable'],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
|
||||
].filter(([_name, button, status, action]) => button && status && action);
|
||||
];
|
||||
let trigger = options.launcher;
|
||||
let ownsDetour = false;
|
||||
let backgroundInert = null;
|
||||
|
|
@ -168,12 +167,10 @@ function mountMobileDeviceSetup(options) {
|
|||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
protectionButton:qs('#device-setup-protection'),
|
||||
appBadgeButton:qs('#device-setup-app-badge'),
|
||||
deadlineButton:qs('#device-setup-deadline'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
protectionStatus:qs('#device-setup-protection-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
||||
appBadgeStatus:qs('#device-setup-app-badge-status'),
|
||||
readyStatus:qs('#device-setup-ready-status'),
|
||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
||||
|
|
@ -196,14 +193,12 @@ function mountMobileDeviceSetup(options) {
|
|||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||
protection:options.storageProtectionReadiness(),
|
||||
push:options.notificationReadiness(),
|
||||
appBadge:options.appBadgeReadiness(),
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
protectStorage:options.protectStorage,
|
||||
enablePush:options.enablePush,
|
||||
enableAppBadge:options.enableAppBadge,
|
||||
enableDeadline:options.enableDeadline,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,10 +62,7 @@
|
|||
|
||||
function renderCoach() {
|
||||
if (!options.coach) return;
|
||||
const visible = options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive();
|
||||
options.coach.hidden = !visible;
|
||||
const host = options.coach.closest?.('[data-mobile-today-hud]');
|
||||
if (visible && host) host.hidden = false;
|
||||
options.coach.hidden = !(options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive());
|
||||
}
|
||||
|
||||
function render() {
|
||||
|
|
|
|||
|
|
@ -8,25 +8,12 @@
|
|||
let active = false;
|
||||
let previous = '#/my-work';
|
||||
let retries = 0;
|
||||
let backgroundInert = null;
|
||||
|
||||
function attr(element, name, value) {
|
||||
if (value === null) element.removeAttribute(name);
|
||||
else element.setAttribute(name, value);
|
||||
}
|
||||
|
||||
function containBackground() {
|
||||
if (backgroundInert) return;
|
||||
backgroundInert = new Map(o.backgrounds.map(element => [element, element.inert]));
|
||||
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
|
||||
}
|
||||
|
||||
function releaseBackground() {
|
||||
if (!backgroundInert) return;
|
||||
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundInert = null;
|
||||
}
|
||||
|
||||
function render(open, focus = false) {
|
||||
const wasActive = active;
|
||||
active = Boolean(open && o.mediaQuery.matches);
|
||||
|
|
@ -41,8 +28,7 @@
|
|||
attr(o.root, 'aria-modal', active ? 'true' : null);
|
||||
attr(o.root, 'aria-hidden', active || !o.mediaQuery.matches ? null : 'true');
|
||||
o.root.inert = !active && o.mediaQuery.matches;
|
||||
if (active) containBackground();
|
||||
else releaseBackground();
|
||||
o.backgrounds.forEach(element => { element.inert = active; });
|
||||
o.dock.hidden = active;
|
||||
attr(o.hud, 'data-overlay-hidden', active ? 'true' : null);
|
||||
if (active) o.closeButton.focus();
|
||||
|
|
|
|||
|
|
@ -8,114 +8,31 @@
|
|||
later: 'No deferred work is ready to open.',
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const labels = {
|
||||
delivery: 'Recover Delivery', gate: 'Review Human Gates', attention: 'Start Attention',
|
||||
today: 'Continue Today', update: 'Resume Updates', agenda: 'Open Agenda',
|
||||
following: 'Review Following', authored: 'Open My PRs', filed: 'Review Filed',
|
||||
later: 'Start Later', draft: 'Open Drafts',
|
||||
};
|
||||
const criticalQueueNames = ['delivery', 'gate'];
|
||||
const defaultRoutineOrder = [
|
||||
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
|
||||
const continuation = [
|
||||
['delivery', 'Recover Delivery'],
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['update', 'Resume Updates'],
|
||||
['agenda', 'Open Agenda'],
|
||||
['filed', 'Review Filed'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
];
|
||||
const onlineOnlyQueues = new Set(['delivery', 'gate']);
|
||||
const allQueues = [
|
||||
'today', 'tomorrow', 'week', 'agenda', 'delivery', 'gate', 'attention',
|
||||
'update', 'following', 'filed', 'authored', 'later', 'draft', 'find', 'recaps',
|
||||
];
|
||||
|
||||
function routineOrder() {
|
||||
const proposed = options.getRoutineOrder ? options.getRoutineOrder() : defaultRoutineOrder;
|
||||
if (!Array.isArray(proposed) || proposed.length !== defaultRoutineOrder.length ||
|
||||
new Set(proposed).size !== defaultRoutineOrder.length ||
|
||||
proposed.some(name => !defaultRoutineOrder.includes(name))) return defaultRoutineOrder.slice();
|
||||
return proposed.slice();
|
||||
}
|
||||
|
||||
function activeQueueNames() {
|
||||
return criticalQueueNames.concat(routineOrder());
|
||||
}
|
||||
|
||||
function recommend() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const online = options.isOnline ? options.isOnline() : true;
|
||||
const match = activeQueueNames().map(name => [name, labels[name]]).find(([name]) =>
|
||||
Number(counts[name]) > 0 && (online || !onlineOnlyQueues.has(name))
|
||||
);
|
||||
const match = continuation.find(([name]) => Number(counts[name]) > 0);
|
||||
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
|
||||
const [name, label] = match;
|
||||
const count = Math.max(0, Number(counts[name]) || 0);
|
||||
return {name, count, label: label + ' (' + count + ')'};
|
||||
}
|
||||
|
||||
function adaptiveRecommendation() {
|
||||
const preparation = options.getPreparation ? options.getPreparation() : null;
|
||||
if (preparation && (preparation.active || Number(preparation.total) > 0)) {
|
||||
const prefix = preparation.active ? 'Resume preparation · ' : 'Start day · ';
|
||||
return {name: 'prepare', count: Math.max(0, Number(preparation.total) || 0), label: prefix + preparation.label};
|
||||
}
|
||||
return recommend();
|
||||
}
|
||||
|
||||
function presentation() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const names = activeQueueNames();
|
||||
const active = names
|
||||
.map(name => ({name, count: Math.max(0, Number(counts[name]) || 0)}))
|
||||
.filter(item => item.count > 0);
|
||||
names.forEach(name => {
|
||||
if (counts[name + 'Unavailable'] && !active.some(item => item.name === name)) {
|
||||
active.push({name, unavailable: true});
|
||||
}
|
||||
});
|
||||
return {
|
||||
nextUp: adaptiveRecommendation(),
|
||||
active,
|
||||
planning: ['today', 'tomorrow', 'week'],
|
||||
all: allQueues.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderPresentation() {
|
||||
const view = presentation();
|
||||
const planning = new Set(view.planning);
|
||||
const active = view.active.filter(item => !planning.has(item.name));
|
||||
const activeNames = new Set(active.map(item => item.name));
|
||||
if (options.nextAction) {
|
||||
options.nextAction.textContent = view.nextUp.label;
|
||||
options.nextAction.dataset.queue = view.nextUp.name;
|
||||
const prefix = view.nextUp.name === 'prepare' ? 'Start or continue: ' : 'Next up: ';
|
||||
options.nextAction.setAttribute('aria-label', prefix + view.nextUp.label);
|
||||
}
|
||||
active.forEach(item => {
|
||||
const row = options.rows?.[item.name];
|
||||
if (!row) return;
|
||||
if (item.unavailable) row.setAttribute('data-unavailable', 'true');
|
||||
else row.removeAttribute('data-unavailable');
|
||||
options.activeList?.append(row);
|
||||
});
|
||||
view.planning.forEach(name => {
|
||||
const row = options.rows?.[name];
|
||||
if (row) options.planningList?.append(row);
|
||||
});
|
||||
view.all.forEach(name => {
|
||||
if (planning.has(name) || activeNames.has(name)) return;
|
||||
const row = options.rows?.[name];
|
||||
if (!row) return;
|
||||
row.removeAttribute('data-unavailable');
|
||||
options.allList?.append(row);
|
||||
});
|
||||
if (options.activeSection) options.activeSection.hidden = active.length === 0;
|
||||
return view;
|
||||
}
|
||||
|
||||
function open(name) {
|
||||
if (name === 'delivery' && options.openDelivery) return options.openDelivery();
|
||||
if (name === 'gate' && options.openHumanGates) return options.openHumanGates();
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
if (name === 'following' && options.openFollowing) return options.openFollowing();
|
||||
if (name === 'filed' && options.openFiled) return options.openFiled();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
|
|
@ -128,11 +45,7 @@
|
|||
}
|
||||
|
||||
function continueWork() {
|
||||
const next = adaptiveRecommendation();
|
||||
if (next.name === 'prepare') {
|
||||
options.openPreparation();
|
||||
return 'prepare';
|
||||
}
|
||||
const next = recommend();
|
||||
if (next.name === 'find') {
|
||||
options.openFindWork();
|
||||
return 'find';
|
||||
|
|
@ -140,5 +53,5 @@
|
|||
return open(next.name);
|
||||
}
|
||||
|
||||
return { open, recommend, adaptiveRecommendation, presentation, renderPresentation, continueWork };
|
||||
return { open, recommend, continueWork };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,340 +0,0 @@
|
|||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileQueuePriority = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileQueuePriority(options = {}) {
|
||||
const DEFAULT_ORDER = [
|
||||
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
|
||||
];
|
||||
const storage = options.storage;
|
||||
const getLogin = options.getLogin || (() => '');
|
||||
const fetchJson = options.fetchJson;
|
||||
const prefix = 'stackchain-mobile-queue-priority-v1:';
|
||||
const labels = options.labels || {};
|
||||
const documentRef = options.document || (typeof document !== 'undefined' ? document : null);
|
||||
const setTimer = options.setTimeout || setTimeout;
|
||||
const clearTimer = options.clearTimeout || clearTimeout;
|
||||
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
|
||||
const retryBaseMs = Number.isFinite(options.retryBaseMs) ? Math.max(1, options.retryBaseMs) : 1000;
|
||||
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryBaseMs, options.retryMaxMs) : 30000;
|
||||
let memory = null;
|
||||
const syncFlights = new Map();
|
||||
let debounceTimer = null;
|
||||
let retryTimer = null;
|
||||
let retryAccount = '';
|
||||
let retryAttempts = 0;
|
||||
|
||||
function key() {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function valid(order) {
|
||||
return Array.isArray(order) && order.length === DEFAULT_ORDER.length &&
|
||||
new Set(order).size === DEFAULT_ORDER.length &&
|
||||
order.every(name => DEFAULT_ORDER.includes(name) && typeof name === 'string');
|
||||
}
|
||||
|
||||
function fresh() {
|
||||
return {revision:0, order:DEFAULT_ORDER.slice(), pending:false, status:'ready', remote:null};
|
||||
}
|
||||
|
||||
function read() {
|
||||
const accountKey = key();
|
||||
if (!accountKey || !storage) return fresh();
|
||||
if (memory?.key === accountKey) return memory.value;
|
||||
let value = fresh();
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(accountKey) || 'null');
|
||||
if (valid(saved)) value = {revision:0, order:saved.slice(), pending:true, status:'pending', remote:null};
|
||||
else if (saved && valid(saved.order) && Number.isInteger(saved.revision) && saved.revision >= 0) {
|
||||
value = {
|
||||
revision:saved.revision, order:saved.order.slice(), pending:Boolean(saved.pending),
|
||||
status:saved.status === 'conflict' ? 'conflict' : (saved.pending ? 'pending' : 'ready'),
|
||||
remote:saved.remote && valid(saved.remote.order) ? {
|
||||
revision:Number(saved.remote.revision) || 0, order:saved.remote.order.slice(),
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
} catch (_error) {}
|
||||
memory = {key:accountKey, value};
|
||||
return value;
|
||||
}
|
||||
|
||||
function persist(value) {
|
||||
const accountKey = key();
|
||||
if (!accountKey || !storage) return false;
|
||||
memory = {key:accountKey, value};
|
||||
try {
|
||||
storage.setItem(accountKey, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const value = read();
|
||||
return {
|
||||
revision:value.revision, order:value.order.slice(), pending:value.pending,
|
||||
status:value.status, remote:value.remote ? {revision:value.remote.revision, order:value.remote.order.slice()} : null,
|
||||
};
|
||||
}
|
||||
|
||||
function announce(value) {
|
||||
if (options.status) {
|
||||
options.status.textContent = ({pending:'Sync pending.', conflict:'Routine order changed on another device.',
|
||||
syncing:'Syncing routine order…', error:'Routine order could not sync.', ready:''})[value.status] || '';
|
||||
}
|
||||
options.onState?.(snapshot());
|
||||
}
|
||||
|
||||
function getOrder() {
|
||||
return read().order.slice();
|
||||
}
|
||||
|
||||
function save(order) {
|
||||
if (!key() || !valid(order)) return false;
|
||||
const current = read();
|
||||
const value = {revision:current.revision, order:order.slice(), pending:true, status:'pending', remote:null};
|
||||
if (!persist(value)) return false;
|
||||
options.onChange?.(order.slice());
|
||||
announce(value);
|
||||
scheduleSync();
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleSync() {
|
||||
if (!fetchJson || !key()) return false;
|
||||
if (debounceTimer) clearTimer(debounceTimer);
|
||||
debounceTimer = setTimer(() => {
|
||||
debounceTimer = null;
|
||||
void sync();
|
||||
}, debounceMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearRetry() {
|
||||
if (retryTimer) clearTimer(retryTimer);
|
||||
retryTimer = null;
|
||||
retryAccount = '';
|
||||
retryAttempts = 0;
|
||||
}
|
||||
|
||||
function transient(error) {
|
||||
const status = Number(error?.status) || 0;
|
||||
return status === 0 || status === 408 || status === 425 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
function scheduleRetry(accountKey) {
|
||||
if (retryTimer && retryAccount !== accountKey) clearRetry();
|
||||
if (retryTimer || key() !== accountKey) return false;
|
||||
const delay = Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempts));
|
||||
retryAttempts += 1;
|
||||
retryAccount = accountKey;
|
||||
retryTimer = setTimer(() => {
|
||||
retryTimer = null;
|
||||
retryAccount = '';
|
||||
const current = read();
|
||||
if (key() !== accountKey || !current.pending || current.status === 'conflict') return;
|
||||
void sync();
|
||||
}, delay);
|
||||
return true;
|
||||
}
|
||||
|
||||
function move(name, delta) {
|
||||
const order = getOrder();
|
||||
const index = order.indexOf(name);
|
||||
const next = index + (Number(delta) < 0 ? -1 : 1);
|
||||
if (index < 0 || next < 0 || next >= order.length) return order;
|
||||
[order[index], order[next]] = [order[next], order[index]];
|
||||
save(order);
|
||||
return order.slice();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
const order = DEFAULT_ORDER.slice();
|
||||
if (fetchJson && key()) save(order);
|
||||
else {
|
||||
const accountKey = key();
|
||||
if (accountKey && storage) {
|
||||
try { storage.removeItem(accountKey); } catch (_error) {}
|
||||
}
|
||||
memory = null;
|
||||
options.onChange?.(order.slice());
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
function adopt(snapshotValue, accountKey = key()) {
|
||||
if (!accountKey || key() !== accountKey) return snapshot();
|
||||
if (!snapshotValue || !Number.isInteger(snapshotValue.revision) || snapshotValue.revision < 0 || !valid(snapshotValue.order)) {
|
||||
throw new Error('Queue priority response is invalid.');
|
||||
}
|
||||
const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null};
|
||||
clearRetry();
|
||||
persist(value);
|
||||
options.onChange?.(value.order.slice());
|
||||
announce(value);
|
||||
render();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const accountKey = key();
|
||||
if (!fetchJson || !accountKey) return snapshot();
|
||||
try {
|
||||
const remote = await fetchJson('api/v1/queue-priority');
|
||||
if (key() !== accountKey) return snapshot();
|
||||
const local = read();
|
||||
if (local.pending) {
|
||||
local.remote = valid(remote?.order) ? {revision:remote.revision, order:remote.order.slice()} : null;
|
||||
persist(local);
|
||||
return sync();
|
||||
}
|
||||
return adopt(remote, accountKey);
|
||||
} catch (_error) {
|
||||
if (key() !== accountKey) return snapshot();
|
||||
const current = read();
|
||||
current.status = current.pending ? 'pending' : 'error';
|
||||
persist(current); announce(current);
|
||||
return snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
async function drain(accountKey) {
|
||||
while (key() === accountKey) {
|
||||
const current = read();
|
||||
if (!current.pending) return snapshot();
|
||||
const sent = {revision:current.revision, order:current.order.slice()};
|
||||
current.status = 'syncing'; persist(current); announce(current);
|
||||
try {
|
||||
const saved = await fetchJson('api/v1/queue-priority', {
|
||||
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(sent),
|
||||
});
|
||||
if (key() !== accountKey) return snapshot();
|
||||
if (!saved || !Number.isInteger(saved.revision) || !valid(saved.order)) {
|
||||
throw new Error('Queue priority response is invalid.');
|
||||
}
|
||||
const latest = read();
|
||||
if (latest.order.some((name, index) => name !== sent.order[index])) {
|
||||
latest.revision = saved.revision; latest.pending = true;
|
||||
latest.status = 'pending'; latest.remote = null;
|
||||
persist(latest); announce(latest);
|
||||
continue;
|
||||
}
|
||||
return adopt(saved, accountKey);
|
||||
} catch (error) {
|
||||
if (key() !== accountKey) return snapshot();
|
||||
const latest = read();
|
||||
const remote = error?.status === 409 && error?.payload?.detail?.snapshot;
|
||||
if (remote && valid(remote.order) && Number.isInteger(remote.revision)) {
|
||||
clearRetry();
|
||||
latest.status = 'conflict'; latest.pending = true;
|
||||
latest.remote = {revision:remote.revision, order:remote.order.slice()};
|
||||
} else {
|
||||
latest.status = 'pending'; latest.pending = true;
|
||||
if (transient(error)) scheduleRetry(accountKey);
|
||||
}
|
||||
persist(latest); announce(latest); render();
|
||||
return snapshot();
|
||||
}
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function sync() {
|
||||
if (debounceTimer) {
|
||||
clearTimer(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
const accountKey = key();
|
||||
const current = read();
|
||||
if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot());
|
||||
if (syncFlights.has(accountKey)) return syncFlights.get(accountKey);
|
||||
const flight = drain(accountKey).finally(() => {
|
||||
if (syncFlights.get(accountKey) === flight) syncFlights.delete(accountKey);
|
||||
});
|
||||
syncFlights.set(accountKey, flight);
|
||||
return flight;
|
||||
}
|
||||
|
||||
async function useLocal() {
|
||||
const current = read();
|
||||
if (!current.remote) return snapshot();
|
||||
current.revision = current.remote.revision;
|
||||
current.remote = null; current.pending = true; current.status = 'pending';
|
||||
persist(current);
|
||||
return sync();
|
||||
}
|
||||
|
||||
function useRemote() {
|
||||
const current = read();
|
||||
return current.remote ? adopt(current.remote) : snapshot();
|
||||
}
|
||||
|
||||
function displayName(name) {
|
||||
return labels[name] || name.charAt(0).toUpperCase() + name.slice(1);
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!options.list || !documentRef) return getOrder();
|
||||
const order = getOrder();
|
||||
const signedIn = Boolean(key());
|
||||
const rows = order.map((name, index) => {
|
||||
const row = documentRef.createElement('div');
|
||||
row.setAttribute('data-queue-priority', name);
|
||||
row.setAttribute('class', 'mobile-queue-priority-row');
|
||||
const label = documentRef.createElement('span');
|
||||
label.textContent = displayName(name);
|
||||
const controls = documentRef.createElement('span');
|
||||
controls.setAttribute('class', 'mobile-queue-priority-controls');
|
||||
const earlier = documentRef.createElement('button');
|
||||
earlier.textContent = 'Earlier'; earlier.setAttribute('type', 'button');
|
||||
earlier.setAttribute('aria-label', 'Move ' + displayName(name) + ' earlier');
|
||||
earlier.disabled = !signedIn || index === 0;
|
||||
earlier.addEventListener('click', () => {
|
||||
move(name, -1); render();
|
||||
});
|
||||
const later = documentRef.createElement('button');
|
||||
later.textContent = 'Later'; later.setAttribute('type', 'button');
|
||||
later.setAttribute('aria-label', 'Move ' + displayName(name) + ' later');
|
||||
later.disabled = !signedIn || index === order.length - 1;
|
||||
later.addEventListener('click', () => {
|
||||
move(name, 1); render();
|
||||
});
|
||||
controls.append(earlier, later); row.append(label, controls);
|
||||
return row;
|
||||
});
|
||||
options.list.replaceChildren(...rows);
|
||||
if (options.resetButton) options.resetButton.disabled = !signedIn;
|
||||
if (options.conflict) options.conflict.hidden = read().status !== 'conflict';
|
||||
return order;
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.resetButton?.addEventListener('click', () => {
|
||||
reset(); render();
|
||||
});
|
||||
options.keepLocalButton?.addEventListener('click', () => { void useLocal(); });
|
||||
options.useRemoteButton?.addEventListener('click', () => { useRemote(); });
|
||||
return render();
|
||||
}
|
||||
|
||||
function startLifecycle(lifecycle = {}) {
|
||||
const windowObject = lifecycle.window;
|
||||
const lifecycleDocument = lifecycle.document;
|
||||
const reconcile = () => {
|
||||
if (!key()) return Promise.resolve(snapshot());
|
||||
return read().pending ? sync() : load();
|
||||
};
|
||||
windowObject?.addEventListener?.('online', () => { void reconcile(); });
|
||||
lifecycleDocument?.addEventListener?.('visibilitychange', () => {
|
||||
if (!lifecycleDocument.hidden) void reconcile();
|
||||
});
|
||||
return reconcile;
|
||||
}
|
||||
|
||||
return {getOrder, move, reset, render, start, startLifecycle, load, sync, scheduleSync, useLocal, useRemote,
|
||||
state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
|
||||
});
|
||||
|
|
@ -1,455 +0,0 @@
|
|||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileRecentWork = factory;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function createMobileRecentWork(options) {
|
||||
'use strict';
|
||||
|
||||
const storage = options.storage;
|
||||
const getLogin = options.getLogin;
|
||||
const fetchJson = options.fetchJson;
|
||||
const limit = Math.max(1, Number(options.limit) || 5);
|
||||
const pinnedLimit = Math.max(1, Number(options.pinnedLimit) || 20);
|
||||
const prefix = 'stackchain.mobile-recent-work.v1.';
|
||||
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
|
||||
const setTimer = options.setTimeout || setTimeout;
|
||||
const clearTimer = options.clearTimeout || clearTimeout;
|
||||
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
|
||||
const retryMs = Number.isFinite(options.retryMs) ? Math.max(1, options.retryMs) : 1000;
|
||||
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryMs, options.retryMaxMs) : 30000;
|
||||
let syncFlight = null;
|
||||
let syncAccount = '';
|
||||
let debounceTimer = null;
|
||||
let retryTimer = null;
|
||||
let retryAccount = '';
|
||||
let retryAttempt = 0;
|
||||
let operationSequence = 0;
|
||||
let pinsExpanded = false;
|
||||
let currentItem = null;
|
||||
|
||||
const detailPins = Array.from(options.detailPins || []);
|
||||
detailPins.forEach(button => button.addEventListener?.('click', () => {
|
||||
if (!currentItem) return;
|
||||
const isPinned = pinned().some(item => item.route === currentItem.route);
|
||||
if (isPinned) unpin(currentItem.route);
|
||||
else pin(currentItem);
|
||||
}));
|
||||
|
||||
options.pinnedToggle?.addEventListener?.('click', () => {
|
||||
pinsExpanded = !pinsExpanded;
|
||||
render();
|
||||
});
|
||||
|
||||
function operationId() {
|
||||
operationSequence += 1;
|
||||
return Date.now().toString(36) + '-' + operationSequence.toString(36);
|
||||
}
|
||||
|
||||
function clearRetry(resetAttempt = false) {
|
||||
if (retryTimer) clearTimer(retryTimer);
|
||||
retryTimer = null;
|
||||
retryAccount = '';
|
||||
if (resetAttempt) retryAttempt = 0;
|
||||
}
|
||||
|
||||
function scheduleRetry(accountKey) {
|
||||
if (retryTimer || key() !== accountKey || !hasPending(read())) return false;
|
||||
retryAccount = accountKey;
|
||||
const delay = Math.min(retryMaxMs, retryMs * (2 ** retryAttempt));
|
||||
retryAttempt += 1;
|
||||
retryTimer = setTimer(() => {
|
||||
const timer = retryTimer;
|
||||
retryTimer = null;
|
||||
retryAccount = '';
|
||||
if (timer) clearTimer(timer);
|
||||
if (key() === accountKey && hasPending(read())) void sync();
|
||||
}, delay);
|
||||
return true;
|
||||
}
|
||||
|
||||
function login() {
|
||||
return String(getLogin?.() || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function key() {
|
||||
const owner = login();
|
||||
return owner ? prefix + owner : '';
|
||||
}
|
||||
|
||||
function normalize(item) {
|
||||
const kind = String(item?.kind || '');
|
||||
const number = Number(item?.number ?? item?.notification_id);
|
||||
if (!kinds.has(kind) || !Number.isSafeInteger(number) || number < 1) return null;
|
||||
let route = '';
|
||||
let repository = '';
|
||||
if (kind === 'update') {
|
||||
route = '#/my-work/update/' + number;
|
||||
} else {
|
||||
repository = String(item?.repository || '');
|
||||
if (!repositoryPattern.test(repository)) return null;
|
||||
route = '#/my-work/' + kind + '/' + repository + '/' + number;
|
||||
}
|
||||
const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180);
|
||||
if (!title) return null;
|
||||
return {kind, ...(repository ? {repository} : {}), number, title, route};
|
||||
}
|
||||
|
||||
function normalizeList(value, maximum = limit) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const unique = [];
|
||||
for (const candidate of value) {
|
||||
const item = normalize(candidate);
|
||||
if (item && !unique.some(existing => existing.route === item.route)) unique.push(item);
|
||||
if (unique.length === maximum) break;
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function normalizePinOps(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const unique = [];
|
||||
for (const candidate of value) {
|
||||
const action = candidate?.action;
|
||||
const item = action === 'pin' ? normalize(candidate.item) : null;
|
||||
const route = action === 'pin' ? item?.route : String(candidate?.route || '');
|
||||
if ((action !== 'pin' && action !== 'unpin') || !route || (action === 'pin' && !item)) continue;
|
||||
if (!unique.some(existing => (existing.item?.route || existing.route) === route)) {
|
||||
const normalized = action === 'pin' ? {action, item} : {action, route};
|
||||
if (typeof candidate.operationId === 'string' && candidate.operationId) normalized.operationId = candidate.operationId;
|
||||
unique.push(normalized);
|
||||
}
|
||||
if (unique.length === pinnedLimit) break;
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function normalizePending(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const unique = [];
|
||||
for (const candidate of value) {
|
||||
const item = normalize(candidate);
|
||||
if (!item || unique.some(existing => existing.route === item.route)) continue;
|
||||
if (typeof candidate.operationId === 'string' && candidate.operationId) item.operationId = candidate.operationId;
|
||||
unique.push(item);
|
||||
if (unique.length === limit) break;
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function empty() {
|
||||
return {items:[], pinned:[], pending:[], pinOps:[]};
|
||||
}
|
||||
|
||||
function read() {
|
||||
const storageKey = key();
|
||||
if (!storageKey) return empty();
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
||||
if (Array.isArray(parsed)) return {...empty(), items:normalizeList(parsed)};
|
||||
return {
|
||||
items:normalizeList(parsed?.items),
|
||||
pinned:normalizeList(parsed?.pinned, pinnedLimit),
|
||||
pending:normalizePending(parsed?.pending),
|
||||
pinOps:normalizePinOps(parsed?.pinOps),
|
||||
};
|
||||
} catch (_) {
|
||||
return empty();
|
||||
}
|
||||
}
|
||||
|
||||
function persist(value, accountKey = key()) {
|
||||
if (!accountKey || accountKey !== key()) return false;
|
||||
try {
|
||||
storage.setItem(accountKey, JSON.stringify({
|
||||
items:normalizeList(value.items),
|
||||
pinned:normalizeList(value.pinned, pinnedLimit),
|
||||
pending:normalizePending(value.pending),
|
||||
pinOps:normalizePinOps(value.pinOps),
|
||||
}));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasPending(value) {
|
||||
return value.pending.length > 0 || value.pinOps.length > 0;
|
||||
}
|
||||
|
||||
function announce(value = read(), status = null) {
|
||||
if (!options.status) return;
|
||||
options.status.textContent = status || (hasPending(value) ? 'Sync pending.' : '');
|
||||
}
|
||||
|
||||
function items() {
|
||||
return read().items;
|
||||
}
|
||||
|
||||
function pinned() {
|
||||
return read().pinned;
|
||||
}
|
||||
|
||||
function state() {
|
||||
const value = read();
|
||||
const pendingCount = value.pending.length + value.pinOps.length;
|
||||
return {pending:pendingCount > 0, pendingCount};
|
||||
}
|
||||
|
||||
function scheduleSync() {
|
||||
if (!fetchJson || !key()) return false;
|
||||
if (debounceTimer) clearTimer(debounceTimer);
|
||||
debounceTimer = setTimer(() => {
|
||||
debounceTimer = null;
|
||||
void sync();
|
||||
}, debounceMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
function record(item) {
|
||||
const accountKey = key();
|
||||
const normalized = normalize(item);
|
||||
if (!accountKey || !normalized) return false;
|
||||
const current = read();
|
||||
current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
||||
current.pinned = current.pinned.some(existing => existing.route === normalized.route)
|
||||
? [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)]
|
||||
: current.pinned;
|
||||
current.pending = [{...normalized, operationId:operationId()}, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
||||
if (!persist(current, accountKey)) return false;
|
||||
announce(current);
|
||||
render();
|
||||
scheduleSync();
|
||||
return true;
|
||||
}
|
||||
|
||||
function queuePinOp(current, operation) {
|
||||
const route = operation.item?.route || operation.route;
|
||||
current.pinOps = [{...operation, operationId:operationId()}, ...current.pinOps.filter(existing => (existing.item?.route || existing.route) !== route)];
|
||||
}
|
||||
|
||||
function pin(item) {
|
||||
const accountKey = key();
|
||||
const normalized = normalize(item);
|
||||
if (!accountKey || !normalized) return false;
|
||||
const current = read();
|
||||
current.pinned = [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)].slice(0, pinnedLimit);
|
||||
queuePinOp(current, {action:'pin', item:normalized});
|
||||
if (!persist(current, accountKey)) return false;
|
||||
announce(current);
|
||||
render();
|
||||
scheduleSync();
|
||||
return true;
|
||||
}
|
||||
|
||||
function unpin(route) {
|
||||
const accountKey = key();
|
||||
route = String(route || '');
|
||||
if (!accountKey || !route) return false;
|
||||
const current = read();
|
||||
if (!current.pinned.some(item => item.route === route)) return false;
|
||||
current.pinned = current.pinned.filter(item => item.route !== route);
|
||||
queuePinOp(current, {action:'unpin', route});
|
||||
if (!persist(current, accountKey)) return false;
|
||||
announce(current);
|
||||
render();
|
||||
scheduleSync();
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPinOps(remote, operations) {
|
||||
let result = normalizeList(remote, pinnedLimit);
|
||||
for (const operation of [...normalizePinOps(operations)].reverse()) {
|
||||
const route = operation.item?.route || operation.route;
|
||||
result = operation.action === 'pin'
|
||||
? [operation.item, ...result.filter(item => item.route !== route)].slice(0, pinnedLimit)
|
||||
: result.filter(item => item.route !== route);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function adopt(snapshot, accountKey, pending = [], pinOps = []) {
|
||||
if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
|
||||
const remote = normalizeList(snapshot.items);
|
||||
const remotePinned = normalizeList(snapshot.pinned, pinnedLimit);
|
||||
const unsent = normalizePending(pending);
|
||||
const unsentPinOps = normalizePinOps(pinOps);
|
||||
const value = {
|
||||
items:normalizeList([...unsent, ...remote]),
|
||||
pinned:applyPinOps(remotePinned, unsentPinOps),
|
||||
pending:unsent,
|
||||
pinOps:unsentPinOps,
|
||||
};
|
||||
persist(value, accountKey);
|
||||
announce(value);
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function drain(accountKey) {
|
||||
while (key() === accountKey) {
|
||||
const current = read();
|
||||
if (!hasPending(current)) return current;
|
||||
const sending = current.pending[current.pending.length - 1];
|
||||
const pinOperation = sending ? null : current.pinOps[current.pinOps.length - 1];
|
||||
announce(current, 'Syncing recent work…');
|
||||
try {
|
||||
let snapshot;
|
||||
if (sending) {
|
||||
snapshot = await fetchJson('api/v1/recent-work', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(normalize(sending)),
|
||||
});
|
||||
} else {
|
||||
const isPin = pinOperation.action === 'pin';
|
||||
snapshot = await fetchJson('api/v1/recent-work/pin', {
|
||||
method:isPin ? 'PUT' : 'DELETE',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(isPin ? pinOperation.item : {route:pinOperation.route}),
|
||||
});
|
||||
}
|
||||
if (key() !== accountKey) return read();
|
||||
const latest = read();
|
||||
const pending = sending
|
||||
? latest.pending.filter(item => item.operationId !== sending.operationId)
|
||||
: latest.pending;
|
||||
const pinOps = pinOperation
|
||||
? latest.pinOps.filter(operation => {
|
||||
const sameRoute = (operation.item?.route || operation.route) === (pinOperation.item?.route || pinOperation.route);
|
||||
return !sameRoute || operation.action !== pinOperation.action || operation.operationId !== pinOperation.operationId;
|
||||
})
|
||||
: latest.pinOps;
|
||||
if (!adopt(snapshot, accountKey, pending, pinOps)) throw new Error('Recent work response is invalid.');
|
||||
retryAttempt = 0;
|
||||
} catch (_error) {
|
||||
if (key() === accountKey) {
|
||||
announce(read());
|
||||
scheduleRetry(accountKey);
|
||||
}
|
||||
return read();
|
||||
}
|
||||
}
|
||||
return read();
|
||||
}
|
||||
|
||||
function sync() {
|
||||
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
|
||||
const accountKey = key();
|
||||
if (retryTimer && retryAccount !== accountKey) clearRetry(true);
|
||||
else if (retryTimer) clearRetry(false);
|
||||
if (!fetchJson || !accountKey || !hasPending(read())) return Promise.resolve(read());
|
||||
if (syncFlight && syncAccount === accountKey) return syncFlight;
|
||||
syncAccount = accountKey;
|
||||
syncFlight = drain(accountKey).finally(() => {
|
||||
if (syncAccount === accountKey) { syncFlight = null; syncAccount = ''; }
|
||||
});
|
||||
return syncFlight;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const accountKey = key();
|
||||
if (!fetchJson || !accountKey) return read();
|
||||
try {
|
||||
const snapshot = await fetchJson('api/v1/recent-work');
|
||||
if (key() !== accountKey) return read();
|
||||
const current = read();
|
||||
adopt(snapshot, accountKey, current.pending, current.pinOps);
|
||||
return hasPending(current) ? sync() : read();
|
||||
} catch (_error) {
|
||||
if (key() === accountKey) announce(read(), hasPending(read()) ? null : 'Recent work could not sync.');
|
||||
return read();
|
||||
}
|
||||
}
|
||||
|
||||
function startLifecycle(lifecycle = {}) {
|
||||
const reconcile = () => hasPending(read()) ? sync() : load();
|
||||
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
|
||||
lifecycle.document?.addEventListener?.('visibilitychange', () => {
|
||||
if (!lifecycle.document.hidden) void reconcile();
|
||||
});
|
||||
return reconcile;
|
||||
}
|
||||
|
||||
function detail(item) {
|
||||
return item.kind === 'update'
|
||||
? 'Update · #' + item.number
|
||||
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
|
||||
}
|
||||
|
||||
function row(item, isPinned) {
|
||||
const itemDetail = detail(item);
|
||||
const wrapper = options.document.createElement('div');
|
||||
const button = options.document.createElement('button');
|
||||
const action = options.document.createElement('button');
|
||||
const copy = options.document.createElement('span');
|
||||
const primary = options.document.createElement('strong');
|
||||
const secondary = options.document.createElement('small');
|
||||
wrapper.setAttribute('class', 'mobile-recent-work-row');
|
||||
primary.textContent = item.title;
|
||||
secondary.textContent = itemDetail;
|
||||
copy.appendChild(primary);
|
||||
copy.appendChild(secondary);
|
||||
button.appendChild(copy);
|
||||
button.setAttribute('type', 'button');
|
||||
button.setAttribute('data-recent-work-route', item.route);
|
||||
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + itemDetail.toLowerCase().replace(' · ', ' '));
|
||||
button.addEventListener('click', () => {
|
||||
if (isPinned) record(item);
|
||||
options.openRoute?.(item.route);
|
||||
});
|
||||
action.textContent = isPinned ? 'Unpin' : 'Pin';
|
||||
action.setAttribute('type', 'button');
|
||||
action.setAttribute('data-recent-work-pin', isPinned ? 'unpin' : 'pin');
|
||||
action.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + item.title);
|
||||
action.addEventListener('click', () => isPinned ? unpin(item.route) : pin(item));
|
||||
wrapper.appendChild(button);
|
||||
wrapper.appendChild(action);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function renderCurrent() {
|
||||
const isPinned = currentItem && pinned().some(item => item.route === currentItem.route);
|
||||
detailPins.forEach(button => {
|
||||
button.hidden = !currentItem;
|
||||
if (!currentItem) return;
|
||||
button.textContent = isPinned ? 'Pinned' : 'Pin';
|
||||
button.setAttribute('aria-pressed', isPinned ? 'true' : 'false');
|
||||
button.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + currentItem.title);
|
||||
button.setAttribute('data-current-work-pin', isPinned ? 'unpin' : 'pin');
|
||||
});
|
||||
}
|
||||
|
||||
function setCurrent(item) {
|
||||
currentItem = normalize(item);
|
||||
renderCurrent();
|
||||
return Boolean(currentItem);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const recent = items();
|
||||
const fixed = pinned();
|
||||
const fixedRoutes = new Set(fixed.map(item => item.route));
|
||||
const visibleRecent = recent.filter(item => !fixedRoutes.has(item.route));
|
||||
const list = options.list;
|
||||
const section = options.section;
|
||||
if (list && section && options.document) {
|
||||
const rows = visibleRecent.map(item => row(item, false));
|
||||
list.replaceChildren(...rows);
|
||||
section.hidden = rows.length === 0;
|
||||
}
|
||||
if (options.pinnedList && options.pinnedSection && options.document) {
|
||||
const visiblePins = pinsExpanded ? fixed : fixed.slice(0, 3);
|
||||
const rows = visiblePins.map(item => row(item, true));
|
||||
options.pinnedList.replaceChildren(...rows);
|
||||
options.pinnedSection.hidden = rows.length === 0;
|
||||
}
|
||||
if (options.pinnedToggle) {
|
||||
options.pinnedToggle.hidden = fixed.length <= 3;
|
||||
options.pinnedToggle.textContent = pinsExpanded ? 'Show fewer' : 'Show all ' + fixed.length;
|
||||
options.pinnedToggle.setAttribute('aria-expanded', pinsExpanded ? 'true' : 'false');
|
||||
options.pinnedToggle.setAttribute('aria-controls', 'mobile-pinned-work-list');
|
||||
}
|
||||
renderCurrent();
|
||||
return visibleRecent.length + fixed.length;
|
||||
}
|
||||
|
||||
return {items, pinned, record, pin, unpin, setCurrent, render, load, sync, startLifecycle, state};
|
||||
});
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
(function (root, factory) {
|
||||
const createMobileSearchModal = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createMobileSearchModal;
|
||||
else root.createMobileSearchModal = createMobileSearchModal;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const FOCUSABLE = [
|
||||
'a[href]', 'button', 'input', 'select', 'textarea',
|
||||
'[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]',
|
||||
].join(',');
|
||||
|
||||
return function createMobileSearchModal(options) {
|
||||
const documentRef = options.document;
|
||||
const backgrounds = Array.from(options.backgrounds || [
|
||||
documentRef.querySelector?.('body > header'),
|
||||
documentRef.querySelector?.('main'),
|
||||
documentRef.querySelector?.('#mobile-task-dock'),
|
||||
]).filter(Boolean);
|
||||
let surface = null;
|
||||
let opener = null;
|
||||
let priorInert = null;
|
||||
|
||||
function focusable() {
|
||||
if (!surface || typeof surface.querySelectorAll !== 'function') return [];
|
||||
return Array.from(surface.querySelectorAll(FOCUSABLE)).filter(element =>
|
||||
!element.disabled && !element.hidden && element.getAttribute?.('aria-hidden') !== 'true' &&
|
||||
(typeof element.matches !== 'function' || element.matches(':not([hidden])')) &&
|
||||
(typeof element.getClientRects !== 'function' || element.getClientRects().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function focus(element) {
|
||||
if (element?.isConnected !== false && typeof element?.focus === 'function') element.focus();
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (!surface || event.key !== 'Tab') return;
|
||||
const controls = focusable();
|
||||
if (!controls.length) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && documentRef.activeElement === first) {
|
||||
event.preventDefault();
|
||||
focus(last);
|
||||
} else if (!event.shiftKey && documentRef.activeElement === last) {
|
||||
event.preventDefault();
|
||||
focus(first);
|
||||
} else if (!controls.includes(documentRef.activeElement)) {
|
||||
event.preventDefault();
|
||||
focus(event.shiftKey ? last : first);
|
||||
}
|
||||
}
|
||||
|
||||
function transition(nextSurface, transitionOptions = {}) {
|
||||
surface = nextSurface;
|
||||
focus(transitionOptions.initialFocus || focusable()[0]);
|
||||
}
|
||||
|
||||
function activate(nextSurface, activateOptions = {}) {
|
||||
if (!surface) {
|
||||
opener = activateOptions.opener || documentRef.activeElement || null;
|
||||
priorInert = backgrounds.map(element => element.inert === true);
|
||||
backgrounds.forEach(element => { element.inert = true; });
|
||||
}
|
||||
transition(nextSurface, activateOptions);
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
if (!surface) return;
|
||||
backgrounds.forEach((element, index) => { element.inert = priorInert[index]; });
|
||||
surface = null;
|
||||
priorInert = null;
|
||||
const restore = opener;
|
||||
opener = null;
|
||||
focus(restore);
|
||||
}
|
||||
|
||||
documentRef.addEventListener('keydown', onKeydown);
|
||||
return { activate, transition, deactivate, current: () => surface };
|
||||
};
|
||||
});
|
||||
|
|
@ -98,14 +98,12 @@ function attachMobileSearchPreviewNavigation({document, window}) {
|
|||
buttons:{
|
||||
overview:bySection('overview'),
|
||||
conversation:bySection('conversation'),
|
||||
changes:bySection('changes'),
|
||||
reply:bySection('reply'),
|
||||
actions:bySection('actions'),
|
||||
},
|
||||
targets:{
|
||||
overview:document.getElementById('search-preview-overview'),
|
||||
conversation:document.getElementById('search-preview-conversation'),
|
||||
changes:document.getElementById('search-preview-review'),
|
||||
reply,
|
||||
actions:document.getElementById('search-preview-actions'),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,12 +6,10 @@
|
|||
const storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null);
|
||||
const reviewOrder = [
|
||||
['delivery', 'Delivery recovery'],
|
||||
['gate', 'Human Gates'],
|
||||
['agenda', 'Agenda'],
|
||||
['attention', 'Attention'],
|
||||
['update', 'Updates'],
|
||||
['filed', 'Filed'],
|
||||
['following', 'Following'],
|
||||
];
|
||||
const anonymousItems = new WeakMap();
|
||||
let anonymousItemSequence = 0;
|
||||
|
|
@ -104,36 +102,19 @@
|
|||
|
||||
function briefing() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
let phases = reviewPhases(counts);
|
||||
const gateUnavailable = counts.gateUnavailable === true;
|
||||
if (gateUnavailable) {
|
||||
phases = phases.filter(phase => phase.name !== 'gate');
|
||||
const afterDelivery = phases[0]?.name === 'delivery' ? 1 : 0;
|
||||
phases.splice(afterDelivery, 0, {name:'gate', label:'Human Gates unavailable · retry', count:count(counts.gate)});
|
||||
}
|
||||
const followingUnavailable = counts.followingUnavailable === true;
|
||||
if (followingUnavailable) {
|
||||
phases = phases.filter(phase => phase.name !== 'following');
|
||||
phases.push({name:'following', label:'Following unavailable', count:count(counts.following)});
|
||||
}
|
||||
const phases = reviewPhases(counts);
|
||||
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
|
||||
const today = count(counts.today);
|
||||
const delivery = phases.find(phase => phase.name === 'delivery')?.count || 0;
|
||||
const other = total - delivery;
|
||||
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
|
||||
const nextLabel = next === 'delivery' ? 'Review Delivery' :
|
||||
(next === 'gate' && gateUnavailable ? 'Retry Human Gates' :
|
||||
(next === 'following' && followingUnavailable ? 'Retry Following' :
|
||||
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'))));
|
||||
const gateRetry = next === 'gate' && gateUnavailable;
|
||||
const followingRetry = next === 'following' && followingUnavailable;
|
||||
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'));
|
||||
return {
|
||||
total,
|
||||
next,
|
||||
label: nextLabel,
|
||||
summary: gateRetry ? 'Human Gates need retry before Today · ' + today + ' planned' :
|
||||
followingRetry ? 'Following needs retry before Today · ' + today + ' planned' :
|
||||
delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
|
||||
summary: delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
|
||||
' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') +
|
||||
' · ' + today + ' planned' :
|
||||
total ? total + ' items before Today · ' + today + ' planned' :
|
||||
|
|
@ -185,16 +166,14 @@
|
|||
options.elements.phases.textContent = current.phases.length ?
|
||||
current.phases.map(phase => phase.label + ' ' + phase.count).join(' · ') :
|
||||
'All urgent queues reviewed';
|
||||
if (options.elements.action) {
|
||||
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
|
||||
}
|
||||
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
|
||||
if (options.elements.finish) options.elements.finish.hidden = !checkpoint();
|
||||
return current;
|
||||
}
|
||||
|
||||
function start() {
|
||||
render();
|
||||
if (options.elements?.action) options.elements.action.addEventListener('click', startNext);
|
||||
if (options.elements) options.elements.action.addEventListener('click', startNext);
|
||||
}
|
||||
|
||||
return {briefing, completePhase, finish, reconcile, render, start, startNext, state};
|
||||
|
|
|
|||
|
|
@ -70,10 +70,9 @@
|
|||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
prepare:'Prepare', 'prepare-resume':'Resume prep',
|
||||
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda',
|
||||
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Following', 'My PRs', 'Filed', 'Later', 'Drafts'].includes(text);
|
||||
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
const actionLabel = mode === 'prepare' ? 'Prepare Today' :
|
||||
mode === 'prepare-resume' ? 'Resume preparation' :
|
||||
|
|
@ -98,9 +97,9 @@
|
|||
}
|
||||
|
||||
function updateQueues(counts) {
|
||||
const names = 'today agenda delivery gate attention update following filed authored later draft'.split(' ');
|
||||
const names = 'today agenda delivery attention update filed later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const actionableNames = ['today', 'delivery', 'gate', 'attention', 'update', 'following', 'filed', 'authored', 'later', 'draft'];
|
||||
const actionableNames = ['today', 'delivery', 'attention', 'update', 'filed', 'later', 'draft'];
|
||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
|
|
@ -108,9 +107,6 @@
|
|||
options.queueRows?.update?.setAttribute(
|
||||
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
|
||||
);
|
||||
options.queueRows?.following?.setAttribute(
|
||||
'aria-label', 'Following, ' + normalized.following + ' unseen changes'
|
||||
);
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = active + ' active';
|
||||
options.queueBadge.hidden = active === 0;
|
||||
|
|
@ -128,12 +124,9 @@
|
|||
: 'Queues: Today ' + normalized.today
|
||||
+ ', Agenda ' + normalized.agenda + ' due'
|
||||
+ ', Delivery ' + normalized.delivery
|
||||
+ ', Human Gates ' + normalized.gate
|
||||
+ ', Attention ' + normalized.attention
|
||||
+ ', Updates ' + normalized.update
|
||||
+ ', Following ' + normalized.following
|
||||
+ ', Filed ' + normalized.filed
|
||||
+ ', My PRs ' + normalized.authored
|
||||
+ ', Later ' + normalized.later
|
||||
+ ', Drafts ' + normalized.draft
|
||||
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ function buildMyWork(data, now = new Date()) {
|
|||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
const isFiled = (item.work_reasons || []).includes('created_by_me');
|
||||
const isAuthored = (item.work_reasons || []).includes('authored_by_me');
|
||||
const isCompleted = isFiled && item.state === 'closed';
|
||||
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||
const normalized = {
|
||||
|
|
@ -79,7 +78,6 @@ function buildMyWork(data, now = new Date()) {
|
|||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
is_filed: isFiled,
|
||||
is_authored: isAuthored,
|
||||
is_completed: isCompleted,
|
||||
is_assigned: assigned,
|
||||
has_update: false,
|
||||
|
|
@ -87,8 +85,7 @@ function buildMyWork(data, now = new Date()) {
|
|||
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||
(due && due.priority < 4 ? due.label :
|
||||
(isReview ? 'Needs your review' : (isCompleted ? 'Completed · review outcome' :
|
||||
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' :
|
||||
(isAuthored ? 'Authored by you' : 'Open work')))))),
|
||||
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work'))))),
|
||||
_priority: priorityLabel ? 0 :
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))),
|
||||
};
|
||||
|
|
@ -660,7 +657,6 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
|||
let filtered = items;
|
||||
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
|
||||
else if (selectedFilter === 'filed') filtered = items.filter((item) => item.is_filed);
|
||||
else if (selectedFilter === 'authored') filtered = items.filter((item) => item.is_authored);
|
||||
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||
|
|
@ -1084,7 +1080,6 @@ function countMyWork(items) {
|
|||
all: items.length,
|
||||
attention: items.filter(needsAttention).length,
|
||||
filed: items.filter((item) => item.is_filed).length,
|
||||
authored: items.filter((item) => item.is_authored).length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
(function (root, factory) {
|
||||
const createPhotoDraftInbox = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createPhotoDraftInbox;
|
||||
else root.createPhotoDraftInbox = createPhotoDraftInbox;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
function targetKey(route) {
|
||||
if (route?.kind === 'update') {
|
||||
const notificationId = Number(route.notification_id || 0);
|
||||
return notificationId > 0 ? 'update:' + notificationId : '';
|
||||
}
|
||||
const kind = route?.kind === 'search' ? route.target_kind : route?.kind;
|
||||
const repository = String(route?.repository || '');
|
||||
const number = Number(route?.number || 0);
|
||||
return ['issue', 'pull'].includes(kind) && repository && number > 0 ?
|
||||
kind + ':' + repository.toLowerCase() + '#' + number : '';
|
||||
}
|
||||
|
||||
function removeTarget(item) {
|
||||
const route = item?.route || {};
|
||||
if (route.kind === 'update') return { kind:'update', notificationId:Number(route.notification_id) };
|
||||
return {
|
||||
kind:route.kind === 'search' ? route.target_kind : route.kind,
|
||||
repository:route.repository,
|
||||
number:Number(route.number),
|
||||
};
|
||||
}
|
||||
|
||||
return function createPhotoDraftInbox({ conversation, search }) {
|
||||
let items = [];
|
||||
|
||||
async function refresh(existing = []) {
|
||||
const occupied = new Set(existing.map(item => targetKey(item?.route)).filter(Boolean));
|
||||
const results = await Promise.allSettled([
|
||||
conversation?.list?.() || [],
|
||||
search?.list?.() || [],
|
||||
]);
|
||||
items = results.flatMap(result => result.status === 'fulfilled' && Array.isArray(result.value) ? result.value : [])
|
||||
.filter(item => {
|
||||
const key = targetKey(item?.route);
|
||||
if (!key || occupied.has(key)) return false;
|
||||
occupied.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort((left, right) => Number(right.updated_at || 0) - Number(left.updated_at || 0) ||
|
||||
String(left.id || '').localeCompare(String(right.id || '')));
|
||||
return items.slice();
|
||||
}
|
||||
|
||||
function list() { return items.slice(); }
|
||||
|
||||
async function discard(item) {
|
||||
if (!item || !items.some(candidate => candidate.id === item.id && candidate.photo_store === item.photo_store)) {
|
||||
return false;
|
||||
}
|
||||
const store = item.photo_store === 'search' ? search : conversation;
|
||||
if (!store?.remove) return false;
|
||||
await store.remove(removeTarget(item));
|
||||
items = items.filter(candidate => !(candidate.id === item.id && candidate.photo_store === item.photo_store));
|
||||
return true;
|
||||
}
|
||||
|
||||
function description(item) {
|
||||
return item?.kind === 'photo-reply' ?
|
||||
Number(item.photo_count || 0) + (Number(item.photo_count) === 1 ? ' saved photo' : ' saved photos') :
|
||||
(item?.preview || 'Unfinished draft');
|
||||
}
|
||||
|
||||
function searchTarget(item) {
|
||||
const route = item?.route || {};
|
||||
return {
|
||||
kind:route.target_kind, repository:route.repository,
|
||||
number:route.number, title:item?.title,
|
||||
};
|
||||
}
|
||||
|
||||
return { refresh, list, discard, description, searchTarget };
|
||||
};
|
||||
});
|
||||
|
|
@ -7,7 +7,6 @@
|
|||
'stackchain-search-reply-drafts-v1',
|
||||
'stackchain-conversation-reply-drafts-v1',
|
||||
'stackchain-today-action-mailbox-v1',
|
||||
'stackchain-app-badge-preference-v1',
|
||||
]);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
|
||||
else root.stackchainPrivateDatabases = databases;
|
||||
|
|
|
|||
|
|
@ -1,182 +0,0 @@
|
|||
function createProgressiveCapture({
|
||||
document,
|
||||
storage,
|
||||
getLogin = () => '',
|
||||
createCaptures = globalThis.createUnfiledCaptures,
|
||||
requestFullWorkspace = async () => {
|
||||
const lifecycle = await globalThis.stackchainWorkspaceLifecycle;
|
||||
return lifecycle?.optionalReady;
|
||||
},
|
||||
createId,
|
||||
now,
|
||||
}) {
|
||||
const button = document.querySelector('[data-mobile-task="new"]');
|
||||
const root = document.querySelector('#create-issue-sheet');
|
||||
const title = document.querySelector('#create-issue-title');
|
||||
const body = document.querySelector('#create-issue-body');
|
||||
const heading = document.querySelector('#create-issue-heading');
|
||||
const save = document.querySelector('#save-unfiled-issue');
|
||||
const file = document.querySelector('#file-new-issue');
|
||||
const cancel = document.querySelector('#cancel-new-issue');
|
||||
const status = document.querySelector('#my-work-action-status');
|
||||
const captures = createCaptures({
|
||||
storage,
|
||||
getCaptureLogin:getLogin,
|
||||
getCurrentLogin:getLogin,
|
||||
...(createId ? {createId} : {}),
|
||||
...(now ? {now} : {}),
|
||||
});
|
||||
const listeners = [];
|
||||
let started = false;
|
||||
let saving = null;
|
||||
let saved = false;
|
||||
let handedOff = false;
|
||||
const checkpointKey = 'stackchain.progressive-capture.v1';
|
||||
|
||||
function listen(element, name, callback) {
|
||||
element?.addEventListener?.(name, callback);
|
||||
listeners.push([element, name, callback]);
|
||||
}
|
||||
|
||||
function close() {
|
||||
root?.classList.remove('open');
|
||||
}
|
||||
|
||||
function readCheckpoints() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(checkpointKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && typeof item.ownerLogin === 'string' &&
|
||||
typeof item.title === 'string' && typeof item.body === 'string');
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function readCheckpoint() {
|
||||
const login = String(getLogin() || '').trim();
|
||||
if (!login) return null;
|
||||
return readCheckpoints().find(item => item.ownerLogin === login) || null;
|
||||
}
|
||||
|
||||
function checkpoint() {
|
||||
const ownerLogin = String(getLogin() || '').trim();
|
||||
if (!ownerLogin) return false;
|
||||
try {
|
||||
const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin);
|
||||
items.push({
|
||||
ownerLogin,
|
||||
title:String(title?.value || '').slice(0, 255),
|
||||
body:String(body?.value || '').slice(0, 10000),
|
||||
});
|
||||
storage?.setItem(checkpointKey, JSON.stringify({
|
||||
version:1, items,
|
||||
}));
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function clearCheckpoint(expected) {
|
||||
const current = readCheckpoint();
|
||||
if (!current || current.title !== expected.title || current.body !== expected.body) return false;
|
||||
try {
|
||||
const ownerLogin = String(getLogin() || '').trim();
|
||||
const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin);
|
||||
if (items.length) storage?.setItem(checkpointKey, JSON.stringify({version:1, items}));
|
||||
else storage?.removeItem(checkpointKey);
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function open(event) {
|
||||
saved = false;
|
||||
const recovered = readCheckpoint();
|
||||
if (recovered) {
|
||||
if (title) title.value = recovered.title;
|
||||
if (body) body.value = recovered.body;
|
||||
if (status) status.textContent = 'Unfinished capture restored from this phone.';
|
||||
}
|
||||
if (heading) heading.textContent = 'Capture work';
|
||||
root?.classList.add('open');
|
||||
title?.focus?.();
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (saved || saving) return saving;
|
||||
save.disabled = true;
|
||||
const draft = {title:title?.value || '', body:body?.value || ''};
|
||||
saving = Promise.resolve().then(() => captures.save(draft)).then(() => {
|
||||
saved = true;
|
||||
clearCheckpoint(draft);
|
||||
if (title) title.value = '';
|
||||
if (body) body.value = '';
|
||||
close();
|
||||
if (status) status.textContent = 'Saved to Drafts. Choose a repository when you’re ready to file it.';
|
||||
return true;
|
||||
}).catch(error => {
|
||||
if (status) status.textContent = error.message;
|
||||
title?.focus?.();
|
||||
return false;
|
||||
}).finally(() => {
|
||||
save.disabled = false;
|
||||
saving = null;
|
||||
});
|
||||
return saving;
|
||||
}
|
||||
|
||||
async function fileNow() {
|
||||
if (file.disabled) return false;
|
||||
file.disabled = true;
|
||||
if (status) status.textContent = 'Loading filing tools…';
|
||||
try {
|
||||
await requestFullWorkspace();
|
||||
return true;
|
||||
} catch (_error) {
|
||||
file.disabled = false;
|
||||
if (status) status.textContent = 'Filing tools unavailable. Your capture is still editable; retry when connected.';
|
||||
title?.focus?.();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return false;
|
||||
started = true;
|
||||
root?.classList.add('progressive-capture');
|
||||
listen(button, 'click', open);
|
||||
listen(save, 'click', saveDraft);
|
||||
listen(file, 'click', fileNow);
|
||||
listen(cancel, 'click', close);
|
||||
listen(title, 'input', checkpoint);
|
||||
listen(body, 'input', checkpoint);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handoff() {
|
||||
if (handedOff) return null;
|
||||
handedOff = true;
|
||||
const state = {
|
||||
open:Boolean(root?.classList.contains('open')),
|
||||
title:title?.value || '', body:body?.value || '',
|
||||
};
|
||||
state.complete = () => clearCheckpoint(state);
|
||||
root?.classList.remove('progressive-capture');
|
||||
file.disabled = false;
|
||||
listeners.forEach(([element, name, callback]) => element?.removeEventListener?.(name, callback));
|
||||
listeners.length = 0;
|
||||
return state;
|
||||
}
|
||||
|
||||
return {start, handoff};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined' &&
|
||||
typeof createUnfiledCaptures === 'function' &&
|
||||
window.matchMedia?.('(max-width: 600px)').matches === true) {
|
||||
window.stackchainProgressiveCapture = createProgressiveCapture({
|
||||
document,
|
||||
storage:window.localStorage,
|
||||
getLogin:() => window.stackchainProgressiveMyWork?.login?.() || '',
|
||||
});
|
||||
window.stackchainProgressiveCapture.start();
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveCapture;
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
function createProgressiveHumanGates(options = {}) {
|
||||
const document = options.document || globalThis.document;
|
||||
const location = options.location || globalThis.location;
|
||||
const history = options.history || globalThis.history;
|
||||
const storage = options.storage || globalThis.localStorage;
|
||||
const isOnline = options.isOnline || (() => globalThis.navigator?.onLine !== false);
|
||||
const fetchJson = options.fetchJson || (async (path, requestOptions = {}) => {
|
||||
const response = await fetch(path, requestOptions);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
|
||||
return payload;
|
||||
});
|
||||
const liveSnapshot = options.liveSnapshot || null;
|
||||
const getIdentity = options.getIdentity || (async () => {
|
||||
const response = liveSnapshot ? await liveSnapshot.acquire({requireIdentity:true}) :
|
||||
await fetchJson('api/v1/live', {headers:{Accept:'application/json'}});
|
||||
const user = response?.context?.user || {};
|
||||
const login = String(user.login || '').trim();
|
||||
return {login, accountKey:login && user.id ? String(user.id) + ':' + login : login};
|
||||
});
|
||||
const query = selector => document.querySelector(selector);
|
||||
const nodes = {
|
||||
count:query('#human-gates-count'), list:query('#human-gates-list'),
|
||||
status:query('#human-gates-status'), panel:query('#human-gates'),
|
||||
detail:query('#human-gate-detail'),
|
||||
pendingTab:query('#human-gates-pending'), historyTab:query('#human-gates-history'),
|
||||
};
|
||||
let login = '';
|
||||
let accountKey = '';
|
||||
let started = false;
|
||||
let startFlight = null;
|
||||
|
||||
const controller = createHumanGates({
|
||||
storage, isOnline, location, fetchJson,
|
||||
getLogin:() => login, getAccountKey:() => accountKey, nodes,
|
||||
});
|
||||
|
||||
const showError = error => {
|
||||
if (nodes.status) nodes.status.textContent = error?.message || 'Human Gates are unavailable.';
|
||||
};
|
||||
const open = () => start(true).catch(showError);
|
||||
query('#open-human-gates')?.addEventListener?.('click', open);
|
||||
query('#close-human-gates')?.addEventListener?.('click', () => {
|
||||
if (nodes.panel) nodes.panel.hidden = true;
|
||||
if (location.hash === '#/my-work/human-gates') history.replaceState({}, '', '#/my-work');
|
||||
});
|
||||
nodes.list?.addEventListener?.('click', event => {
|
||||
const card = event.target?.closest?.('[data-human-gate-id]');
|
||||
if (card) controller.select(card.dataset.humanGateId);
|
||||
});
|
||||
nodes.detail?.addEventListener?.('click', event => {
|
||||
const recovery = event.target?.closest?.('[data-gate-recover]');
|
||||
if (recovery) {
|
||||
recovery.disabled = true;
|
||||
controller.recoverDecision().catch(error => {
|
||||
showError(error);
|
||||
recovery.disabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const decision = event.target?.closest?.('[data-gate-decision]')?.dataset.gateDecision;
|
||||
if (!decision) return;
|
||||
controller.submitDecision(decision).catch(error => {
|
||||
if (!error?.targetSelector) showError(error);
|
||||
});
|
||||
});
|
||||
nodes.pendingTab?.addEventListener?.('click', () => controller.showPending());
|
||||
nodes.historyTab?.addEventListener?.('click', () => controller.showHistory().catch(showError));
|
||||
|
||||
async function start(force = false) {
|
||||
if (!force && location.hash !== '#/my-work/human-gates') return false;
|
||||
if (started) return controller.open().then(() => true);
|
||||
if (startFlight) return startFlight;
|
||||
startFlight = (async () => {
|
||||
const identity = await getIdentity();
|
||||
login = String(identity?.login || '').trim();
|
||||
accountKey = String(identity?.accountKey || login).trim();
|
||||
if (!login) throw new Error('Authenticated account identity is required.');
|
||||
await controller.open();
|
||||
started = true;
|
||||
return true;
|
||||
})();
|
||||
try { return await startFlight; }
|
||||
finally { startFlight = null; }
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
handoff() {
|
||||
return {
|
||||
controller, started,
|
||||
adoptIdentity(nextLogin, nextAccountKey) {
|
||||
login = String(nextLogin || '').trim();
|
||||
accountKey = String(nextAccountKey || login).trim();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
window.stackchainProgressiveHumanGates = createProgressiveHumanGates({
|
||||
document, liveSnapshot:window.stackchainProgressiveLiveSnapshot,
|
||||
});
|
||||
void window.stackchainProgressiveHumanGates.start().catch(() => {});
|
||||
}
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
globalThis.createHumanGates = globalThis.createHumanGates || require('./human-gates.js');
|
||||
module.exports = createProgressiveHumanGates;
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
function createProgressiveLiveSnapshot({fetchSnapshot}) {
|
||||
let value = null;
|
||||
let flight = null;
|
||||
|
||||
const hasIdentity = snapshot => {
|
||||
const user = snapshot?.context?.user || {};
|
||||
return Boolean(String(user.login || '').trim());
|
||||
};
|
||||
const requireUsable = (snapshot, options) => {
|
||||
if (!options?.requireIdentity || hasIdentity(snapshot)) return snapshot;
|
||||
if (value === snapshot) value = null;
|
||||
throw new Error('Authenticated account identity is unavailable.');
|
||||
};
|
||||
const acquire = (options = {}) => {
|
||||
if (value) return Promise.resolve().then(() => requireUsable(value, options));
|
||||
if (flight) return flight.then(snapshot => requireUsable(snapshot, options));
|
||||
flight = Promise.resolve().then(() => fetchSnapshot()).then(snapshot => {
|
||||
value = snapshot;
|
||||
flight = null;
|
||||
return snapshot;
|
||||
}, error => {
|
||||
flight = null;
|
||||
throw error;
|
||||
});
|
||||
return flight.then(snapshot => requireUsable(snapshot, options));
|
||||
};
|
||||
|
||||
return {
|
||||
acquire,
|
||||
snapshot:() => value,
|
||||
pending:() => flight,
|
||||
identity() {
|
||||
const user = value?.context?.user || {};
|
||||
const login = String(user.login || '').trim();
|
||||
return {login, accountKey:login && user.id ? String(user.id) + ':' + login : login};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.stackchainProgressiveLiveSnapshot = createProgressiveLiveSnapshot({
|
||||
fetchSnapshot:async () => {
|
||||
const response = await fetch('api/v1/live', {headers:{Accept:'application/json'}});
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveLiveSnapshot;
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createProgressiveMobileDock = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createProgressiveMobileDock(options) {
|
||||
const document = options.document;
|
||||
const myWork = options.myWork;
|
||||
const work = document.querySelector('[data-mobile-task="work"]');
|
||||
const queues = document.querySelector('[data-mobile-task="queues"]');
|
||||
const sheet = document.querySelector('#mobile-queue-sheet');
|
||||
const close = document.querySelector('#close-mobile-queues');
|
||||
const next = document.querySelector('#mobile-queue-next-action');
|
||||
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
|
||||
.map(row => [row.dataset.mobileQueue, row]));
|
||||
const countElements = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue-count]'))
|
||||
.map(element => [element.dataset.mobileQueueCount, element]));
|
||||
const originalDescriptions = Object.fromEntries(Object.entries(rows)
|
||||
.map(([name, row]) => [name, row.querySelector?.('small')?.textContent || '']));
|
||||
const originalCounts = Object.fromEntries(Object.entries(countElements)
|
||||
.map(([name, element]) => [name, element.textContent]));
|
||||
const supported = new Set(['all', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update']);
|
||||
const listeners = [];
|
||||
let started = false;
|
||||
let lastTask = null;
|
||||
let unsubscribe = null;
|
||||
|
||||
function listen(element, name, listener) {
|
||||
if (!element) return;
|
||||
element.addEventListener(name, listener);
|
||||
listeners.push([element, name, listener]);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const counts = myWork?.counts?.() || {};
|
||||
Object.entries(rows).forEach(([name, row]) => {
|
||||
const count = Math.max(0, Number(counts[name]) || 0);
|
||||
const countElement = countElements[name];
|
||||
if (supported.has(name)) {
|
||||
row.removeAttribute('data-unavailable');
|
||||
row.removeAttribute('data-progressive-loading');
|
||||
if (countElement) countElement.textContent = String(count);
|
||||
return;
|
||||
}
|
||||
row.setAttribute('data-unavailable', 'true');
|
||||
row.setAttribute('data-progressive-loading', 'true');
|
||||
const description = row.querySelector?.('small');
|
||||
if (description) description.textContent = 'Still loading';
|
||||
if (countElement) countElement.textContent = '…';
|
||||
});
|
||||
const actionable = ['attention', 'filed', 'authored', 'review', 'update']
|
||||
.find(name => Number(counts[name]) > 0);
|
||||
if (next) {
|
||||
if (actionable) {
|
||||
next.textContent = 'Open ' + (actionable === 'authored' ? 'My PRs' : actionable[0].toUpperCase() + actionable.slice(1))
|
||||
+ ' (' + counts[actionable] + ')';
|
||||
next.dataset.queue = actionable;
|
||||
next.removeAttribute('data-unavailable');
|
||||
} else {
|
||||
next.textContent = Number(counts.all) > 0 ? 'Open assigned work (' + counts.all + ')' : 'Assigned work is still loading';
|
||||
next.dataset.queue = 'all';
|
||||
if (!Number(counts.all)) next.setAttribute('data-unavailable', 'true');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSheet() {
|
||||
lastTask = 'queues';
|
||||
render();
|
||||
if (sheet && !sheet.open) sheet.showModal();
|
||||
}
|
||||
|
||||
function openQueue(name) {
|
||||
if (!supported.has(name)) return false;
|
||||
lastTask = 'work';
|
||||
if (sheet?.open) sheet.close();
|
||||
myWork?.selectQueue?.(name, {openFirst:true});
|
||||
return true;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return false;
|
||||
started = true;
|
||||
listen(work, 'click', () => { lastTask = 'work'; myWork?.openFirst?.(); });
|
||||
listen(queues, 'click', openSheet);
|
||||
listen(close, 'click', () => sheet?.open && sheet.close());
|
||||
Object.entries(rows).forEach(([name, row]) => listen(row, 'click', () => openQueue(name)));
|
||||
listen(next, 'click', () => openQueue(next.dataset.queue || 'all'));
|
||||
unsubscribe = myWork?.subscribe?.(render) || null;
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handoff() {
|
||||
return {queueSheetOpen:Boolean(sheet?.open), lastTask};
|
||||
}
|
||||
|
||||
function stop() {
|
||||
listeners.splice(0).forEach(([element, name, listener]) => element.removeEventListener(name, listener));
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
Object.entries(rows).forEach(([name, row]) => {
|
||||
row.removeAttribute('data-unavailable');
|
||||
row.removeAttribute('data-progressive-loading');
|
||||
const description = row.querySelector?.('small');
|
||||
if (description) description.textContent = originalDescriptions[name];
|
||||
if (countElements[name]) countElements[name].textContent = originalCounts[name];
|
||||
});
|
||||
started = false;
|
||||
}
|
||||
|
||||
return {start, stop, handoff, render, openQueue};
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
window.stackchainProgressiveMobileDock = createProgressiveMobileDock({
|
||||
document,
|
||||
myWork:window.stackchainProgressiveMyWork,
|
||||
});
|
||||
window.stackchainProgressiveMobileDock.start();
|
||||
}
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
function createProgressiveMyWork({
|
||||
document, fetchSnapshot, liveSnapshot: snapshotBroker = null,
|
||||
pollerOptions = {}, lifecycleTarget = globalThis,
|
||||
}) {
|
||||
const list = document.querySelector('#my-work-list');
|
||||
const status = document.querySelector('#my-work-status');
|
||||
const detail = document.querySelector('#progressive-work-detail');
|
||||
const detailTitle = document.querySelector('#progressive-work-detail-title');
|
||||
const detailMeta = document.querySelector('#progressive-work-detail-meta');
|
||||
const detailReason = document.querySelector('#progressive-work-detail-reason');
|
||||
const closeDetail = document.querySelector('#close-progressive-work-detail');
|
||||
const openGitea = document.querySelector('#open-progressive-work-gitea');
|
||||
const filters = Array.from(document.querySelectorAll('[data-work-filter]'));
|
||||
const listeners = [];
|
||||
const lifecycleListeners = [];
|
||||
const subscribers = new Set();
|
||||
let items = [];
|
||||
let active = 'all';
|
||||
let stopped = false;
|
||||
let selectedByUser = false;
|
||||
let liveSnapshot = null;
|
||||
let liveSnapshotPromise = null;
|
||||
let confirmedLogin = '';
|
||||
let poller = null;
|
||||
let detailTrigger = null;
|
||||
let openWork = null;
|
||||
let contextUnavailable = false;
|
||||
const deferredQueues = {
|
||||
today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||
};
|
||||
const fetchProgressiveSnapshot = (revisions = {}, options = {}) =>
|
||||
snapshotBroker && Object.keys(revisions || {}).length === 0 ?
|
||||
snapshotBroker.acquire() : fetchSnapshot(revisions, options);
|
||||
|
||||
const escapeHtml = value => String(value || '').replace(/[&<>"']/g, character => ({
|
||||
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||
})[character]);
|
||||
const safeUrl = value => {
|
||||
try {
|
||||
const url = new URL(String(value || ''), globalThis.location?.href || 'https://invalid.example/');
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||||
} catch (_error) { return ''; }
|
||||
};
|
||||
const matches = (item, filter) =>
|
||||
filter === 'review' ? item.is_review :
|
||||
filter === 'update' ? item.has_update :
|
||||
filter === 'attention' ? item.needs_attention :
|
||||
filter === 'filed' ? item.is_filed :
|
||||
filter === 'authored' ? item.is_authored :
|
||||
filter === 'pull' ? item.kind === 'pull' && !item.is_review : item.kind === filter;
|
||||
const visibleItems = () => active === 'all' ? items : items.filter(item => matches(item, active));
|
||||
const notify = () => subscribers.forEach(subscriber => subscriber());
|
||||
const queueCounts = () => ({
|
||||
all:items.length,
|
||||
filed:items.filter(item => matches(item, 'filed')).length,
|
||||
authored:items.filter(item => matches(item, 'authored')).length,
|
||||
attention:items.filter(item => matches(item, 'attention')).length,
|
||||
update:items.filter(item => matches(item, 'update')).length,
|
||||
review:items.filter(item => matches(item, 'review')).length,
|
||||
});
|
||||
const closeProgressiveDetail = ({ restoreFocus = true } = {}) => {
|
||||
if (!detail || detail.hidden) return;
|
||||
detail.hidden = true;
|
||||
if (restoreFocus) detailTrigger?.focus?.();
|
||||
detailTrigger = null;
|
||||
openWork = null;
|
||||
};
|
||||
const openProgressiveDetail = (item, trigger) => {
|
||||
if (!detail || !item) return;
|
||||
detailTrigger = trigger;
|
||||
openWork = item;
|
||||
detailTitle.textContent = item.title || item.key || 'Untitled work';
|
||||
detailMeta.textContent = (item.key || 'Unknown work item') + ' · ' +
|
||||
(item.kind === 'pull' ? 'Pull request' : 'Issue');
|
||||
detailReason.textContent = item.reason || 'Assigned to you';
|
||||
const href = safeUrl(item.url);
|
||||
openGitea.hidden = !href;
|
||||
if (href) openGitea.href = href;
|
||||
else openGitea.removeAttribute?.('href');
|
||||
detail.hidden = false;
|
||||
closeDetail?.focus?.();
|
||||
};
|
||||
const updateCounts = () => {
|
||||
['all','attention','filed','authored','issue','pull','review','update'].forEach(filter => {
|
||||
const count = filter === 'all' ? items.length : items.filter(item => matches(item, filter)).length;
|
||||
const element = document.querySelector('[data-work-count="' + filter + '"]');
|
||||
if (element) element.textContent = String(count);
|
||||
});
|
||||
};
|
||||
const render = () => {
|
||||
if (stopped || !list) return;
|
||||
const visible = visibleItems();
|
||||
list.innerHTML = contextUnavailable && !items.length ?
|
||||
'<div class="muted">Assigned work is reconnecting…</div>' : deferredQueues[active] ?
|
||||
'<div class="muted">' + deferredQueues[active] + ' is still loading…</div>' : visible.length ? visible.map((item, index) => {
|
||||
const title = escapeHtml(item.title || item.key || 'Untitled work');
|
||||
const context = escapeHtml(item.key || '');
|
||||
const reason = escapeHtml(item.reason || 'Assigned to you');
|
||||
return '<article class="my-work-card progressive-my-work-card">' +
|
||||
'<button class="my-work-card-main" type="button" data-progressive-work-index="' + index + '">' +
|
||||
'<strong>' + title + '</strong><span class="small">' + context + ' · ' + reason + '</span>' +
|
||||
'</button></article>';
|
||||
}).join('') : '<div class="muted">No work in this queue.</div>';
|
||||
filters.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.workFilter === active)));
|
||||
};
|
||||
filters.forEach(button => {
|
||||
const listener = () => { active = button.dataset.workFilter || 'all'; selectedByUser = true; render(); };
|
||||
button.addEventListener('click', listener);
|
||||
listeners.push([button, listener]);
|
||||
});
|
||||
const openListener = event => {
|
||||
const trigger = event.target?.closest?.('[data-progressive-work-index]');
|
||||
if (!trigger) return;
|
||||
const item = visibleItems()[Number(trigger.dataset.progressiveWorkIndex)];
|
||||
if (!item) return;
|
||||
event.preventDefault?.();
|
||||
openProgressiveDetail(item, trigger);
|
||||
};
|
||||
const closeListener = () => closeProgressiveDetail();
|
||||
const keyListener = event => {
|
||||
if (event.key !== 'Escape' || detail?.hidden) return;
|
||||
event.preventDefault?.();
|
||||
closeProgressiveDetail();
|
||||
};
|
||||
list?.addEventListener?.('click', openListener);
|
||||
closeDetail?.addEventListener?.('click', closeListener);
|
||||
lifecycleTarget.addEventListener?.('keydown', keyListener);
|
||||
|
||||
const applySnapshot = snapshot => {
|
||||
if (stopped) return false;
|
||||
const transferable = snapshot && typeof snapshot === 'object' &&
|
||||
Object.prototype.hasOwnProperty.call(snapshot, 'context');
|
||||
const contextDegraded = transferable && (
|
||||
!snapshot.context || snapshot.freshness?.sections?.context?.degraded
|
||||
);
|
||||
if (contextDegraded) {
|
||||
contextUnavailable = true;
|
||||
render();
|
||||
notify();
|
||||
if (status) status.textContent = 'Assigned work is reconnecting…';
|
||||
return false;
|
||||
}
|
||||
contextUnavailable = false;
|
||||
if (transferable) liveSnapshot = snapshot;
|
||||
const context = snapshot?.context || snapshot || {};
|
||||
confirmedLogin = String(context.user?.login || '').trim();
|
||||
items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] });
|
||||
updateCounts();
|
||||
render();
|
||||
notify();
|
||||
const assigned = items.filter(item => item.is_assigned).length;
|
||||
if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.';
|
||||
return true;
|
||||
};
|
||||
|
||||
if (typeof createContextPoller === 'function') {
|
||||
poller = createContextPoller({
|
||||
...pollerOptions,
|
||||
fetchContext: fetchProgressiveSnapshot,
|
||||
onSnapshot: applySnapshot,
|
||||
onError: () => {
|
||||
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
|
||||
},
|
||||
isHidden: pollerOptions.isHidden || (() => Boolean(document.hidden)),
|
||||
});
|
||||
const recover = () => {
|
||||
if (!document.hidden) void poller.refresh();
|
||||
};
|
||||
['online', 'visibilitychange'].forEach(eventName => {
|
||||
lifecycleTarget.addEventListener?.(eventName, recover);
|
||||
lifecycleListeners.push([eventName, recover]);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
login() { return confirmedLogin; },
|
||||
counts() { return queueCounts(); },
|
||||
subscribe(subscriber) {
|
||||
subscribers.add(subscriber);
|
||||
return () => subscribers.delete(subscriber);
|
||||
},
|
||||
openFirst() {
|
||||
const item = visibleItems()[0];
|
||||
if (!item) return deferredQueues[active] ? 'loading' : 'empty';
|
||||
const trigger = list?.querySelector?.('[data-progressive-work-index="0"]') || null;
|
||||
openProgressiveDetail(item, trigger);
|
||||
return 'opened';
|
||||
},
|
||||
selectQueue(name, {openFirst = false} = {}) {
|
||||
const allowed = new Set(['all','attention','filed','authored','issue','pull','review','update','today','agenda','later','draft']);
|
||||
if (!allowed.has(name)) return 'unsupported';
|
||||
active = name;
|
||||
selectedByUser = true;
|
||||
render();
|
||||
notify();
|
||||
return openFirst ? this.openFirst() : 'selected';
|
||||
},
|
||||
handoff() {
|
||||
const state = { selectedFilter:selectedByUser ? active : null };
|
||||
if (openWork) {
|
||||
state.openWork = {
|
||||
kind:openWork.kind, key:openWork.key, number:openWork.number,
|
||||
repository:openWork.repository,
|
||||
};
|
||||
openWork = null;
|
||||
}
|
||||
if (liveSnapshot) {
|
||||
state.liveSnapshot = liveSnapshot;
|
||||
liveSnapshot = null;
|
||||
liveSnapshotPromise = null;
|
||||
} else if (liveSnapshotPromise) {
|
||||
state.liveSnapshotPromise = liveSnapshotPromise;
|
||||
liveSnapshotPromise = null;
|
||||
}
|
||||
return state;
|
||||
},
|
||||
async start() {
|
||||
if (status) status.textContent = 'Loading assigned work…';
|
||||
if (poller) {
|
||||
const request = poller.start();
|
||||
liveSnapshotPromise = request.then(snapshot => (
|
||||
snapshot && typeof snapshot === 'object' &&
|
||||
Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null
|
||||
));
|
||||
return Boolean(await request);
|
||||
}
|
||||
const request = Promise.resolve().then(() => fetchProgressiveSnapshot());
|
||||
liveSnapshotPromise = request.then(snapshot => (
|
||||
snapshot && typeof snapshot === 'object' &&
|
||||
Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null
|
||||
), () => null);
|
||||
try {
|
||||
const snapshot = await request;
|
||||
if (stopped) return false;
|
||||
const applied = applySnapshot(snapshot);
|
||||
if (!liveSnapshot) liveSnapshotPromise = null;
|
||||
return applied;
|
||||
} catch (_error) {
|
||||
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
poller?.stop();
|
||||
listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener));
|
||||
list?.removeEventListener?.('click', openListener);
|
||||
closeDetail?.removeEventListener?.('click', closeListener);
|
||||
lifecycleTarget.removeEventListener?.('keydown', keyListener);
|
||||
lifecycleListeners.forEach(([eventName, listener]) =>
|
||||
lifecycleTarget.removeEventListener?.(eventName, listener));
|
||||
subscribers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
window.stackchainProgressiveMyWork = createProgressiveMyWork({
|
||||
document,
|
||||
lifecycleTarget: window,
|
||||
liveSnapshot:window.stackchainProgressiveLiveSnapshot,
|
||||
fetchSnapshot: async (revisions = {}, { signal } = {}) => {
|
||||
const query = createContextPoller.buildRevisionQuery(revisions);
|
||||
const response = await fetch('api/v1/live' + (query ? '?' + query : ''), {
|
||||
headers:{Accept:'application/json'}, signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = new Error('HTTP ' + response.status);
|
||||
error.retryAfterMs = createContextPoller.retryAfterMs(response.headers.get('Retry-After'));
|
||||
throw error;
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
void window.stackchainProgressiveMyWork.start();
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveMyWork;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,12 +4,6 @@
|
|||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
followingControl, followingStatus,
|
||||
humanGateControl, humanGateStatus,
|
||||
quietControl = globalThis.document?.querySelector('#push-quiet-hours'),
|
||||
quietStart = globalThis.document?.querySelector('#push-quiet-start'),
|
||||
quietEnd = globalThis.document?.querySelector('#push-quiet-end'),
|
||||
quietStatus = globalThis.document?.querySelector('#push-quiet-status'),
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
|
||||
notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
|
|
@ -135,18 +129,12 @@
|
|||
if (testControl) testControl.hidden = true;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
if (startDayControl) startDayControl.checked = false;
|
||||
if (followingControl) followingControl.checked = false;
|
||||
if (humanGateControl) humanGateControl.checked = false;
|
||||
configuration.subscribed = false;
|
||||
configuration.deadline_enabled = false;
|
||||
configuration.start_day_enabled = false;
|
||||
configuration.following_enabled = false;
|
||||
configuration.human_gates_enabled = false;
|
||||
pendingIntent = null;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
if (followingStatus) followingStatus.textContent = 'Following change alerts are off for this device.';
|
||||
if (humanGateStatus) humanGateStatus.textContent = 'Human Gate decision alerts are off for this device.';
|
||||
}
|
||||
|
||||
async function ensureSubscription() {
|
||||
|
|
@ -278,107 +266,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function changeFollowing() {
|
||||
followingControl.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (followingControl.checked) pendingIntent = 'following';
|
||||
if (followingControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (followingControl.checked && !subscription) {
|
||||
followingControl.checked = false;
|
||||
followingStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
await fetchJson('api/v1/push-subscription/following', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:followingControl.checked}),
|
||||
});
|
||||
configuration.following_enabled = followingControl.checked;
|
||||
pendingIntent = null;
|
||||
followingStatus.textContent = followingControl.checked
|
||||
? 'Following change alerts enabled for this device.'
|
||||
: 'Following change alerts are off for this device.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
followingControl.checked = !followingControl.checked;
|
||||
followingStatus.textContent = 'Could not change Following alerts. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
followingControl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeHumanGates() {
|
||||
humanGateControl.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (humanGateControl.checked) pendingIntent = 'human-gates';
|
||||
if (humanGateControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (humanGateControl.checked && !subscription) {
|
||||
humanGateControl.checked = false;
|
||||
humanGateStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
await fetchJson('api/v1/push-subscription/human-gates', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:humanGateControl.checked}),
|
||||
});
|
||||
configuration.human_gates_enabled = humanGateControl.checked;
|
||||
pendingIntent = null;
|
||||
humanGateStatus.textContent = humanGateControl.checked
|
||||
? 'Human Gate decision alerts enabled for this device.'
|
||||
: 'Human Gate decision alerts are off for this device.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
humanGateControl.checked = !humanGateControl.checked;
|
||||
humanGateStatus.textContent = 'Could not change Human Gate alerts. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
humanGateControl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeQuietHours() {
|
||||
if (!quietControl) return false;
|
||||
for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = true;
|
||||
try {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
await fetchJson('api/v1/push-subscription/quiet-hours', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({
|
||||
enabled:quietControl.checked,
|
||||
start:quietStart?.value || '22:00',
|
||||
end:quietEnd?.value || '07:00',
|
||||
timezone,
|
||||
}),
|
||||
});
|
||||
configuration.quiet_hours_enabled = quietControl.checked;
|
||||
configuration.quiet_hours_start = quietStart?.value || '22:00';
|
||||
configuration.quiet_hours_end = quietEnd?.value || '07:00';
|
||||
quietStatus.textContent = quietControl.checked
|
||||
? `Routine alerts paused from ${configuration.quiet_hours_start} to ${configuration.quiet_hours_end} local time.`
|
||||
: 'Routine alert quiet hours are off for this device.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
quietStatus.textContent = 'Could not save quiet hours. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDeadline() {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function recoverPermission(intent = null) {
|
||||
if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following', 'human-gates'].includes(intent)) pendingIntent = intent;
|
||||
if (!pendingIntent && ['updates', 'deadline', 'start-day'].includes(intent)) pendingIntent = intent;
|
||||
if (!pendingIntent || notification.permission !== 'granted') return false;
|
||||
if (recoveryPromise) return recoveryPromise;
|
||||
recoveryPromise = (async () => {
|
||||
|
|
@ -390,14 +284,6 @@
|
|||
startDayControl.checked = true;
|
||||
return changeStartDay();
|
||||
}
|
||||
if (pendingIntent === 'following') {
|
||||
followingControl.checked = true;
|
||||
return changeFollowing();
|
||||
}
|
||||
if (pendingIntent === 'human-gates') {
|
||||
humanGateControl.checked = true;
|
||||
return changeHumanGates();
|
||||
}
|
||||
return Boolean(await enable());
|
||||
})();
|
||||
try {
|
||||
|
|
@ -413,31 +299,18 @@
|
|||
testControl?.addEventListener('click', testDelivery);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
startDayControl?.addEventListener('change', changeStartDay);
|
||||
followingControl?.addEventListener('change', changeFollowing);
|
||||
humanGateControl?.addEventListener('change', changeHumanGates);
|
||||
quietControl?.addEventListener('change', changeQuietHours);
|
||||
quietStart?.addEventListener('change', changeQuietHours);
|
||||
quietEnd?.addEventListener('change', changeQuietHours);
|
||||
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
if (deadlineControl) deadlineControl.disabled = true;
|
||||
if (startDayControl) startDayControl.disabled = true;
|
||||
if (followingControl) followingControl.disabled = true;
|
||||
if (humanGateControl) humanGateControl.disabled = true;
|
||||
if (quietControl) quietControl.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
|
||||
if (followingControl) followingControl.checked = Boolean(configuration.following_enabled);
|
||||
if (humanGateControl) humanGateControl.checked = Boolean(configuration.human_gates_enabled);
|
||||
if (quietControl) quietControl.checked = Boolean(configuration.quiet_hours_enabled);
|
||||
if (quietStart) quietStart.value = configuration.quiet_hours_start || '22:00';
|
||||
if (quietEnd) quietEnd.value = configuration.quiet_hours_end || '07:00';
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
|
||||
|
|
@ -448,17 +321,8 @@
|
|||
if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled
|
||||
? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.`
|
||||
: 'Start-day reminders are off for this device.';
|
||||
if (followingStatus) followingStatus.textContent = configuration.following_enabled
|
||||
? 'Following change alerts enabled for this device.'
|
||||
: 'Following change alerts are off for this device.';
|
||||
if (humanGateStatus) humanGateStatus.textContent = configuration.human_gates_enabled
|
||||
? 'Human Gate decision alerts enabled for this device.'
|
||||
: 'Human Gate decision alerts are off for this device.';
|
||||
if (quietStatus) quietStatus.textContent = configuration.quiet_hours_enabled
|
||||
? `Routine alerts paused from ${quietStart.value} to ${quietEnd.value} local time.`
|
||||
: 'Routine alert quiet hours are off for this device.';
|
||||
renderDeadlineSnooze();
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, changeStartDay, changeFollowing, changeHumanGates, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
return {init, change, changeDeadline, changeStartDay, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, confirmAction = null, openPull = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
|
||||
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
|
||||
const prefix = 'stackchain.release-receipt.v1:';
|
||||
const limit = 12;
|
||||
let entries = [];
|
||||
let refreshing = null;
|
||||
const recoveries = new Map();
|
||||
const retrying = new Map();
|
||||
const rollingBack = new Map();
|
||||
let timer = null;
|
||||
let bound = false;
|
||||
documentRef ||= typeof document !== 'undefined' ? document : null;
|
||||
|
|
@ -65,17 +62,6 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
|||
number: Number(item.number),
|
||||
key: item.key || item.repository + '#' + item.number,
|
||||
commit_sha: commitSha,
|
||||
...(
|
||||
mergeResult?.source_repository === item.repository
|
||||
&& mergeResult?.source_branch
|
||||
&& mergeResult?.source_head_sha
|
||||
? {
|
||||
source_branch: String(mergeResult.source_branch),
|
||||
source_head_sha: String(mergeResult.source_head_sha),
|
||||
cleanup: { state: 'available', message: 'Merged branch retained.' },
|
||||
}
|
||||
: {}
|
||||
),
|
||||
status: null,
|
||||
captured_at: new Date().toISOString(),
|
||||
};
|
||||
|
|
@ -95,17 +81,10 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
|||
|
||||
function summarize(payload) {
|
||||
const checks = Array.isArray(payload?.checks) ? payload.checks : [];
|
||||
const failedChecks = checks.filter(check => ['failure', 'error'].includes(check.state));
|
||||
const failing = failedChecks.map(check => check.name).filter(Boolean);
|
||||
const failures = failedChecks.filter(check => check?.recovery).map(check => ({
|
||||
name: check.name || 'Failed check',
|
||||
description: check.description || '',
|
||||
url: check.url || '',
|
||||
recovery: check.recovery,
|
||||
}));
|
||||
const failing = checks.filter(check => ['failure', 'error'].includes(check.state)).map(check => check.name).filter(Boolean);
|
||||
const pending = checks.filter(check => check.state === 'pending').map(check => check.name).filter(Boolean);
|
||||
if (payload?.release) return { ...payload, label: 'Released · ' + payload.release.tag, checks: [] };
|
||||
if (failing.length) return { ...payload, label: 'Checks failed', checks: failing, failures };
|
||||
if (failing.length) return { ...payload, label: 'Checks failed', checks: failing };
|
||||
if (payload?.ci_state === 'success') return { ...payload, label: 'Checks passed · waiting for release', checks: [] };
|
||||
return { ...payload, label: 'Checks running', checks: pending };
|
||||
}
|
||||
|
|
@ -142,117 +121,18 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
|||
state.className = 'small';
|
||||
state.textContent = entry.status?.label || 'Checking the exact merge commit…';
|
||||
copy.append(title, state);
|
||||
if (entry.source_branch && entry.source_head_sha) {
|
||||
const branch = document.createElement('span');
|
||||
branch.className = 'small release-branch-cleanup-status';
|
||||
branch.textContent = entry.cleanup?.state === 'deleted'
|
||||
? 'Branch ' + entry.source_branch + ' · deleted'
|
||||
: 'Branch ' + entry.source_branch + ' · ' + entry.source_head_sha.slice(0, 8);
|
||||
copy.append(branch);
|
||||
}
|
||||
if (entry.rollback?.state === 'prepared') {
|
||||
const rollbackStatus = document.createElement('span');
|
||||
rollbackStatus.className = 'small release-rollback-status';
|
||||
rollbackStatus.textContent = entry.rollback.message;
|
||||
copy.append(rollbackStatus);
|
||||
}
|
||||
if (entry.status?.release?.url) {
|
||||
const link = document.createElement('a');
|
||||
link.href = entry.status.release.url;
|
||||
link.textContent = 'Open release ' + entry.status.release.tag;
|
||||
copy.append(link);
|
||||
}
|
||||
for (const failure of (entry.status?.failures || [])) {
|
||||
const runId = failure.recovery?.run_id;
|
||||
const jobIndex = failure.recovery?.job_index;
|
||||
if (!Number.isInteger(runId) || !Number.isInteger(jobIndex)) continue;
|
||||
const key = identity(entry) + '@' + runId + ':' + jobIndex;
|
||||
const summary = document.createElement('section');
|
||||
summary.className = 'release-failure-summary';
|
||||
const failureName = document.createElement('span');
|
||||
failureName.className = 'small';
|
||||
failureName.textContent = failure.name + (failure.description ? ' · ' + failure.description : '');
|
||||
const review = document.createElement('button');
|
||||
review.type = 'button';
|
||||
review.textContent = 'Review failure';
|
||||
review.setAttribute('aria-label', 'Review failed check ' + failure.name + ' for ' + entry.commit_sha.slice(0, 8));
|
||||
review.addEventListener('click', () => reviewFailure(
|
||||
entry.repository, entry.commit_sha, runId, jobIndex
|
||||
).catch(error => {
|
||||
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
|
||||
}));
|
||||
summary.append(failureName, review);
|
||||
const detail = recoveries.get(key);
|
||||
if (detail) {
|
||||
const recovery = document.createElement('div');
|
||||
recovery.className = 'release-failure-recovery';
|
||||
const sha = document.createElement('strong');
|
||||
sha.textContent = 'Merge ' + entry.commit_sha.slice(0, 8);
|
||||
const excerpt = document.createElement('pre');
|
||||
excerpt.textContent = detail.excerpt || 'No log excerpt was returned.';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'release-failure-actions';
|
||||
if (failure.url) {
|
||||
const job = document.createElement('a');
|
||||
job.href = failure.url;
|
||||
job.textContent = 'Open job';
|
||||
actions.append(job);
|
||||
}
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.textContent = retrying.has(key) ? 'Retrying…' : 'Retry failed check';
|
||||
retry.disabled = retrying.has(key);
|
||||
retry.addEventListener('click', () => retryFailure(
|
||||
entry.repository, entry.commit_sha, runId, jobIndex
|
||||
).catch(error => {
|
||||
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
|
||||
render();
|
||||
}));
|
||||
actions.append(retry);
|
||||
const rollback = document.createElement('button');
|
||||
rollback.type = 'button';
|
||||
rollback.textContent = entry.rollback?.state === 'prepared'
|
||||
? 'Open rollback #' + entry.rollback.number
|
||||
: (rollingBack.has(identity(entry)) ? 'Preparing rollback…' : 'Prepare rollback PR');
|
||||
rollback.disabled = rollingBack.has(identity(entry));
|
||||
rollback.setAttribute(
|
||||
'aria-label',
|
||||
'Prepare rollback pull request for ' + entry.key + ' at ' + entry.commit_sha.slice(0, 8),
|
||||
);
|
||||
rollback.addEventListener('click', () => prepareRollback(entry.repository, entry.commit_sha).catch(error => {
|
||||
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
|
||||
render();
|
||||
}));
|
||||
actions.append(rollback);
|
||||
recovery.append(sha, excerpt, actions);
|
||||
summary.append(recovery);
|
||||
}
|
||||
copy.append(summary);
|
||||
}
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = 'Dismiss';
|
||||
button.setAttribute('aria-label', 'Dismiss ' + entry.key + ' from release tracking');
|
||||
button.addEventListener('click', () => dismiss(entry.repository, entry.commit_sha));
|
||||
if (entry.source_branch && entry.cleanup?.state !== 'deleted') {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'release-watchlist-actions';
|
||||
const cleanup = document.createElement('button');
|
||||
cleanup.type = 'button';
|
||||
cleanup.textContent = entry.cleanup?.state === 'deleting' ? 'Deleting…' : 'Delete source branch';
|
||||
cleanup.disabled = entry.cleanup?.state === 'deleting' || entry.cleanup?.state === 'advanced';
|
||||
cleanup.setAttribute(
|
||||
'aria-label',
|
||||
'Delete merged source branch ' + entry.source_branch + ' at ' + entry.source_head_sha.slice(0, 8),
|
||||
);
|
||||
cleanup.addEventListener('click', () => deleteBranch(entry.repository, entry.commit_sha).catch(error => {
|
||||
if (statusNode) statusNode.textContent = String(error?.message || error);
|
||||
}));
|
||||
actions.append(cleanup, button);
|
||||
row.append(copy, actions);
|
||||
} else {
|
||||
row.append(copy, button);
|
||||
}
|
||||
row.append(copy, button);
|
||||
return row;
|
||||
});
|
||||
listNode.replaceChildren(...rows);
|
||||
|
|
@ -288,149 +168,6 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
|||
finally { refreshing = null; }
|
||||
}
|
||||
|
||||
function recoveryPath(entry, runId, jobIndex) {
|
||||
return 'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
|
||||
+ '/pulls/' + encodeURIComponent(entry.number)
|
||||
+ '/release-receipt/' + encodeURIComponent(entry.commit_sha)
|
||||
+ '/checks/' + encodeURIComponent(runId) + '/jobs/' + encodeURIComponent(jobIndex);
|
||||
}
|
||||
|
||||
async function reviewFailure(repository, commitSha, runId, jobIndex) {
|
||||
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
|
||||
if (!entry) throw new Error('Tracked release is unavailable.');
|
||||
const key = identity(entry) + '@' + runId + ':' + jobIndex;
|
||||
const detail = await fetchJson(recoveryPath(entry, runId, jobIndex) + '/failure', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (detail?.commit_sha !== entry.commit_sha) {
|
||||
throw new Error('Release failure evidence did not match the merged commit.');
|
||||
}
|
||||
recoveries.set(key, detail);
|
||||
render();
|
||||
return detail;
|
||||
}
|
||||
|
||||
function retryFailure(repository, commitSha, runId, jobIndex) {
|
||||
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
|
||||
if (!entry) return Promise.reject(new Error('Tracked release is unavailable.'));
|
||||
const key = identity(entry) + '@' + runId + ':' + jobIndex;
|
||||
if (retrying.has(key)) return retrying.get(key);
|
||||
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
|
||||
const failure = entry.status?.failures?.find(value =>
|
||||
value.recovery?.run_id === runId && value.recovery?.job_index === jobIndex
|
||||
);
|
||||
if (!approve('Retry ' + (failure?.name || 'failed release check') + ' for ' + commitSha.slice(0, 8) + '?')) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const operation = fetchJson(recoveryPath(entry, runId, jobIndex) + '/retry', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (result?.commit_sha !== entry.commit_sha) {
|
||||
throw new Error('Release retry did not match the merged commit.');
|
||||
}
|
||||
entry.status = { ...entry.status, ci_state: 'pending', label: 'Checks running', checks: [], failures: [] };
|
||||
recoveries.delete(key);
|
||||
persist();
|
||||
render();
|
||||
schedule(0);
|
||||
return true;
|
||||
}).finally(() => retrying.delete(key));
|
||||
retrying.set(key, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
function prepareRollback(repository, commitSha) {
|
||||
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
|
||||
if (!entry) return Promise.reject(new Error('Tracked release is unavailable.'));
|
||||
if (entry.rollback?.state === 'prepared') {
|
||||
openPull?.(entry.rollback.pull);
|
||||
return Promise.resolve(entry.rollback.pull);
|
||||
}
|
||||
const key = identity(entry);
|
||||
if (rollingBack.has(key)) return rollingBack.get(key);
|
||||
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
|
||||
if (!approve('Prepare a draft rollback PR for ' + entry.key + ' at ' + commitSha.slice(0, 8) + '?')) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const operation = fetchJson(
|
||||
'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
|
||||
+ '/pulls/' + encodeURIComponent(entry.number)
|
||||
+ '/release-receipt/' + encodeURIComponent(entry.commit_sha) + '/rollback',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': 'release-rollback-' + entry.number + '-' + entry.commit_sha,
|
||||
},
|
||||
},
|
||||
).then(result => {
|
||||
if (result?.rollback_of !== entry.commit_sha || !Number.isInteger(result?.number)) {
|
||||
throw new Error('Rollback preparation did not match the failed release.');
|
||||
}
|
||||
const pull = {
|
||||
...result,
|
||||
kind: 'pull',
|
||||
key: result.repository + '#' + result.number,
|
||||
state: 'open',
|
||||
};
|
||||
entry.rollback = {
|
||||
state: 'prepared',
|
||||
number: result.number,
|
||||
message: 'Draft rollback #' + result.number + ' is ready for review.',
|
||||
pull,
|
||||
};
|
||||
persist();
|
||||
render();
|
||||
if (dialog?.open) dialog.close();
|
||||
openPull?.(pull);
|
||||
return result;
|
||||
}).finally(() => rollingBack.delete(key));
|
||||
rollingBack.set(key, operation);
|
||||
render();
|
||||
return operation;
|
||||
}
|
||||
|
||||
async function deleteBranch(repository, commitSha) {
|
||||
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
|
||||
if (!entry?.source_branch || !entry?.source_head_sha || entry.cleanup?.state === 'deleted') {
|
||||
throw new Error('Source branch cleanup is unavailable.');
|
||||
}
|
||||
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
|
||||
if (!approve('Delete ' + entry.source_branch + ' at ' + entry.source_head_sha.slice(0, 8) + '?')) return false;
|
||||
entry.cleanup = { state: 'deleting', message: 'Deleting source branch…' };
|
||||
persist();
|
||||
render();
|
||||
try {
|
||||
await fetchJson(
|
||||
'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
|
||||
+ '/pulls/' + encodeURIComponent(entry.number) + '/source-branch',
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source_branch: entry.source_branch,
|
||||
expected_head_sha: entry.source_head_sha,
|
||||
}),
|
||||
},
|
||||
);
|
||||
entry.cleanup = { state: 'deleted', message: 'Source branch deleted.' };
|
||||
persist();
|
||||
render();
|
||||
return true;
|
||||
} catch (error) {
|
||||
entry.cleanup = {
|
||||
state: error?.status === 409 ? 'advanced' : 'available',
|
||||
message: error?.status === 409
|
||||
? 'Source branch has newer commits and was retained.'
|
||||
: 'Branch retained. Retry when connected.',
|
||||
};
|
||||
persist();
|
||||
render();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss(repository, commitSha) {
|
||||
if (repository && commitSha) entries = entries.filter(entry => identity(entry) !== repository + '@' + commitSha);
|
||||
else entries = [];
|
||||
|
|
@ -458,7 +195,7 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
|
|||
schedule();
|
||||
}
|
||||
|
||||
return { capture, restore, refresh, reviewFailure, retryFailure, prepareRollback, deleteBranch, dismiss, bind, fetchJson };
|
||||
return { capture, restore, refresh, dismiss, bind, fetchJson };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;
|
||||
|
|
|
|||
|
|
@ -384,20 +384,10 @@ function renderChecks(checks, escapeHtml, { offline = false } = {}) {
|
|||
const url = typeof check.url === 'string' ? check.url : '';
|
||||
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
|
||||
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
|
||||
const recovery = check.recovery;
|
||||
const inspect = !offline && ['failure', 'error'].includes(check.state) &&
|
||||
Number.isInteger(recovery?.run_id) && recovery.run_id > 0 &&
|
||||
Number.isInteger(recovery?.job_index) && recovery.job_index >= 0
|
||||
? '<button type="button" class="ci-check-inspect" data-run-id="' + recovery.run_id +
|
||||
'" data-job-index="' + recovery.job_index + '">Inspect failure</button>'
|
||||
: '';
|
||||
const actions = inspect || link
|
||||
? '<div class="ci-check-actions">' + inspect + link + '</div>'
|
||||
: '';
|
||||
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
|
||||
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
|
||||
'<span class="small">' + escapeHtml(check.state || 'unknown') +
|
||||
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + actions + '</article>';
|
||||
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
|
||||
}).join('');
|
||||
return { summary, html, expanded: failed > 0 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,116 +30,8 @@
|
|||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview/conversation?' + query.toString();
|
||||
};
|
||||
root.followingPullReviewPath = item => {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return 'api/v1/following/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review-data';
|
||||
};
|
||||
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||
'/comments?kind=' + encodeURIComponent(item.kind);
|
||||
root.searchPreviewMutation = fetchJson => (detail, action) => {
|
||||
if (action === 'reopen-pull') {
|
||||
const path = root.searchPreviewPath(detail).replace('/issues/', '/pulls/').replace(/\/preview.*$/, '/reopen');
|
||||
return fetchJson(path, {
|
||||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({expected_head_sha:detail.head_sha}),
|
||||
});
|
||||
}
|
||||
return fetchJson(root.searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'});
|
||||
};
|
||||
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||
'/subscription?kind=' + encodeURIComponent(item.kind);
|
||||
root.searchPreviewSubscriptionOptions = fetchJson => {
|
||||
const options = {
|
||||
load:async detail => {
|
||||
if (['issue', 'pull'].includes(detail.kind) && detail.state === 'closed' && detail.following === true) {
|
||||
return {...detail, watching:true};
|
||||
}
|
||||
if (!(['issue', 'pull'].includes(detail.kind) && detail.state === 'open')) return detail;
|
||||
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
|
||||
return {...detail, watching:result.watching === true};
|
||||
},
|
||||
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
|
||||
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
|
||||
}),
|
||||
review:item => fetchJson(root.followingPullReviewPath(item)),
|
||||
};
|
||||
options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), {
|
||||
headers:{Accept:'application/json'},
|
||||
})});
|
||||
return options;
|
||||
};
|
||||
root.createDetailWatch = ({fetchJson,refreshFollowing,onState}) => {
|
||||
const api=root.searchPreviewSubscriptionOptions(fetchJson);
|
||||
let item,watching=false,mutation;
|
||||
return {
|
||||
async open(next) {
|
||||
item=next; onState('loading',watching);
|
||||
const result=await api.load(next).catch(error=>{
|
||||
if(item===next)onState('error',watching,error);throw error;
|
||||
});
|
||||
if (item !== next) return;
|
||||
watching=result.watching === true; onState('ready',watching);
|
||||
},
|
||||
toggle() {
|
||||
if (mutation) return mutation;
|
||||
const next=!watching;
|
||||
onState(next?'watching':'unwatching',watching);
|
||||
mutation=api.watch(item,next).then(async result => {
|
||||
if (result?.watching !== next || result?.following_synced !== true)
|
||||
throw new Error(result?.error || 'Unconfirmed.');
|
||||
watching=next; await refreshFollowing();
|
||||
onState(watching?'watched':'unwatched',watching);
|
||||
}).catch(error => {onState('error',watching,error);throw error;})
|
||||
.finally(()=>{mutation=null;});
|
||||
return mutation;
|
||||
},
|
||||
};
|
||||
};
|
||||
root.searchPreviewWatchStatus = state => ({
|
||||
watching:'Starting watch…', unwatching:'Stopping watch…',
|
||||
watched:'Watching · available in Following. Future activity will appear in Updates.',
|
||||
unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.',
|
||||
'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch ' +
|
||||
(state.detail?.kind === 'pull' ? 'pull request' : 'issue') + ' to repair.',
|
||||
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
|
||||
})[state.status] || '';
|
||||
root.renderSearchPreviewWatch = (detail, state, button) => {
|
||||
const watchableKind = ['issue', 'pull'].includes(detail.kind);
|
||||
const retiring = watchableKind && detail.state === 'closed' &&
|
||||
detail.following === true && detail.watching === true;
|
||||
button.hidden = !(watchableKind && detail.state === 'open') && !retiring;
|
||||
button.textContent = retiring ? 'Stop watching & next' :
|
||||
(detail.watching ? 'Stop watching' : 'Watch ' + (detail.kind === 'pull' ? 'pull request' : 'issue'));
|
||||
button.disabled = state.status === 'watching' || state.status === 'unwatching';
|
||||
};
|
||||
root.renderSearchPreviewStart = (detail, state, button) => {
|
||||
const pull = detail.kind === 'pull' && detail.state === 'closed' &&
|
||||
detail.authored_pull_reopenable === true;
|
||||
const issue = detail.kind === 'issue' &&
|
||||
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)));
|
||||
button.hidden = !(detail.reviewable || pull || issue);
|
||||
button.textContent = detail.reviewable ? 'Review now' : pull ? 'Reopen in My Work' :
|
||||
detail.reopenable ? 'Reopen & resume' :
|
||||
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
|
||||
button.disabled = state.status === 'claiming' || state.status === 'reopening';
|
||||
};
|
||||
root.createSearchAuthoredPullRecovery = o =>
|
||||
async d => {
|
||||
if (!o.confirm('Reopen ' + d.repository + ' #' + d.number + '?')) return 'canceled';
|
||||
await o.reopen(d);
|
||||
await o.refresh();
|
||||
const item = o.find(d);
|
||||
if (!item) {
|
||||
o.unavailable('Reopened. Refresh My Work.');
|
||||
return 'unavailable';
|
||||
}
|
||||
o.open(item);
|
||||
return 'opened';
|
||||
};
|
||||
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
|
||||
const detail = getDetail();
|
||||
if (detail) preview.setWatching(detail.watching !== true).catch(() => {});
|
||||
});
|
||||
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
|
||||
storage,
|
||||
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
|
|
@ -148,7 +40,7 @@
|
|||
body:JSON.stringify({body}),
|
||||
}),
|
||||
});
|
||||
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown, actions) => {
|
||||
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
|
||||
const comments = document.querySelector('#search-preview-comments');
|
||||
const status = document.querySelector('#search-preview-conversation-status');
|
||||
const retry = document.querySelector('#retry-search-preview-conversation');
|
||||
|
|
@ -158,16 +50,10 @@
|
|||
retry.hidden = older.hidden = true;
|
||||
return;
|
||||
}
|
||||
const isNew = comment => Boolean(conversation.reviewedAt && comment.created_at &&
|
||||
new Date(comment.created_at).getTime() > new Date(conversation.reviewedAt).getTime());
|
||||
const newCount = (conversation.comments || []).filter(isNew).length;
|
||||
comments.innerHTML = (conversation.comments || []).map(comment =>
|
||||
'<article class="search-preview-comment issue-comment' + (isNew(comment) ? ' new-since-review' : '') +
|
||||
'" data-comment-id="' + Number(comment.id || 0) + '">' +
|
||||
'<article class="search-preview-comment" data-comment-id="' + Number(comment.id || 0) + '">' +
|
||||
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') +
|
||||
(isNew(comment) ? ' · <strong>New since last review</strong>' : '') + '</div>' +
|
||||
(actions?.actionHtml?.(comment) || '') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '</div>' +
|
||||
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
|
||||
).join('');
|
||||
retry.hidden = conversation.status !== 'error';
|
||||
|
|
@ -179,21 +65,7 @@
|
|||
'Conversation unavailable. Preview and planning actions still work.';
|
||||
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
|
||||
else status.textContent = conversation.comments.length +
|
||||
(conversation.comments.length === 1 ? ' message' : ' messages') +
|
||||
(newCount ? ' · ' + newCount + ' new since last review' : '') + '.';
|
||||
};
|
||||
root.createSearchPreviewConversationActions = ({hydrator,rootNode,retry,paint,wire}) => {
|
||||
let latest = null;
|
||||
const show = conversation => {
|
||||
latest = conversation;
|
||||
if (!conversation) {
|
||||
paint(null, null);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return hydrator.show({root:rootNode,state:conversation,paint,retry,wire});
|
||||
};
|
||||
retry.addEventListener('click', () => { if (latest) void show(latest); });
|
||||
return show;
|
||||
(conversation.comments.length === 1 ? ' message.' : ' messages.');
|
||||
};
|
||||
root.renderSearchPreviewReply = (state, detail, preview, document) => {
|
||||
const section = document.querySelector('.search-preview-reply');
|
||||
|
|
@ -218,58 +90,9 @@
|
|||
else if (state.status === 'reply-error') status.textContent =
|
||||
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
|
||||
};
|
||||
root.renderSearchPreviewReview = (review, document, escapeHtml) => {
|
||||
const section = document.querySelector('#search-preview-review');
|
||||
const status = document.querySelector('#search-preview-review-status');
|
||||
const files = document.querySelector('#search-preview-files');
|
||||
const retry = document.querySelector('#retry-search-preview-review');
|
||||
section.hidden = !review;
|
||||
retry.hidden = review?.status !== 'error';
|
||||
files.innerHTML = '';
|
||||
if (!review) {
|
||||
status.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (review.status === 'loading') {
|
||||
status.textContent = 'Loading CI and changed files…';
|
||||
return;
|
||||
}
|
||||
if (review.status === 'error') {
|
||||
status.textContent = 'Changes unavailable. This revision has not been marked reviewed.';
|
||||
return;
|
||||
}
|
||||
const data = review.data || {};
|
||||
const changed = Array.isArray(data.files) ? data.files : [];
|
||||
const ci = ({success:'CI passed', failure:'CI failed', error:'CI failed', pending:'CI pending'})[
|
||||
data.ci_state
|
||||
] || 'CI status unavailable';
|
||||
status.textContent = ci + ' · ' + changed.length + ' changed ' +
|
||||
(changed.length === 1 ? 'file.' : 'files.');
|
||||
files.innerHTML = changed.map(file => {
|
||||
const lines = (Array.isArray(file.diff_lines) ? file.diff_lines : []).map(raw => {
|
||||
const line = String(raw);
|
||||
const kind = line.startsWith('@@') ? 'hunk' : line.startsWith('+') ? 'added' :
|
||||
line.startsWith('-') ? 'removed' : 'context';
|
||||
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(line) + '</span>';
|
||||
}).join('');
|
||||
const diff = file.diff_available
|
||||
? '<pre class="pull-diff">' + lines +
|
||||
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
|
||||
: '<div class="pull-diff-empty">' +
|
||||
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') + '</div>';
|
||||
return '<article class="search-preview-file"><strong>' + escapeHtml(file.filename || 'Unknown file') +
|
||||
'</strong><span class="small">' + escapeHtml(file.status || 'changed') + ' · +' +
|
||||
Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span>' + diff + '</article>';
|
||||
}).join('');
|
||||
};
|
||||
root.renderSearchPreviewWorkspaces = (state, detail, preview, document, escapeHtml) => {
|
||||
root.renderSearchPreviewReply(state, detail, preview, document);
|
||||
root.renderSearchPreviewReview(state.review, document, escapeHtml);
|
||||
document.querySelector('[data-search-preview-section="changes"]').hidden = !state.review;
|
||||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, fetchReview, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||
if (Array.isArray(session)) {
|
||||
getSession = session[0];
|
||||
loadMore = () => session[1].loadMore();
|
||||
|
|
@ -282,24 +105,13 @@
|
|||
let shareRequest = null;
|
||||
let moveRequest = null;
|
||||
let replyRequest = null;
|
||||
let watchRequest = null;
|
||||
let conversation = null;
|
||||
let review = null;
|
||||
let openedRevision = null;
|
||||
|
||||
function sameItem(left, right) {
|
||||
return left && right && left.kind === right.kind && left.repository === right.repository &&
|
||||
Number(left.number) === Number(right.number);
|
||||
}
|
||||
|
||||
async function notifyOpened(item, requestGeneration) {
|
||||
const revision = [item?.kind, item?.repository, item?.number, item?.updated_at].join(':');
|
||||
if (requestGeneration !== generation || openedRevision === revision) return false;
|
||||
await onOpened?.({...item});
|
||||
if (requestGeneration === generation) openedRevision = revision;
|
||||
return true;
|
||||
}
|
||||
|
||||
function replyKey(item, suffix) {
|
||||
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
|
||||
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
|
||||
|
|
@ -331,10 +143,6 @@
|
|||
}
|
||||
|
||||
function publish(state) {
|
||||
if (conversation && !Object.prototype.hasOwnProperty.call(state, 'conversation')) {
|
||||
state = {...state, conversation};
|
||||
}
|
||||
if (review && !Object.prototype.hasOwnProperty.call(state, 'review')) state = {...state, review};
|
||||
const position = navigation(state.item || current);
|
||||
if (navigationRoot) {
|
||||
const bar = navigationRoot.querySelector('.search-preview-navigation');
|
||||
|
|
@ -368,10 +176,7 @@
|
|||
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
|
||||
const previousComments = page && Array.isArray(conversation?.comments)
|
||||
? conversation.comments : [];
|
||||
conversation = {
|
||||
status:'loading', comments:previousComments, olderPage:page ?? null,
|
||||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||||
};
|
||||
conversation = { status:'loading', comments:previousComments, olderPage:page ?? null };
|
||||
publish({ status:'ready', item:current, detail, conversation });
|
||||
return fetchConversation(detail, page).then(result => {
|
||||
if (requestGeneration !== generation) return result;
|
||||
|
|
@ -383,7 +188,6 @@
|
|||
status:'ready',
|
||||
comments,
|
||||
olderPage:result?.older_page ?? null,
|
||||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
return result;
|
||||
|
|
@ -392,7 +196,6 @@
|
|||
conversation = {
|
||||
status:'error', comments:previousComments,
|
||||
olderPage:page ?? conversation?.olderPage ?? null, error,
|
||||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
}
|
||||
|
|
@ -400,58 +203,7 @@
|
|||
});
|
||||
}
|
||||
|
||||
function loadReview(detail, requestGeneration) {
|
||||
if (!(detail?.following === true && detail?.kind === 'pull' && typeof fetchReview === 'function')) {
|
||||
review = null;
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
review = {status:'loading', data:null};
|
||||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||
return fetchReview(detail).then(data => {
|
||||
if (requestGeneration === generation) {
|
||||
review = {status:'ready', data};
|
||||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||
}
|
||||
return data;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
review = {status:'error', data:null, error};
|
||||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async function acknowledgeWhenContextReady(requestGeneration) {
|
||||
const conversationReady = typeof fetchConversation !== 'function' || conversation?.status === 'ready';
|
||||
const reviewRequired = current?.following === true && current?.kind === 'pull' &&
|
||||
typeof fetchReview === 'function';
|
||||
if (conversationReady && (!reviewRequired || review?.status === 'ready')) {
|
||||
await notifyOpened(current, requestGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
const conversationPager = {
|
||||
snapshot() {
|
||||
return {...(conversation || {}), comments:(conversation?.comments || []).map(comment => ({...comment}))};
|
||||
},
|
||||
replace(comment) {
|
||||
if (!comment || !Number.isInteger(comment.id) || !conversation) return conversationPager.snapshot();
|
||||
conversation = {...conversation, comments:(conversation.comments || []).map(existing =>
|
||||
existing.id === comment.id ? {...comment} : existing)};
|
||||
publish({status:'ready', item:current, detail:current, conversation});
|
||||
return conversationPager.snapshot();
|
||||
},
|
||||
remove(commentId) {
|
||||
if (!conversation) return conversationPager.snapshot();
|
||||
conversation = {...conversation, comments:(conversation.comments || []).filter(comment => comment.id !== commentId)};
|
||||
publish({status:'ready', item:current, detail:current, conversation});
|
||||
return conversationPager.snapshot();
|
||||
},
|
||||
};
|
||||
|
||||
const api = {
|
||||
commentPager() { return conversationPager; },
|
||||
hasReplyAttachments() {
|
||||
return Boolean(hasAttachments?.());
|
||||
},
|
||||
|
|
@ -461,24 +213,14 @@
|
|||
const requestGeneration = generation;
|
||||
current = { ...item };
|
||||
conversation = null;
|
||||
review = null;
|
||||
openedRevision = null;
|
||||
publish({ status: 'loading', item: current });
|
||||
return fetchJson(current).then(async detail => {
|
||||
return fetchJson(current).then(detail => {
|
||||
if (requestGeneration === generation) {
|
||||
current = { ...current, ...detail };
|
||||
if (typeof fetchConversation === 'function') {
|
||||
const context = loadConversation(current, requestGeneration);
|
||||
if (current.following === true) {
|
||||
const reviewContext = loadReview(current, requestGeneration);
|
||||
await Promise.all([context, reviewContext]);
|
||||
await acknowledgeWhenContextReady(requestGeneration);
|
||||
} else {
|
||||
await notifyOpened(current, requestGeneration);
|
||||
}
|
||||
loadConversation(current, requestGeneration);
|
||||
} else {
|
||||
publish({ status: 'ready', item: current, detail });
|
||||
await notifyOpened(current, requestGeneration);
|
||||
}
|
||||
}
|
||||
return detail;
|
||||
|
|
@ -527,19 +269,9 @@
|
|||
})().finally(() => { moveRequest = null; });
|
||||
return moveRequest;
|
||||
},
|
||||
async retryConversation() {
|
||||
if (!current) return null;
|
||||
const requestGeneration = generation;
|
||||
const result = await loadConversation(current, requestGeneration);
|
||||
if (current?.following === true) await acknowledgeWhenContextReady(requestGeneration);
|
||||
return result;
|
||||
},
|
||||
async retryReview() {
|
||||
if (!current) return null;
|
||||
const requestGeneration = generation;
|
||||
const result = await loadReview(current, requestGeneration);
|
||||
await acknowledgeWhenContextReady(requestGeneration);
|
||||
return result;
|
||||
retryConversation() {
|
||||
if (!current) return Promise.resolve(null);
|
||||
return loadConversation(current, generation);
|
||||
},
|
||||
loadOlderConversation() {
|
||||
if (!current || !conversation?.olderPage) return Promise.resolve(null);
|
||||
|
|
@ -616,41 +348,11 @@
|
|||
reopen(detail) {
|
||||
return run('reopen', 'reopening', 'reopened', detail);
|
||||
},
|
||||
reopenPull(detail) {
|
||||
return run('reopen-pull', 'reopening', 'reopened', detail);
|
||||
},
|
||||
setWatching(watching) {
|
||||
if (watchRequest) return watchRequest;
|
||||
if (!current || typeof watch !== 'function') {
|
||||
return Promise.reject(new Error('Watching is unavailable.'));
|
||||
}
|
||||
const detail = current;
|
||||
publish({ status:watching ? 'watching' : 'unwatching', item:current, detail });
|
||||
watchRequest = watch(detail, watching).then(async result => {
|
||||
current = { ...current, watching:result?.watching === true };
|
||||
const status = result?.following_synced === false
|
||||
? 'watch-partial' : (watching ? 'watched' : 'unwatched');
|
||||
publish({ status, item:current, detail:current, result });
|
||||
if (!watching && result?.watching === false && typeof afterUnwatch === 'function') {
|
||||
const next = await afterUnwatch({...current});
|
||||
if (next) {
|
||||
onNavigate?.(next);
|
||||
await api.open(next);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}).catch(error => {
|
||||
publish({ status:'watch-error', item:current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { watchRequest = null; });
|
||||
return watchRequest;
|
||||
},
|
||||
};
|
||||
if (navigationRoot) {
|
||||
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
|
||||
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
|
||||
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
|
||||
navigationRoot.querySelector('#retry-search-preview-review').addEventListener('click', () => api.retryReview());
|
||||
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
|
||||
const reply = navigationRoot.querySelector('#search-preview-reply');
|
||||
reply?.addEventListener('input', event => {
|
||||
|
|
|
|||
|
|
@ -33,11 +33,10 @@
|
|||
}
|
||||
return async (operation, key, value) => {
|
||||
const db = await database();
|
||||
const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
|
||||
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
|
||||
const records = transaction.objectStore(storeName);
|
||||
if (operation === 'put') return requestResult(records.put(value));
|
||||
if (operation === 'delete') return requestResult(records.delete(key));
|
||||
if (operation === 'list') return requestResult(records.getAll());
|
||||
return requestResult(records.get(key));
|
||||
};
|
||||
}
|
||||
|
|
@ -96,9 +95,7 @@
|
|||
await transact('delete', id);
|
||||
return null;
|
||||
}
|
||||
await transact('put', id, {
|
||||
id, version:1, ownerLogin, ...normalized, updatedAt:Date.now(), attachments:list,
|
||||
});
|
||||
await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
|
||||
return list;
|
||||
}
|
||||
|
||||
|
|
@ -119,28 +116,6 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
async function list() {
|
||||
if (!transact) return [];
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) return [];
|
||||
const records = await transact('list');
|
||||
return (Array.isArray(records) ? records : []).flatMap(record => {
|
||||
const repository = String(record?.repository || '');
|
||||
const number = Number(record?.number || 0);
|
||||
if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
|
||||
!['issue', 'pull'].includes(record.kind) || !repository ||
|
||||
!Number.isInteger(number) || number < 1 ||
|
||||
!Array.isArray(record.attachments) || !record.attachments.length) return [];
|
||||
return [{
|
||||
id:record.id, kind:'photo-reply',
|
||||
label:'Search ' + (record.kind === 'pull' ? 'PR' : 'issue') + ' photos',
|
||||
repository, number, title:repository + '#' + number,
|
||||
photo_count:record.attachments.length, updated_at:Number(record.updatedAt || 0),
|
||||
route:{ kind:'search', target_kind:record.kind, repository, number }, photo_store:'search',
|
||||
}];
|
||||
}).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
return { save, load, remove, list };
|
||||
return { save, load, remove };
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5}
|
|||
await week.flush();
|
||||
return {status:'planned',date:options.date,estimate,item};
|
||||
} catch(_error) {
|
||||
return {status:'planned-pending',date:options.date,estimate,item};
|
||||
return {status:'planned-pending',date:options.date,estimate};
|
||||
}
|
||||
};
|
||||
running=perform().finally(()=>{running=null;});
|
||||
|
|
@ -99,8 +99,8 @@ function createSearchWeekPlanUI({planner,document,window,getDetail,onPlanned=()=
|
|||
else if(outcome.status==='day-full')status.textContent='That day already has '+outcome.limit+' items. Choose another day.';
|
||||
else if(outcome.status==='already-planned')status.textContent='Already planned for '+outcome.date+'. Choose Move to change the day.';
|
||||
else if(outcome.status==='assigned-not-planned')status.textContent='Assigned, not planned. Find it in My Work and retry.';
|
||||
else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');await onPlanned(outcome.item||detail,true,outcome);close(true);}
|
||||
else if(outcome.status==='planned'){announce('Planned for '+date+'.');await onPlanned(outcome.item||detail,false,outcome);close(true);}
|
||||
else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');onPlanned(detail,true);close(true);}
|
||||
else if(outcome.status==='planned'){announce('Planned for '+date+'.');onPlanned(detail,false);close(true);}
|
||||
else status.textContent='Could not plan this issue. Retry.';
|
||||
button.disabled=false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,9 +231,6 @@
|
|||
passkey_counter_anomaly: 'Passkey counter anomaly',
|
||||
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
source_branch_deleted: 'Source branch deleted',
|
||||
release_rollback_prepared: 'Release rollback prepared',
|
||||
ci_job_retried: 'CI job retried',
|
||||
comment_deleted: 'Comment deleted',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v150';
|
||||
const CACHE = 'stackchain-dashboard-shell-v130';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
|
|
@ -9,91 +9,6 @@ const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
|
|||
const TODAY_ACTION_TTL_MS = 2 * 60 * 1000;
|
||||
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
|
||||
|
||||
function createAppBadgePreference() {
|
||||
const dbName = 'stackchain-app-badge-preference-v1';
|
||||
const storeName = 'preferences';
|
||||
const open = () => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
|
||||
});
|
||||
return {
|
||||
async get() {
|
||||
const database = await open();
|
||||
const enabled = await new Promise((resolve, reject) => {
|
||||
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('enabled');
|
||||
request.onsuccess = () => resolve(request.result === true);
|
||||
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
|
||||
});
|
||||
database.close();
|
||||
return enabled;
|
||||
},
|
||||
async set(enabled) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put(enabled === true, 'enabled');
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('App badge preference could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
async getCounts() {
|
||||
const database = await open();
|
||||
const counts = {};
|
||||
for (const channel of ['updates', 'following', 'human-gates']) {
|
||||
counts[channel] = await new Promise((resolve, reject) => {
|
||||
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('count:' + channel);
|
||||
request.onsuccess = () => resolve(Number.isSafeInteger(request.result) ? request.result : 0);
|
||||
request.onerror = () => reject(request.error || new Error('App badge count unavailable.'));
|
||||
});
|
||||
}
|
||||
database.close();
|
||||
return counts;
|
||||
},
|
||||
async setCount(channel, count) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put(count, 'count:' + channel);
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('App badge count could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
async clearCounts() {
|
||||
await this.setCount('updates', 0);
|
||||
await this.setCount('following', 0);
|
||||
await this.setCount('human-gates', 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBadgePreference();
|
||||
let renderedBackgroundBadgeCount = null;
|
||||
|
||||
async function reconcileBackgroundAppBadge(channel, count) {
|
||||
if (!['updates', 'following', 'human-gates'].includes(channel)
|
||||
|| !Number.isSafeInteger(count) || count < 0 || count > 9999
|
||||
|| typeof self.registration.setAppBadge !== 'function'
|
||||
|| typeof self.registration.clearAppBadge !== 'function') return false;
|
||||
let enabled = false;
|
||||
try { enabled = await appBadgePreference.get(); } catch (_error) { return false; }
|
||||
if (!enabled) return false;
|
||||
try {
|
||||
await appBadgePreference.setCount(channel, count);
|
||||
const counts = await appBadgePreference.getCounts();
|
||||
const total = Math.min(9999, counts.updates + counts.following + counts['human-gates']);
|
||||
if (renderedBackgroundBadgeCount === total) return false;
|
||||
if (total > 0) await self.registration.setAppBadge(total);
|
||||
else await self.registration.clearAppBadge();
|
||||
renderedBackgroundBadgeCount = total;
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function createTodayActionStore() {
|
||||
const dbName = 'stackchain-today-action-mailbox-v1';
|
||||
const storeName = 'commands';
|
||||
|
|
@ -152,8 +67,6 @@ const SHELL = [
|
|||
BASE + 'manifest.webmanifest',
|
||||
BASE + 'static/dashboard.css',
|
||||
BASE + 'static/dashboard.js',
|
||||
BASE + 'static/human-gates.js',
|
||||
BASE + 'static/progressive-human-gates.js',
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
|
|
@ -166,11 +79,9 @@ const SHELL = [
|
|||
BASE + 'static/commands.js',
|
||||
BASE + 'static/saved-searches.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/following.js',
|
||||
BASE + 'static/search-reply-draft-store.js',
|
||||
BASE + 'static/conversation-reply-draft-store.js',
|
||||
BASE + 'static/conversation-photo-drafts.js',
|
||||
BASE + 'static/photo-draft-inbox.js',
|
||||
BASE + 'static/search-defer.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
|
|
@ -189,10 +100,6 @@ const SHELL = [
|
|||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/progressive-live-snapshot.js',
|
||||
BASE + 'static/progressive-my-work.js',
|
||||
BASE + 'static/progressive-mobile-dock.js',
|
||||
BASE + 'static/progressive-capture.js',
|
||||
BASE + 'static/agenda-replan.js',
|
||||
BASE + 'static/agenda-calendar.js',
|
||||
BASE + 'static/protect-today.js',
|
||||
|
|
@ -253,7 +160,6 @@ const SHELL = [
|
|||
BASE + 'static/voice-issue-capture.js',
|
||||
BASE + 'static/voice-conversation-capture.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-pull-sheet.js',
|
||||
BASE + 'static/mobile-create-issue-nav.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
|
|
@ -270,8 +176,6 @@ const SHELL = [
|
|||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-first-task.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-recent-work.js',
|
||||
BASE + 'static/mobile-queue-priority.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-delivery-recovery.js',
|
||||
BASE + 'static/mobile-start-day.js',
|
||||
|
|
@ -286,14 +190,12 @@ const SHELL = [
|
|||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-insights.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/mobile-app-badge.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/private-data-inventory.js',
|
||||
BASE + 'static/private-device-data.js',
|
||||
BASE + 'static/device-storage.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-search-modal.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/mention-composer.js',
|
||||
BASE + 'static/push-notifications.js',
|
||||
|
|
@ -302,8 +204,6 @@ const SHELL = [
|
|||
];
|
||||
const OPTIONAL_FEATURES = [
|
||||
];
|
||||
const DEMAND_FEATURES = [
|
||||
];
|
||||
|
||||
const SHARED_IMAGE_ID = 'shared-image';
|
||||
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
|
@ -623,35 +523,6 @@ async function updateTodayLockScreen(active, running, rawActionToken = '', rawBr
|
|||
}
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-app-badge-count') {
|
||||
event.waitUntil((async () => {
|
||||
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
const channel = event.data.channel;
|
||||
const count = event.data.count;
|
||||
if (!['updates', 'following', 'human-gates'].includes(channel)
|
||||
|| !Number.isSafeInteger(count) || count < 0 || count > 9999) return;
|
||||
try {
|
||||
if (await appBadgePreference.get()) {
|
||||
await appBadgePreference.setCount(channel, count);
|
||||
renderedBackgroundBadgeCount = null;
|
||||
}
|
||||
} catch (_error) { /* A later authoritative refresh can restore the count. */ }
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-app-badge-preference') {
|
||||
event.waitUntil((async () => {
|
||||
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
const enabled = event.data.enabled === true;
|
||||
try {
|
||||
await appBadgePreference.set(enabled);
|
||||
renderedBackgroundBadgeCount = null;
|
||||
if (!enabled && typeof self.registration.clearAppBadge === 'function') {
|
||||
await appBadgePreference.clearCounts();
|
||||
await self.registration.clearAppBadge();
|
||||
}
|
||||
} catch (_error) { /* Page preference remains authoritative on next launch. */ }
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
|
||||
await issueSync.resume();
|
||||
await flushAndNotify();
|
||||
|
|
@ -710,10 +581,7 @@ self.addEventListener('push', event => {
|
|||
const tag = String(payload.tag || '');
|
||||
const notificationId = Number(payload.notification_id);
|
||||
const updateCount = Number(payload.update_count);
|
||||
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
const followingCount = Number(payload.following_count);
|
||||
const humanGateCount = Number(payload.human_gate_count);
|
||||
const planDate = String(payload.plan_date || '');
|
||||
if (
|
||||
route === '#/my-work/start-day'
|
||||
|
|
@ -750,43 +618,6 @@ self.addEventListener('push', event => {
|
|||
));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/human-gates'
|
||||
&& tag === 'stackchain-human-gates-' + humanGateCount
|
||||
&& Number.isSafeInteger(humanGateCount)
|
||||
&& humanGateCount > 0
|
||||
&& humanGateCount <= 50
|
||||
) {
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge('human-gates', humanGateCount),
|
||||
self.registration.showNotification(
|
||||
humanGateCount + ' release decision' + (humanGateCount === 1 ? ' is' : 's are') + ' waiting', {
|
||||
body: 'Open Human Gates to review ' + (humanGateCount === 1 ? 'it.' : 'them.'),
|
||||
tag,
|
||||
data: {route},
|
||||
}
|
||||
),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/following'
|
||||
&& /^stackchain-following-[0-9a-f]{16}$/.test(tag)
|
||||
&& Number.isSafeInteger(followingCount)
|
||||
&& followingCount > 0
|
||||
&& followingCount <= 50
|
||||
) {
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge('following', followingCount),
|
||||
self.registration.showNotification(
|
||||
followingCount + ' watched item' + (followingCount === 1 ? '' : 's') + ' changed', {
|
||||
body: 'Open Following to review the latest activity.',
|
||||
tag,
|
||||
data: {route},
|
||||
}),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/updates'
|
||||
&& tag === 'stackchain-update-digest'
|
||||
|
|
@ -794,12 +625,11 @@ self.addEventListener('push', event => {
|
|||
&& updateCount > 0
|
||||
&& updateCount <= 50
|
||||
) {
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge('updates', unreadCount),
|
||||
self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.', tag, data: {route},
|
||||
}),
|
||||
]));
|
||||
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
|
||||
|
|
@ -819,10 +649,7 @@ self.addEventListener('push', event => {
|
|||
];
|
||||
options.data.notificationId = notificationId;
|
||||
}
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge('updates', unreadCount),
|
||||
self.registration.showNotification('New work update', options),
|
||||
]));
|
||||
event.waitUntil(self.registration.showNotification('New work update', options));
|
||||
});
|
||||
|
||||
async function openWorkRoute(route) {
|
||||
|
|
@ -1030,8 +857,7 @@ self.addEventListener('fetch', event => {
|
|||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin &&
|
||||
(OPTIONAL_FEATURES.includes(url.pathname) || DEMAND_FEATURES.includes(url.pathname))) {
|
||||
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
|
||||
event.respondWith(cachedOptionalFeature(request));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
function createTodaySessionSync({
|
||||
fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {},
|
||||
onOwnedRestore = () => {},
|
||||
setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval,
|
||||
}) {
|
||||
let current = null;
|
||||
|
|
@ -26,8 +25,6 @@ function createTodaySessionSync({
|
|||
current = session;
|
||||
if (session.device_id === deviceId()) {
|
||||
ownedRevision = session.revision;
|
||||
if (Number.isFinite(session.break_deadline_at) &&
|
||||
timer?.restoreBreak?.(session.identity, session.break_deadline_at)) onOwnedRestore(session);
|
||||
onRemote(null);
|
||||
} else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) {
|
||||
if (previousOwned) {
|
||||
|
|
@ -63,7 +60,7 @@ function createTodaySessionSync({
|
|||
onStatus('syncing');
|
||||
const body = {
|
||||
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
|
||||
elapsed_ms:current.elapsed_ms, entries:current.entries, running:true,
|
||||
elapsed_ms:current.elapsed_ms, running:true,
|
||||
};
|
||||
if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null;
|
||||
const session = await fetchJson(endpoint, {
|
||||
|
|
@ -71,7 +68,7 @@ function createTodaySessionSync({
|
|||
body:JSON.stringify(body),
|
||||
});
|
||||
adopt(session);
|
||||
timer?.adopt?.(session.identity, session.elapsed_ms, session.running, session.entries);
|
||||
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error?.status === 409 || error?.code === 'session_changed') {
|
||||
|
|
@ -92,7 +89,6 @@ function createTodaySessionSync({
|
|||
const body = {
|
||||
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
|
||||
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
|
||||
entries:Array.isArray(snapshot.entries) ? snapshot.entries : undefined,
|
||||
running:Boolean(snapshot.running),
|
||||
};
|
||||
if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) {
|
||||
|
|
@ -116,7 +112,7 @@ function createTodaySessionSync({
|
|||
}
|
||||
}
|
||||
|
||||
function publish(snapshot = timer?.sessionSnapshot?.() || timer?.snapshot?.()) {
|
||||
function publish(snapshot = timer?.snapshot?.()) {
|
||||
snapshot = snapshot?.identity ? snapshot : latestSnapshot;
|
||||
if (!snapshot?.identity || !deviceId()) return Promise.resolve(null);
|
||||
latestSnapshot = snapshot;
|
||||
|
|
@ -167,10 +163,6 @@ function todaySessionHandoffSummary(session, item) {
|
|||
return `On break until ${end} · ready to resume here`;
|
||||
}
|
||||
const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000));
|
||||
if (Array.isArray(session?.entries) && session.entries.length > 1) {
|
||||
const total = session.entries.reduce((sum, entry) => sum + Math.max(0, Number(entry?.elapsed_ms) || 0), 0);
|
||||
return `${session.entries.length} tracked items · ${Math.floor(total / 60000)} min total`;
|
||||
}
|
||||
return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
|
||||
}
|
||||
|
||||
|
|
@ -194,7 +186,6 @@ function attachTodaySessionHandoff({
|
|||
};
|
||||
const sync = createTodaySessionSync({
|
||||
fetchJson, getDeviceId, timer,
|
||||
onOwnedRestore:renderTimer,
|
||||
onStatus:state => showSessionStatus(
|
||||
state === 'syncing' ? 'Session syncing…' :
|
||||
(state === 'offline' ? 'Session offline · will retry.' : '')
|
||||
|
|
|
|||
|
|
@ -75,9 +75,8 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
|||
entry.running = false;
|
||||
return state;
|
||||
};
|
||||
const snapshot = (identity = '', includeEntries = false) => {
|
||||
const snapshot = (identity = '') => {
|
||||
const state = read();
|
||||
const sampledAt = now();
|
||||
const selected = identity || state.active_identity;
|
||||
const entry = state.entries[selected];
|
||||
const result = !selected || !entry ?
|
||||
|
|
@ -85,49 +84,31 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
|||
{
|
||||
identity:selected,
|
||||
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
|
||||
Math.max(0, sampledAt - Number(entry.started_at ?? sampledAt)) : 0),
|
||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0),
|
||||
running:Boolean(entry.running),
|
||||
};
|
||||
const timedBreak = validBreak(state);
|
||||
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
|
||||
if (includeEntries) {
|
||||
result.entries = Object.entries(state.entries).slice(0, 20).map(([entryIdentity, value]) => ({
|
||||
identity:entryIdentity,
|
||||
elapsed_ms:Math.max(0, Math.floor(Number(value.elapsed_ms) || 0) + (value.running ?
|
||||
Math.max(0, sampledAt - Number(value.started_at ?? sampledAt)) : 0)),
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
adopt(identity, elapsedMs, running, entries = null) {
|
||||
adopt(identity, elapsedMs, running) {
|
||||
if (!key() || typeof identity !== 'string' || !identity ||
|
||||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
|
||||
const incoming = entries === null ? [{identity, elapsed_ms:elapsedMs}] : entries;
|
||||
if (!Array.isArray(incoming) || incoming.length > 20) return false;
|
||||
const seen = new Set();
|
||||
const ledger = {};
|
||||
for (const entry of incoming) {
|
||||
if (typeof entry?.identity !== 'string' || !entry.identity || entry.identity.length > 500 ||
|
||||
seen.has(entry.identity) || !Number.isFinite(Number(entry.elapsed_ms)) || Number(entry.elapsed_ms) < 0) return false;
|
||||
seen.add(entry.identity);
|
||||
ledger[entry.identity] = {
|
||||
elapsed_ms:Math.floor(Number(entry.elapsed_ms)), started_at:null, running:false,
|
||||
};
|
||||
}
|
||||
if (!ledger[identity] || ledger[identity].elapsed_ms !== Math.floor(Number(elapsedMs))) return false;
|
||||
const state = read();
|
||||
const keepBreak = validBreak(state)?.identity === identity;
|
||||
settle(state);
|
||||
state.active_identity = identity;
|
||||
state.entries = ledger;
|
||||
state.entries[identity].started_at = running && !keepBreak ? now() : null;
|
||||
state.entries[identity].running = Boolean(running) && !keepBreak;
|
||||
state.entries[identity] = {
|
||||
elapsed_ms:Math.floor(Number(elapsedMs)),
|
||||
started_at:running ? now() : null,
|
||||
running:Boolean(running),
|
||||
};
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
state.search_interruption = null;
|
||||
state.detour_interruption = null;
|
||||
if (!keepBreak) state.timed_break = null;
|
||||
state.timed_break = null;
|
||||
return write(state);
|
||||
},
|
||||
activate(identity) {
|
||||
|
|
@ -178,15 +159,6 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
|||
state.timed_break = { identity, deadline_at:now() + duration * 60000 };
|
||||
return write(state) ? validBreak(state) : false;
|
||||
},
|
||||
restoreBreak(identity, deadlineAt) {
|
||||
const deadline = Number(deadlineAt);
|
||||
if (typeof identity !== 'string' || !identity || !Number.isFinite(deadline) || deadline <= now()) return false;
|
||||
const state = read();
|
||||
const entry = state.entries[identity];
|
||||
if (state.active_identity !== identity || !entry || entry.running || validBreak(state)) return false;
|
||||
state.timed_break = { identity, deadline_at:Math.floor(deadline) };
|
||||
return write(state);
|
||||
},
|
||||
breakSnapshot() {
|
||||
return validBreak(read());
|
||||
},
|
||||
|
|
@ -416,7 +388,6 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
|||
}, 0);
|
||||
},
|
||||
snapshot,
|
||||
sessionSnapshot:(identity = '') => snapshot(identity, true),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -489,10 +460,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
|||
const snapshot = timer.snapshot();
|
||||
breakView?.render();
|
||||
const active = Boolean(progress && isActive() && snapshot.identity);
|
||||
queryAll('[data-mobile-today-hud]').forEach(element => {
|
||||
const coaching = element.querySelector?.('[data-mobile-first-task-coach]:not([hidden])');
|
||||
element.hidden = !active && !coaching;
|
||||
});
|
||||
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
|
||||
queryAll('[data-mobile-today-open]').forEach(element => {
|
||||
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,12 +70,11 @@ function createTodayWeekReschedule({
|
|||
throw conflict;
|
||||
}
|
||||
|
||||
function flush(retryConflict = false) {
|
||||
function flush() {
|
||||
if (flushing) return flushing;
|
||||
if (!pending()) return Promise.resolve(false);
|
||||
flushing = (async () => {
|
||||
let result = null;
|
||||
let conflictRetried = false;
|
||||
while (true) {
|
||||
const records = readQueue();
|
||||
const record = records[0];
|
||||
|
|
@ -84,25 +83,7 @@ function createTodayWeekReschedule({
|
|||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status === 409 && retryConflict && !conflictRetried) {
|
||||
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
||||
adoptToday(today); week.adopt(weekState);
|
||||
const latest = readQueue();
|
||||
if (today?.ids?.includes(record.body.identity) &&
|
||||
latest[0]?.body?.operation_id === record.body.operation_id) {
|
||||
latest[0] = {...latest[0], body:{
|
||||
...latest[0].body,
|
||||
today_revision:today.revision,
|
||||
week_revision:weekState.revision,
|
||||
}};
|
||||
if (!saveQueue(latest)) throw new Error('Could not update this saved move after plans changed.');
|
||||
conflictRetried = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await restoreConflict(error);
|
||||
}
|
||||
} catch (error) { await restoreConflict(error); }
|
||||
adoptToday(result.today);
|
||||
week.adopt(result.week);
|
||||
const latest = readQueue();
|
||||
|
|
@ -226,24 +207,9 @@ function createTodayWeekReschedule({
|
|||
}
|
||||
let result;
|
||||
try {
|
||||
if (admitted) {
|
||||
result = await flush(true);
|
||||
} else {
|
||||
try {
|
||||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 409) throw error;
|
||||
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
||||
adoptToday(today); week.adopt(weekState);
|
||||
if (!today?.ids?.includes(body.identity)) throw error;
|
||||
body.today_revision = today.revision; body.week_revision = weekState.revision;
|
||||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
}
|
||||
result = admitted ? await flush() : await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
||||
});
|
||||
} catch(error) {
|
||||
if (admitted && error?.status === 409) await restoreConflict(error);
|
||||
if (admitted && error?.status !== 401 && error?.status !== 403) {
|
||||
|
|
@ -337,19 +303,10 @@ function mountTodayWeekReschedule({
|
|||
dialog.addEventListener('cancel',event=>{event.preventDefault();close();});
|
||||
confirm.addEventListener('click',async()=>{
|
||||
if(!selectedDate)return;
|
||||
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';dialog.close();
|
||||
let result;
|
||||
try{
|
||||
result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
||||
}catch(error){
|
||||
if(!dialog.open)dialog.showModal();
|
||||
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
||||
status.textContent=error.message;
|
||||
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
|
||||
confirm.disabled=false;
|
||||
return;
|
||||
}
|
||||
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';
|
||||
try{
|
||||
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
||||
dialog.close();
|
||||
if(result.sync_pending){
|
||||
announce('Moved locally. Saved on this device · sync pending.');warm();
|
||||
}else{
|
||||
|
|
@ -357,7 +314,10 @@ function mountTodayWeekReschedule({
|
|||
}
|
||||
await continueToday();
|
||||
}catch(error){
|
||||
announce(`${error.message||'Refresh unavailable.'} Move completed; refresh to continue.`);
|
||||
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
||||
status.textContent=error.message;
|
||||
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
|
||||
confirm.disabled=false;
|
||||
}
|
||||
});
|
||||
return {controller,close,flushPending,resumePending};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
function createWeekCalendarImport() {
|
||||
const maxBytes=1024*1024;
|
||||
const zonedInstantFormatters=new Map(),zonePartFormatters=new Map(),supportedTimeZones=new Map();
|
||||
function clock(value) {
|
||||
const match=/^(\d{2}):(\d{2})$/.exec(String(value||''));
|
||||
if(!match)return null;
|
||||
|
|
@ -20,11 +19,9 @@ function createWeekCalendarImport() {
|
|||
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/.exec(value||'');
|
||||
if(!match)return null;
|
||||
try {
|
||||
const wanted=match.slice(1).map(Number);
|
||||
if(!zonedInstantFormatters.has(timeZone))zonedInstantFormatters.set(timeZone,new Intl.DateTimeFormat('en-CA',{
|
||||
const wanted=match.slice(1).map(Number),formatter=new Intl.DateTimeFormat('en-CA',{
|
||||
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23',
|
||||
}));
|
||||
const formatter=zonedInstantFormatters.get(timeZone);
|
||||
});
|
||||
let result=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5]);
|
||||
for(let attempt=0;attempt<2;attempt+=1) {
|
||||
const shown=Object.fromEntries(formatter.formatToParts(new Date(result)).map(part=>[part.type,part.value]));
|
||||
|
|
@ -36,18 +33,15 @@ function createWeekCalendarImport() {
|
|||
}
|
||||
function supportedTimeZone(timeZone) {
|
||||
if(!timeZone)return true;
|
||||
if(supportedTimeZones.has(timeZone))return supportedTimeZones.get(timeZone);
|
||||
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);supportedTimeZones.set(timeZone,true);return true;}
|
||||
catch(error) {supportedTimeZones.set(timeZone,false);return false;}
|
||||
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);return true;}
|
||||
catch(error) {return false;}
|
||||
}
|
||||
function zoneParts(value,timeZone) {
|
||||
const date=new Date(value);
|
||||
if(!timeZone)return {year:date.getFullYear(),month:date.getMonth()+1,day:date.getDate(),weekday:date.getDay(),
|
||||
hour:date.getHours(),minute:date.getMinutes(),second:date.getSeconds()};
|
||||
if(!zonePartFormatters.has(timeZone))zonePartFormatters.set(timeZone,new Intl.DateTimeFormat('en-US',{
|
||||
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',weekday:'short',hourCycle:'h23',
|
||||
}));
|
||||
const formatter=zonePartFormatters.get(timeZone);
|
||||
const formatter=new Intl.DateTimeFormat('en-US',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',
|
||||
hour:'2-digit',minute:'2-digit',second:'2-digit',weekday:'short',hourCycle:'h23'});
|
||||
const parts=Object.fromEntries(formatter.formatToParts(date).map(part=>[part.type,part.value]));
|
||||
return {year:Number(parts.year),month:Number(parts.month),day:Number(parts.day),
|
||||
weekday:['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(parts.weekday),hour:Number(parts.hour),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'authored', 'update', 'later', 'draft', 'following'];
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
|
||||
const sections = {
|
||||
issue: ['overview', 'conversation', 'reply', 'actions'],
|
||||
filed: ['overview', 'conversation', 'reply', 'actions'],
|
||||
|
|
|
|||
|
|
@ -2,154 +2,76 @@ async function loadWorkspace({
|
|||
document,
|
||||
window = null,
|
||||
createLoader = createFeatureLoader,
|
||||
schedule = callback => setTimeout(callback, 750),
|
||||
schedule = callback => setTimeout(callback,750),
|
||||
}) {
|
||||
let cameOnline = false;
|
||||
let replayed = false;
|
||||
|
||||
let retryInFlight = null;
|
||||
const captureOnline = () => { cameOnline = true; };
|
||||
window?.addEventListener('online', captureOnline);
|
||||
const status = document.querySelector('#my-work-action-status');
|
||||
const retryButton = document.querySelector('#retry-workspace');
|
||||
const names = ['work-core', 'today-timer', 'planning'];
|
||||
const urls = Object.fromEntries(names.map(name => [name,
|
||||
const urls = Object.fromEntries(['today-timer', 'planning'].map(name => [name,
|
||||
document.querySelector(`meta[name="stackchain-feature-${name}"]`)?.content || ''
|
||||
]));
|
||||
const originalUrls = {...urls};
|
||||
const loader = createLoader({document, urls});
|
||||
const attempts = Object.fromEntries(names.map(name => [name, 0]));
|
||||
const failed = new Set();
|
||||
const recoveries = new Map();
|
||||
let retryInFlight = null;
|
||||
let resolveWorkspaceReady;
|
||||
const workspaceReady = new Promise(resolve => { resolveWorkspaceReady = resolve; });
|
||||
let workspaceMarkedReady = false;
|
||||
const markWorkspaceReady = () => {
|
||||
if (workspaceMarkedReady) return false;
|
||||
workspaceMarkedReady = true;
|
||||
resolveWorkspaceReady(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadFeature = name => {
|
||||
attempts[name] += 1;
|
||||
urls[name] = originalUrls[name] + (attempts[name] > 1 ? '?retry=' + attempts[name] : '');
|
||||
return loader.load(name);
|
||||
let attempts = 0;
|
||||
const load = () => {
|
||||
if (attempts++) Object.keys(urls).forEach(name => {
|
||||
urls[name] = originalUrls[name] + '?retry=' + attempts;
|
||||
});
|
||||
return Promise.all(Object.keys(urls).map(name => loader.load(name)));
|
||||
};
|
||||
const retryOnce = async name => {
|
||||
try { return await loadFeature(name); }
|
||||
catch (_error) {
|
||||
await new Promise(resolve => schedule(resolve));
|
||||
return loadFeature(name);
|
||||
const waitForRecovery = () => new Promise(resolve => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) {
|
||||
retryButton.hidden = retryButton.disabled = false;
|
||||
}
|
||||
};
|
||||
const showRecovery = () => {
|
||||
if (status) status.textContent = failed.has('work-core') ?
|
||||
'Workspace unavailable. Reconnect or retry.' :
|
||||
'Today or planning tools unavailable. My Work is ready; reconnect or retry.';
|
||||
if (retryButton) retryButton.hidden = retryButton.disabled = false;
|
||||
};
|
||||
const hideRecovery = () => {
|
||||
if (failed.size) return;
|
||||
if (retryButton) retryButton.hidden = true;
|
||||
if (status) status.textContent = '';
|
||||
};
|
||||
const retryFailed = () => {
|
||||
if (retryInFlight) return retryInFlight;
|
||||
if (retryButton) retryButton.disabled = true;
|
||||
const pending = Array.from(failed);
|
||||
retryInFlight = Promise.all(pending.map(async name => {
|
||||
try {
|
||||
await loadFeature(name);
|
||||
failed.delete(name);
|
||||
recoveries.get(name)?.resolve(true);
|
||||
recoveries.delete(name);
|
||||
} catch (_error) {}
|
||||
})).then(() => {
|
||||
if (failed.size) showRecovery();
|
||||
else hideRecovery();
|
||||
}).finally(() => { retryInFlight = null; });
|
||||
return retryInFlight;
|
||||
};
|
||||
retryButton?.addEventListener?.('click', retryFailed);
|
||||
const handleOnline = () => { cameOnline = true; void retryFailed(); };
|
||||
window?.addEventListener('online', handleOnline);
|
||||
const serviceWorkerReady = window?.navigator?.serviceWorker?.register ?
|
||||
window.navigator.serviceWorker.register('service-worker.js') : Promise.resolve(null);
|
||||
|
||||
const recover = () => {
|
||||
if (retryInFlight) return retryInFlight;
|
||||
if (retryButton) retryButton.disabled = true;
|
||||
retryInFlight = load().then(() => {
|
||||
window?.removeEventListener('online', recover);
|
||||
resolve();
|
||||
}).catch(() => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) retryButton.disabled = false;
|
||||
}).finally(() => {
|
||||
retryInFlight = null;
|
||||
});
|
||||
return retryInFlight;
|
||||
};
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
window?.addEventListener('online', recover);
|
||||
retryButton?.addEventListener('click', recover);
|
||||
});
|
||||
|
||||
try {
|
||||
await retryOnce('work-core');
|
||||
} catch (_error) {
|
||||
failed.add('work-core');
|
||||
showRecovery();
|
||||
await new Promise(resolve => recoveries.set('work-core', {resolve}));
|
||||
await load();
|
||||
} catch {
|
||||
await new Promise(resolve => schedule(resolve));
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await waitForRecovery();
|
||||
}
|
||||
}
|
||||
hideRecovery();
|
||||
|
||||
let optionalReady = null;
|
||||
const hydrateWorkspace = () => {
|
||||
if (optionalReady) return optionalReady;
|
||||
let resolveOptional;
|
||||
optionalReady = new Promise(resolve => { resolveOptional = resolve; });
|
||||
const optional = ['today-timer', 'planning'].map(async name => {
|
||||
try {
|
||||
await retryOnce(name);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
failed.add(name);
|
||||
showRecovery();
|
||||
return new Promise(resolve => recoveries.set(name, {resolve}));
|
||||
}
|
||||
});
|
||||
Promise.all(optional).then(() => {
|
||||
hideRecovery();
|
||||
resolveOptional(true);
|
||||
});
|
||||
return optionalReady;
|
||||
};
|
||||
|
||||
const hydrationSelector = [
|
||||
'[data-mobile-task]:not([data-mobile-task="queues"])',
|
||||
'[data-progressive-loading="true"]',
|
||||
'#app-menu-toggle',
|
||||
'#work-settings-toggle',
|
||||
].join(',');
|
||||
const hydrateForAction = async event => {
|
||||
const target = event.target?.closest?.(hydrationSelector);
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
await hydrateWorkspace();
|
||||
await workspaceReady;
|
||||
document.removeEventListener?.('click', hydrateForAction, true);
|
||||
target.click?.();
|
||||
};
|
||||
document.addEventListener?.('click', hydrateForAction, true);
|
||||
const hash = window?.location?.hash || '';
|
||||
const deepLinkReady = hash.startsWith('#/') && hash !== '#/my-work' ?
|
||||
hydrateWorkspace() : Promise.resolve(false);
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (retryButton) retryButton.hidden = true;
|
||||
if (status) status.textContent = '';
|
||||
return {
|
||||
hydrateWorkspace,
|
||||
deepLinkReady,
|
||||
workspaceReady,
|
||||
markWorkspaceReady,
|
||||
serviceWorkerReady,
|
||||
get optionalReady() { return hydrateWorkspace(); },
|
||||
retryFeature(name) {
|
||||
if (!failed.has(name)) return Promise.resolve(true);
|
||||
return retryFailed().then(() => !failed.has(name));
|
||||
},
|
||||
replayOnline(callback) {
|
||||
if (replayed) return;
|
||||
replayed = true;
|
||||
window?.removeEventListener('online', handleOnline);
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (cameOnline) callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined' &&
|
||||
typeof createFeatureLoader === 'function') {
|
||||
window.stackchainWorkspaceLifecycle = loadWorkspace({document, window});
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||
|
|
|
|||
|
|
@ -78,12 +78,6 @@ STORES = (
|
|||
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
|
||||
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
|
||||
)),
|
||||
Store("queue-priority", "queue-priority", "STACKCHAIN_QUEUE_PRIORITY_DB", "queue-priority.sqlite3", (
|
||||
Table("queue_priorities", ("login",), (Field("queue_order", "order:{login}"),)),
|
||||
)),
|
||||
Store("recent-work", "recent-work", "STACKCHAIN_RECENT_WORK_DB", "recent-work.sqlite3", (
|
||||
Table("recent_work", ("login",), (Field("items", "items:{login}"),)),
|
||||
)),
|
||||
Store(
|
||||
"completed-filed-reviews",
|
||||
"completed-filed-reviews",
|
||||
|
|
|
|||
|
|
@ -32,8 +32,6 @@ class Session:
|
|||
csrf: str
|
||||
expires_at: int
|
||||
idle_expires_at: int | None = None
|
||||
principal_id: int | None = None
|
||||
principal_login: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -147,8 +145,6 @@ def issue_session(
|
|||
*,
|
||||
device_label: str = "This device",
|
||||
management_id: str | None = None,
|
||||
principal_id: int | None = None,
|
||||
principal_login: str | None = None,
|
||||
) -> tuple[str, Session]:
|
||||
issued_at = int(time.time() if now is None else now)
|
||||
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
||||
|
|
@ -169,8 +165,6 @@ def issue_session(
|
|||
session.expires_at,
|
||||
device_label=device_label,
|
||||
management_id=management_id,
|
||||
principal_id=principal_id,
|
||||
principal_login=principal_login,
|
||||
)
|
||||
return f"{encoded}.{signature}", session
|
||||
|
||||
|
|
@ -217,8 +211,6 @@ def verify_session_with_reason(
|
|||
csrf=session.csrf,
|
||||
expires_at=session.expires_at,
|
||||
idle_expires_at=getattr(status, "idle_expires_at", None),
|
||||
principal_id=getattr(status, "principal_id", None),
|
||||
principal_login=getattr(status, "principal_login", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -236,11 +228,7 @@ async def revoke_all_sessions() -> None:
|
|||
|
||||
|
||||
async def active_devices(session: Session):
|
||||
return await asyncio.to_thread(
|
||||
_session_store().list_active,
|
||||
session.session_id,
|
||||
principal_id=session.principal_id,
|
||||
)
|
||||
return await asyncio.to_thread(_session_store().list_active, session.session_id)
|
||||
|
||||
|
||||
async def session_management_id(session: Session) -> str:
|
||||
|
|
@ -256,14 +244,11 @@ async def managed_session_active(management_id: str) -> bool:
|
|||
return status == "active"
|
||||
|
||||
|
||||
async def managed_session_statuses(
|
||||
management_ids, *, expected_principal_id: int | None = None
|
||||
) -> dict[str, str]:
|
||||
options = {"idle_timeout_seconds": idle_timeout_seconds()}
|
||||
if expected_principal_id is not None:
|
||||
options["expected_principal_id"] = expected_principal_id
|
||||
async def managed_session_statuses(management_ids) -> dict[str, str]:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().managed_statuses, management_ids, **options
|
||||
_session_store().managed_statuses,
|
||||
management_ids,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
"""Disk-capacity incident assessment shared by operations checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from os import PathLike
|
||||
|
||||
|
||||
def assess_disk_capacity(
|
||||
*,
|
||||
total_bytes: int,
|
||||
available_bytes: int,
|
||||
threshold_percent: float = 85.0,
|
||||
) -> dict[str, float | bool]:
|
||||
"""Return the capacity status using the runbook's inclusive threshold."""
|
||||
usage_percent = round((total_bytes - available_bytes) / total_bytes * 100, 1)
|
||||
return {
|
||||
"usage_percent": usage_percent,
|
||||
"threshold_percent": threshold_percent,
|
||||
"incident": usage_percent >= threshold_percent,
|
||||
}
|
||||
|
||||
|
||||
def read_disk_capacity(
|
||||
path: str | PathLike[str] = "/",
|
||||
*,
|
||||
threshold_percent: float = 85.0,
|
||||
) -> dict[str, float | bool]:
|
||||
"""Assess capacity for a real filesystem path."""
|
||||
usage = shutil.disk_usage(path)
|
||||
return assess_disk_capacity(
|
||||
total_bytes=usage.total,
|
||||
available_bytes=usage.free,
|
||||
threshold_percent=threshold_percent,
|
||||
)
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
"""Encrypted, account-scoped registry of explicitly followed Gitea issues."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_STATES = {"open", "closed"}
|
||||
_KINDS = {"issue", "pull"}
|
||||
|
||||
|
||||
class FollowingStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 50,
|
||||
timeout: float = 1.0,
|
||||
encryption_key: bytes | None = None,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="following",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS following_issues (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
items TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
normalized = str(login).strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "items": []}, False
|
||||
items, legacy = self._cipher.open(row[1], binding=f"items:{login}")
|
||||
if not isinstance(items, list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return {"revision": int(row[0]), "items": items}, legacy
|
||||
|
||||
def _seal(self, login: str, items: list[dict]) -> str:
|
||||
return self._cipher.seal(items, binding=f"items:{login}")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_item(raw: dict) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("following item must be an object")
|
||||
repository = raw.get("repository")
|
||||
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
||||
raise ValueError("repository is invalid")
|
||||
number = raw.get("number")
|
||||
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||
raise ValueError("number is invalid")
|
||||
kind = raw.get("kind", "issue")
|
||||
if kind not in _KINDS:
|
||||
raise ValueError("kind is invalid")
|
||||
title = raw.get("title")
|
||||
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300:
|
||||
raise ValueError("title is invalid")
|
||||
state = raw.get("state")
|
||||
if state not in _STATES:
|
||||
raise ValueError("state is invalid")
|
||||
updated_at = raw.get("updated_at")
|
||||
if not isinstance(updated_at, str) or not updated_at or len(updated_at) > 64:
|
||||
raise ValueError("updated_at is invalid")
|
||||
url = raw.get("url")
|
||||
if not isinstance(url, str) or not url.startswith(("http://", "https://")) or len(url) > 2048:
|
||||
raise ValueError("url is invalid")
|
||||
last_seen_updated_at = raw.get("last_seen_updated_at", updated_at)
|
||||
if (
|
||||
not isinstance(last_seen_updated_at, str)
|
||||
or not last_seen_updated_at
|
||||
or len(last_seen_updated_at) > 64
|
||||
):
|
||||
raise ValueError("last seen update is invalid")
|
||||
item = {
|
||||
"repository": repository,
|
||||
"kind": kind,
|
||||
"number": number,
|
||||
"title": title.strip(),
|
||||
"state": state,
|
||||
"updated_at": updated_at,
|
||||
"url": url,
|
||||
"last_seen_updated_at": last_seen_updated_at,
|
||||
}
|
||||
kept_updated_at = raw.get("kept_updated_at")
|
||||
if kept_updated_at is not None:
|
||||
if not isinstance(kept_updated_at, str) or not kept_updated_at or len(kept_updated_at) > 64:
|
||||
raise ValueError("kept update is invalid")
|
||||
item["kept_updated_at"] = kept_updated_at
|
||||
reviewed_title = raw.get("reviewed_title")
|
||||
if reviewed_title is not None:
|
||||
if not isinstance(reviewed_title, str) or not reviewed_title.strip() or len(reviewed_title.strip()) > 300:
|
||||
raise ValueError("reviewed title is invalid")
|
||||
item["reviewed_title"] = reviewed_title.strip()
|
||||
reviewed_state = raw.get("reviewed_state")
|
||||
if reviewed_state is not None:
|
||||
if reviewed_state not in _STATES:
|
||||
raise ValueError("reviewed state is invalid")
|
||||
item["reviewed_state"] = reviewed_state
|
||||
return item
|
||||
|
||||
@classmethod
|
||||
def _present(cls, snapshot: dict) -> dict:
|
||||
changed = []
|
||||
unchanged = []
|
||||
for raw in snapshot["items"]:
|
||||
stored = cls._normalize_item(raw)
|
||||
unseen = (stored["updated_at"] != stored["last_seen_updated_at"] or
|
||||
stored.get("kept_updated_at") == stored["updated_at"])
|
||||
item = {key: value for key, value in stored.items()
|
||||
if key not in {"last_seen_updated_at", "kept_updated_at",
|
||||
"reviewed_title", "reviewed_state"}}
|
||||
item["has_unseen_change"] = unseen
|
||||
if unseen:
|
||||
item["reviewed_at"] = stored["last_seen_updated_at"]
|
||||
if stored.get("reviewed_state") == "open" and stored["state"] == "closed":
|
||||
item["change_summary"] = "Closed since last review"
|
||||
elif stored.get("reviewed_state") == "closed" and stored["state"] == "open":
|
||||
item["change_summary"] = "Reopened since last review"
|
||||
elif stored.get("reviewed_title") not in {None, stored["title"]}:
|
||||
item["change_summary"] = "Title changed since last review"
|
||||
else:
|
||||
item["change_summary"] = "New activity"
|
||||
(changed if unseen else unchanged).append(item)
|
||||
changed.sort(key=lambda item: item["updated_at"], reverse=True)
|
||||
return {"revision": snapshot["revision"], "items": changed + unchanged}
|
||||
|
||||
@staticmethod
|
||||
def _identity(item: dict) -> tuple[str, str, int]:
|
||||
return item.get("kind", "issue"), item["repository"].lower(), item["number"]
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot, legacy = self._snapshot(row, login)
|
||||
snapshot["items"] = [self._normalize_item(item) for item in snapshot["items"]]
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE following_issues SET items = ? WHERE login = ? AND items = ?",
|
||||
(self._seal(login, snapshot["items"]), login, row[1]),
|
||||
)
|
||||
return self._present(snapshot)
|
||||
|
||||
def preflight(self, login: str, raw_item: dict, watching: bool) -> dict:
|
||||
"""Validate a requested change and capacity without mutating the registry."""
|
||||
login = self._login(login)
|
||||
item = self._normalize_item(raw_item)
|
||||
if not isinstance(watching, bool):
|
||||
raise ValueError("watching is invalid")
|
||||
if not watching:
|
||||
return item
|
||||
identity = self._identity(item)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
if (
|
||||
len(current["items"]) >= self.limit
|
||||
and not any(self._identity(candidate) == identity for candidate in current["items"])
|
||||
):
|
||||
raise ValueError(f"following is limited to {self.limit} issues")
|
||||
return item
|
||||
|
||||
def set_watching(self, login: str, raw_item: dict, watching: bool) -> dict:
|
||||
login = self._login(login)
|
||||
item = self._normalize_item(raw_item)
|
||||
if not isinstance(watching, bool):
|
||||
raise ValueError("watching is invalid")
|
||||
identity = self._identity(item)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
items = [self._normalize_item(candidate) for candidate in current["items"]]
|
||||
index = next(
|
||||
(position for position, candidate in enumerate(items)
|
||||
if self._identity(candidate) == identity),
|
||||
None,
|
||||
)
|
||||
if watching:
|
||||
if index is None:
|
||||
if len(items) >= self.limit:
|
||||
raise ValueError(f"following is limited to {self.limit} issues")
|
||||
items.insert(0, item)
|
||||
else:
|
||||
item["last_seen_updated_at"] = items[index]["last_seen_updated_at"]
|
||||
for key in ("kept_updated_at", "reviewed_title", "reviewed_state"):
|
||||
if key in items[index]:
|
||||
item[key] = items[index][key]
|
||||
if items[index] == item:
|
||||
return self._present({"revision": current["revision"], "items": items})
|
||||
items.pop(index)
|
||||
items.insert(0, item)
|
||||
elif index is None:
|
||||
return self._present({"revision": current["revision"], "items": items})
|
||||
else:
|
||||
items.pop(index)
|
||||
revision = current["revision"] + 1
|
||||
connection.execute(
|
||||
"INSERT INTO following_issues(login, revision, items) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, items=excluded.items",
|
||||
(login, revision, self._seal(login, items)),
|
||||
)
|
||||
return self._present({"revision": revision, "items": items})
|
||||
|
||||
def refresh(self, login: str, raw_items: list[dict]) -> dict:
|
||||
"""Merge successful upstream snapshots while preserving seen revisions."""
|
||||
login = self._login(login)
|
||||
fresh = {self._identity(item): self._normalize_item(item) for item in raw_items}
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
items = [self._normalize_item(candidate) for candidate in current["items"]]
|
||||
changed = False
|
||||
for index, item in enumerate(items):
|
||||
update = fresh.get(self._identity(item))
|
||||
if update is None:
|
||||
continue
|
||||
update["last_seen_updated_at"] = item["last_seen_updated_at"]
|
||||
for key in ("kept_updated_at", "reviewed_title", "reviewed_state"):
|
||||
if key in item:
|
||||
update[key] = item[key]
|
||||
if update["updated_at"] != item["updated_at"]:
|
||||
update.setdefault("reviewed_title", item["title"])
|
||||
update.setdefault("reviewed_state", item["state"])
|
||||
if update != item:
|
||||
items[index] = update
|
||||
changed = True
|
||||
revision = current["revision"]
|
||||
if changed:
|
||||
revision += 1
|
||||
connection.execute(
|
||||
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
|
||||
(revision, self._seal(login, items), login),
|
||||
)
|
||||
return self._present({"revision": revision, "items": items})
|
||||
|
||||
def acknowledge(
|
||||
self,
|
||||
login: str,
|
||||
repository: str,
|
||||
number: int,
|
||||
updated_at: str,
|
||||
*,
|
||||
kind: str = "issue",
|
||||
) -> dict:
|
||||
"""Mark only the exact upstream revision successfully opened by the operator."""
|
||||
login = self._login(login)
|
||||
if kind not in _KINDS:
|
||||
raise ValueError("kind is invalid")
|
||||
identity = (kind, str(repository).lower(), number)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
items = [self._normalize_item(candidate) for candidate in current["items"]]
|
||||
revision = current["revision"]
|
||||
for item in items:
|
||||
if self._identity(item) != identity or item["updated_at"] != updated_at:
|
||||
continue
|
||||
if (item["last_seen_updated_at"] != updated_at or
|
||||
item.get("kept_updated_at") == updated_at):
|
||||
item["last_seen_updated_at"] = updated_at
|
||||
item.pop("kept_updated_at", None)
|
||||
item.pop("reviewed_title", None)
|
||||
item.pop("reviewed_state", None)
|
||||
revision += 1
|
||||
connection.execute(
|
||||
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
|
||||
(revision, self._seal(login, items), login),
|
||||
)
|
||||
break
|
||||
return self._present({"revision": revision, "items": items})
|
||||
|
||||
def keep_unseen(
|
||||
self,
|
||||
login: str,
|
||||
repository: str,
|
||||
number: int,
|
||||
updated_at: str,
|
||||
*,
|
||||
kind: str = "issue",
|
||||
) -> dict:
|
||||
"""Restore only the exact loaded revision to the unseen review queue."""
|
||||
login = self._login(login)
|
||||
if kind not in _KINDS:
|
||||
raise ValueError("kind is invalid")
|
||||
identity = (kind, str(repository).lower(), number)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
items = [self._normalize_item(candidate) for candidate in current["items"]]
|
||||
revision = current["revision"]
|
||||
for item in items:
|
||||
if self._identity(item) != identity or item["updated_at"] != updated_at:
|
||||
continue
|
||||
if item.get("kept_updated_at") != updated_at:
|
||||
item["kept_updated_at"] = updated_at
|
||||
revision += 1
|
||||
connection.execute(
|
||||
"UPDATE following_issues SET revision = ?, items = ? WHERE login = ?",
|
||||
(revision, self._seal(login, items), login),
|
||||
)
|
||||
break
|
||||
return self._present({"revision": revision, "items": items})
|
||||
|
|
@ -21,13 +21,9 @@ COMMONJS_BROWSER_BRANCH = re.compile(
|
|||
)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"work-core": (
|
||||
"static/my-work.js", "static/progressive-my-work.js", "static/progressive-mobile-dock.js",
|
||||
"static/unfiled-captures.js", "static/progressive-capture.js",
|
||||
),
|
||||
"comment-actions": ("static/comment-actions.js",),
|
||||
"issue-capture": (
|
||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/create-pull-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
|
|
@ -39,18 +35,18 @@ FEATURE_SOURCES = {
|
|||
"security-center": ("static/security-center.js",),
|
||||
"planning": (
|
||||
"static/plan-today.js", "static/plan-today-readiness.js",
|
||||
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js", "static/following.js", "static/search-preview.js",
|
||||
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
|
||||
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
|
||||
),
|
||||
"today-timer": (
|
||||
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-recent-work.js", "static/mobile-queue-priority.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-draft-sync.js",
|
||||
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
"static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/mobile-update-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", "static/dashboard.js",
|
||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
|
|
@ -126,7 +122,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
|||
)
|
||||
workspace_preload = (
|
||||
f'<link rel="preload" as="script" '
|
||||
f'href="{feature_bundles["work-core"].runtime_name}">'
|
||||
f'href="{feature_bundles["today-timer"].runtime_name}">'
|
||||
)
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</head>", feature_metadata + "\n" + workspace_preload + "\n</head>"
|
||||
|
|
@ -140,16 +136,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
|||
for source in sources:
|
||||
if source != WORKER_RUNTIME_SOURCE:
|
||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||
# The workspace hydrator fetches these large chunks only when a route or action
|
||||
# needs them; warming them here would defeat demand loading on every visit.
|
||||
optional_features = {
|
||||
name: bundle for name, bundle in feature_bundles.items()
|
||||
if name not in {"today-timer", "planning"}
|
||||
}
|
||||
demand_features = {
|
||||
name: bundle for name, bundle in feature_bundles.items()
|
||||
if name in {"today-timer", "planning"}
|
||||
}
|
||||
optional_features = feature_bundles
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
" BASE + 'static/dashboard.css',\n"
|
||||
|
|
@ -160,11 +147,6 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
|||
"const OPTIONAL_FEATURES = [\n"
|
||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
|
||||
)
|
||||
worker = worker.replace(
|
||||
"const DEMAND_FEATURES = [\n",
|
||||
"const DEMAND_FEATURES = [\n"
|
||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in demand_features.values()),
|
||||
)
|
||||
worker = CACHE_DECLARATION.sub(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||
)
|
||||
|
|
|
|||
1602
src/gitea_proxy.py
1602
src/gitea_proxy.py
File diff suppressed because it is too large
Load Diff
|
|
@ -1,400 +0,0 @@
|
|||
"""Durable, account-bound human release gate inbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
_HASH = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_PROJECT = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_STATES = {"pending", "released", "held", "superseded"}
|
||||
_CHECKLIST = {"exact_hash", "artifacts_reviewed", "provenance_reviewed"}
|
||||
|
||||
|
||||
class GateConflict(RuntimeError):
|
||||
"""The gate or idempotency revision no longer matches."""
|
||||
|
||||
|
||||
class GateValidationError(ValueError):
|
||||
"""The producer or reviewer payload is not safe to persist."""
|
||||
|
||||
|
||||
class GateNotFound(LookupError):
|
||||
"""No gate exists for this account."""
|
||||
|
||||
|
||||
def _canonical(value: object) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def _fingerprint(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode()).hexdigest()
|
||||
|
||||
|
||||
class HumanGateStore:
|
||||
def __init__(self, path: str | Path, *, clock: Callable[[], float]):
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = connect_private_sqlite(self.path, timeout=2.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS human_gates (
|
||||
id TEXT PRIMARY KEY, login TEXT NOT NULL, source TEXT NOT NULL,
|
||||
project TEXT NOT NULL, candidate_hash TEXT NOT NULL,
|
||||
title TEXT NOT NULL, priority INTEGER NOT NULL,
|
||||
payload_json TEXT NOT NULL, state TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL, created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL, superseded_by TEXT,
|
||||
decision_reason TEXT NOT NULL DEFAULT '',
|
||||
override_reason TEXT NOT NULL DEFAULT '',
|
||||
checklist_json TEXT NOT NULL DEFAULT '{}',
|
||||
UNIQUE(login, source, project, candidate_hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS human_gates_queue
|
||||
ON human_gates(login, state, priority DESC, created_at, id);
|
||||
CREATE INDEX IF NOT EXISTS human_gates_decision_history
|
||||
ON human_gates(login, updated_at DESC, id DESC)
|
||||
WHERE state IN ('released','held','superseded');
|
||||
CREATE TABLE IF NOT EXISTS human_gate_intake_keys (
|
||||
login TEXT NOT NULL, idempotency_key TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL, gate_id TEXT NOT NULL,
|
||||
PRIMARY KEY(login, idempotency_key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS human_gate_history (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT, gate_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL, at REAL NOT NULL, details_json TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS human_gate_history_gate_sequence
|
||||
ON human_gate_history(gate_id, sequence);
|
||||
CREATE TABLE IF NOT EXISTS human_gate_receipts (
|
||||
receipt_id TEXT PRIMARY KEY, login TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL,
|
||||
gate_id TEXT NOT NULL, receipt_json TEXT NOT NULL,
|
||||
UNIQUE(login, idempotency_key)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _login(value: str) -> str:
|
||||
value = str(value).strip().lower()
|
||||
if not value or len(value) > 255:
|
||||
raise GateValidationError("login is required")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _key(value: str) -> str:
|
||||
value = str(value).strip()
|
||||
if not value or len(value) > 128:
|
||||
raise GateValidationError("Idempotency key is required")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _candidate(raw: dict) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise GateValidationError("Candidate must be an object")
|
||||
source = str(raw.get("source", "")).strip()
|
||||
project = str(raw.get("project", "")).strip()
|
||||
candidate_hash = str(raw.get("candidate_hash", "")).strip()
|
||||
title = " ".join(str(raw.get("title", "")).split())
|
||||
priority = raw.get("priority", 0)
|
||||
if not source or len(source) > 128:
|
||||
raise GateValidationError("source is invalid")
|
||||
if not _PROJECT.fullmatch(project):
|
||||
raise GateValidationError("project is invalid")
|
||||
if not _HASH.fullmatch(candidate_hash):
|
||||
raise GateValidationError("candidate hash is invalid")
|
||||
if not title or len(title) > 300:
|
||||
raise GateValidationError("title is invalid")
|
||||
if isinstance(priority, bool) or not isinstance(priority, int) or not 0 <= priority <= 100:
|
||||
raise GateValidationError("priority is invalid")
|
||||
normalized = {
|
||||
"source": source, "project": project, "candidate_hash": candidate_hash,
|
||||
"title": title, "priority": priority,
|
||||
"artifacts": HumanGateStore._references(raw.get("artifacts", []), "name"),
|
||||
"links": HumanGateStore._references(raw.get("links", []), "label"),
|
||||
"checks": HumanGateStore._checks(raw.get("checks", [])),
|
||||
"score": raw.get("score") if isinstance(raw.get("score"), dict) else {},
|
||||
"provenance": raw.get("provenance") if isinstance(raw.get("provenance"), dict) else {},
|
||||
}
|
||||
if len(_canonical(normalized)) > 100_000:
|
||||
raise GateValidationError("Candidate payload is too large")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _references(raw: object, label: str) -> list[dict]:
|
||||
if not isinstance(raw, list) or len(raw) > 50:
|
||||
raise GateValidationError("references are invalid")
|
||||
result = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
raise GateValidationError("reference is invalid")
|
||||
name, url = str(item.get(label, "")).strip(), str(item.get("url", "")).strip()
|
||||
if not name or len(name) > 200 or not url.startswith("https://") or len(url) > 2048:
|
||||
raise GateValidationError("reference is invalid")
|
||||
result.append({label: name, "url": url})
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _checks(raw: object) -> list[dict]:
|
||||
if not isinstance(raw, list) or len(raw) > 100:
|
||||
raise GateValidationError("checks are invalid")
|
||||
result = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
raise GateValidationError("check is invalid")
|
||||
name, state = str(item.get("name", "")).strip(), item.get("state")
|
||||
if not name or len(name) > 200 or state not in {"success", "failure", "pending", "skipped"}:
|
||||
raise GateValidationError("check is invalid")
|
||||
result.append({"name": name, "state": state, "required": bool(item.get("required", True))})
|
||||
return result
|
||||
|
||||
def _history(self, connection: sqlite3.Connection, gate_id: str) -> list[dict]:
|
||||
return [
|
||||
{"sequence": row[0], "action": row[1], "at": row[2], **json.loads(row[3])}
|
||||
for row in connection.execute(
|
||||
"SELECT sequence, action, at, details_json FROM human_gate_history WHERE gate_id=? ORDER BY sequence",
|
||||
(gate_id,),
|
||||
)
|
||||
]
|
||||
|
||||
def _present(self, connection: sqlite3.Connection, row: sqlite3.Row, *, history: bool = False) -> dict:
|
||||
payload = json.loads(row["payload_json"])
|
||||
item = {
|
||||
"id": row["id"], **payload, "state": row["state"], "revision": row["revision"],
|
||||
"created_at": row["created_at"], "updated_at": row["updated_at"],
|
||||
"superseded_by": row["superseded_by"],
|
||||
}
|
||||
if row["state"] in {"released", "held"}:
|
||||
item.update({
|
||||
"reason": row["decision_reason"], "override_reason": row["override_reason"],
|
||||
"checklist": json.loads(row["checklist_json"]),
|
||||
})
|
||||
receipt = connection.execute(
|
||||
"SELECT receipt_json FROM human_gate_receipts WHERE login=? AND gate_id=? ORDER BY rowid DESC LIMIT 1",
|
||||
(row["login"], row["id"]),
|
||||
).fetchone()
|
||||
if receipt:
|
||||
item["receipt_id"] = json.loads(receipt[0])["receipt_id"]
|
||||
if history:
|
||||
item["history"] = self._history(connection, row["id"])
|
||||
return item
|
||||
|
||||
def has_intake_key(self, login: str, idempotency_key: str) -> bool:
|
||||
with self._connect() as connection:
|
||||
return connection.execute(
|
||||
"SELECT 1 FROM human_gate_intake_keys WHERE login=? AND idempotency_key=?",
|
||||
(self._login(login), self._key(idempotency_key)),
|
||||
).fetchone() is not None
|
||||
|
||||
def intake(self, login: str, raw: dict, *, idempotency_key: str) -> dict:
|
||||
login, key, payload = self._login(login), self._key(idempotency_key), self._candidate(raw)
|
||||
fingerprint = _fingerprint(payload)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
prior = connection.execute(
|
||||
"SELECT fingerprint, gate_id FROM human_gate_intake_keys WHERE login=? AND idempotency_key=?",
|
||||
(login, key),
|
||||
).fetchone()
|
||||
if prior:
|
||||
if prior["fingerprint"] != fingerprint:
|
||||
raise GateConflict("Idempotency key was already used for another candidate")
|
||||
row = connection.execute("SELECT * FROM human_gates WHERE id=? AND login=?", (prior["gate_id"], login)).fetchone()
|
||||
return self._present(connection, row, history=True)
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM human_gates WHERE login=? AND source=? AND project=? AND candidate_hash=?",
|
||||
(login, payload["source"], payload["project"], payload["candidate_hash"]),
|
||||
).fetchone()
|
||||
if existing:
|
||||
old_payload = json.loads(existing["payload_json"])
|
||||
immutable_old = {key: value for key, value in old_payload.items() if key != "checks"}
|
||||
immutable_new = {key: value for key, value in payload.items() if key != "checks"}
|
||||
if immutable_old != immutable_new:
|
||||
raise GateConflict("Candidate hash is already bound to different facts")
|
||||
if old_payload != payload:
|
||||
if existing["state"] == "superseded":
|
||||
raise GateConflict("Candidate hash was superseded by a newer candidate")
|
||||
now = float(self.clock())
|
||||
revision = existing["revision"] + 1
|
||||
reopened = existing["state"] in {"released", "held"}
|
||||
state = "pending" if reopened else existing["state"]
|
||||
action = "reopened" if reopened else "updated"
|
||||
connection.execute(
|
||||
"UPDATE human_gates SET payload_json=?, state=?, revision=?, updated_at=?, decision_reason='', override_reason='', checklist_json='{}' WHERE id=?",
|
||||
(_canonical(payload), state, revision, now, existing["id"]),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)",
|
||||
(existing["id"], action, now, _canonical({"candidate_hash": payload["candidate_hash"]})),
|
||||
)
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM human_gates WHERE id=? AND login=?",
|
||||
(existing["id"], login),
|
||||
).fetchone()
|
||||
connection.execute("INSERT INTO human_gate_intake_keys VALUES (?,?,?,?)", (login, key, fingerprint, existing["id"]))
|
||||
return self._present(connection, existing, history=True)
|
||||
now, gate_id = float(self.clock()), str(uuid.uuid4())
|
||||
old_rows = connection.execute(
|
||||
"SELECT id, revision FROM human_gates WHERE login=? AND source=? AND project=? AND state='pending'",
|
||||
(login, payload["source"], payload["project"]),
|
||||
).fetchall()
|
||||
connection.execute(
|
||||
"INSERT INTO human_gates(id,login,source,project,candidate_hash,title,priority,payload_json,state,revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?, 'pending',1,?,?)",
|
||||
(gate_id, login, payload["source"], payload["project"], payload["candidate_hash"], payload["title"], payload["priority"], _canonical(payload), now, now),
|
||||
)
|
||||
connection.execute("INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)", (gate_id, "intake", now, _canonical({"candidate_hash": payload["candidate_hash"]})))
|
||||
for old in old_rows:
|
||||
connection.execute("UPDATE human_gates SET state='superseded', revision=?, updated_at=?, superseded_by=? WHERE id=? AND state='pending'", (old["revision"] + 1, now, gate_id, old["id"]))
|
||||
connection.execute("INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)", (old["id"], "superseded", now, _canonical({"superseded_by": gate_id, "candidate_hash": payload["candidate_hash"]})))
|
||||
connection.execute("INSERT INTO human_gate_intake_keys VALUES (?,?,?,?)", (login, key, fingerprint, gate_id))
|
||||
row = connection.execute("SELECT * FROM human_gates WHERE id=?", (gate_id,)).fetchone()
|
||||
return self._present(connection, row, history=True)
|
||||
|
||||
@staticmethod
|
||||
def _history_cursor(login: str, updated_at: float, gate_id: str) -> str:
|
||||
principal = hashlib.sha256(login.encode()).hexdigest()[:16]
|
||||
raw = _canonical([principal, updated_at, gate_id]).encode()
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
@staticmethod
|
||||
def _parse_history_cursor(login: str, cursor: str) -> tuple[float, str]:
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
|
||||
principal, updated_at, gate_id = json.loads(raw)
|
||||
expected = hashlib.sha256(login.encode()).hexdigest()[:16]
|
||||
if (
|
||||
principal != expected
|
||||
or not isinstance(updated_at, (int, float))
|
||||
or not isinstance(gate_id, str)
|
||||
or not gate_id
|
||||
):
|
||||
raise ValueError
|
||||
return float(updated_at), gate_id
|
||||
except (ValueError, TypeError, json.JSONDecodeError, UnicodeDecodeError) as error:
|
||||
raise GateValidationError("history cursor is invalid") from error
|
||||
|
||||
def list(
|
||||
self, login: str, *, state: str = "pending", limit: int = 100,
|
||||
cursor: str | None = None,
|
||||
) -> dict:
|
||||
login = self._login(login)
|
||||
if state not in _STATES and state not in {"all", "history"}:
|
||||
raise GateValidationError("state is invalid")
|
||||
limit = min(max(int(limit), 1), 100)
|
||||
with self._connect() as connection:
|
||||
pending_count = connection.execute("SELECT COUNT(*) FROM human_gates WHERE login=? AND state='pending'", (login,)).fetchone()[0]
|
||||
if state == "history":
|
||||
args: list[object] = [login]
|
||||
cursor_clause = ""
|
||||
if cursor:
|
||||
updated_at, gate_id = self._parse_history_cursor(login, cursor)
|
||||
cursor_clause = " AND (updated_at < ? OR (updated_at = ? AND id < ?))"
|
||||
args.extend([updated_at, updated_at, gate_id])
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM human_gates WHERE login=? "
|
||||
"AND state IN ('released','held','superseded')" + cursor_clause +
|
||||
" ORDER BY updated_at DESC, id DESC LIMIT ?",
|
||||
(*args, limit + 1),
|
||||
).fetchall()
|
||||
visible = rows[:limit]
|
||||
next_cursor = None
|
||||
if len(rows) > limit:
|
||||
last = visible[-1]
|
||||
next_cursor = self._history_cursor(login, last["updated_at"], last["id"])
|
||||
return {
|
||||
"pending_count": pending_count,
|
||||
"items": [self._present(connection, row) for row in visible],
|
||||
"next_cursor": next_cursor,
|
||||
}
|
||||
if cursor:
|
||||
raise GateValidationError("history cursor is invalid")
|
||||
where, args = ("login=?", [login]) if state == "all" else ("login=? AND state=?", [login, state])
|
||||
rows = connection.execute(
|
||||
f"SELECT * FROM human_gates WHERE {where} "
|
||||
"ORDER BY CASE WHEN state='pending' THEN 0 ELSE 1 END, "
|
||||
"CASE WHEN state='pending' THEN priority END DESC, "
|
||||
"CASE WHEN state='pending' THEN created_at END, "
|
||||
"CASE WHEN state!='pending' THEN updated_at END DESC, id LIMIT ?",
|
||||
(*args, limit),
|
||||
).fetchall()
|
||||
return {"pending_count": pending_count, "items": [self._present(connection, row) for row in rows]}
|
||||
|
||||
def detail(self, login: str, gate_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT * FROM human_gates WHERE login=? AND id=?", (self._login(login), gate_id)).fetchone()
|
||||
if row is None:
|
||||
raise GateNotFound("Gate not found")
|
||||
return self._present(connection, row, history=True)
|
||||
|
||||
def decide(self, login: str, gate_id: str, *, expected_revision: int, decision: str, reason: str, override_reason: str, checklist: dict, idempotency_key: str) -> dict:
|
||||
login, key = self._login(login), self._key(idempotency_key)
|
||||
reason, override_reason = str(reason).strip(), str(override_reason).strip()
|
||||
if decision not in {"release", "hold"}:
|
||||
raise GateValidationError("decision is invalid")
|
||||
if decision == "hold" and not reason:
|
||||
raise GateValidationError("Hold reason is required")
|
||||
if decision == "release" and (
|
||||
not isinstance(checklist, dict)
|
||||
or set(checklist) != _CHECKLIST
|
||||
or not all(value is True for value in checklist.values())
|
||||
):
|
||||
raise GateValidationError("A complete release checklist is required")
|
||||
if decision == "hold":
|
||||
checklist = {}
|
||||
request = {"gate_id": gate_id, "expected_revision": expected_revision, "decision": decision, "reason": reason, "override_reason": override_reason, "checklist": checklist}
|
||||
fingerprint = _fingerprint(request)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
prior = connection.execute("SELECT fingerprint, receipt_json FROM human_gate_receipts WHERE login=? AND idempotency_key=?", (login, key)).fetchone()
|
||||
if prior:
|
||||
if prior["fingerprint"] != fingerprint:
|
||||
raise GateConflict("Idempotency key was already used for another decision")
|
||||
return json.loads(prior["receipt_json"])
|
||||
row = connection.execute("SELECT * FROM human_gates WHERE login=? AND id=?", (login, gate_id)).fetchone()
|
||||
if row is None:
|
||||
raise GateNotFound("Gate not found")
|
||||
if row["state"] != "pending" or row["revision"] != expected_revision:
|
||||
raise GateConflict("Gate revision is stale")
|
||||
payload = json.loads(row["payload_json"])
|
||||
unmet = [check["name"] for check in payload["checks"] if check["required"] and check["state"] != "success"]
|
||||
if decision == "release" and unmet and not override_reason:
|
||||
raise GateValidationError("An explicit override reason is required for unmet checks")
|
||||
now, receipt_id, revision = float(self.clock()), str(uuid.uuid4()), row["revision"] + 1
|
||||
state = "released" if decision == "release" else "held"
|
||||
connection.execute("UPDATE human_gates SET state=?,revision=?,updated_at=?,decision_reason=?,override_reason=?,checklist_json=? WHERE id=?", (state, revision, now, reason, override_reason, _canonical(checklist), gate_id))
|
||||
receipt = {"receipt_id": receipt_id, "gate_id": gate_id, "candidate_hash": row["candidate_hash"], "state": state, "revision": revision, "decided_at": now, "reason": reason, "override_reason": override_reason, "checklist": checklist, "unmet_required_checks": unmet}
|
||||
connection.execute("INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)", (gate_id, state, now, _canonical({"receipt_id": receipt_id, "reason": reason, "override_reason": override_reason, "unmet_required_checks": unmet})))
|
||||
connection.execute("INSERT INTO human_gate_receipts VALUES (?,?,?,?,?,?)", (receipt_id, login, key, fingerprint, gate_id, _canonical(receipt)))
|
||||
return receipt
|
||||
|
||||
def has_receipt_key(self, login: str, idempotency_key: str) -> bool:
|
||||
with self._connect() as connection:
|
||||
return connection.execute(
|
||||
"SELECT 1 FROM human_gate_receipts WHERE login=? AND idempotency_key=?",
|
||||
(self._login(login), self._key(idempotency_key)),
|
||||
).fetchone() is not None
|
||||
|
||||
def receipt(self, login: str, receipt_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT receipt_json FROM human_gate_receipts WHERE login=? AND receipt_id=?", (self._login(login), receipt_id)).fetchone()
|
||||
if row is None:
|
||||
raise GateNotFound("Receipt not found")
|
||||
return json.loads(row[0])
|
||||
2153
src/main.py
2153
src/main.py
File diff suppressed because it is too large
Load Diff
|
|
@ -18,7 +18,6 @@ class StoredPasskey:
|
|||
device_label: str
|
||||
management_id: str
|
||||
created_at: int
|
||||
principal_id: int
|
||||
|
||||
|
||||
class PasskeyStore:
|
||||
|
|
@ -51,19 +50,10 @@ class PasskeyStore:
|
|||
sign_count INTEGER NOT NULL,
|
||||
device_label TEXT NOT NULL,
|
||||
management_id TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
principal_id INTEGER
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
credential_columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(passkey_credentials)")
|
||||
}
|
||||
if "principal_id" not in credential_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE passkey_credentials ADD COLUMN principal_id INTEGER"
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS passkey_challenges (
|
||||
|
|
@ -182,14 +172,12 @@ class PasskeyStore:
|
|||
sign_count: int,
|
||||
device_label: str,
|
||||
management_id: str,
|
||||
principal_id: int,
|
||||
) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO passkey_credentials(credential_id, public_key, sign_count, "
|
||||
"device_label, management_id, created_at, principal_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"device_label, management_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
credential_id,
|
||||
public_key,
|
||||
|
|
@ -197,48 +185,41 @@ class PasskeyStore:
|
|||
device_label,
|
||||
management_id,
|
||||
int(self.clock()),
|
||||
principal_id,
|
||||
),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def all(self, *, principal_id: int) -> list[StoredPasskey]:
|
||||
def all(self) -> list[StoredPasskey]:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
|
||||
"created_at, principal_id FROM passkey_credentials "
|
||||
"WHERE principal_id = ? ORDER BY created_at DESC",
|
||||
(principal_id,),
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return [StoredPasskey(*row) for row in rows]
|
||||
|
||||
def get(self, credential_id: bytes, *, principal_id: int) -> StoredPasskey | None:
|
||||
def get(self, credential_id: bytes) -> StoredPasskey | None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
|
||||
"created_at, principal_id FROM passkey_credentials "
|
||||
"WHERE credential_id = ? AND principal_id = ?",
|
||||
(credential_id, principal_id),
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials WHERE credential_id = ?",
|
||||
(credential_id,),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return StoredPasskey(*row) if row else None
|
||||
|
||||
def get_management_id(
|
||||
self, management_id: str, *, principal_id: int
|
||||
) -> StoredPasskey | None:
|
||||
def get_management_id(self, management_id: str) -> StoredPasskey | None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
|
||||
"created_at, principal_id FROM passkey_credentials "
|
||||
"WHERE management_id = ? AND principal_id = ?",
|
||||
(management_id, principal_id),
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
|
@ -322,14 +303,13 @@ class PasskeyStore:
|
|||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_device_access(self, management_id: str, *, principal_id: int) -> bool:
|
||||
def revoke_device_access(self, management_id: str) -> bool:
|
||||
"""Atomically remove one active session, its grants, and its linked passkey."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
session = connection.execute(
|
||||
"SELECT session_hash FROM active_sessions "
|
||||
"WHERE management_id = ? AND principal_id = ?",
|
||||
(management_id, principal_id),
|
||||
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
if session is None:
|
||||
return False
|
||||
|
|
@ -337,44 +317,22 @@ class PasskeyStore:
|
|||
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM passkey_credentials "
|
||||
"WHERE management_id = ? AND principal_id = ?",
|
||||
(management_id, principal_id),
|
||||
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM active_sessions "
|
||||
"WHERE management_id = ? AND principal_id = ?",
|
||||
(management_id, principal_id),
|
||||
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all_access(self, *, principal_id: int) -> list[str]:
|
||||
"""Atomically remove one principal's passkeys, grants, and active sessions."""
|
||||
def revoke_all_access(self) -> None:
|
||||
"""Atomically remove every passkey, challenge, grant, and active session."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
management_ids = [
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT management_id FROM active_sessions "
|
||||
"WHERE principal_id = ? ORDER BY management_id",
|
||||
(principal_id,),
|
||||
).fetchall()
|
||||
]
|
||||
connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE principal_id = ?",
|
||||
(principal_id,),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash IN ("
|
||||
"SELECT session_hash FROM active_sessions WHERE principal_id = ?)",
|
||||
(principal_id,),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM active_sessions WHERE principal_id = ?",
|
||||
(principal_id,),
|
||||
)
|
||||
return management_ids
|
||||
connection.execute("DELETE FROM passkey_credentials")
|
||||
connection.execute("DELETE FROM passkey_challenges")
|
||||
connection.execute("DELETE FROM step_up_grants")
|
||||
connection.execute("DELETE FROM active_sessions")
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
|
|
@ -100,244 +99,6 @@ async def send_web_push(
|
|||
)
|
||||
|
||||
|
||||
async def dispatch_following_changes(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
following: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
lease_seconds: float = 60.0,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""Notify opted-in devices once for each privacy-safe Following change set."""
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="following",
|
||||
now=time.time() if now is None else now,
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
devices = await asyncio.to_thread(
|
||||
store.following_notification_devices, now=now
|
||||
)
|
||||
if not devices:
|
||||
return 0
|
||||
snapshot = await following()
|
||||
if not isinstance(snapshot, dict) or snapshot.get("complete") is False:
|
||||
return 0
|
||||
changed = []
|
||||
for item in snapshot.get("items", []):
|
||||
if not isinstance(item, dict) or item.get("has_unseen_change") is not True:
|
||||
continue
|
||||
repository = item.get("repository")
|
||||
kind = item.get("kind")
|
||||
number = item.get("number")
|
||||
updated_at = item.get("updated_at")
|
||||
if (
|
||||
isinstance(repository, str)
|
||||
and kind in {"issue", "pull"}
|
||||
and isinstance(number, int)
|
||||
and not isinstance(number, bool)
|
||||
and number > 0
|
||||
and isinstance(updated_at, str)
|
||||
and updated_at
|
||||
):
|
||||
changed.append((repository.lower(), kind, number, updated_at))
|
||||
changed.sort()
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps(changed, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
if not changed:
|
||||
await asyncio.gather(*(
|
||||
asyncio.to_thread(store.mark_following_delivered, device.session_id, fingerprint)
|
||||
for device in devices
|
||||
))
|
||||
return 0
|
||||
pending = [device for device in devices if device.delivered_fingerprint != fingerprint]
|
||||
if not pending:
|
||||
return 0
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses([device.session_id for device in pending])
|
||||
except Exception:
|
||||
return 0
|
||||
for device in pending:
|
||||
if statuses.get(device.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
pending = [
|
||||
device for device in pending if statuses.get(device.session_id) == "active"
|
||||
]
|
||||
count = min(len(changed), 50)
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def dispatch_device(device) -> int:
|
||||
async with semaphore:
|
||||
payload = json.dumps({
|
||||
"title": (
|
||||
f"{count} watched update{'s' if count != 1 else ''} while alerts were paused"
|
||||
if device.catch_up
|
||||
else f"{count} watched item{'s' if count != 1 else ''} changed"
|
||||
),
|
||||
"body": "Open Following to review the latest activity.",
|
||||
"route": "#/my-work/following",
|
||||
"tag": (
|
||||
"stackchain-following-catch-up"
|
||||
if device.catch_up
|
||||
else f"stackchain-following-{fingerprint[:16]}"
|
||||
),
|
||||
"following_count": count,
|
||||
}, separators=(",", ":"))
|
||||
still_owner = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="following",
|
||||
now=time.time(),
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not still_owner:
|
||||
return 0
|
||||
try:
|
||||
operation = (
|
||||
send(device.subscription, payload)
|
||||
if send is not None
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
device.session_id,
|
||||
"following",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, device.session_id, "following"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_following_delivered, device.session_id, fingerprint
|
||||
)
|
||||
return 1
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(dispatch_device(device) for device in pending), return_exceptions=True
|
||||
)
|
||||
return sum(result for result in results if isinstance(result, int))
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="following")
|
||||
|
||||
|
||||
async def dispatch_human_gate_changes(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
pending_gates: Callable[[], Awaitable[dict]],
|
||||
send: Callable[[dict, str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
|
||||
lease_seconds: float = 60.0,
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""Notify opted-in active devices when the pending Human Gate count changes."""
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
owner = secrets.token_urlsafe(18)
|
||||
acquired = await asyncio.to_thread(
|
||||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="human-gates",
|
||||
now=time.time() if now is None else now,
|
||||
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
|
||||
)
|
||||
if not acquired:
|
||||
return 0
|
||||
try:
|
||||
devices = await asyncio.to_thread(store.human_gate_notification_devices, now=now)
|
||||
if not devices:
|
||||
return 0
|
||||
snapshot = await pending_gates()
|
||||
if not isinstance(snapshot, dict) or snapshot.get("complete") is False:
|
||||
return 0
|
||||
count = snapshot.get("count")
|
||||
if not isinstance(count, int) or isinstance(count, bool) or count < 0:
|
||||
return 0
|
||||
count = min(count, 50)
|
||||
pending = [device for device in devices if device.delivered_count != count]
|
||||
if not pending:
|
||||
return 0
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses([device.session_id for device in pending])
|
||||
except Exception:
|
||||
return 0
|
||||
for device in pending:
|
||||
if statuses.get(device.session_id) != "active":
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
pending = [device for device in pending if statuses.get(device.session_id) == "active"]
|
||||
if count == 0:
|
||||
await asyncio.gather(*(
|
||||
asyncio.to_thread(store.mark_human_gate_delivered, device.session_id, 0)
|
||||
for device in pending
|
||||
))
|
||||
return 0
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def dispatch_device(device) -> int:
|
||||
async with semaphore:
|
||||
payload = json.dumps({
|
||||
"title": f"{count} release decision{' is' if count == 1 else 's are'} waiting",
|
||||
"body": f"Open Human Gates to review {'it' if count == 1 else 'them'}.",
|
||||
"route": "#/my-work/human-gates",
|
||||
"tag": f"stackchain-human-gates-{count}",
|
||||
"human_gate_count": count,
|
||||
}, separators=(",", ":"))
|
||||
try:
|
||||
operation = (
|
||||
send(device.subscription, payload)
|
||||
if send is not None
|
||||
else send_web_push(device.subscription, payload, configuration)
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed, device.session_id, "human-gates",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, device.session_id, "human-gates"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_human_gate_delivered, device.session_id, count
|
||||
)
|
||||
return 1
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(dispatch_device(device) for device in pending), return_exceptions=True
|
||||
)
|
||||
return sum(result for result in results if isinstance(result, int))
|
||||
finally:
|
||||
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="human-gates")
|
||||
|
||||
|
||||
async def dispatch_unread_updates(
|
||||
store: PushSubscriptionStore,
|
||||
configuration: PushConfiguration,
|
||||
|
|
@ -350,7 +111,6 @@ async def dispatch_unread_updates(
|
|||
max_concurrency: int = 8,
|
||||
max_individual_notifications: int = 3,
|
||||
endpoint_validator: Callable[[str], Awaitable[str]] | None = None,
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
|
|
@ -359,7 +119,7 @@ async def dispatch_unread_updates(
|
|||
store.acquire_dispatch_lease,
|
||||
owner,
|
||||
channel="unread",
|
||||
now=time.time() if now is None else now,
|
||||
now=time.time(),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
if not acquired:
|
||||
|
|
@ -373,11 +133,8 @@ async def dispatch_unread_updates(
|
|||
for item in page.get("items", [])
|
||||
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
|
||||
}
|
||||
unread_count = min(len(thread_revisions), 9999)
|
||||
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
|
||||
deliveries = await asyncio.to_thread(
|
||||
store.claim_unseen, thread_revisions, now=now
|
||||
)
|
||||
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
|
||||
if session_statuses is not None:
|
||||
try:
|
||||
statuses = await session_statuses(
|
||||
|
|
@ -418,10 +175,10 @@ async def dispatch_unread_updates(
|
|||
for thread_revision in delivery.thread_revisions
|
||||
if thread_revision not in digest_pending
|
||||
)
|
||||
individual_revisions = () if delivery.catch_up else new_revisions[
|
||||
individual_revisions = new_revisions[
|
||||
:max(0, max_individual_notifications)
|
||||
]
|
||||
overflow_revisions = delivery.thread_revisions if delivery.catch_up else (
|
||||
overflow_revisions = (
|
||||
delivery.digest_revisions
|
||||
+ new_revisions[len(individual_revisions):]
|
||||
)
|
||||
|
|
@ -444,7 +201,6 @@ async def dispatch_unread_updates(
|
|||
"route": f"#/my-work/update/{thread_id}",
|
||||
"tag": f"stackchain-update-{thread_id}",
|
||||
"notification_id": thread_id,
|
||||
"unread_count": unread_count,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
|
@ -497,24 +253,11 @@ async def dispatch_unread_updates(
|
|||
return count
|
||||
payload = json.dumps(
|
||||
{
|
||||
"title": (
|
||||
f"{len(overflow_revisions)} updates while alerts were paused"
|
||||
if delivery.catch_up
|
||||
else f"{len(overflow_revisions)} new work updates"
|
||||
),
|
||||
"body": (
|
||||
"Open Updates to catch up in Stackchain."
|
||||
if delivery.catch_up
|
||||
else "Tap to review them in Stackchain."
|
||||
),
|
||||
"title": f"{len(overflow_revisions)} new work updates",
|
||||
"body": "Tap to review them in Stackchain.",
|
||||
"route": "#/my-work/updates",
|
||||
"tag": (
|
||||
"stackchain-update-catch-up"
|
||||
if delivery.catch_up
|
||||
else "stackchain-update-digest"
|
||||
),
|
||||
"tag": "stackchain-update-digest",
|
||||
"update_count": len(overflow_revisions),
|
||||
"unread_count": unread_count,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ import sqlite3
|
|||
import time
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone as datetime_timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import (
|
||||
|
|
@ -24,7 +22,6 @@ class PushDelivery:
|
|||
subscription: dict
|
||||
thread_revisions: tuple[tuple[int, str], ...]
|
||||
digest_revisions: tuple[tuple[int, str], ...] = ()
|
||||
catch_up: bool = False
|
||||
|
||||
@property
|
||||
def thread_ids(self) -> tuple[int, ...]:
|
||||
|
|
@ -55,22 +52,6 @@ class StartDayReminderDevice:
|
|||
delivered_plan_date: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FollowingNotificationDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
delivered_fingerprint: str | None
|
||||
catch_up: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HumanGateNotificationDevice:
|
||||
session_id: str
|
||||
subscription: dict
|
||||
delivered_count: int
|
||||
catch_up: bool = False
|
||||
|
||||
|
||||
class DisabledPushSubscriptionStore:
|
||||
"""No-persistence store used when Web Push is not configured."""
|
||||
|
||||
|
|
@ -100,22 +81,7 @@ class DisabledPushSubscriptionStore:
|
|||
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
|
||||
return []
|
||||
|
||||
def following_preferences(self, session_id: str) -> dict:
|
||||
return {"enabled": False}
|
||||
|
||||
def human_gate_preferences(self, session_id: str) -> dict:
|
||||
return {"enabled": False}
|
||||
|
||||
def quiet_hours(self, session_id: str) -> dict:
|
||||
return {"enabled": False, "start": "22:00", "end": "07:00", "timezone": "UTC"}
|
||||
|
||||
def following_notification_devices(self, *, now: float | None = None) -> list[FollowingNotificationDevice]:
|
||||
return []
|
||||
|
||||
def human_gate_notification_devices(self, *, now: float | None = None) -> list[HumanGateNotificationDevice]:
|
||||
return []
|
||||
|
||||
def claim_unseen(self, thread_revisions, *, now: float | None = None) -> list[PushDelivery]:
|
||||
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
|
||||
return []
|
||||
|
||||
def acquire_dispatch_lease(self, *args, **kwargs) -> bool:
|
||||
|
|
@ -133,9 +99,6 @@ class DisabledPushSubscriptionStore:
|
|||
def delete_session(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def delete_sessions(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def delete_all(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
|
@ -154,21 +117,6 @@ class DisabledPushSubscriptionStore:
|
|||
def mark_start_day_reminder_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_following_preferences(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_human_gate_preferences(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def set_quiet_hours(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_following_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_human_gate_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def reconcile_unread(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
|
@ -216,16 +164,6 @@ def _revisions(
|
|||
return tuple(sorted(normalized.items()))
|
||||
|
||||
|
||||
def _inside_quiet_hours(*, now: float, start: str, end: str, timezone: str) -> bool:
|
||||
local = datetime.fromtimestamp(now, tz=datetime_timezone.utc).astimezone(ZoneInfo(timezone))
|
||||
minute = local.hour * 60 + local.minute
|
||||
start_minute = int(start[:2]) * 60 + int(start[3:])
|
||||
end_minute = int(end[:2]) * 60 + int(end[3:])
|
||||
if start_minute < end_minute:
|
||||
return start_minute <= minute < end_minute
|
||||
return minute >= start_minute or minute < end_minute
|
||||
|
||||
|
||||
class PushSubscriptionStore:
|
||||
"""Durable, device-bound Web Push subscriptions and delivery deduplication."""
|
||||
|
||||
|
|
@ -298,30 +236,6 @@ class PushSubscriptionStore:
|
|||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_following_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
delivered_fingerprint TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_human_gate_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
delivered_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_quiet_hours (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
start_time TEXT NOT NULL DEFAULT '22:00',
|
||||
end_time TEXT NOT NULL DEFAULT '07:00',
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
suppressed INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
delivery_columns = {
|
||||
|
|
@ -445,17 +359,6 @@ class PushSubscriptionStore:
|
|||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
||||
|
||||
def delete_sessions(self, session_ids) -> None:
|
||||
requested = set(session_ids)
|
||||
if not requested:
|
||||
return
|
||||
placeholders = ",".join("?" for _ in requested)
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
f"DELETE FROM push_subscriptions WHERE session_id IN ({placeholders})",
|
||||
tuple(requested),
|
||||
)
|
||||
|
||||
def delete_all(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM push_subscriptions")
|
||||
|
|
@ -666,154 +569,8 @@ class PushSubscriptionStore:
|
|||
(plan_date, session_id),
|
||||
)
|
||||
|
||||
def set_following_preferences(self, session_id: str, *, enabled: bool) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_following_preferences(session_id, enabled)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""",
|
||||
(session_id, int(enabled)),
|
||||
)
|
||||
|
||||
def following_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT enabled FROM push_following_preferences WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {"enabled": bool(row[0]) if row else False}
|
||||
|
||||
def set_human_gate_preferences(self, session_id: str, *, enabled: bool) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_human_gate_preferences(session_id, enabled)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""",
|
||||
(session_id, int(enabled)),
|
||||
)
|
||||
|
||||
def human_gate_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT enabled FROM push_human_gate_preferences WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {"enabled": bool(row[0]) if row else False}
|
||||
|
||||
def set_quiet_hours(
|
||||
self, session_id: str, *, enabled: bool, start: str, end: str, timezone: str
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_quiet_hours(
|
||||
session_id, enabled, start_time, end_time, timezone
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
start_time = excluded.start_time,
|
||||
end_time = excluded.end_time,
|
||||
timezone = excluded.timezone,
|
||||
suppressed = CASE WHEN excluded.enabled = 1 THEN suppressed ELSE 0 END""",
|
||||
(session_id, int(enabled), start, end, timezone),
|
||||
)
|
||||
|
||||
def quiet_hours(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT enabled, start_time, end_time, timezone
|
||||
FROM push_quiet_hours WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"enabled": bool(row[0]) if row else False,
|
||||
"start": row[1] if row else "22:00",
|
||||
"end": row[2] if row else "07:00",
|
||||
"timezone": row[3] if row else "UTC",
|
||||
}
|
||||
|
||||
def following_notification_devices(
|
||||
self, *, now: float | None = None
|
||||
) -> list[FollowingNotificationDevice]:
|
||||
checked_at = time.time() if now is None else now
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.delivered_fingerprint,
|
||||
q.enabled, q.start_time, q.end_time, q.timezone, q.suppressed
|
||||
FROM push_subscriptions s
|
||||
JOIN push_following_preferences p ON p.session_id = s.session_id
|
||||
LEFT JOIN push_quiet_hours q ON q.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
devices = []
|
||||
for row in rows:
|
||||
if row[3] and _inside_quiet_hours(
|
||||
now=checked_at, start=row[4], end=row[5], timezone=row[6]
|
||||
):
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?",
|
||||
(row[0],),
|
||||
)
|
||||
continue
|
||||
devices.append(FollowingNotificationDevice(
|
||||
row[0], self._open_subscription(row[0], row[1]), row[2], bool(row[7])
|
||||
))
|
||||
return devices
|
||||
|
||||
def mark_following_delivered(self, session_id: str, fingerprint: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_following_preferences SET delivered_fingerprint = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(fingerprint, session_id),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
|
||||
def human_gate_notification_devices(
|
||||
self, *, now: float | None = None
|
||||
) -> list[HumanGateNotificationDevice]:
|
||||
checked_at = time.time() if now is None else now
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.delivered_count,
|
||||
q.enabled, q.start_time, q.end_time, q.timezone, q.suppressed
|
||||
FROM push_subscriptions s
|
||||
JOIN push_human_gate_preferences p ON p.session_id = s.session_id
|
||||
LEFT JOIN push_quiet_hours q ON q.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
devices = []
|
||||
for row in rows:
|
||||
if row[3] and _inside_quiet_hours(
|
||||
now=checked_at, start=row[4], end=row[5], timezone=row[6]
|
||||
):
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?",
|
||||
(row[0],),
|
||||
)
|
||||
continue
|
||||
devices.append(HumanGateNotificationDevice(
|
||||
row[0], self._open_subscription(row[0], row[1]), row[2], bool(row[7])
|
||||
))
|
||||
return devices
|
||||
|
||||
def mark_human_gate_delivered(self, session_id: str, count: int) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE push_human_gate_preferences SET delivered_count = ?
|
||||
WHERE session_id = ? AND enabled = 1""",
|
||||
(count, session_id),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
|
||||
def claim_unseen(
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||
*, now: float | None = None,
|
||||
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
|
||||
) -> list[PushDelivery]:
|
||||
candidates = _revisions(thread_revisions)
|
||||
if not candidates:
|
||||
|
|
@ -824,21 +581,6 @@ class PushSubscriptionStore:
|
|||
).fetchall()
|
||||
deliveries = []
|
||||
for session_id, encoded in rows:
|
||||
quiet = connection.execute(
|
||||
"""SELECT enabled, start_time, end_time, timezone, suppressed
|
||||
FROM push_quiet_hours WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
catch_up = bool(quiet and quiet[4])
|
||||
if quiet and quiet[0] and _inside_quiet_hours(
|
||||
now=time.time() if now is None else now,
|
||||
start=quiet[1], end=quiet[2], timezone=quiet[3],
|
||||
):
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
continue
|
||||
delivered = {
|
||||
row[0]: row[1]
|
||||
for row in connection.execute(
|
||||
|
|
@ -892,7 +634,6 @@ class PushSubscriptionStore:
|
|||
self._open_subscription(session_id, encoded),
|
||||
unseen,
|
||||
tuple(digest_revisions),
|
||||
catch_up,
|
||||
)
|
||||
)
|
||||
return deliveries
|
||||
|
|
@ -963,7 +704,3 @@ class PushSubscriptionStore:
|
|||
"DELETE FROM push_digest_pending WHERE session_id = ? AND thread_id = ?",
|
||||
((session_id, thread_id) for thread_id, _revision in values),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
"""Encrypted, revisioned mobile routine queue priority."""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
DEFAULT_QUEUE_ORDER = (
|
||||
"attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft",
|
||||
)
|
||||
|
||||
|
||||
class QueuePriorityConflict(ValueError):
|
||||
"""Raised when a stale client attempts to replace the queue order."""
|
||||
|
||||
def __init__(self, snapshot: dict):
|
||||
super().__init__("queue priority changed on another device")
|
||||
self.snapshot = snapshot
|
||||
|
||||
|
||||
class QueuePriorityStore:
|
||||
def __init__(self, path: str | Path, *, timeout: float = 1.0, encryption_key: bytes | None = None):
|
||||
self.path = Path(path)
|
||||
self.timeout = timeout
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="queue-priority",
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS queue_priorities ("
|
||||
"login TEXT PRIMARY KEY, revision INTEGER NOT NULL, queue_order TEXT NOT NULL)"
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize(order: list[str] | tuple[str, ...]) -> list[str]:
|
||||
if not isinstance(order, (list, tuple)) or len(order) != len(DEFAULT_QUEUE_ORDER):
|
||||
raise ValueError("order must contain every routine queue")
|
||||
if any(not isinstance(name, str) for name in order) or set(order) != set(DEFAULT_QUEUE_ORDER):
|
||||
raise ValueError("order must contain every routine queue exactly once")
|
||||
return list(order)
|
||||
|
||||
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "order": list(DEFAULT_QUEUE_ORDER)}, False
|
||||
order, legacy = self._cipher.open(row[1], binding=f"order:{login}")
|
||||
try:
|
||||
normalized = self._normalize(order)
|
||||
except ValueError as error:
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
||||
return {"revision": int(row[0]), "order": normalized}, legacy
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, queue_order FROM queue_priorities WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot, legacy = self._snapshot(row, login)
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE queue_priorities SET queue_order = ? WHERE login = ? AND queue_order = ?",
|
||||
(self._cipher.seal(snapshot["order"], binding=f"order:{login}"), login, row[1]),
|
||||
)
|
||||
return snapshot
|
||||
|
||||
def replace(self, login: str, expected_revision: int, order: list[str]) -> dict:
|
||||
login = self._login(login)
|
||||
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||
raise ValueError("revision is invalid")
|
||||
normalized = self._normalize(order)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, queue_order FROM queue_priorities WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
if current["revision"] != expected_revision:
|
||||
raise QueuePriorityConflict(current)
|
||||
revision = expected_revision + 1
|
||||
sealed = self._cipher.seal(normalized, binding=f"order:{login}")
|
||||
connection.execute(
|
||||
"INSERT INTO queue_priorities(login, revision, queue_order) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, queue_order=excluded.queue_order",
|
||||
(login, revision, sealed),
|
||||
)
|
||||
return {"revision": revision, "order": normalized}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
"""Encrypted, account-scoped recent work shared by signed-in devices."""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
KINDS = frozenset({"issue", "filed", "pull", "review", "update"})
|
||||
|
||||
|
||||
class RecentWorkStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
timeout: float = 1.0,
|
||||
encryption_key: bytes | None = None,
|
||||
limit: int = 5,
|
||||
pinned_limit: int = 20,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.timeout = timeout
|
||||
self.limit = max(1, int(limit))
|
||||
self.pinned_limit = max(1, int(pinned_limit))
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="recent-work",
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS recent_work ("
|
||||
"login TEXT PRIMARY KEY, items TEXT NOT NULL)"
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize(item: dict) -> dict:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("recent work item is invalid")
|
||||
kind = item.get("kind")
|
||||
number = item.get("number")
|
||||
title = item.get("title")
|
||||
repository = item.get("repository", "")
|
||||
if (
|
||||
kind not in KINDS
|
||||
or not isinstance(number, int)
|
||||
or isinstance(number, bool)
|
||||
or number < 1
|
||||
or not isinstance(title, str)
|
||||
or not title.strip()
|
||||
):
|
||||
raise ValueError("recent work item is invalid")
|
||||
title = title.strip()[:180]
|
||||
if kind == "update":
|
||||
if repository:
|
||||
raise ValueError("recent work item is invalid")
|
||||
route = f"#/my-work/update/{number}"
|
||||
normalized = {"kind": kind, "number": number, "title": title, "route": route}
|
||||
else:
|
||||
if (
|
||||
not isinstance(repository, str)
|
||||
or repository.count("/") != 1
|
||||
or any(not part or not all(character.isalnum() or character in "_.-" for character in part)
|
||||
for part in repository.split("/"))
|
||||
):
|
||||
raise ValueError("recent work item is invalid")
|
||||
route = f"#/my-work/{kind}/{repository}/{number}"
|
||||
normalized = {
|
||||
"kind": kind,
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"route": route,
|
||||
}
|
||||
if item.get("route", route) != route:
|
||||
raise ValueError("recent work item is invalid")
|
||||
return normalized
|
||||
|
||||
def _state(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"items": [], "pinned": []}, False
|
||||
payload, legacy = self._cipher.open(row[0], binding=f"items:{login}")
|
||||
if isinstance(payload, list):
|
||||
payload = {"items": payload, "pinned": []}
|
||||
legacy = True
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list) or not isinstance(payload.get("pinned"), list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
try:
|
||||
items = [self._normalize(item) for item in payload["items"]][: self.limit]
|
||||
pinned = [self._normalize(item) for item in payload["pinned"]][: self.pinned_limit]
|
||||
if len({item["route"] for item in pinned}) != len(pinned):
|
||||
raise ValueError("recent work item is invalid")
|
||||
return {"items": items, "pinned": pinned}, legacy
|
||||
except ValueError as error:
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
||||
|
||||
def _seal(self, state: dict, login: str) -> str:
|
||||
return self._cipher.seal(state, binding=f"items:{login}")
|
||||
|
||||
def _write(self, connection: sqlite3.Connection, login: str, state: dict) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO recent_work(login, items) VALUES (?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET items=excluded.items",
|
||||
(login, self._seal(state, login)),
|
||||
)
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
state, legacy = self._state(row, login)
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE recent_work SET items = ? WHERE login = ? AND items = ?",
|
||||
(self._seal(state, login), login, row[0]),
|
||||
)
|
||||
return state
|
||||
|
||||
def record(self, login: str, item: dict) -> dict:
|
||||
login = self._login(login)
|
||||
normalized = self._normalize(item)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
state, _legacy = self._state(row, login)
|
||||
state["items"] = [normalized, *(entry for entry in state["items"] if entry["route"] != normalized["route"])][: self.limit]
|
||||
state["pinned"] = [
|
||||
normalized,
|
||||
*(entry for entry in state["pinned"] if entry["route"] != normalized["route"]),
|
||||
] if any(entry["route"] == normalized["route"] for entry in state["pinned"]) else state["pinned"]
|
||||
self._write(connection, login, state)
|
||||
return state
|
||||
|
||||
def pin(self, login: str, item: dict) -> dict:
|
||||
login = self._login(login)
|
||||
normalized = self._normalize(item)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
state, _legacy = self._state(row, login)
|
||||
state["pinned"] = [
|
||||
normalized,
|
||||
*(entry for entry in state["pinned"] if entry["route"] != normalized["route"]),
|
||||
][: self.pinned_limit]
|
||||
self._write(connection, login, state)
|
||||
return state
|
||||
|
||||
def unpin(self, login: str, route: str) -> dict:
|
||||
login = self._login(login)
|
||||
if not isinstance(route, str) or not route:
|
||||
raise ValueError("recent work route is invalid")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT items FROM recent_work WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
state, _legacy = self._state(row, login)
|
||||
state["pinned"] = [entry for entry in state["pinned"] if entry["route"] != route]
|
||||
self._write(connection, login, state)
|
||||
return state
|
||||
|
|
@ -8,7 +8,7 @@ API_MUTATION_BODY_LIMIT = 64 * 1024
|
|||
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
|
||||
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
|
||||
UNFILED_DRAFT_SYNC_BODY_LIMIT = 17 * 1024 * 1024
|
||||
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
||||
|
||||
|
||||
def request_body_limit(method: str, path: str) -> int | None:
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ class SecurityEventStore:
|
|||
CREATE TABLE IF NOT EXISTS security_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
payload TEXT,
|
||||
principal_id INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
|
|
@ -94,10 +93,6 @@ class SecurityEventStore:
|
|||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
|
||||
)
|
||||
if "principal_id" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN principal_id INTEGER"
|
||||
)
|
||||
plaintext_columns = {"kind", "method", "device_label", "target"}
|
||||
if "payload" not in columns or plaintext_columns.intersection(columns):
|
||||
self._migrate_plaintext(
|
||||
|
|
@ -107,10 +102,6 @@ class SecurityEventStore:
|
|||
"CREATE INDEX IF NOT EXISTS security_events_created "
|
||||
"ON security_events(created_at DESC, id DESC)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS security_events_principal "
|
||||
"ON security_events(principal_id, id DESC)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
|
||||
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
|
||||
|
|
@ -135,7 +126,6 @@ class SecurityEventStore:
|
|||
CREATE TABLE security_events_encrypted (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
payload TEXT,
|
||||
principal_id INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
|
|
@ -155,8 +145,7 @@ class SecurityEventStore:
|
|||
) in rows:
|
||||
connection.execute(
|
||||
"INSERT INTO security_events_encrypted "
|
||||
"(id, payload, principal_id, created_at, status, operation_id) "
|
||||
"VALUES (?, ?, NULL, ?, ?, ?)",
|
||||
"(id, payload, created_at, status, operation_id) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
event_id,
|
||||
payload
|
||||
|
|
@ -206,7 +195,6 @@ class SecurityEventStore:
|
|||
self,
|
||||
kind: str,
|
||||
*,
|
||||
principal_id: int,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
|
|
@ -215,9 +203,8 @@ class SecurityEventStore:
|
|||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO security_events(principal_id, created_at, status) "
|
||||
"VALUES (?, ?, 'completed')",
|
||||
(principal_id, now),
|
||||
"INSERT INTO security_events(created_at, status) VALUES (?, 'completed')",
|
||||
(now,),
|
||||
)
|
||||
event_id = cursor.lastrowid
|
||||
payload = self._seal_event(
|
||||
|
|
@ -237,7 +224,6 @@ class SecurityEventStore:
|
|||
self,
|
||||
kind: str,
|
||||
*,
|
||||
principal_id: int,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
|
|
@ -247,9 +233,9 @@ class SecurityEventStore:
|
|||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO security_events(principal_id, created_at, status, operation_id) "
|
||||
"VALUES (?, ?, 'pending', ?)",
|
||||
(principal_id, now, operation_id),
|
||||
"INSERT INTO security_events(created_at, status, operation_id) "
|
||||
"VALUES (?, 'pending', ?)",
|
||||
(now, operation_id),
|
||||
)
|
||||
event_id = cursor.lastrowid
|
||||
connection.execute(
|
||||
|
|
@ -296,14 +282,12 @@ class SecurityEventStore:
|
|||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def list(
|
||||
self, *, principal_id: int, limit: int = 50, cursor: int | None = None
|
||||
) -> SecurityEventPage:
|
||||
def list(self, *, limit: int = 50, cursor: int | None = None) -> SecurityEventPage:
|
||||
bounded_limit = min(100, max(1, limit))
|
||||
parameters: list[int] = [principal_id]
|
||||
where = "WHERE principal_id = ?"
|
||||
parameters: list[int] = []
|
||||
where = ""
|
||||
if cursor is not None:
|
||||
where += " AND id < ?"
|
||||
where = "WHERE id < ?"
|
||||
parameters.append(cursor)
|
||||
parameters.append(bounded_limit + 1)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -18,20 +18,10 @@ class SessionStatus(str):
|
|||
"""String-compatible status carrying the server-confirmed idle deadline."""
|
||||
|
||||
idle_expires_at: int | None
|
||||
principal_id: int | None
|
||||
principal_login: str | None
|
||||
|
||||
def __new__(
|
||||
cls,
|
||||
value: str,
|
||||
idle_expires_at: int | None = None,
|
||||
principal_id: int | None = None,
|
||||
principal_login: str | None = None,
|
||||
):
|
||||
def __new__(cls, value: str, idle_expires_at: int | None = None):
|
||||
instance = super().__new__(cls, value)
|
||||
instance.idle_expires_at = idle_expires_at
|
||||
instance.principal_id = principal_id
|
||||
instance.principal_login = principal_login
|
||||
return instance
|
||||
|
||||
|
||||
|
|
@ -81,9 +71,7 @@ class SessionStore:
|
|||
management_id TEXT,
|
||||
device_label TEXT,
|
||||
created_at INTEGER,
|
||||
last_active_at INTEGER,
|
||||
principal_id INTEGER,
|
||||
principal_login TEXT
|
||||
last_active_at INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
|
@ -110,8 +98,6 @@ class SessionStore:
|
|||
"device_label": "TEXT",
|
||||
"created_at": "INTEGER",
|
||||
"last_active_at": "INTEGER",
|
||||
"principal_id": "INTEGER",
|
||||
"principal_login": "TEXT",
|
||||
}
|
||||
for name, column_type in additions.items():
|
||||
if name not in columns:
|
||||
|
|
@ -149,8 +135,6 @@ class SessionStore:
|
|||
*,
|
||||
device_label: str = "This device",
|
||||
management_id: str | None = None,
|
||||
principal_id: int | None = None,
|
||||
principal_login: str | None = None,
|
||||
) -> None:
|
||||
label = " ".join(str(device_label).split())[:64] or "This device"
|
||||
now = int(self.clock())
|
||||
|
|
@ -161,9 +145,8 @@ class SessionStore:
|
|||
)
|
||||
connection.execute(
|
||||
"INSERT INTO active_sessions("
|
||||
"session_hash, expires_at, management_id, device_label, created_at, last_active_at, "
|
||||
"principal_id, principal_login"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"session_hash, expires_at, management_id, device_label, created_at, last_active_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
self._digest(session_id),
|
||||
expires_at,
|
||||
|
|
@ -171,8 +154,6 @@ class SessionStore:
|
|||
label,
|
||||
now,
|
||||
now,
|
||||
principal_id,
|
||||
principal_login,
|
||||
),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
|
|
@ -195,8 +176,7 @@ class SessionStore:
|
|||
) -> str:
|
||||
now = int(self.clock())
|
||||
query = (
|
||||
"SELECT expires_at, last_active_at, principal_id, principal_login "
|
||||
"FROM active_sessions "
|
||||
"SELECT expires_at, last_active_at FROM active_sessions "
|
||||
"WHERE session_hash = ?"
|
||||
)
|
||||
parameters = (self._digest(session_id),)
|
||||
|
|
@ -205,11 +185,7 @@ class SessionStore:
|
|||
with self._connect() as connection:
|
||||
row = connection.execute(query, parameters).fetchone()
|
||||
except sqlite3.OperationalError as exc:
|
||||
missing_migrated_column = any(
|
||||
f"no such column: {name}" in str(exc)
|
||||
for name in ("last_active_at", "principal_id", "principal_login")
|
||||
)
|
||||
if not missing_migrated_column:
|
||||
if "no such column: last_active_at" not in str(exc):
|
||||
raise
|
||||
with self._connect(initialize=True) as connection:
|
||||
row = connection.execute(query, parameters).fetchone()
|
||||
|
|
@ -220,7 +196,7 @@ class SessionStore:
|
|||
idle_expires_at = min(row[0], row[1] + max(1, idle_timeout_seconds))
|
||||
if idle_expires_at <= now:
|
||||
return SessionStatus("idle", idle_expires_at)
|
||||
return SessionStatus("active", idle_expires_at, row[2], row[3])
|
||||
return SessionStatus("active", idle_expires_at)
|
||||
|
||||
def touch(
|
||||
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
|
||||
|
|
@ -259,18 +235,16 @@ class SessionStore:
|
|||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def list_active(
|
||||
self, current_session_id: str, *, principal_id: int
|
||||
) -> list[ActiveDevice]:
|
||||
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 > ? AND principal_id = ? "
|
||||
"FROM active_sessions WHERE expires_at > ? "
|
||||
"ORDER BY expires_at DESC, created_at DESC",
|
||||
(now, principal_id),
|
||||
(now,),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
|
@ -305,11 +279,7 @@ class SessionStore:
|
|||
)[management_id]
|
||||
|
||||
def managed_statuses(
|
||||
self,
|
||||
management_ids,
|
||||
*,
|
||||
idle_timeout_seconds: int,
|
||||
expected_principal_id: int | None = None,
|
||||
self, management_ids, *, idle_timeout_seconds: int
|
||||
) -> dict[str, str]:
|
||||
"""Return authorization states for durable device identifiers in one read."""
|
||||
requested = set(management_ids)
|
||||
|
|
@ -320,7 +290,7 @@ class SessionStore:
|
|||
with self._connect() as connection:
|
||||
placeholders = ",".join("?" for _ in requested)
|
||||
rows = connection.execute(
|
||||
"SELECT management_id, expires_at, last_active_at, principal_id "
|
||||
"SELECT management_id, expires_at, last_active_at "
|
||||
f"FROM active_sessions WHERE management_id IN ({placeholders})",
|
||||
tuple(requested),
|
||||
).fetchall()
|
||||
|
|
@ -335,8 +305,6 @@ class SessionStore:
|
|||
statuses[management_id] = "revoked"
|
||||
elif row[1] + idle_timeout <= now:
|
||||
statuses[management_id] = "idle"
|
||||
elif expected_principal_id is not None and row[2] != expected_principal_id:
|
||||
statuses[management_id] = "principal_mismatch"
|
||||
else:
|
||||
statuses[management_id] = "active"
|
||||
return statuses
|
||||
|
|
|
|||
|
|
@ -1001,31 +1001,9 @@ class TodayStore:
|
|||
def _empty_session() -> dict:
|
||||
return {
|
||||
"revision": 0, "device_id": "", "identity": "",
|
||||
"elapsed_ms": 0, "entries": [], "running": False,
|
||||
"break_deadline_at": None, "updated_at": None,
|
||||
"elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_session_entries(entries: list[dict]) -> list[dict]:
|
||||
if not isinstance(entries, list) or len(entries) > 20:
|
||||
raise ValueError("Today session entries are limited to 20 items")
|
||||
normalized = []
|
||||
seen = set()
|
||||
for item in entries:
|
||||
identity = item.get("identity", "") if isinstance(item, dict) else ""
|
||||
elapsed_ms = item.get("elapsed_ms") if isinstance(item, dict) else None
|
||||
if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
|
||||
raise ValueError("Today session entry identity is required and bounded")
|
||||
identity = identity.strip()
|
||||
if identity in seen:
|
||||
raise ValueError("Today session entry identities must be unique")
|
||||
if (not isinstance(elapsed_ms, int) or isinstance(elapsed_ms, bool)
|
||||
or elapsed_ms < 0 or elapsed_ms > 7 * 24 * 60 * 60 * 1000):
|
||||
raise ValueError("Today session elapsed time is invalid")
|
||||
seen.add(identity)
|
||||
normalized.append({"identity": identity, "elapsed_ms": elapsed_ms})
|
||||
return normalized
|
||||
|
||||
def _session_snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return self._empty_session(), False
|
||||
|
|
@ -1033,28 +1011,17 @@ class TodayStore:
|
|||
payload, legacy = self._cipher.open(row[1], binding=f"session:{login}")
|
||||
if not isinstance(payload, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
if "entries" not in payload:
|
||||
payload["entries"] = ([{
|
||||
"identity": payload.get("identity", ""),
|
||||
"elapsed_ms": int(payload.get("elapsed_ms", 0)),
|
||||
}] if payload.get("identity") else [])
|
||||
legacy = True
|
||||
return {"revision": int(row[0]), **payload, "updated_at": row[6]}, legacy
|
||||
session = {
|
||||
return {
|
||||
"revision": int(row[0]), "device_id": row[1], "identity": row[2],
|
||||
"elapsed_ms": int(row[3]), "running": bool(row[4]),
|
||||
"break_deadline_at": row[5], "updated_at": row[6],
|
||||
}
|
||||
session["entries"] = ([{
|
||||
"identity": session["identity"], "elapsed_ms": session["elapsed_ms"],
|
||||
}] if session["identity"] else [])
|
||||
return session, True
|
||||
}, True
|
||||
|
||||
def _sealed_session(self, login: str, session: dict) -> str:
|
||||
return self._cipher.seal({
|
||||
"device_id": session["device_id"], "identity": session["identity"],
|
||||
"elapsed_ms": session["elapsed_ms"], "running": session["running"],
|
||||
"entries": session["entries"],
|
||||
"break_deadline_at": session["break_deadline_at"],
|
||||
}, binding=f"session:{login}")
|
||||
|
||||
|
|
@ -1077,7 +1044,6 @@ class TodayStore:
|
|||
def update_session(
|
||||
self, login: str, *, base_revision: int, device_id: str,
|
||||
identity: str, elapsed_ms: int, running: bool, break_deadline_at: int | None = None,
|
||||
entries: list[dict] | None = None,
|
||||
) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
updated_at = self.clock()
|
||||
|
|
@ -1092,16 +1058,9 @@ class TodayStore:
|
|||
if base_revision != current_revision:
|
||||
raise TodaySessionConflict(current_session)
|
||||
revision = current_revision + 1
|
||||
normalized_entries = self._normalize_session_entries(
|
||||
entries if entries is not None else
|
||||
([{"identity": identity, "elapsed_ms": elapsed_ms}] if identity else [])
|
||||
)
|
||||
active_entries = [entry for entry in normalized_entries if entry["identity"] == identity]
|
||||
if identity and (len(active_entries) != 1 or active_entries[0]["elapsed_ms"] != elapsed_ms):
|
||||
raise ValueError("active Today session must match its ledger entry")
|
||||
session = {
|
||||
"revision": revision, "device_id": device_id, "identity": identity,
|
||||
"elapsed_ms": elapsed_ms, "entries": normalized_entries, "running": bool(running),
|
||||
"elapsed_ms": elapsed_ms, "running": bool(running),
|
||||
"break_deadline_at": break_deadline_at, "updated_at": updated_at,
|
||||
}
|
||||
connection.execute(
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
os.environ.setdefault(
|
||||
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
|
||||
|
|
@ -12,15 +10,4 @@ os.environ.setdefault(
|
|||
os.environ.setdefault(
|
||||
"STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY",
|
||||
"cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHA=",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def stable_upstream_identity(monkeypatch):
|
||||
"""Keep API tests off the network when security ownership resolves identity."""
|
||||
from src import main
|
||||
|
||||
async def current_user():
|
||||
return {"id": 42, "login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", current_user)
|
||||
)
|
||||
|
|
@ -125,8 +125,6 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
|||
number = 0
|
||||
comments = self.server.activity_comments.get(number, [])
|
||||
self._json(200, comments, **{"X-Total-Count": str(len(comments))})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/subscriptions/check"):
|
||||
self._json(200, {"subscribed": False, "ignored": False})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"):
|
||||
self._json(200, [])
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/"):
|
||||
|
|
|
|||
|
|
@ -1,231 +0,0 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged adaptive Queues journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 375, "height": 667},
|
||||
{"width": 430, "height": 932},
|
||||
])
|
||||
def test_adaptive_queues_put_truthful_next_action_above_the_fold(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
page.evaluate("""() => {
|
||||
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
|
||||
.map(row => [row.dataset.mobileQueue, row]));
|
||||
window.adaptiveQueueLauncher = createMobileQueueLauncher({
|
||||
getCounts:() => ({delivery:1, gate:2, today:3, attentionUnavailable:true}),
|
||||
rows,
|
||||
nextAction:document.querySelector('#mobile-queue-next-action'),
|
||||
activeList:document.querySelector('#mobile-queue-active-list'),
|
||||
planningList:document.querySelector('#mobile-queue-planning-list'),
|
||||
allList:document.querySelector('#mobile-queue-all-list'),
|
||||
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
|
||||
});
|
||||
window.adaptiveQueueLauncher.renderPresentation();
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""")
|
||||
|
||||
next_action = page.locator("#mobile-queue-next-action")
|
||||
expect(next_action).to_be_visible()
|
||||
expect(next_action).to_have_text("Recover Delivery (1)")
|
||||
bounds = next_action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
|
||||
assert bounds["y"] + bounds["height"] <= viewport["height"]
|
||||
|
||||
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.mobileQueue)"
|
||||
) == ["delivery", "gate", "attention"]
|
||||
expect(page.locator('[data-mobile-queue="attention"]')).to_have_attribute("data-unavailable", "true")
|
||||
assert page.locator("#mobile-queue-planning-list [data-mobile-queue]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.mobileQueue)"
|
||||
) == ["today", "tomorrow", "week"]
|
||||
expect(page.locator(".mobile-queue-all")).not_to_have_attribute("open", "")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 375, "height": 667},
|
||||
{"width": 430, "height": 932},
|
||||
])
|
||||
@pytest.mark.parametrize(("queue", "count", "label", "destination"), [
|
||||
("following", 3, "Review Following (3)", ["following"]),
|
||||
("authored", 2, "Open My PRs (2)", ["filter:authored", "open:authored"]),
|
||||
])
|
||||
def test_adaptive_queues_open_existing_following_and_authored_work(viewport, queue, count, label, destination):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
page.evaluate("""({queue, count}) => {
|
||||
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
|
||||
.map(row => [row.dataset.mobileQueue, row]));
|
||||
window.destinations = [];
|
||||
window.adaptiveQueueLauncher = createMobileQueueLauncher({
|
||||
getCounts:() => ({[queue]:count}),
|
||||
rows,
|
||||
nextAction:document.querySelector('#mobile-queue-next-action'),
|
||||
activeList:document.querySelector('#mobile-queue-active-list'),
|
||||
planningList:document.querySelector('#mobile-queue-planning-list'),
|
||||
allList:document.querySelector('#mobile-queue-all-list'),
|
||||
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
|
||||
openFollowing:() => window.destinations.push('following'),
|
||||
selectFilter:name => window.destinations.push('filter:' + name),
|
||||
firstAction:name => name === 'authored' ? {click:() => window.destinations.push('open:authored')} : null,
|
||||
announce:message => window.destinations.push('announce:' + message),
|
||||
});
|
||||
window.adaptiveQueueLauncher.renderPresentation();
|
||||
document.querySelector('#mobile-queue-next-action').addEventListener(
|
||||
'click', () => window.adaptiveQueueLauncher.continueWork()
|
||||
);
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""", {"queue": queue, "count": count})
|
||||
|
||||
next_action = page.locator("#mobile-queue-next-action")
|
||||
expect(next_action).to_be_visible()
|
||||
expect(next_action).to_have_text(label)
|
||||
expect(next_action).to_have_attribute("aria-label", "Next up: " + label)
|
||||
bounds = next_action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert bounds["y"] + bounds["height"] <= viewport["height"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.mobileQueue)"
|
||||
) == [queue]
|
||||
|
||||
next_action.focus()
|
||||
next_action.press("Enter")
|
||||
assert page.evaluate("window.destinations") == destination
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_adaptive_queues_keep_start_continue_actionable_offline_on_phone():
|
||||
viewport = {"width": 390, "height": 844}
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
page.evaluate("""() => {
|
||||
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
|
||||
.map(row => [row.dataset.mobileQueue, row]));
|
||||
window.online = false;
|
||||
window.destinations = [];
|
||||
window.adaptiveQueueLauncher = createMobileQueueLauncher({
|
||||
getCounts:() => ({delivery:1, gate:2, today:3}),
|
||||
isOnline:() => window.online,
|
||||
rows,
|
||||
nextAction:document.querySelector('#mobile-queue-next-action'),
|
||||
activeList:document.querySelector('#mobile-queue-active-list'),
|
||||
planningList:document.querySelector('#mobile-queue-planning-list'),
|
||||
allList:document.querySelector('#mobile-queue-all-list'),
|
||||
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
|
||||
openToday:() => window.destinations.push('today'),
|
||||
});
|
||||
window.adaptiveQueueLauncher.renderPresentation();
|
||||
document.querySelector('#mobile-queue-next-action').addEventListener(
|
||||
'click', () => window.adaptiveQueueLauncher.continueWork()
|
||||
);
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""")
|
||||
|
||||
next_action = page.locator("#mobile-queue-next-action")
|
||||
expect(next_action).to_be_visible()
|
||||
expect(next_action).to_have_text("Continue Today (3)")
|
||||
next_action.press("Enter")
|
||||
assert page.evaluate("window.destinations") == ["today"]
|
||||
|
||||
page.evaluate("""() => {
|
||||
window.online = true;
|
||||
window.adaptiveQueueLauncher.renderPresentation();
|
||||
}""")
|
||||
expect(next_action).to_have_text("Recover Delivery (1)")
|
||||
bounds = next_action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert bounds["y"] + bounds["height"] <= viewport["height"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 375, "height": 667},
|
||||
{"width": 430, "height": 932},
|
||||
])
|
||||
def test_operator_reorders_routine_queues_without_leaking_priority_between_accounts(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-priority.js")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
page.evaluate("""() => {
|
||||
const values = new Map();
|
||||
window.login = 'alice';
|
||||
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
|
||||
.map(row => [row.dataset.mobileQueue, row]));
|
||||
window.launcher = createMobileQueueLauncher({
|
||||
getCounts:() => ({attention:2, following:3, authored:1}),
|
||||
getRoutineOrder:() => window.priority.getOrder(), rows,
|
||||
nextAction:document.querySelector('#mobile-queue-next-action'),
|
||||
activeList:document.querySelector('#mobile-queue-active-list'),
|
||||
planningList:document.querySelector('#mobile-queue-planning-list'),
|
||||
allList:document.querySelector('#mobile-queue-all-list'),
|
||||
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
|
||||
});
|
||||
window.priority = createMobileQueuePriority({
|
||||
storage:{getItem:key => values.get(key) || null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:() => window.login, document,
|
||||
list:document.querySelector('#mobile-queue-priority-list'),
|
||||
resetButton:document.querySelector('#reset-mobile-queue-priority'),
|
||||
status:document.querySelector('#mobile-queue-priority-status'),
|
||||
labels:{attention:'Attention',today:'Today',update:'Updates',agenda:'Agenda',following:'Following',authored:'My PRs',filed:'Filed',later:'Later',draft:'Drafts'},
|
||||
onChange:() => window.launcher.renderPresentation(),
|
||||
});
|
||||
window.priority.start(); window.launcher.renderPresentation();
|
||||
document.querySelector('#mobile-queue-priority').open = true;
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""")
|
||||
|
||||
for _ in range(4):
|
||||
page.get_by_role("button", name="Move Following earlier").click()
|
||||
expect(page.locator("#mobile-queue-next-action")).to_have_text("Review Following (3)")
|
||||
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.mobileQueue)"
|
||||
) == ["following", "attention", "authored"]
|
||||
expect(page.locator("#mobile-queue-priority-status")).to_have_text("Sync pending.")
|
||||
controls = page.locator(".mobile-queue-priority-controls button")
|
||||
assert controls.count() == 18
|
||||
assert all((controls.nth(i).bounding_box() or {}).get("height", 0) >= 44 for i in range(controls.count()))
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.evaluate("""() => {
|
||||
window.login = 'bob'; window.priority.render(); window.launcher.renderPresentation();
|
||||
}""")
|
||||
expect(page.locator("#mobile-queue-next-action")).to_have_text("Start Attention (2)")
|
||||
assert page.locator("#mobile-queue-priority-list [data-queue-priority]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.queuePriority)"
|
||||
)[:3] == ["attention", "today", "update"]
|
||||
browser.close()
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Human Gates journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
current = {"gate": "g1"}
|
||||
evidence_url = {"value": "/evidence/manifest"}
|
||||
list_requests: list[str] = []
|
||||
decision_requests: list[dict] = []
|
||||
browser_errors: list[str] = []
|
||||
|
||||
def gate(gate_id: str) -> dict:
|
||||
return {
|
||||
"id": gate_id,
|
||||
"title": "First candidate" if gate_id == "g1" else "Fresh candidate",
|
||||
"project": "stackchain/stackchain-dashboard",
|
||||
"candidate_hash": "a1" if gate_id == "g1" else "b2",
|
||||
"revision": 1,
|
||||
"priority": 5,
|
||||
"checks": [],
|
||||
"artifacts": [{"name": "Signed manifest", "url": evidence_url["value"]}],
|
||||
"links": [],
|
||||
"provenance": {},
|
||||
"history": [],
|
||||
}
|
||||
|
||||
try:
|
||||
with release_server(
|
||||
archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
|
||||
) as origin, sync_playwright() as playwright:
|
||||
evidence_url["value"] = origin + "/evidence/manifest"
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
|
||||
def human_gates_route(route):
|
||||
path = route.request.url.split("?", 1)[0]
|
||||
if route.request.method == "POST":
|
||||
decision_requests.append({
|
||||
"path": path,
|
||||
"key": route.request.headers.get("idempotency-key"),
|
||||
"body": route.request.post_data_json,
|
||||
})
|
||||
if len(decision_requests) == 1:
|
||||
route.abort("failed")
|
||||
return
|
||||
payload = {"receipt_id": "receipt-1", "state": "released"}
|
||||
elif path.endswith("/api/v1/human-gates"):
|
||||
list_requests.append(current["gate"])
|
||||
payload = {"pending_count": 1, "items": [gate(current["gate"])]}
|
||||
else:
|
||||
payload = gate(path.rsplit("/", 1)[-1])
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps(payload))
|
||||
|
||||
page.route("**/api/v1/human-gates**", human_gates_route)
|
||||
page.route("**/api/v1/human-gates/**", human_gates_route)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Human Gates release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
|
||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||
expect(page.locator("#human-gates")).to_be_visible()
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("First candidate")
|
||||
|
||||
tray = page.locator(".human-gate-decision-tray")
|
||||
expect(tray).to_be_visible()
|
||||
expect(page.locator("[data-gate-readiness]")).to_have_text(
|
||||
"0 of 3 confirmations complete"
|
||||
)
|
||||
tray_bounds = tray.bounding_box()
|
||||
assert tray_bounds
|
||||
assert tray_bounds["y"] + tray_bounds["height"] <= height + 1
|
||||
for action in ("hold", "release"):
|
||||
bounds = page.locator(f'[data-gate-decision="{action}"]').bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator('[data-gate-decision="release"]').click()
|
||||
expect(page.locator("[data-gate-error]")).to_contain_text(
|
||||
"Complete the release checklist"
|
||||
)
|
||||
expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_focused()
|
||||
assert decision_requests == []
|
||||
|
||||
page.locator('[data-gate-checklist="exact_hash"]').check()
|
||||
page.locator('[data-gate-checklist="artifacts_reviewed"]').check()
|
||||
page.locator('[data-gate-checklist="provenance_reviewed"]').check()
|
||||
expect(page.locator("[data-gate-readiness]")).to_have_text(
|
||||
"3 of 3 confirmations complete"
|
||||
)
|
||||
page.locator("[data-gate-reason]").fill("Awaiting final approval")
|
||||
with page.expect_popup() as popup_info:
|
||||
page.get_by_role("link", name="Signed manifest").click()
|
||||
popup = popup_info.value
|
||||
popup.wait_for_load_state("domcontentloaded")
|
||||
assert not page.url.endswith("/evidence/manifest")
|
||||
expect(page.locator("#human-gates")).to_be_visible()
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("First candidate")
|
||||
popup.close()
|
||||
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||
expect(page.locator("#human-gates")).to_be_visible()
|
||||
expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_checked()
|
||||
expect(page.locator('[data-gate-checklist="artifacts_reviewed"]')).to_be_checked()
|
||||
expect(page.locator('[data-gate-checklist="provenance_reviewed"]')).to_be_checked()
|
||||
expect(page.locator("[data-gate-reason]")).to_have_value("Awaiting final approval")
|
||||
progress_key = page.evaluate(
|
||||
"Object.keys(localStorage).find(key => key.startsWith('stackchain.human-gate-review.v1:'))"
|
||||
)
|
||||
assert progress_key and progress_key.endswith(":g1:1")
|
||||
assert page.evaluate("key => localStorage.getItem(key) !== null", progress_key)
|
||||
page.locator('[data-gate-decision="release"]').click()
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
|
||||
expect(page.locator("[data-gate-recover]")).to_have_count(1)
|
||||
decision_key = page.evaluate(
|
||||
"Object.keys(localStorage).find(key => key.startsWith('stackchain.human-gate-decision.v1:'))"
|
||||
)
|
||||
assert decision_key
|
||||
assert len(decision_requests) == 1
|
||||
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||
expect(page.locator("#human-gates")).to_be_visible()
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
|
||||
recover = page.locator("[data-gate-recover]")
|
||||
expect(recover).to_be_visible()
|
||||
recover_height = recover.evaluate("element => element.getBoundingClientRect().height")
|
||||
assert recover_height >= 44
|
||||
recover.click()
|
||||
expect(page.locator("#human-gates-status")).to_contain_text("Decision saved")
|
||||
expect(page.locator(".human-gate-decision-tray")).to_have_count(0)
|
||||
assert len(decision_requests) == 2
|
||||
assert decision_requests[0] == decision_requests[1]
|
||||
assert not page.evaluate("key => localStorage.getItem(key) !== null", decision_key)
|
||||
assert not page.evaluate("key => localStorage.getItem(key) !== null", progress_key)
|
||||
|
||||
page.evaluate("document.querySelector('#close-human-gates').click()")
|
||||
|
||||
before_reopen = len(list_requests)
|
||||
current["gate"] = "g2"
|
||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("Fresh candidate")
|
||||
expect(page.locator("#human-gates-list")).to_contain_text("Fresh candidate")
|
||||
assert len(list_requests) == before_reopen + 1
|
||||
assert page.evaluate("window.innerWidth") == width
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
bounds = page.locator('[data-human-gate-id="g2"]').bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert not browser_errors
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
browser_errors: list[str] = []
|
||||
history_requests: list[str] = []
|
||||
held = {
|
||||
"id": "held", "title": "Held candidate", "project": "stackchain/stackchain-dashboard",
|
||||
"candidate_hash": "bbb222", "state": "held", "revision": 2, "priority": 5,
|
||||
"created_at": 100, "updated_at": 200, "reason": "Needs mobile evidence",
|
||||
"override_reason": "", "checklist": {}, "receipt_id": "receipt-2",
|
||||
"checks": [], "artifacts": [], "links": [], "provenance": {},
|
||||
"history": [{"action": "held", "at": 200, "receipt_id": "receipt-2"}],
|
||||
}
|
||||
released = {
|
||||
**held, "id": "released", "title": "Released candidate", "candidate_hash": "aaa111",
|
||||
"state": "released", "updated_at": 150, "reason": "", "receipt_id": "receipt-1",
|
||||
}
|
||||
superseded = {
|
||||
**held, "id": "superseded", "title": "Superseded candidate",
|
||||
"candidate_hash": "old000", "state": "superseded", "updated_at": 100,
|
||||
"reason": "", "receipt_id": None,
|
||||
}
|
||||
try:
|
||||
with release_server(
|
||||
archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
|
||||
) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": 390, "height": 844}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
|
||||
def gates_route(route):
|
||||
history_requests.append(route.request.url)
|
||||
if "state=history" in route.request.url and "cursor=older-page" in route.request.url:
|
||||
payload = {"pending_count": 0, "items": [superseded], "next_cursor": None}
|
||||
elif "state=history" in route.request.url:
|
||||
payload = {"pending_count": 0, "items": [held, released], "next_cursor": "older-page"}
|
||||
elif route.request.url.endswith("/held"):
|
||||
payload = held
|
||||
else:
|
||||
payload = {"pending_count": 0, "items": []}
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps(payload))
|
||||
|
||||
page.route("**/api/v1/human-gates**", gates_route)
|
||||
page.route("**/api/v1/human-gates/**", gates_route)
|
||||
page.route("**/api/v1/human-gate-receipts/receipt-2", lambda route: route.fulfill(
|
||||
status=200, content_type="application/json", body=json.dumps({
|
||||
"receipt_id": "receipt-2", "gate_id": "held", "candidate_hash": "bbb222",
|
||||
"state": "held", "decided_at": 200, "reason": "Needs mobile evidence",
|
||||
"override_reason": "", "checklist": {}, "unmet_required_checks": [],
|
||||
})
|
||||
))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Human Gate history phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||
expect(page.locator("#human-gates")).to_be_visible()
|
||||
cached_before = page.evaluate(
|
||||
"Object.keys(localStorage).filter(key => key.includes('human-gate')).sort()"
|
||||
)
|
||||
|
||||
page.locator("#human-gates-history").click()
|
||||
expect(page.locator("#human-gates-status")).to_contain_text("Older decisions are available")
|
||||
expect(page.locator('[data-human-gate-history-id="held"]')).to_contain_text("Held")
|
||||
expect(page.locator('[data-human-gate-history-id="released"]')).to_contain_text("Released")
|
||||
page.locator('[data-human-gate-history-id="held"]').click()
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("Needs mobile evidence")
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2")
|
||||
expect(page.locator("#human-gate-detail [data-gate-decision]")).to_have_count(0)
|
||||
more = page.get_by_role("button", name="Load older decisions")
|
||||
more_bounds = more.bounding_box()
|
||||
assert more_bounds and more_bounds["height"] >= 44
|
||||
more.click()
|
||||
expect(page.locator('[data-human-gate-history-id="superseded"]')).to_contain_text("Superseded")
|
||||
expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2")
|
||||
expect(page.locator("#human-gates-status")).to_have_text("All 3 Human Gate decisions loaded.")
|
||||
expect(page.locator("[data-human-gate-history-more]")).to_have_count(0)
|
||||
assert page.evaluate(
|
||||
"Object.keys(localStorage).filter(key => key.includes('human-gate')).sort()"
|
||||
) == cached_before
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
for selector in ("#human-gates-pending", "#human-gates-history"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert any("state=history&limit=20" in url for url in history_requests)
|
||||
assert any("cursor=older-page" in url for url in history_requests)
|
||||
assert not browser_errors
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_addresses_every_review_comment_and_requests_updated_review(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"merged": False,
|
||||
"ci_state": "success",
|
||||
"head_sha": "current-head",
|
||||
"reviewers": [{
|
||||
"review_id": 42,
|
||||
"login": "sam",
|
||||
"status": "changes_requested",
|
||||
"head_sha": "current-head",
|
||||
"blocking": True,
|
||||
"comments": [
|
||||
{"id": 99, "path": "src/api.py", "body": "Handle the empty state.", "line": 12},
|
||||
{"id": 100, "path": "frontend/app.js", "body": "Explain the retry.", "line": 8},
|
||||
],
|
||||
}],
|
||||
"files": [
|
||||
{"filename": "src/api.py", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -11 +11 @@", "+return empty"]},
|
||||
{"filename": "frontend/app.js", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -7 +7 @@", "+showRetry()"]},
|
||||
],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.feedbackCalls = [];
|
||||
globalThis.feedbackStored = new Map();
|
||||
globalThis.feedbackStorage = {
|
||||
getItem:key => feedbackStored.has(key) ? feedbackStored.get(key) : null,
|
||||
setItem:(key, value) => feedbackStored.set(key, String(value)),
|
||||
removeItem:key => feedbackStored.delete(key),
|
||||
};
|
||||
globalThis.feedbackItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
globalThis.feedbackDetail = detail;
|
||||
globalThis.feedbackController = createPullSheet({
|
||||
storage: feedbackStorage,
|
||||
createOperationId: () => 'feedback-operation',
|
||||
fetchJson: async (path, options = {}) => {
|
||||
feedbackCalls.push({path, options});
|
||||
if (path.endsWith('/comments')) return {id: 501, body:JSON.parse(options.body).body};
|
||||
if (path.endsWith('/request-review')) return {
|
||||
number:7, reviewer:'sam', head_sha:'current-head', requested_reviewers:['sam']
|
||||
};
|
||||
throw new Error('unexpected request');
|
||||
},
|
||||
});
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
|
||||
createPullSheet.renderFile(file, index, false, value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'))
|
||||
).join('');
|
||||
createPullSheet.bindFeedbackControls(document, feedbackController,
|
||||
() => feedbackItem, () => feedbackDetail, () => 'timmy');
|
||||
createPullSheet.review(detail, null, document);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
page.locator('[data-address-review-feedback="42"]').click()
|
||||
assert page.locator("#pull-feedback-progress").text_content() == "Comment 1 of 2"
|
||||
assert page.locator("#finish-pull-feedback").is_disabled()
|
||||
page.locator('[data-feedback-disposition="addressed"]').click()
|
||||
page.locator("#next-pull-feedback").click()
|
||||
page.locator("#pull-feedback-note").fill("Added a visible retry message.")
|
||||
page.locator('[data-feedback-disposition="discussion"]').click()
|
||||
assert not page.locator("#finish-pull-feedback").is_disabled()
|
||||
page.locator("#finish-pull-feedback").click()
|
||||
page.locator("#request-feedback-review").click()
|
||||
|
||||
page.wait_for_function("feedbackCalls.length === 2")
|
||||
result = page.evaluate(
|
||||
"""() => ({
|
||||
calls:feedbackCalls,
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
controlHeights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
|
||||
.filter(button => !button.hidden).map(button => button.getBoundingClientRect().height),
|
||||
status:document.querySelector('#pull-feedback-status').textContent,
|
||||
secondExpanded:document.querySelector('[data-pull-filename="frontend/app.js"] .pull-file-toggle')
|
||||
.getAttribute('aria-expanded'),
|
||||
persisted:Array.from(feedbackStored.keys()).some(key => key.includes('feedback-pass')),
|
||||
})"""
|
||||
)
|
||||
result["resetHidden"] = page.evaluate(
|
||||
"""() => {
|
||||
createPullSheet.resetReviewRequestControls(document, feedbackDetail);
|
||||
return document.querySelector('#pull-feedback-pass').hidden;
|
||||
}"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert result["scrollWidth"] <= result["clientWidth"]
|
||||
assert min(result["controlHeights"]) >= 44
|
||||
assert result["secondExpanded"] == "true"
|
||||
assert result["persisted"] is True
|
||||
assert result["resetHidden"] is True
|
||||
assert result["calls"][0]["path"].endswith("/comments")
|
||||
assert result["calls"][0]["options"]["headers"]["Idempotency-Key"] == "feedback-operation"
|
||||
summary = result["calls"][0]["options"]["body"]
|
||||
assert "Handle the empty state" in summary
|
||||
assert "Addressed" in summary
|
||||
assert "Needs discussion" in summary
|
||||
assert "Added a visible retry message" in summary
|
||||
assert result["calls"][1]["path"].endswith("/request-review")
|
||||
assert '"reviewer":"sam"' in result["calls"][1]["options"]["body"]
|
||||
assert result["status"] == "Response posted; updated review requested from @sam."
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_commits_one_review_feedback_fix(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open", "merged": False, "head_sha": "abc1234",
|
||||
"capabilities": {"authored": True},
|
||||
"reviewers": [{
|
||||
"review_id": 42, "login": "sam", "status": "changes_requested",
|
||||
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
|
||||
"comments": [{"id": 99, "path": "src/api.py", "body": "Handle empty state.", "line": 12}],
|
||||
}],
|
||||
"files": [{"filename": "src/api.py", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -12 +12 @@", "+return empty"]}],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.fixCalls = [];
|
||||
globalThis.fixItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
globalThis.fixDetail = detail;
|
||||
globalThis.fixController = createPullSheet({fetchJson: async (path, options = {}) => {
|
||||
fixCalls.push({path, options});
|
||||
if ((options.method || 'GET') === 'GET') return {
|
||||
path:'src/api.py', head_sha:'abc1234', blob_sha:'blob123', content:'return empty\\n'
|
||||
};
|
||||
return {path:'src/api.py', previous_head_sha:'abc1234', head_sha:'def5678', blob_sha:'blob456'};
|
||||
}});
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
|
||||
createPullSheet.renderFile(file, index, false, value => String(value))
|
||||
).join('');
|
||||
createPullSheet.bindFeedbackControls(document, fixController,
|
||||
() => fixItem, () => fixDetail, () => 'alex');
|
||||
createPullSheet.review(detail, null, document);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
page.locator('[data-address-review-feedback="42"]').click()
|
||||
page.locator("#make-pull-feedback-fix").click()
|
||||
page.locator("#pull-feedback-file-content").fill("return handled\n")
|
||||
page.locator("#pull-feedback-commit-message").fill("fix: handle empty state")
|
||||
page.locator("#commit-pull-feedback-fix").click()
|
||||
page.wait_for_function("fixCalls.length === 2")
|
||||
result = page.evaluate("""() => ({
|
||||
calls:fixCalls, head:fixDetail.head_sha,
|
||||
editorHidden:document.querySelector('#pull-feedback-file-editor').hidden,
|
||||
status:document.querySelector('#pull-feedback-status').textContent,
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
|
||||
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
|
||||
})""")
|
||||
browser.close()
|
||||
|
||||
assert result["scrollWidth"] <= result["clientWidth"]
|
||||
assert min(result["heights"]) >= 44
|
||||
assert result["head"] == "def5678"
|
||||
assert result["editorHidden"] is True
|
||||
assert "committed" in result["status"].lower()
|
||||
assert "path=src%2Fapi.py" in result["calls"][0]["path"]
|
||||
body = result["calls"][1]["options"]["body"]
|
||||
assert '"expected_blob_sha":"blob123"' in body
|
||||
assert '"content":"return handled\\n"' in body
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_stages_and_commits_cross_file_suggestions_atomically(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open", "merged": False, "head_sha": "abc1234",
|
||||
"capabilities": {"authored": True},
|
||||
"reviewers": [{
|
||||
"review_id": 42, "login": "sam", "status": "changes_requested",
|
||||
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
|
||||
"comments": [
|
||||
{"id": 99, "path": "src/api.py", "line": 2, "body": "Use the typed empty result.",
|
||||
"suggestion": {"line": 2, "content": " return empty_result()"}},
|
||||
{"id": 100, "path": "tests/test_api.py", "line": 2, "body": "Prove the typed result.",
|
||||
"suggestion": {"line": 2, "content": " assert lookup() == empty_result()"}},
|
||||
],
|
||||
}],
|
||||
"files": [
|
||||
{"filename": "src/api.py", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -2 +2 @@", "+ return None"]},
|
||||
{"filename": "tests/test_api.py", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -2 +2 @@", "+ assert lookup() is None"]},
|
||||
],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.batchCalls = [];
|
||||
globalThis.batchItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
globalThis.batchDetail = detail;
|
||||
globalThis.batchController = createPullSheet({fetchJson: async (path, options = {}) => {
|
||||
batchCalls.push({path, options});
|
||||
if ((options.method || 'GET') === 'GET') {
|
||||
if (batchCalls.length === 1) return {path:'src/api.py', head_sha:'abc1234', blob_sha:'blob1',
|
||||
content:'def lookup():\\n return None\\n'};
|
||||
return {path:'tests/test_api.py', head_sha:'abc1234', blob_sha:'blob2',
|
||||
content:'def test_lookup():\\n assert lookup() is None\\n'};
|
||||
}
|
||||
return {paths:['src/api.py','tests/test_api.py'], suggestion_count:2,
|
||||
previous_head_sha:'abc1234', head_sha:'def5678'};
|
||||
}});
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
|
||||
createPullSheet.renderFile(file, index, false, value => String(value))
|
||||
).join('');
|
||||
createPullSheet.bindFeedbackControls(document, batchController,
|
||||
() => batchItem, () => batchDetail, () => 'alex');
|
||||
createPullSheet.review(detail, null, document);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
page.locator('[data-address-review-feedback="42"]').click()
|
||||
page.locator("#review-pull-feedback-suggestion").click()
|
||||
page.wait_for_function("batchCalls.length === 1")
|
||||
page.locator("#stage-pull-feedback-suggestion").click()
|
||||
assert page.locator("#review-pull-feedback-batch").text_content() == "Review fixes (1)"
|
||||
page.locator("#next-pull-feedback").click()
|
||||
page.locator("#review-pull-feedback-suggestion").click()
|
||||
page.wait_for_function("batchCalls.length === 2")
|
||||
page.locator("#stage-pull-feedback-suggestion").click()
|
||||
assert page.locator("#review-pull-feedback-batch").text_content() == "Review fixes (2)"
|
||||
page.locator("#review-pull-feedback-batch").click()
|
||||
assert page.locator("#pull-feedback-batch-review").is_visible()
|
||||
assert page.locator("#pull-feedback-batch-items li").count() == 2
|
||||
page.locator("#commit-pull-feedback-batch").click()
|
||||
page.wait_for_function("batchCalls.length === 3")
|
||||
result = page.evaluate("""() => ({
|
||||
calls:batchCalls, head:batchDetail.head_sha,
|
||||
reviewHidden:document.querySelector('#pull-feedback-batch-review').hidden,
|
||||
status:document.querySelector('#pull-feedback-status').textContent,
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
|
||||
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
|
||||
})""")
|
||||
browser.close()
|
||||
|
||||
assert result["scrollWidth"] <= result["clientWidth"]
|
||||
assert min(result["heights"]) >= 44
|
||||
assert result["head"] == "def5678"
|
||||
assert result["reviewHidden"] is True
|
||||
assert "2 suggested changes committed" in result["status"].lower()
|
||||
request = result["calls"][2]
|
||||
assert request["path"].endswith("/feedback-batch")
|
||||
assert request["options"]["method"] == "POST"
|
||||
body = request["options"]["body"]
|
||||
assert '"expected_head_sha":"abc1234"' in body
|
||||
assert '"files":[{"path":"src/api.py"' in body
|
||||
assert '"path":"tests/test_api.py"' in body
|
||||
assert '"suggestion_count":2' in body
|
||||
assert 'return empty_result()' in body
|
||||
assert 'assert lookup() == empty_result()' in body
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_previews_and_applies_review_suggestion(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open", "merged": False, "head_sha": "abc1234",
|
||||
"capabilities": {"authored": True},
|
||||
"reviewers": [{
|
||||
"review_id": 42, "login": "sam", "status": "changes_requested",
|
||||
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
|
||||
"comments": [{
|
||||
"id": 99, "path": "src/api.py", "line": 2,
|
||||
"body": "Return the typed empty result.\n```suggestion\n return empty_result()\n```",
|
||||
"suggestion": {"line": 2, "content": " return empty_result()"},
|
||||
}, {
|
||||
"id": 100, "path": "src/api.py", "line": 3,
|
||||
"body": "Keep the result typed.",
|
||||
}],
|
||||
}],
|
||||
"files": [{"filename": "src/api.py", "status": "modified", "diff_available": True,
|
||||
"diff_lines": ["@@ -2 +2 @@", "+ return None"]}],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.suggestionCalls = [];
|
||||
globalThis.suggestionItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
globalThis.suggestionDetail = detail;
|
||||
globalThis.suggestionController = createPullSheet({fetchJson: async (path, options = {}) => {
|
||||
suggestionCalls.push({path, options});
|
||||
if ((options.method || 'GET') === 'GET') return {
|
||||
path:'src/api.py', head_sha:'abc1234', blob_sha:'blob123',
|
||||
content:'def lookup():\\n return None\\n'
|
||||
};
|
||||
return {path:'src/api.py', previous_head_sha:'abc1234', head_sha:'def5678', blob_sha:'blob456'};
|
||||
}});
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
|
||||
createPullSheet.renderFile(file, index, false, value => String(value))
|
||||
).join('');
|
||||
createPullSheet.bindFeedbackControls(document, suggestionController,
|
||||
() => suggestionItem, () => suggestionDetail, () => 'alex');
|
||||
createPullSheet.review(detail, null, document);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
page.locator('[data-address-review-feedback="42"]').click()
|
||||
assert page.locator("#pull-feedback-file-editor").is_hidden()
|
||||
assert page.locator("#review-pull-feedback-suggestion").is_visible()
|
||||
page.locator("#review-pull-feedback-suggestion").click()
|
||||
page.wait_for_function("suggestionCalls.length === 1")
|
||||
assert page.locator("#pull-feedback-suggestion-preview").is_visible()
|
||||
assert page.locator("#pull-feedback-suggestion-before").text_content() == " return None"
|
||||
assert page.locator("#pull-feedback-suggestion-after").text_content() == " return empty_result()"
|
||||
assert page.locator("#pull-feedback-file-editor").is_hidden()
|
||||
page.locator("#next-pull-feedback").click()
|
||||
assert page.locator("#pull-feedback-suggestion-preview").is_hidden()
|
||||
assert page.locator("#review-pull-feedback-suggestion").is_hidden()
|
||||
page.locator("#previous-pull-feedback").click()
|
||||
page.locator("#review-pull-feedback-suggestion").click()
|
||||
page.wait_for_function("suggestionCalls.length === 2")
|
||||
page.locator("#apply-pull-feedback-suggestion").click()
|
||||
page.wait_for_function("suggestionCalls.length === 3")
|
||||
result = page.evaluate("""() => ({
|
||||
calls:suggestionCalls, head:suggestionDetail.head_sha,
|
||||
previewHidden:document.querySelector('#pull-feedback-suggestion-preview').hidden,
|
||||
status:document.querySelector('#pull-feedback-status').textContent,
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
|
||||
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
|
||||
})""")
|
||||
browser.close()
|
||||
|
||||
assert result["scrollWidth"] <= result["clientWidth"]
|
||||
assert min(result["heights"]) >= 44
|
||||
assert result["head"] == "def5678"
|
||||
assert result["previewHidden"] is True
|
||||
assert "suggested change committed" in result["status"].lower()
|
||||
body = result["calls"][2]["options"]["body"]
|
||||
assert '"expected_blob_sha":"blob123"' in body
|
||||
assert '"content":"def lookup():\\n return empty_result()\\n"' in body
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile My PRs journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "my-work.js")
|
||||
page.add_script_tag(path=FRONTEND / "work-route.js")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
page.add_script_tag(path=FRONTEND / "pull-sheet.js")
|
||||
page.evaluate("document.querySelector('#mobile-queue-sheet').showModal()")
|
||||
page.locator(".mobile-queue-all summary").click()
|
||||
|
||||
row = page.locator('[data-mobile-queue="authored"]')
|
||||
expect(row).to_have_count(1)
|
||||
expect(row).to_contain_text("My PRs")
|
||||
expect(row).to_contain_text("Pull requests you authored")
|
||||
assert row.bounding_box()["height"] >= 44
|
||||
|
||||
result = page.evaluate("""() => {
|
||||
const work = buildMyWork({
|
||||
user:{login:'timmy'},
|
||||
pull_requests:[{
|
||||
number:41,title:'Ship the authored mobile flow',repository:'stackchain/dashboard',
|
||||
state:'open',user:'timmy',assignees:[],work_reasons:['authored_by_me'],
|
||||
url:'https://forge.example/git/stackchain/dashboard/pulls/41'
|
||||
}]
|
||||
});
|
||||
const authored = filterMyWork(work, 'authored');
|
||||
let selected = '';
|
||||
let route = '';
|
||||
const action = document.createElement('button');
|
||||
action.className = 'my-work-card-main';
|
||||
action.addEventListener('click', () => { route = createWorkRoute.serialize(authored[0]); });
|
||||
const launcher = createMobileQueueLauncher({
|
||||
selectFilter:name => { selected = name; },
|
||||
firstAction:() => action,
|
||||
announce:() => {},
|
||||
});
|
||||
const opened = launcher.open('authored');
|
||||
return {selected, route, opened, count:authored.length};
|
||||
}""")
|
||||
assert result == {
|
||||
"selected": "authored",
|
||||
"route": "#/my-work/pull/stackchain/dashboard/41",
|
||||
"opened": "opened",
|
||||
"count": 1,
|
||||
}
|
||||
capabilities = page.evaluate("""() => {
|
||||
const item = {key:'stackchain/dashboard#41'};
|
||||
createPullSheet.resetOwnershipControls(
|
||||
document, item, () => false, {authored:true, assigned:false}
|
||||
);
|
||||
const ownership = document.querySelector('#pull-ownership');
|
||||
return {
|
||||
hidden: ownership.hidden,
|
||||
inert: ownership.inert,
|
||||
releaseDisabled: document.querySelector('#release-pull').disabled,
|
||||
handoffDisabled: document.querySelector('#load-pull-handoff').disabled,
|
||||
};
|
||||
}""")
|
||||
assert capabilities == {
|
||||
"hidden": True,
|
||||
"inert": True,
|
||||
"releaseDisabled": True,
|
||||
"handoffDisabled": True,
|
||||
}
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_empty_authored_queue_announces_the_standard_empty_message():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 320, "height": 568})
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
|
||||
message = page.evaluate("""() => {
|
||||
let announcement = '';
|
||||
const launcher = createMobileQueueLauncher({
|
||||
selectFilter:() => {}, firstAction:() => null,
|
||||
announce:value => { announcement = value; },
|
||||
});
|
||||
launcher.open('authored');
|
||||
return announcement;
|
||||
}""")
|
||||
assert message == "No work is ready to open."
|
||||
browser.close()
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
if os.environ.get("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("release browser journey is opt-in", allow_module_level=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_cancels_pending_review_and_opens_replacement_picker(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.cancelCalls = [];
|
||||
globalThis.cancelItem = {
|
||||
repository:'stackchain/api', number:7, key:'stackchain/api#7'
|
||||
};
|
||||
globalThis.cancelDetail = {
|
||||
state:'open', draft:false, mergeable:true, merged:false,
|
||||
ci_state:'success', head_sha:'abc1234', files:[],
|
||||
reviewers:[{
|
||||
login:'sam', status:'waiting', head_sha:'abc1234', blocking:true
|
||||
}],
|
||||
};
|
||||
globalThis.cancelController = createPullSheet({
|
||||
storage:null,
|
||||
fetchJson:async (path, options={}) => {
|
||||
cancelCalls.push({path, options});
|
||||
return {
|
||||
repository:'stackchain/api', number:7, reviewer:'sam',
|
||||
head_sha:'abc1234', requested_reviewers:[],
|
||||
};
|
||||
},
|
||||
});
|
||||
createPullSheet.bindReviewRequestControls(
|
||||
document, cancelController, () => cancelItem, () => cancelDetail
|
||||
);
|
||||
createPullSheet.review(cancelDetail, null, document);
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
}"""
|
||||
)
|
||||
|
||||
cancel = page.locator('[data-cancel-review-request="sam"]')
|
||||
bounds = cancel.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
cancel.click()
|
||||
page.wait_for_function("cancelCalls.length === 1")
|
||||
page.wait_for_function(
|
||||
"document.querySelector('#pull-review-request-status').textContent.includes('Choose a replacement reviewer')"
|
||||
)
|
||||
result = page.evaluate(
|
||||
"""() => ({
|
||||
calls:cancelCalls,
|
||||
waitingRows:document.querySelectorAll('[data-reviewer-status="waiting"]').length,
|
||||
pickerOpen:document.querySelector('#pull-review-request').open,
|
||||
focused:document.activeElement?.id,
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
})"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert result["scrollWidth"] <= result["clientWidth"]
|
||||
assert result["waitingRows"] == 0
|
||||
assert result["pickerOpen"] is True
|
||||
assert result["focused"] == "load-pull-reviewers"
|
||||
assert len(result["calls"]) == 1
|
||||
assert result["calls"][0]["options"]["method"] == "DELETE"
|
||||
assert result["calls"][0]["options"]["body"] == (
|
||||
'{"reviewer":"sam","expected_head_sha":"abc1234"}'
|
||||
)
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("STACKCHAIN_RUN_RELEASE_E2E") != "1",
|
||||
reason="release browser journey is opt-in",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_closes_obsolete_pull_then_recovers_it(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
window.lifecycleCalls = [];
|
||||
window.lifecycleItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
window.lifecycleDetail = {
|
||||
number:7, title:'Obsolete experiment', author:'timmy', state:'open',
|
||||
merged:false, head_sha:'abc1234', capabilities:{authored:true, assigned:false},
|
||||
};
|
||||
const controller = createPullSheet({
|
||||
storage:null,
|
||||
fetchJson:async (path, options) => {
|
||||
lifecycleCalls.push({path, method:options.method, body:JSON.parse(options.body)});
|
||||
const state = path.endsWith('/close') ? 'closed' : 'open';
|
||||
return {repository:'stackchain/api', number:7, title:'Obsolete experiment',
|
||||
head_sha:'abc1234', state, merged:false};
|
||||
},
|
||||
});
|
||||
createPullSheet.bindLifecycleControls(
|
||||
document, controller, () => lifecycleItem, () => lifecycleDetail,
|
||||
result => { lifecycleDetail = {...lifecycleDetail, ...result}; }
|
||||
);
|
||||
createPullSheet.resetLifecycleControls(document, lifecycleDetail);
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
}"""
|
||||
)
|
||||
|
||||
page.locator("#pull-lifecycle summary").click()
|
||||
close = page.get_by_role("button", name="Close pull request")
|
||||
before = close.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.hidden})")
|
||||
close.click()
|
||||
page.get_by_text("Pull request closed.").wait_for()
|
||||
reopen = page.get_by_role("button", name="Reopen pull request")
|
||||
receipt = reopen.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.closest('[hidden]') !== null})")
|
||||
reopen.click()
|
||||
page.get_by_text("Pull request reopened.").wait_for()
|
||||
metrics = page.evaluate(
|
||||
"""() => ({
|
||||
calls:lifecycleCalls,
|
||||
state:lifecycleDetail.state,
|
||||
closeHidden:document.querySelector('#close-authored-pull').hidden,
|
||||
receiptHidden:document.querySelector('#pull-close-receipt').hidden,
|
||||
focused:document.activeElement === document.querySelector('#close-authored-pull'),
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
})"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert before["hidden"] is False
|
||||
assert before["height"] >= 44
|
||||
assert receipt["height"] >= 44
|
||||
assert receipt["hidden"] is False
|
||||
assert [call["path"].rsplit("/", 1)[-1] for call in metrics["calls"]] == ["close", "reopen"]
|
||||
assert all(call["body"] == {"expected_head_sha": "abc1234"} for call in metrics["calls"])
|
||||
assert metrics["state"] == "open"
|
||||
assert metrics["closeHidden"] is False
|
||||
assert metrics["receiptHidden"] is True
|
||||
assert metrics["focused"] is True
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip(
|
||||
"packaged mobile comment-reaction journey runs only in its gated CI job",
|
||||
allow_module_level=True,
|
||||
)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
def test_comment_reactions_are_touch_safe_and_focus_preserving_at_320px():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 320, "height": 568})
|
||||
page.set_content(
|
||||
'<main><div id="status" aria-live="polite"></div>'
|
||||
'<div id="comments"><article class="issue-comment" data-comment-id="91">'
|
||||
'<p>Ship feedback</p><div id="actions"></div></article></div></main>'
|
||||
)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "comment-actions.js")
|
||||
page.evaluate(
|
||||
"""
|
||||
window.reactionCalls = [];
|
||||
window.offline = false;
|
||||
const controller = createCommentActions({
|
||||
getLogin: () => 'timmy',
|
||||
fetchJson: async (path, options = {}) => {
|
||||
reactionCalls.push([path, options.method || 'GET']);
|
||||
return {
|
||||
comment_id: 91,
|
||||
reactions: [{content: 'heart', count: options.method === 'PUT' ? 2 : 1,
|
||||
selected: options.method === 'PUT'}],
|
||||
};
|
||||
},
|
||||
});
|
||||
document.querySelector('#actions').innerHTML = controller.actionHtml({id: 91, author: 'alex'});
|
||||
controller.wire({
|
||||
root: document.querySelector('#comments'),
|
||||
getSurface: () => ({
|
||||
context: {kind: 'issue', item: {repository: 'stackchain/api', number: 7}},
|
||||
status: document.querySelector('#status'),
|
||||
}),
|
||||
isOffline: () => window.offline,
|
||||
escapeHtml: value => String(value),
|
||||
});
|
||||
"""
|
||||
)
|
||||
|
||||
trigger = page.get_by_role("button", name="React to this comment")
|
||||
trigger.click()
|
||||
menu = page.locator("[data-comment-reaction-menu]")
|
||||
expect(menu).to_be_visible()
|
||||
for control in menu.get_by_role("button").all():
|
||||
box = control.bounding_box()
|
||||
assert box is not None and box["height"] >= 44
|
||||
|
||||
page.get_by_role("button", name="Heart 1").click()
|
||||
expect(menu).to_be_hidden()
|
||||
expect(trigger).to_be_focused()
|
||||
expect(page.locator("#status")).to_have_text("Reaction updated.")
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
assert page.evaluate("window.reactionCalls") == [
|
||||
["api/v1/repos/stackchain/api/issues/7/comments/91/reactions", "GET"],
|
||||
["api/v1/repos/stackchain/api/issues/7/comments/91/reactions/heart", "PUT"],
|
||||
]
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_search_following_conversation_actions_are_touch_safe_at_320px():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 320, "height": 568})
|
||||
page.set_content(
|
||||
'<main><section id="search-preview-conversation" class="search-preview-conversation">'
|
||||
'<div id="search-preview-comments"></div>'
|
||||
'<div id="search-preview-conversation-status"></div>'
|
||||
'<button id="retry-search-preview-conversation" hidden></button>'
|
||||
'<button id="load-older-search-preview-comments" hidden></button>'
|
||||
'</section></main>'
|
||||
)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||
page.add_script_tag(path=FRONTEND / "comment-actions.js")
|
||||
page.evaluate(
|
||||
"""
|
||||
const actions=createCommentActions({fetchJson:async()=>({}),getLogin:()=> 'timmy'});
|
||||
renderSearchPreviewConversation({status:'ready',comments:[
|
||||
{id:41,author:'timmy',body:'Correct this from Following'},
|
||||
{id:42,author:'alexander',body:'React without leaving Search'},
|
||||
]},document,value=>String(value),value=>String(value),value=>String(value),actions);
|
||||
"""
|
||||
)
|
||||
|
||||
expect(page.locator(".search-preview-comment.issue-comment")).to_have_count(2)
|
||||
expect(page.get_by_role("button", name="Edit")).to_have_count(1)
|
||||
expect(page.get_by_role("button", name="Delete")).to_have_count(1)
|
||||
expect(page.get_by_role("button", name="React to this comment")).to_have_count(2)
|
||||
for control in page.locator("[data-comment-action], [data-comment-reactions-open]").all():
|
||||
box = control.bounding_box()
|
||||
assert box is not None and box["height"] >= 44
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
browser.close()
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile detail-watch journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_issue_and_pull_detail_watch_controls_are_phone_usable(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||
|
||||
for sheet, button, expected in [
|
||||
("#issue-sheet", "#watch-issue-detail", "Watch issue"),
|
||||
("#pull-sheet", "#watch-pull-detail", "Watch pull request"),
|
||||
]:
|
||||
page.locator(sheet).evaluate("node => node.classList.add('open')")
|
||||
control = page.locator(button)
|
||||
expect(control).to_be_visible()
|
||||
expect(control).to_have_text(expected)
|
||||
assert control.bounding_box()["height"] >= 44
|
||||
control.focus()
|
||||
expect(control).to_be_focused()
|
||||
page.locator(sheet).evaluate("node => node.classList.remove('open')")
|
||||
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
browser.close()
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile Following journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_following_queue_is_phone_usable_at_narrow_viewport(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||
page.add_script_tag(path=FRONTEND / "following.js")
|
||||
|
||||
row = page.locator('[data-mobile-queue="following"]')
|
||||
expect(row).to_have_count(1)
|
||||
expect(row).to_contain_text("Issues and pull requests you watch")
|
||||
following_alert = page.locator('label[for="push-following"]')
|
||||
expect(following_alert).to_have_count(1)
|
||||
expect(following_alert).to_contain_text("Notify me when Following changes")
|
||||
page.locator("#work-settings-toggle").click()
|
||||
expect(following_alert).to_be_visible()
|
||||
assert following_alert.bounding_box()["height"] >= 44
|
||||
page.evaluate("""() => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok:true,
|
||||
json:async () => ({revision:1,items:[{
|
||||
repository:'stackchain/api', kind:'pull', number:42,
|
||||
title:'Changed pull request with a long mobile title', state:'open',
|
||||
updated_at:'2026-08-23T05:00:00Z', has_unseen_change:true,
|
||||
change_summary:'Closed since last review'
|
||||
}]})
|
||||
});
|
||||
globalThis.followingReleaseQueue = attachFollowing(() => {});
|
||||
globalThis.followingReleaseQueue.open();
|
||||
}""")
|
||||
|
||||
expect(page.locator("#following-sheet")).to_be_visible()
|
||||
expect(page.locator(".following-card")).to_be_visible()
|
||||
expect(page.locator(".following-card")).to_contain_text("Closed since last review")
|
||||
expect(page.locator(".following-card")).to_contain_text("Pull request")
|
||||
expect(page.locator("#review-following")).to_have_text("Review new activity")
|
||||
assert page.locator("#review-following").bounding_box()["height"] >= 44
|
||||
assert page.locator(".following-card").bounding_box()["height"] >= 44
|
||||
assert page.locator("#close-following").bounding_box()["height"] >= 44
|
||||
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#following-sheet').close();
|
||||
document.querySelector('#search-preview').classList.add('open');
|
||||
renderSearchPreviewReview({status:'ready',data:{ci_state:'success',files:[{
|
||||
filename:'src/a/very/long/mobile/path/that-must-wrap/review.py',status:'modified',
|
||||
additions:1,deletions:1,diff_available:true,
|
||||
diff_lines:['@@ -1 +1 @@','-unsafe long line that scrolls inside the diff only',
|
||||
'+safe long line that scrolls inside the diff only']
|
||||
}]}},document,value=>String(value));
|
||||
document.querySelector('[data-search-preview-section="changes"]').hidden=false;
|
||||
}""")
|
||||
changes = page.locator("#search-preview-review")
|
||||
expect(changes).to_be_visible()
|
||||
expect(changes).to_contain_text("CI passed · 1 changed file.")
|
||||
expect(changes).to_contain_text("review.py")
|
||||
assert page.locator('[data-search-preview-section="changes"]').bounding_box()["height"] >= 44
|
||||
assert page.locator("#search-preview-review .pull-diff").evaluate(
|
||||
"node => node.scrollWidth >= node.clientWidth"
|
||||
) is True
|
||||
assert page.evaluate(
|
||||
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
|
||||
) is False
|
||||
|
||||
retire = page.locator("#watch-search-result")
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#following-sheet').close();
|
||||
document.querySelector('#search-preview').classList.add('open');
|
||||
const button = document.querySelector('#watch-search-result');
|
||||
renderSearchPreviewWatch(
|
||||
{repository:'stackchain/api', number:42, kind:'issue', state:'closed',
|
||||
following:true, watching:true},
|
||||
{status:'ready'},
|
||||
button
|
||||
);
|
||||
}""")
|
||||
expect(retire).to_be_visible()
|
||||
expect(retire).to_have_text("Stop watching & next")
|
||||
assert retire.bounding_box()["height"] >= 44
|
||||
page.evaluate("""() => renderSearchPreviewWatch(
|
||||
{repository:'stackchain/api', number:42, kind:'pull', state:'open', watching:false},
|
||||
{status:'ready'}, document.querySelector('#watch-search-result'))""")
|
||||
expect(retire).to_have_text("Watch pull request")
|
||||
assert retire.bounding_box()["height"] >= 44
|
||||
keep = page.locator("#keep-following-for-later")
|
||||
page.evaluate("document.querySelector('#keep-following-for-later').hidden = false")
|
||||
expect(keep).to_be_visible()
|
||||
expect(keep).to_have_text("Keep for later & next")
|
||||
assert keep.bounding_box()["height"] >= 44
|
||||
assert page.locator("#keep-following-status").evaluate(
|
||||
"node => getComputedStyle(node).overflowWrap"
|
||||
) == "anywhere"
|
||||
assert page.locator("#keep-following-status").bounding_box()["height"] >= 20
|
||||
overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
|
||||
assert overflow is False
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_following_alert_route_opens_truthful_empty_state_with_one_refresh():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "following.js")
|
||||
|
||||
result = page.evaluate("""async () => {
|
||||
let requests=0;
|
||||
globalThis.fetch=async () => {
|
||||
requests += 1;
|
||||
return {ok:true,json:async () => ({revision:2,items:[]})};
|
||||
};
|
||||
const queue=attachFollowing(() => {});
|
||||
await queue.route();
|
||||
return {requests,open:document.querySelector('#following-sheet').open,
|
||||
status:document.querySelector('#following-status').textContent};
|
||||
}""")
|
||||
|
||||
assert result == {
|
||||
"requests": 1,
|
||||
|
||||
"open": True,
|
||||
"status": "No watched items yet. Watch an issue or pull request from Search to keep it here.",
|
||||
}
|
||||
expect(page.locator("#following-sheet")).to_be_visible()
|
||||
assert page.locator("#close-following").bounding_box()["height"] >= 44
|
||||
browser.close()
|
||||
|
|
@ -41,10 +41,6 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
|||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.evaluate(
|
||||
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
|
||||
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
|
||||
)
|
||||
expect(page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
|
|
@ -60,7 +56,6 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
|||
expect(sheet).to_be_hidden()
|
||||
expect(page.locator("#find-work-sheet")).to_be_visible()
|
||||
page.locator("#close-find-work").click()
|
||||
expect(page.locator("#find-work-sheet")).to_be_hidden()
|
||||
|
||||
context.set_offline(True)
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
|
|
@ -71,9 +66,8 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
|||
expect(page.locator("#mobile-first-task-create")).to_be_focused()
|
||||
|
||||
page.locator("#close-mobile-first-task").click()
|
||||
expect(sheet).to_be_hidden()
|
||||
context.set_offline(False)
|
||||
probe_result = page.evaluate(
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
document.querySelector('[data-mobile-today-hud]').hidden = false;
|
||||
localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching');
|
||||
|
|
@ -84,54 +78,17 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
|||
getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true,
|
||||
coach: isolatedCoach,
|
||||
});
|
||||
return {
|
||||
result: window.firstTaskOutcomeProbe.refresh(),
|
||||
state: localStorage.getItem('stackchain.first-task.v1:timmy'),
|
||||
mobile: matchMedia('(max-width: 600px)').matches,
|
||||
};
|
||||
return window.firstTaskOutcomeProbe.refresh();
|
||||
}"""
|
||||
)
|
||||
assert probe_result == {"result": "coaching", "state": "coaching", "mobile": True}
|
||||
coach = page.locator("[data-mobile-first-task-coach]")
|
||||
try:
|
||||
expect(coach).to_be_visible()
|
||||
except AssertionError as error:
|
||||
raise AssertionError(page.evaluate("""() => {
|
||||
const coach = document.querySelector('[data-mobile-first-task-coach]');
|
||||
const hud = coach.closest('[data-mobile-today-hud]');
|
||||
return {
|
||||
coachHidden: coach.hidden,
|
||||
coachDisplay: getComputedStyle(coach).display,
|
||||
hudHidden: hud.hidden,
|
||||
hudOverlay: hud.getAttribute('data-overlay-hidden'),
|
||||
hudDisplay: getComputedStyle(hud).display,
|
||||
open: Array.from(document.querySelectorAll('dialog, .open'))
|
||||
.filter(element => element.open || element.classList.contains('open'))
|
||||
.map(element => element.id || element.className),
|
||||
};
|
||||
}""")) from error
|
||||
expect(coach).to_be_visible()
|
||||
expect(coach).to_contain_text("Complete your first task")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
completion = page.evaluate("""() => {
|
||||
const completed = window.firstTaskOutcomeProbe.completeOutcome();
|
||||
return {
|
||||
completed,
|
||||
status: document.querySelector('#today-sync-status').textContent,
|
||||
pending: Object.keys(localStorage).filter(key =>
|
||||
key.startsWith('stackchain.today-sync.v1.timmy.operation.')),
|
||||
};
|
||||
}""")
|
||||
assert completion["completed"] is True
|
||||
assert completion["pending"]
|
||||
assert "sync pending" in completion["status"]
|
||||
assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True
|
||||
expect(page.locator("#mobile-first-task-receipt")).to_be_visible()
|
||||
expect(coach).to_be_hidden()
|
||||
expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account")
|
||||
remote_activation = page.evaluate("""async () => {
|
||||
const response = await fetch('api/v1/today');
|
||||
return (await response.json()).first_task_state;
|
||||
}""")
|
||||
assert remote_activation == "complete"
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
fresh_context = browser.new_context(
|
||||
|
|
@ -143,12 +100,7 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
|||
fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
fresh_page.locator("#submit-sign-in").click()
|
||||
fresh_page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
fresh_page.evaluate(
|
||||
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
|
||||
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
|
||||
)
|
||||
expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||
fresh_page.wait_for_load_state("networkidle")
|
||||
assert fresh_page.evaluate(
|
||||
"localStorage.getItem('stackchain.first-task.v1:timmy')"
|
||||
) == "complete"
|
||||
|
|
@ -176,7 +128,6 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
|||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
workspace_requests: list[str] = []
|
||||
optional_workspace_requests: list[str] = []
|
||||
launch_transfer_events: list[str] = []
|
||||
live_requests: list[str] = []
|
||||
|
||||
|
|
@ -218,13 +169,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
|||
workspace_requests.append(request.url),
|
||||
launch_transfer_events.append("workspace-requested"),
|
||||
)
|
||||
if "feature-work-core-" in request.url else None,
|
||||
)
|
||||
page.on(
|
||||
"request",
|
||||
lambda request: optional_workspace_requests.append(request.url)
|
||||
if "feature-today-timer-" in request.url or "feature-planning-" in request.url
|
||||
else None,
|
||||
if "feature-today-timer-" in request.url else None,
|
||||
)
|
||||
page.on(
|
||||
"requestfinished",
|
||||
|
|
@ -239,10 +184,8 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
|||
)
|
||||
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
assert optional_workspace_requests == []
|
||||
page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
live_requests.clear()
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
assert launch_transfer_events.index("workspace-requested") < (
|
||||
|
|
@ -250,24 +193,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
|||
), launch_transfer_events
|
||||
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
assert optional_workspace_requests == []
|
||||
revisionless_live_requests = [url for url in live_requests if "?" not in url]
|
||||
assert len(revisionless_live_requests) == 1, live_requests
|
||||
initial_live_requests = len(live_requests)
|
||||
|
||||
page.locator("#work-settings-toggle").click()
|
||||
tomorrow = page.locator("#plan-tomorrow")
|
||||
expect(tomorrow).to_be_visible()
|
||||
assert len(optional_workspace_requests) == 2, optional_workspace_requests
|
||||
tomorrow_bounds = tomorrow.bounding_box()
|
||||
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
|
||||
tomorrow.click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
|
||||
expect(page.locator("#save-and-start-today")).to_be_hidden()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
|
|
@ -297,8 +223,19 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
|||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator("#work-settings-toggle").click()
|
||||
tomorrow = page.locator("#plan-tomorrow")
|
||||
expect(tomorrow).to_be_visible()
|
||||
tomorrow_bounds = tomorrow.bounding_box()
|
||||
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
|
||||
tomorrow.click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
|
||||
expect(page.locator("#save-and-start-today")).to_be_hidden()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator(".mobile-queue-all summary").click()
|
||||
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
|
||||
expect(delivery_queue).to_be_visible()
|
||||
expect(delivery_queue).to_contain_text("Delivery")
|
||||
|
|
@ -405,10 +342,7 @@ def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_pa
|
|||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": 390, "height": 844}, service_workers="block"
|
||||
)
|
||||
page = context.new_page()
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
|
||||
def interrupt_once(route):
|
||||
|
|
@ -423,7 +357,6 @@ def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_pa
|
|||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.locator('[data-mobile-task="find"]').click()
|
||||
|
||||
page.wait_for_timeout(1500)
|
||||
resources = page.evaluate(
|
||||
|
|
@ -469,10 +402,6 @@ def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path:
|
|||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.evaluate(
|
||||
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
|
||||
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
|
||||
)
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
|
|
@ -575,10 +504,6 @@ def test_release_artifact_reviews_and_downloads_mobile_agenda_snapshot(
|
|||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.evaluate(
|
||||
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
|
||||
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
|
||||
)
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="agenda"]').click()
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("rendered mobile quiet-hours checks run only in the browser gate", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_notification_quiet_hours_are_phone_usable_without_horizontal_scroll(viewport):
|
||||
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content(html)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.locator("#work-settings-toggle").click()
|
||||
|
||||
toggle = page.locator('label[for="push-quiet-hours"]')
|
||||
start = page.locator("#push-quiet-start")
|
||||
end = page.locator("#push-quiet-end")
|
||||
expect(toggle).to_be_visible()
|
||||
expect(start).to_be_visible()
|
||||
expect(end).to_be_visible()
|
||||
expect(start).to_have_value("22:00")
|
||||
expect(end).to_have_value("07:00")
|
||||
for control in (toggle, start, end):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
|
|
@ -114,13 +114,6 @@ def release_server(archive: Path, tmp_path: Path, gitea_url: str):
|
|||
process.communicate()
|
||||
|
||||
|
||||
def hydrate_workspace(page) -> None:
|
||||
page.evaluate(
|
||||
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
|
||||
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
|
||||
)
|
||||
|
||||
|
||||
def indexed_issue_records(page: Page) -> list[dict]:
|
||||
return page.evaluate(
|
||||
"""async () => {
|
||||
|
|
@ -282,7 +275,6 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
|||
assert fake.created_issues == []
|
||||
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
hydrate_workspace(page)
|
||||
expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
|
||||
durable_after_reload = indexed_issue_records(page)
|
||||
assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)]
|
||||
|
|
@ -314,13 +306,9 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
|||
f"requests={fake.requests[-20:]!r}"
|
||||
)
|
||||
# Deferred workspace startup can report its already-issued offline request
|
||||
# after the pre-reconnect clear. Chromium uses either its explicit offline
|
||||
# code or a generic fetch rejection for that same request, depending on
|
||||
# whether the service worker or page observes the disconnect first.
|
||||
# after the pre-reconnect clear; retain every non-offline browser error.
|
||||
browser_errors[:] = [
|
||||
error for error in browser_errors
|
||||
if "ERR_INTERNET_DISCONNECTED" not in error
|
||||
and not error.startswith("context failed TypeError: Failed to fetch")
|
||||
error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error
|
||||
]
|
||||
|
||||
for _ in range(40):
|
||||
|
|
@ -330,7 +318,6 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
|||
page.wait_for_timeout(100)
|
||||
assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||
for _ in range(40):
|
||||
completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile photo-draft journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||
|
||||
|
||||
def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must provide exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
|
||||
page = context.new_page()
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Photo draft release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.evaluate(
|
||||
"""() => new Promise((resolve, reject) => {
|
||||
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
|
||||
request.onerror=()=>reject(request.error);
|
||||
request.onupgradeneeded=()=>{
|
||||
if (!request.result.objectStoreNames.contains('drafts')) {
|
||||
request.result.createObjectStore('drafts',{keyPath:'id'});
|
||||
}
|
||||
};
|
||||
request.onsuccess=()=>{
|
||||
const transaction=request.result.transaction('drafts','readwrite');
|
||||
transaction.objectStore('drafts').put({
|
||||
id:'timmy:issue:acme%2Fmobile%3A41', version:1, ownerLogin:'timmy',
|
||||
scope:'conversation', kind:'issue', repository:'acme/mobile', number:41,
|
||||
updatedAt:Date.now(), attachments:[{
|
||||
filename:'rack-label.jpg', contentType:'image/jpeg',
|
||||
blob:new Blob(['field evidence'],{type:'image/jpeg'}), note:'Rack label',
|
||||
}],
|
||||
});
|
||||
transaction.oncomplete=resolve;
|
||||
transaction.onerror=()=>reject(transaction.error);
|
||||
};
|
||||
})"""
|
||||
)
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
stored = page.evaluate(
|
||||
"""() => new Promise((resolve,reject)=>{
|
||||
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
|
||||
request.onerror=()=>reject(request.error);
|
||||
request.onsuccess=()=>{
|
||||
const values=request.result.transaction('drafts','readonly').objectStore('drafts').getAll();
|
||||
values.onsuccess=()=>resolve(values.result.map(item=>({ownerLogin:item.ownerLogin,kind:item.kind,number:item.number})));
|
||||
values.onerror=()=>reject(values.error);
|
||||
};
|
||||
})"""
|
||||
)
|
||||
assert stored == [{"ownerLogin": "timmy", "kind": "issue", "number": 41}]
|
||||
inventory = page.evaluate(
|
||||
"""() => createConversationReplyDraftStore({indexedDB,getOwnerLogin:()=> 'timmy'}).list()"""
|
||||
)
|
||||
assert len(inventory) == 1
|
||||
expect(page.locator('[data-work-count="draft"]')).to_have_text("1", timeout=10_000)
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="draft"]').click()
|
||||
|
||||
card = page.locator(".draft-card", has_text="acme/mobile#41")
|
||||
expect(card).to_contain_text("1 saved photo")
|
||||
resume = card.get_by_role("button", name="Resume draft")
|
||||
bounds = resume.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
resume.dispatch_event("click")
|
||||
expect(page.locator("#issue-sheet")).to_be_visible()
|
||||
expect(page.locator("#issue-attachment-preview")).to_be_visible()
|
||||
expect(page.locator("#issue-attachment-meta")).to_contain_text("rack-label.jpg")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged pinned-work journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_operator_pins_and_reopens_frequent_work_without_phone_overflow(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-recent-work.js")
|
||||
page.evaluate("""() => {
|
||||
const values = new Map();
|
||||
const storage = {
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key, value) => values.set(key, value),
|
||||
};
|
||||
window.opened = [];
|
||||
window.recentWork = createMobileRecentWork({
|
||||
storage, getLogin:() => 'timmy', document,
|
||||
section:document.querySelector('#mobile-recent-work'),
|
||||
list:document.querySelector('#mobile-recent-work-list'),
|
||||
pinnedSection:document.querySelector('#mobile-pinned-work'),
|
||||
pinnedList:document.querySelector('#mobile-pinned-work-list'),
|
||||
pinnedToggle:document.querySelector('#mobile-pinned-work-toggle'),
|
||||
detailPins:document.querySelectorAll('[data-current-work-pin]'),
|
||||
status:document.querySelector('#mobile-recent-work-status'),
|
||||
openRoute:route => window.opened.push(route),
|
||||
});
|
||||
for (let number=1; number<=20; number += 1) {
|
||||
const item = {
|
||||
kind:'issue', repository:'stackchain/stackchain-dashboard', number,
|
||||
title:'Pinned mobile work ' + number,
|
||||
};
|
||||
window.recentWork.record(item);
|
||||
window.recentWork.pin(item);
|
||||
}
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""")
|
||||
|
||||
expect(page.locator("#mobile-pinned-work")).to_be_visible()
|
||||
expect(page.locator("#mobile-recent-work")).to_be_hidden()
|
||||
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(3)
|
||||
expect(page.locator("#mobile-recent-work-status")).to_have_text("Sync pending.")
|
||||
toggle = page.locator("#mobile-pinned-work-toggle")
|
||||
open_button = page.get_by_role(
|
||||
"button", name="Open Pinned mobile work 20, issue stackchain/stackchain-dashboard #20"
|
||||
).first
|
||||
pin_button = page.get_by_role(
|
||||
"button", name="Unpin Pinned mobile work 20"
|
||||
)
|
||||
for control in (open_button, pin_button, toggle):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
toggle.click()
|
||||
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(20)
|
||||
expect(toggle).to_have_text("Show fewer")
|
||||
assert toggle.get_attribute("aria-expanded") == "true"
|
||||
toggle.click()
|
||||
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(3)
|
||||
|
||||
open_button.focus()
|
||||
open_button.press("Enter")
|
||||
assert page.evaluate("window.opened") == [
|
||||
"#/my-work/issue/stackchain/stackchain-dashboard/20"
|
||||
]
|
||||
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#mobile-queue-sheet').close();
|
||||
document.querySelector('#issue-sheet').classList.add('open');
|
||||
window.recentWork.setCurrent({
|
||||
kind:'issue', repository:'stackchain/stackchain-dashboard', number:1489,
|
||||
title:'Pin the current item from mobile detail',
|
||||
});
|
||||
}""")
|
||||
detail_pin = page.get_by_role(
|
||||
"button", name="Pin Pin the current item from mobile detail"
|
||||
)
|
||||
expect(detail_pin).to_be_visible()
|
||||
bounds = detail_pin.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
detail_pin.focus()
|
||||
detail_pin.press("Enter")
|
||||
expect(detail_pin).to_have_text("Pinned")
|
||||
expect(detail_pin).to_have_attribute("aria-pressed", "true")
|
||||
assert page.evaluate("window.recentWork.pinned()[0].number") == 1489
|
||||
browser.close()
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_publishes_draft_and_continues_to_reviewer_picker(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"number": 7,
|
||||
"title": "WIP: Ship mobile flow",
|
||||
"author": "timmy",
|
||||
"state": "open",
|
||||
"draft": True,
|
||||
"merged": False,
|
||||
"head_sha": "abc1234",
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
window.publishDetail = detail;
|
||||
window.publishItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
const controller = createPullSheet({
|
||||
storage:null,
|
||||
fetchJson:(path, options) => {
|
||||
if (!path.endsWith('/ready') || options.method !== 'PATCH') {
|
||||
return Promise.reject(new Error('unexpected request'));
|
||||
}
|
||||
const payload = JSON.parse(options.body);
|
||||
if (payload.expected_head_sha !== detail.head_sha) {
|
||||
return Promise.reject(new Error('wrong head'));
|
||||
}
|
||||
return Promise.resolve({
|
||||
repository:'stackchain/api', number:7, title:'Ship mobile flow',
|
||||
head_sha:detail.head_sha, state:'open', draft:false,
|
||||
});
|
||||
},
|
||||
});
|
||||
createPullSheet.bindOwnershipControls(
|
||||
document, controller, () => window.publishItem, async () => false,
|
||||
() => window.publishDetail, () => 'timmy'
|
||||
);
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
controller.setReviewDetail(detail);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
ready = page.get_by_role("button", name="Ready for review")
|
||||
before = ready.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.hidden})")
|
||||
ready.click()
|
||||
page.get_by_text("Published and ready for review.").wait_for()
|
||||
metrics = page.evaluate(
|
||||
"""() => ({
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
readyHidden:document.querySelector('#publish-draft-pull').hidden,
|
||||
requestOpen:document.querySelector('#pull-review-request').open,
|
||||
reviewerFocused:document.activeElement === document.querySelector('#load-pull-reviewers'),
|
||||
title:window.publishDetail.title,
|
||||
draft:window.publishDetail.draft,
|
||||
})"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert before["hidden"] is False
|
||||
assert before["height"] >= 44
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
assert metrics["readyHidden"] is True
|
||||
assert metrics["requestOpen"] is True
|
||||
assert metrics["reviewerFocused"] is True
|
||||
assert metrics["title"] == "Ship mobile flow"
|
||||
assert metrics["draft"] is False
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_received_review_feedback_opens_matching_change(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"merged": False,
|
||||
"ci_state": "success",
|
||||
"head_sha": "current-head",
|
||||
"reviewers": [{
|
||||
"login": "sam",
|
||||
"status": "outdated",
|
||||
"head_sha": "old-head-123456",
|
||||
"blocking": True,
|
||||
"summary": "Please handle the narrow empty state before merging.",
|
||||
"comments": [{
|
||||
"path": "src/a/very/long/mobile/path/review_target.py",
|
||||
"body": "Return before parsing when the payload is empty.",
|
||||
"line": 12,
|
||||
}],
|
||||
}],
|
||||
"files": [{
|
||||
"filename": "src/a/very/long/mobile/path/review_target.py",
|
||||
"status": "modified",
|
||||
"additions": 2,
|
||||
"deletions": 1,
|
||||
"diff_available": True,
|
||||
"diff_lines": ["@@ -10,2 +10,3 @@", "-parse(payload)", "+if not payload: return", "+parse(payload)"],
|
||||
}],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
createPullSheet.review(detail, null, document);
|
||||
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
|
||||
createPullSheet.renderFile(file, index, false, value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'))
|
||||
).join('');
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
jump = page.locator('[data-review-feedback-file]')
|
||||
jump.click()
|
||||
metrics = page.evaluate(
|
||||
"""() => {
|
||||
const jump = document.querySelector('[data-review-feedback-file]');
|
||||
const toggle = document.querySelector('.pull-file-toggle');
|
||||
const panel = document.getElementById(toggle.getAttribute('aria-controls'));
|
||||
return {
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
jumpHeight: jump.getBoundingClientRect().height,
|
||||
expanded: toggle.getAttribute('aria-expanded'),
|
||||
panelHidden: panel.hidden,
|
||||
focused: document.activeElement === toggle,
|
||||
reviewedHead: document.querySelector('.pull-review-feedback').textContent,
|
||||
updatedReviewVisible: !document.querySelector('#request-updated-pull-review').hidden,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
assert metrics["jumpHeight"] >= 44
|
||||
assert metrics["expanded"] == "true"
|
||||
assert metrics["panelHidden"] is False
|
||||
assert metrics["focused"] is True
|
||||
assert "Reviewed head old-head" in metrics["reviewedHead"]
|
||||
assert metrics["updatedReviewVisible"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_pull_review_retries_inline_feedback_without_losing_core_review(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open", "draft": False, "mergeable": True, "merged": False,
|
||||
"ci_state": "success", "head_sha": "abc1234",
|
||||
"reviewers": [{
|
||||
"login": "sam", "review_id": 8, "status": "changes_requested",
|
||||
"head_sha": "abc1234", "blocking": True,
|
||||
"summary": "Please handle the empty state.",
|
||||
}],
|
||||
"files": [],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
window.lazyFeedbackDetail = detail;
|
||||
window.lazyFeedbackItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
let attempts = 0;
|
||||
const controller = createPullSheet({
|
||||
storage:null,
|
||||
fetchJson:() => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return Promise.reject(new Error('feedback unavailable'));
|
||||
return Promise.resolve({
|
||||
review_id:8, head_sha:'abc1234', reviewed_head_sha:'abc1234',
|
||||
comments:[{id:99, path:'src/api.py', body:'Return before parsing.', line:12}],
|
||||
});
|
||||
},
|
||||
});
|
||||
createPullSheet.bindFeedbackControls(
|
||||
document, controller, () => window.lazyFeedbackItem,
|
||||
() => window.lazyFeedbackDetail, () => 'timmy'
|
||||
);
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
const eligibility = createPullSheet.review(detail, null, document);
|
||||
document.querySelector('#pull-merge-state').textContent = eligibility.reason;
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
page.locator(".pull-review-feedback summary").click()
|
||||
load = page.get_by_role("button", name="Load inline feedback")
|
||||
load.click()
|
||||
retry = page.get_by_role("button", name="Retry feedback")
|
||||
retry.wait_for()
|
||||
retry.click()
|
||||
page.get_by_text("Return before parsing.").wait_for()
|
||||
metrics = page.evaluate(
|
||||
"""() => {
|
||||
const details = document.querySelector('.pull-review-feedback');
|
||||
const address = document.querySelector('[data-address-review-feedback]');
|
||||
return {
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
blocker: document.querySelector('#pull-merge-state').textContent,
|
||||
feedback: details.textContent,
|
||||
addressHeight: address.getBoundingClientRect().height,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
assert "requested changes" in metrics["blocker"]
|
||||
assert "Return before parsing." in metrics["feedback"]
|
||||
assert metrics["addressHeight"] >= 44
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_pull_reviewer_status_wraps_and_keeps_updated_review_reachable(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
detail = {
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"merged": False,
|
||||
"ci_state": "success",
|
||||
"head_sha": "current-head",
|
||||
"reviewers": [
|
||||
{
|
||||
"login": "casey-with-a-very-long-mobile-login",
|
||||
"status": "waiting",
|
||||
"head_sha": "current-head",
|
||||
"blocking": True,
|
||||
},
|
||||
{
|
||||
"login": "sam",
|
||||
"status": "changes_requested",
|
||||
"head_sha": "current-head",
|
||||
"blocking": True,
|
||||
},
|
||||
{
|
||||
"login": "lee",
|
||||
"status": "outdated",
|
||||
"head_sha": "old-head",
|
||||
"blocking": True,
|
||||
},
|
||||
{
|
||||
"login": "pat",
|
||||
"status": "approved",
|
||||
"head_sha": "current-head",
|
||||
"blocking": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""detail => {
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
document.querySelector('#pull-review').open = true;
|
||||
createPullSheet.review(detail, null, document);
|
||||
}""",
|
||||
detail,
|
||||
)
|
||||
|
||||
metrics = page.evaluate(
|
||||
"""() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
updatedReviewHeight: document.querySelector('#request-updated-pull-review')
|
||||
.getBoundingClientRect().height,
|
||||
updatedReviewVisible: !document.querySelector('#request-updated-pull-review').hidden,
|
||||
statuses: Array.from(document.querySelectorAll('.pull-reviewer-status span'))
|
||||
.map(node => node.textContent),
|
||||
})"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
assert metrics["updatedReviewHeight"] >= 44
|
||||
assert metrics["updatedReviewVisible"] is True
|
||||
assert metrics["statuses"] == [
|
||||
"Waiting for review",
|
||||
"Changes requested",
|
||||
"Outdated after new commits",
|
||||
"Approved current head",
|
||||
]
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged release-failure recovery runs only in the browser gate", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_failed_release_check_is_diagnosable_and_retryable_on_phone(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "release-receipt.js")
|
||||
result = page.evaluate("""async () => {
|
||||
const calls=[];
|
||||
const values=new Map();
|
||||
const storage={
|
||||
getItem:key=>values.get(key)||null,
|
||||
setItem:(key,value)=>values.set(key,value),
|
||||
removeItem:key=>values.delete(key),
|
||||
};
|
||||
const receipt=createReleaseReceipt({
|
||||
storage,
|
||||
getLogin:()=> 'timmy',
|
||||
listNode:document.querySelector('#release-watchlist'),
|
||||
confirmAction:()=>true,
|
||||
fetchJson:async(path, options={})=>{
|
||||
calls.push({path, method:options.method || 'GET'});
|
||||
if (path.endsWith('/failure')) return {
|
||||
commit_sha:'abc1234', run_id:91, job_index:3,
|
||||
name:'CI / browser', excerpt:'AssertionError: mobile viewport overflow',
|
||||
};
|
||||
if (path.endsWith('/retry')) return {commit_sha:'abc1234', status:'queued'};
|
||||
return {
|
||||
commit_sha:'abc1234', ci_state:'failure', release:null,
|
||||
checks:[{
|
||||
name:'CI / browser with a deliberately long workflow name',
|
||||
state:'failure', description:'Playwright failed at the narrow viewport',
|
||||
url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',
|
||||
recovery:{run_id:91, job_index:3},
|
||||
}],
|
||||
};
|
||||
},
|
||||
});
|
||||
receipt.capture(
|
||||
{repository:'stackchain/api-with-a-long-mobile-name', number:7, key:'stackchain/api-with-a-long-mobile-name#7'},
|
||||
{merge_commit_sha:'abc1234'},
|
||||
);
|
||||
document.querySelector('#release-receipt-sheet').showModal();
|
||||
await receipt.refresh();
|
||||
const review=document.querySelector('.release-failure-summary button');
|
||||
const reviewHeight=review.getBoundingClientRect().height;
|
||||
review.click();
|
||||
await new Promise(resolve=>setTimeout(resolve, 0));
|
||||
const recovery=document.querySelector('.release-failure-recovery');
|
||||
const retry=recovery.querySelector('button');
|
||||
const retryHeight=retry.getBoundingClientRect().height;
|
||||
const excerpt=recovery.querySelector('pre').textContent;
|
||||
const sha=recovery.querySelector('strong').textContent;
|
||||
const job=recovery.querySelector('a').href;
|
||||
const overflow=document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||
retry.click();
|
||||
await new Promise(resolve=>setTimeout(resolve, 0));
|
||||
return {
|
||||
calls, reviewHeight, retryHeight, excerpt, sha, job, overflow,
|
||||
state:receipt.restore()[0].status.label,
|
||||
};
|
||||
}""")
|
||||
expect(page.locator("#release-receipt-sheet")).to_be_visible()
|
||||
assert result["reviewHeight"] >= 44
|
||||
assert result["retryHeight"] >= 44
|
||||
assert result["excerpt"] == "AssertionError: mobile viewport overflow"
|
||||
assert result["sha"] == "Merge abc1234"
|
||||
assert result["job"].endswith("/actions/runs/91/jobs/3")
|
||||
assert result["overflow"] is False
|
||||
assert result["state"] == "Checks running"
|
||||
assert [call["method"] for call in result["calls"]] == ["GET", "GET", "POST"]
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_failed_release_prepares_a_visible_draft_rollback_receipt_on_phone(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "release-receipt.js")
|
||||
result = page.evaluate("""async () => {
|
||||
const values=new Map(), calls=[], opened=[];
|
||||
const receipt=createReleaseReceipt({
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:()=> 'timmy', listNode:document.querySelector('#release-watchlist'),
|
||||
confirmAction:()=>true, openPull:pull=>opened.push(pull),
|
||||
fetchJson:async(path, options={})=>{
|
||||
calls.push({path,method:options.method||'GET'});
|
||||
if(path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'failed'};
|
||||
if(path.endsWith('/rollback')) return {repository:'stackchain/api',number:9,draft:true,rollback_of:'abc1234',url:'https://forge.example/git/stackchain/api/pulls/9'};
|
||||
return {commit_sha:'abc1234',ci_state:'failure',release:null,checks:[{name:'CI / browser',state:'failure',recovery:{run_id:91,job_index:3}}]};
|
||||
},
|
||||
});
|
||||
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'},{merge_commit_sha:'abc1234'});
|
||||
document.querySelector('#release-receipt-sheet').showModal();
|
||||
await receipt.refresh();
|
||||
document.querySelector('.release-failure-summary button').click();
|
||||
await new Promise(resolve=>setTimeout(resolve,0));
|
||||
const rollback=[...document.querySelectorAll('.release-failure-actions button')].find(button=>button.textContent==='Prepare rollback PR');
|
||||
const actionHeight=rollback.getBoundingClientRect().height;
|
||||
rollback.click();
|
||||
await new Promise(resolve=>setTimeout(resolve,0));
|
||||
const status=document.querySelector('.release-rollback-status');
|
||||
return {calls,opened,actionHeight,status:status?.textContent||'',overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth};
|
||||
}""")
|
||||
assert result["actionHeight"] >= 44
|
||||
assert result["status"] == "Draft rollback #9 is ready for review."
|
||||
assert result["opened"][0]["number"] == 9
|
||||
assert result["calls"][-1]["path"].endswith("/release-receipt/abc1234/rollback")
|
||||
assert result["overflow"] is False
|
||||
browser.close()
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("rendered authored-pull recovery checks run only in the browser gate", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_mobile_search_reopens_closed_authored_pull_into_my_work(width, height):
|
||||
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch()
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page.set_content(html)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||
page.locator("#search-preview").evaluate("node => node.classList.add('open')")
|
||||
page.evaluate("""() => {
|
||||
const detail = {
|
||||
repository:'stackchain/web', number:9, kind:'pull', state:'closed',
|
||||
authored_pull_reopenable:true, head_sha:'abc1234',
|
||||
};
|
||||
const button = document.querySelector('#start-search-result');
|
||||
globalThis.renderSearchPreviewStart(detail, {status:'ready'}, button);
|
||||
globalThis.recoveryMetrics = {calls:[], history:['search']};
|
||||
const mutate = globalThis.searchPreviewMutation((path, options) => {
|
||||
globalThis.recoveryMetrics.calls.push({path, body:JSON.parse(options.body)});
|
||||
return Promise.resolve({...detail, state:'open'});
|
||||
});
|
||||
const preview = globalThis.createSearchPreview({
|
||||
fetchJson:async () => detail,
|
||||
mutate,
|
||||
onState:() => {},
|
||||
});
|
||||
preview.open(detail);
|
||||
const recover = globalThis.createSearchAuthoredPullRecovery({
|
||||
confirm:message => window.confirm(message),
|
||||
reopen:target => preview.reopenPull(target),
|
||||
refresh:async () => { globalThis.recoveryMetrics.refreshed = true; },
|
||||
find:() => ({repository:'stackchain/web',number:9,kind:'pull',key:'stackchain/web#9'}),
|
||||
open:item => globalThis.recoveryMetrics.history.push(item.kind),
|
||||
unavailable:message => { globalThis.recoveryMetrics.unavailable = message; },
|
||||
});
|
||||
button.addEventListener('click', () => recover(detail));
|
||||
}""")
|
||||
|
||||
action = page.locator("#start-search-result")
|
||||
expect(action).to_be_visible()
|
||||
expect(action).to_have_text("Reopen in My Work")
|
||||
bounds = action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.once("dialog", lambda dialog: dialog.accept())
|
||||
action.click()
|
||||
page.wait_for_function("globalThis.recoveryMetrics.history.length === 2")
|
||||
metrics = page.evaluate("globalThis.recoveryMetrics")
|
||||
|
||||
assert metrics["calls"] == [{
|
||||
"path": "api/v1/repos/stackchain/web/pulls/9/reopen",
|
||||
"body": {"expected_head_sha": "abc1234"},
|
||||
}]
|
||||
assert metrics["refreshed"] is True
|
||||
assert metrics["history"] == ["search", "pull"]
|
||||
assert "unavailable" not in metrics
|
||||
browser.close()
|
||||
|
|
@ -32,29 +32,12 @@ def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_d
|
|||
page.locator("#search-preview-conversation").evaluate("node => node.style.minHeight = '500px'")
|
||||
page.locator("#search-preview-reply-workspace").evaluate("node => node.style.minHeight = '500px'")
|
||||
page.locator("#search-preview-actions").evaluate("node => node.style.minHeight = '300px'")
|
||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||
page.evaluate("""() => {
|
||||
const assignedIssue = {
|
||||
kind:'issue', state:'open', claimable:false, assigned_to_me:false,
|
||||
assignees:['alexander'], watching:false,
|
||||
};
|
||||
globalThis.renderSearchPreviewWatch(
|
||||
assignedIssue, {status:'ready'}, document.querySelector('#watch-search-result')
|
||||
);
|
||||
}""")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-search-preview-nav.js")
|
||||
|
||||
watch = page.locator("#watch-search-result")
|
||||
expect(watch).to_be_visible()
|
||||
expect(watch).to_have_text("Watch issue")
|
||||
watch_bounds = watch.bounding_box()
|
||||
assert watch_bounds and watch_bounds["height"] >= 44
|
||||
navigation = page.locator(".mobile-search-preview-nav")
|
||||
expect(navigation).to_be_visible()
|
||||
buttons = navigation.locator("button:visible")
|
||||
assert navigation.locator("button").count() == 5
|
||||
buttons = navigation.locator("button")
|
||||
assert buttons.count() == 4
|
||||
expect(navigation.locator('[data-search-preview-section="changes"]')).to_be_hidden()
|
||||
for button in buttons.all():
|
||||
bounds = button.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
|
@ -88,62 +71,3 @@ def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_d
|
|||
page.set_viewport_size({"width": 800, "height": 700})
|
||||
expect(navigation).to_be_hidden()
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_rendered_search_to_preview_journey_contains_focus_until_final_close(width, height):
|
||||
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch()
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page.set_content(html)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-search-modal.js")
|
||||
page.evaluate("""() => {
|
||||
const palette = document.querySelector('#cmd-palette');
|
||||
const preview = document.querySelector('#search-preview');
|
||||
palette.classList.add('open');
|
||||
window.searchModalTest = createMobileSearchModal({document});
|
||||
window.searchModalTest.activate(palette, {
|
||||
opener:document.querySelector('[data-mobile-task="search"]'),
|
||||
initialFocus:document.querySelector('#cmd-input'),
|
||||
});
|
||||
preview.append(Object.assign(document.createElement('button'), {id:'dynamic-preview-action', textContent:'Dynamic action'}));
|
||||
}""")
|
||||
|
||||
expect(page.locator("#cmd-input")).to_be_focused()
|
||||
expect(page.locator("#cmd-palette")).to_have_attribute("aria-modal", "true")
|
||||
assert page.locator("body > header").evaluate("node => node.inert") is True
|
||||
assert page.locator("main").evaluate("node => node.inert") is True
|
||||
assert page.locator("#mobile-task-dock").evaluate("node => node.inert") is True
|
||||
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#cmd-palette').classList.remove('open');
|
||||
document.querySelector('#search-preview').classList.add('open');
|
||||
searchModalTest.transition(document.querySelector('#search-preview'), {
|
||||
initialFocus:document.querySelector('#close-search-preview'),
|
||||
});
|
||||
document.querySelector('#dynamic-preview-action').focus();
|
||||
}""")
|
||||
page.keyboard.press("Tab")
|
||||
expect(page.locator("#close-search-preview")).to_be_focused()
|
||||
assert page.locator("main").evaluate("node => node.inert") is True
|
||||
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('#search-preview').classList.remove('open');
|
||||
document.querySelector('#cmd-palette').classList.add('open');
|
||||
searchModalTest.transition(document.querySelector('#cmd-palette'), {
|
||||
initialFocus:document.querySelector('#cmd-input'),
|
||||
});
|
||||
}""")
|
||||
expect(page.locator("#cmd-input")).to_be_focused()
|
||||
assert page.locator("main").evaluate("node => node.inert") is True
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.evaluate("searchModalTest.deactivate()")
|
||||
expect(page.locator('[data-mobile-task="search"]')).to_be_focused()
|
||||
assert page.locator("body > header").evaluate("node => node.inert") is False
|
||||
assert page.locator("main").evaluate("node => node.inert") is False
|
||||
assert page.locator("#mobile-task-dock").evaluate("node => node.inert") is False
|
||||
browser.close()
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged branch-cleanup journey runs only in the browser gate", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_merged_source_branch_cleanup_is_phone_openable_and_exact(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "release-receipt.js")
|
||||
result = page.evaluate("""async () => {
|
||||
const calls=[];
|
||||
const values=new Map();
|
||||
const storage={
|
||||
getItem:key=>values.get(key)||null,
|
||||
setItem:(key,value)=>values.set(key,value),
|
||||
removeItem:key=>values.delete(key),
|
||||
};
|
||||
const receipt=createReleaseReceipt({
|
||||
storage,
|
||||
getLogin:()=> 'timmy',
|
||||
listNode:document.querySelector('#release-watchlist'),
|
||||
confirmAction:()=>true,
|
||||
fetchJson:async(path, options={})=>{
|
||||
calls.push({path, method:options.method, body:options.body});
|
||||
return {deleted:true, source_branch:'timmy/feature', source_head_sha:'abc1234'};
|
||||
},
|
||||
});
|
||||
receipt.capture(
|
||||
{repository:'stackchain/api', number:7, key:'stackchain/api#7'},
|
||||
{merge_commit_sha:'merge456', source_branch:'timmy/feature', source_head_sha:'abc1234', source_repository:'stackchain/api'},
|
||||
);
|
||||
document.querySelector('#release-receipt-sheet').showModal();
|
||||
const button=document.querySelector('.release-watchlist-actions button');
|
||||
const box=button.getBoundingClientRect();
|
||||
const label=button.getAttribute('aria-label');
|
||||
button.click();
|
||||
await new Promise(resolve=>setTimeout(resolve, 0));
|
||||
return {
|
||||
calls, label, height:box.height,
|
||||
branch:document.querySelector('.release-branch-cleanup-status').textContent,
|
||||
overflow:document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
};
|
||||
}""")
|
||||
expect(page.locator("#release-receipt-sheet")).to_be_visible()
|
||||
assert result["height"] >= 44
|
||||
assert result["label"] == "Delete merged source branch timmy/feature at abc1234"
|
||||
assert result["calls"][0]["method"] == "DELETE"
|
||||
assert result["branch"] == "Branch timmy/feature · deleted"
|
||||
assert result["overflow"] is False
|
||||
browser.close()
|
||||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
|||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def open_today_action(page, selector: str):
|
||||
|
|
@ -52,7 +52,6 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
|||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
|
||||
try:
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
|
|
@ -270,7 +269,6 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
|||
localStorage.setItem(prefix + 'broken', '{not-json');
|
||||
}""")
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
expect(page.locator("#today-sync-status")).to_have_text(
|
||||
"Today queue recovered · discarded 1 unreadable device record."
|
||||
|
|
@ -278,16 +276,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
|||
assert page.evaluate("""() => !Object.keys(localStorage).some(
|
||||
key => key.startsWith('stackchain.today-sync.v1.timmy.operation.')
|
||||
)""")
|
||||
try:
|
||||
expect(page.locator("#today-break-status")).to_contain_text("On break · resume in")
|
||||
except AssertionError as error:
|
||||
raise AssertionError(page.evaluate("""() => ({
|
||||
login: document.querySelector('#current-user')?.textContent,
|
||||
timers: Object.fromEntries(Object.keys(localStorage)
|
||||
.filter(key => key.startsWith('stackchain.today-timer.v1.'))
|
||||
.map(key => [key, JSON.parse(localStorage.getItem(key))])),
|
||||
status: document.querySelector('#today-break-status').outerHTML,
|
||||
})""")) from error
|
||||
expect(page.locator("#today-break-status")).to_contain_text("On break · resume in")
|
||||
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
|
||||
page.locator("#close-issue-sheet").click()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
|
|
@ -388,7 +377,6 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t
|
|||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
|
|
@ -428,7 +416,6 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t
|
|||
queue_pause = page.locator("#mobile-queue-sheet [data-today-detour]")
|
||||
expect(queue_pause).to_be_visible()
|
||||
expect(queue_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||
page.locator(".mobile-queue-all summary").click()
|
||||
page.locator('[data-mobile-queue="later"]').click()
|
||||
persistent_pause = page.locator("#my-work > [data-today-detour]")
|
||||
expect(persistent_pause).to_be_visible()
|
||||
|
|
@ -458,7 +445,6 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t
|
|||
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'insights';
|
||||
}""")
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
restored_pause = page.locator("#insights-sheet > [data-today-detour]")
|
||||
expect(restored_pause).to_be_visible()
|
||||
expect(restored_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||
|
|
@ -525,7 +511,6 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
|
|||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
|
|
@ -576,7 +561,6 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
|
|||
}));
|
||||
}""")
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
|
||||
page.locator("#close-issue-sheet").click()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
|||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
|
|
@ -50,7 +50,6 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
|
|||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
|||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
|
|
@ -47,7 +47,6 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
|
|||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pytest.importorskip("playwright.sync_api")
|
|||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
|
|
@ -71,7 +71,6 @@ def test_release_artifact_resolves_cross_device_tomorrow_conflicts_on_mobile(
|
|||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
|
||||
def stage_phone_plan():
|
||||
page.evaluate(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
|||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path):
|
||||
|
|
@ -36,7 +36,6 @@ def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path)
|
|||
'issue:acme/mobile:41:', 'issue:acme/mobile:42:'
|
||||
]))""")
|
||||
page.reload(wait_until="networkidle")
|
||||
hydrate_workspace(page)
|
||||
|
||||
dialog = page.locator("#today-handoff-dialog")
|
||||
expect(dialog).to_be_visible()
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("progressive mobile dock checks run only in the browser gate", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [{"width": 320, "height": 568}, {"width": 390, "height": 844}])
|
||||
def test_mobile_work_and_queues_are_usable_before_optional_hydration(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch()
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "my-work.js")
|
||||
page.add_script_tag(path=FRONTEND / "context-poller.js")
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
window.fetch = async () => ({
|
||||
ok:true, headers:{get:()=>null}, json:async()=>({
|
||||
context:{
|
||||
user:{login:'timmy'},
|
||||
issues:[{
|
||||
number:1427,title:'Progressive mobile dock',repository:'stackchain/stackchain-dashboard',
|
||||
assignees:['timmy'],work_reasons:['created_by_me'],url:'https://forge.example/issues/1427',
|
||||
}],
|
||||
pull_requests:[],
|
||||
},
|
||||
events:[],notifications:[],
|
||||
}),
|
||||
});
|
||||
}"""
|
||||
)
|
||||
page.add_script_tag(path=FRONTEND / "progressive-my-work.js")
|
||||
expect(page.locator("#my-work-status")).to_contain_text("1 assigned work item")
|
||||
page.add_script_tag(path=FRONTEND / "progressive-mobile-dock.js")
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
expect(page.locator("#mobile-queue-sheet")).to_be_visible()
|
||||
expect(page.locator('[data-mobile-queue="filed"] [data-mobile-queue-count]')).to_have_text("1")
|
||||
page.locator(".mobile-queue-all summary").click()
|
||||
expect(page.locator('[data-mobile-queue="delivery"]')).to_contain_text("Still loading")
|
||||
next_action = page.locator("#mobile-queue-next-action")
|
||||
bounds = next_action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator('[data-mobile-queue="filed"]').click()
|
||||
expect(page.locator("#progressive-work-detail")).to_be_visible()
|
||||
expect(page.locator("#progressive-work-detail-title")).to_have_text("Progressive mobile dock")
|
||||
page.locator("#close-progressive-work-detail").click()
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
expect(page.locator("#progressive-work-detail")).to_be_visible()
|
||||
expect(page.locator("#progressive-work-detail-title")).to_have_text("Progressive mobile dock")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
|
|
@ -6,39 +6,6 @@ from src.passkey_store import PasskeyStore
|
|||
from src.session_store import SessionStore, SessionStoreError
|
||||
|
||||
|
||||
def test_global_access_revocation_preserves_other_principal_credentials_and_sessions(
|
||||
tmp_path,
|
||||
):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
for principal_id in (42, 84):
|
||||
sessions.activate(
|
||||
f"session-{principal_id}",
|
||||
2_000,
|
||||
management_id=f"management-{principal_id}",
|
||||
principal_id=principal_id,
|
||||
principal_login=f"operator-{principal_id}",
|
||||
)
|
||||
passkeys.register(
|
||||
credential_id=f"credential-{principal_id}".encode(),
|
||||
public_key=f"public-key-{principal_id}".encode(),
|
||||
sign_count=0,
|
||||
device_label=f"Device {principal_id}",
|
||||
management_id=f"management-{principal_id}",
|
||||
principal_id=principal_id,
|
||||
)
|
||||
|
||||
passkeys.revoke_all_access(principal_id=42)
|
||||
|
||||
assert passkeys.all(principal_id=42) == []
|
||||
assert [item.principal_id for item in passkeys.all(principal_id=84)] == [84]
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT principal_id FROM active_sessions ORDER BY principal_id"
|
||||
).fetchall() == [(84,)]
|
||||
|
||||
|
||||
def test_global_access_revocation_rolls_back_every_credential_and_session_change(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
|
|
@ -47,8 +14,6 @@ def test_global_access_revocation_rolls_back_every_credential_and_session_change
|
|||
2_000,
|
||||
management_id="phone-management-id",
|
||||
device_label="Phone",
|
||||
principal_id=42,
|
||||
principal_login="timmy",
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
"phone-session",
|
||||
|
|
@ -63,7 +28,6 @@ def test_global_access_revocation_rolls_back_every_credential_and_session_change
|
|||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
principal_id=42,
|
||||
)
|
||||
passkeys.issue_challenge(
|
||||
b"pending-challenge",
|
||||
|
|
@ -79,7 +43,7 @@ def test_global_access_revocation_rolls_back_every_credential_and_session_change
|
|||
)
|
||||
|
||||
with pytest.raises(SessionStoreError):
|
||||
passkeys.revoke_all_access(principal_id=42)
|
||||
passkeys.revoke_all_access()
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM passkey_credentials").fetchone() == (1,)
|
||||
|
|
@ -99,8 +63,6 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
|
|||
2_000,
|
||||
management_id=f"{slug}-management-id",
|
||||
device_label=label,
|
||||
principal_id=42,
|
||||
principal_login="timmy",
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
f"{slug}-session",
|
||||
|
|
@ -114,7 +76,6 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
|
|||
sign_count=0,
|
||||
device_label=label,
|
||||
management_id=f"{slug}-management-id",
|
||||
principal_id=42,
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
|
|
@ -124,7 +85,7 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
|
|||
)
|
||||
|
||||
with pytest.raises(SessionStoreError):
|
||||
passkeys.revoke_device_access("phone-management-id", principal_id=42)
|
||||
passkeys.revoke_device_access("phone-management-id")
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
|
|
@ -134,69 +95,3 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
|
|||
"SELECT management_id FROM passkey_credentials ORDER BY management_id"
|
||||
).fetchall() == [("laptop-management-id",), ("phone-management-id",)]
|
||||
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (2,)
|
||||
|
||||
|
||||
def test_device_access_revocation_cannot_cross_principal_boundary(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
for principal_id in (42, 84):
|
||||
sessions.activate(
|
||||
f"session-{principal_id}",
|
||||
2_000,
|
||||
management_id=f"management-{principal_id}",
|
||||
principal_id=principal_id,
|
||||
principal_login=f"operator-{principal_id}",
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
f"session-{principal_id}",
|
||||
action="close_issue",
|
||||
target="stackchain/dashboard#1",
|
||||
ttl_seconds=90,
|
||||
)
|
||||
passkeys.register(
|
||||
credential_id=f"credential-{principal_id}".encode(),
|
||||
public_key=f"public-key-{principal_id}".encode(),
|
||||
sign_count=0,
|
||||
device_label=f"Device {principal_id}",
|
||||
management_id=f"management-{principal_id}",
|
||||
principal_id=principal_id,
|
||||
)
|
||||
|
||||
assert passkeys.revoke_device_access("management-84", principal_id=42) is False
|
||||
assert passkeys.revoke_device_access("management-42", principal_id=42) is True
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT principal_id FROM active_sessions ORDER BY principal_id"
|
||||
).fetchall() == [(84,)]
|
||||
assert connection.execute(
|
||||
"SELECT principal_id FROM passkey_credentials ORDER BY principal_id"
|
||||
).fetchall() == [(84,)]
|
||||
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_global_access_revocation_returns_only_affected_management_ids(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
for principal_id in (42, 84):
|
||||
for suffix in ("phone", "laptop"):
|
||||
sessions.activate(
|
||||
f"session-{principal_id}-{suffix}",
|
||||
2_000,
|
||||
management_id=f"management-{principal_id}-{suffix}",
|
||||
principal_id=principal_id,
|
||||
principal_login=f"operator-{principal_id}",
|
||||
)
|
||||
|
||||
affected = passkeys.revoke_all_access(principal_id=42)
|
||||
|
||||
assert affected == ["management-42-laptop", "management-42-phone"]
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT management_id FROM active_sessions ORDER BY management_id"
|
||||
).fetchall() == [
|
||||
("management-84-laptop",),
|
||||
("management-84-phone",),
|
||||
]
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user