Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9189d13db9 | ||
|
|
9786724678 | ||
|
|
6b99d4f171 | ||
|
|
7d633e67e8 | ||
|
|
bd5e66b4e5 | ||
|
|
add45365ab | ||
|
|
6138b1f24e | ||
|
|
40c66df143 | ||
|
|
7356530318 | ||
|
|
9f6476a5b4 | ||
|
|
de8227f4f4 | ||
|
|
ccf5f69400 | ||
|
|
207df76b69 | ||
|
|
43c7a85eec | ||
|
|
d0b8c3dc0d | ||
|
|
6e6e63e553 | ||
|
|
54029d2668 | ||
|
|
1f57b75d5d | ||
|
|
fefbe7e084 | ||
|
|
31a07e4a46 | ||
|
|
b458dbaa78 | ||
|
|
8c4e729c4a | ||
|
|
80417abd5b | ||
|
|
37d55e9b20 | ||
|
|
94cbde3cf9 | ||
|
|
fedddb246d | ||
|
|
e28472b268 | ||
|
|
3b85b4f35d | ||
|
|
997cb7dcd7 | ||
|
|
04bb6839ae | ||
|
|
1fdf6f417d | ||
|
|
cc38b3d0f6 | ||
|
|
b11ec6a732 | ||
|
|
e58808f2d4 | ||
|
|
e0543b2007 | ||
|
|
0ac1277479 | ||
|
|
6185f6a7ff | ||
|
|
98d6a29fa0 | ||
|
|
3decdb4cac | ||
|
|
0fa3e675dc | ||
|
|
3210b8be6d | ||
|
|
87becb08ad | ||
|
|
334fe77e84 | ||
|
|
c8de28ca0c | ||
|
|
741d768fc3 | ||
|
|
acd2d82d10 | ||
|
|
65d1f9f194 | ||
|
|
c74c63d29c | ||
|
|
94aad3e76e | ||
|
|
00a956b1fb | ||
|
|
7bdfe62495 | ||
|
|
1b10a5a8a8 | ||
|
|
a7afd85634 |
No files matched your search
@@ -24,11 +24,19 @@ issue-comment API; mobile issue capture requires issue
|
||||
creation and assignment permission. The New issue sheet can optionally select an open
|
||||
repository milestone and due date; the dashboard validates both and sends them with
|
||||
self-assignment in the single create request, so planned work appears in its release
|
||||
lane immediately. Issue capture and authored mobile actions (issue
|
||||
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
|
||||
account-bound title/description captures without selecting a repository or entering the
|
||||
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
|
||||
the same Gitea login, **Choose repository** restores the capture to the normal planning
|
||||
and durable delivery flow. A different or unconfirmed account can only copy or discard
|
||||
the private content. Issue capture and authored mobile actions (issue
|
||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||
another worker replays a confirmed result instead of posting duplicate content. Results
|
||||
are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
another worker replays a confirmed result instead of posting duplicate content. Closed-app
|
||||
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
|
||||
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
|
||||
with the unchanged idempotency key. Device purge cancels an active drain before closing
|
||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
||||
writes run outside the request event loop, and lock admission is bounded to 100 ms by
|
||||
@@ -45,34 +53,77 @@ comment path and submits the summary, decision, and inline comments in one revie
|
||||
request. The dashboard rechecks the current pull-request head, CI success, draft
|
||||
state, and mergeability immediately before every merge.
|
||||
Serve the dashboard only to trusted users on its own origin; cross-origin API
|
||||
access is intentionally disabled. For any deployment not already behind an
|
||||
authenticated gateway, configure the built-in single-operator boundary with two
|
||||
independent high-entropy secrets. The access token is entered at `/login`; the
|
||||
browser receives only a short-lived signed session and CSRF proof. Generate the
|
||||
values in your secret manager (for example, `openssl rand -hex 32`) and inject
|
||||
access is intentionally disabled. Authentication defaults to fail-closed
|
||||
`operator` mode. It requires two independent secrets of at least 24 characters;
|
||||
missing, short, reused, or invalid configuration leaves `/healthz` available but
|
||||
returns HTTP 503 before any Gitea access. The access token is entered at `/login`;
|
||||
the browser receives only a short-lived signed session and CSRF proof. Generate
|
||||
both values in your secret manager (for example, `openssl rand -hex 32`) and inject
|
||||
them at runtime—never commit them:
|
||||
|
||||
```bash
|
||||
export GITEA_URL='https://forge.example.com'
|
||||
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
|
||||
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
|
||||
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
||||
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
|
||||
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
|
||||
# Optional; defaults to eight hours.
|
||||
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
|
||||
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
|
||||
export STACKCHAIN_LOGIN_MAX_FAILURES=5
|
||||
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
|
||||
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
||||
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Sign-in failures are scoped to a hashed canonical client address and persisted across
|
||||
workers and restarts. Once the budget is exhausted, the server returns `429` with
|
||||
`Retry-After`; the mobile login form disables retries for that interval. Expired
|
||||
source records are pruned and the ledger is size-bounded. Keep its SQLite file on
|
||||
shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is
|
||||
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
|
||||
that setting, a reverse proxy is safely treated as one shared source.
|
||||
|
||||
Each signed cookie includes an opaque session identifier whose hash and expiry are
|
||||
kept in the SQLite session registry. Keep that registry on persistent, writable
|
||||
storage shared by all dashboard workers. If an active session expires or is revoked,
|
||||
the first authenticated API rejection replaces the dashboard with sign-in and explains
|
||||
that private drafts remain on the device; signing in again resumes account-bound queued
|
||||
delivery. This recovery does not clear offline state. **Sign out & clear this device** revokes
|
||||
only the current session before clearing browser state, so a copied cookie cannot
|
||||
be replayed afterward; other signed-in devices remain active. **Sign out all
|
||||
devices** is a separately confirmed lost-device safety action that atomically
|
||||
revokes every existing operator session before clearing the current browser and
|
||||
returning to sign-in. Deploying this version invalidates older cookies that do not
|
||||
contain a registered identifier, so operators must sign in once again. Registry
|
||||
read or write failures return a sanitized HTTP 503 before Gitea is contacted.
|
||||
|
||||
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
|
||||
`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath.
|
||||
Every response also defines the browser execution boundary with a Content Security
|
||||
Policy that allows scripts only from the dashboard origin, denies framing and
|
||||
plugins, and blocks unused browser capabilities. Keep these response headers when
|
||||
proxying; do not add inline scripts or broaden `script-src`. The dashboard bootstrap
|
||||
and stylesheet are same-origin static assets included in the offline PWA shell.
|
||||
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
||||
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
|
||||
removing unrelated forge preferences. Rotate either dashboard secret by replacing
|
||||
the injected value and restarting the service; changing the signing secret expires
|
||||
all sessions immediately.
|
||||
all sessions immediately. The signing key is never derived from the access token.
|
||||
|
||||
Open `http://127.0.0.1:8000/` only for an unprotected local development run. To
|
||||
verify the backend and its Gitea connection directly, sign in and request
|
||||
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
|
||||
For direct, unproxied local development only, set
|
||||
`STACKCHAIN_DASHBOARD_AUTH_MODE=insecure-local` and open
|
||||
`http://127.0.0.1:8000/`. This mode checks the network peer and rejects non-loopback
|
||||
clients; do not use it behind a reverse proxy, whose loopback connection would hide
|
||||
the browser's peer address. To verify the backend and its Gitea connection in
|
||||
operator mode, sign in and request `http://127.0.0.1:8000/api/v1/context`; a
|
||||
successful response is JSON containing
|
||||
`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard
|
||||
to search commands plus issues and pull requests across every repository visible to
|
||||
the configured Gitea token. Remote search starts after two characters, is debounced,
|
||||
@@ -100,6 +151,12 @@ other writes. Requests that cannot enter within 250 ms fail as retryable HTTP
|
||||
below the route deadlines. Streaming diff reads share the same read capacity,
|
||||
while POST, PATCH, PUT, and DELETE requests are never coalesced.
|
||||
|
||||
Live snapshots expose independent `context`, `events`, and `notifications`
|
||||
revisions. The browser sends its known revisions on later polls, so `/api/v1/live`
|
||||
can omit unchanged section bodies while still returning current freshness and
|
||||
retry metadata. The client retains omitted data and only rebuilds or persists the
|
||||
sections that changed.
|
||||
|
||||
## Offline mobile shell
|
||||
|
||||
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,
|
||||
@@ -141,7 +198,19 @@ and registered with Background Sync, so a supporting installed browser can deliv
|
||||
new issues, issue comments, pull-request comments, and unread-update replies after
|
||||
every dashboard client has closed. The worker verifies the current Gitea login, shares
|
||||
an atomic delivery claim with the foreground path, and preserves the original
|
||||
idempotency key. Browsers without
|
||||
idempotency key. Installed browsers can explicitly enable **Notify me when queued
|
||||
work finishes**; permission is requested only from that user gesture and the choice
|
||||
is stored for the confirmed account in the private background outbox database.
|
||||
Successful deliveries produce privacy-safe receipts that open the created issue or
|
||||
source conversation, while permanent validation failures open Drafts for recovery.
|
||||
Authentication expiry during a worker mutation is recoverable: the worker releases the
|
||||
claim without creating an Attention receipt, stops that drain, and keeps the original
|
||||
idempotency key. Loading the authenticated dashboard after signing in asks the worker
|
||||
to resume queued delivery automatically.
|
||||
Notification text never includes issue titles, comment bodies, or validation details.
|
||||
The preference is off by default, unsupported or denied browsers retain foreground
|
||||
reconciliation, and **Sign out & clear this device** removes the account-bound choice.
|
||||
Browsers without
|
||||
IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows
|
||||
queued and needs-attention messages with explicit send/discard controls; reopening the
|
||||
dashboard reconciles worker completions and permanent failures into the visible outbox.
|
||||
@@ -196,6 +265,14 @@ claim, reruns the test command, pushes the branch, and opens a PR containing
|
||||
`Closes #19` plus test evidence. Replace `19` with the selected issue number;
|
||||
do not run without `--issue` when processing a preselected ticket.
|
||||
|
||||
Agent and test commands use POSIX argument quoting and run as argument vectors,
|
||||
not through a shell. Issue context is available to the agent only through
|
||||
`RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`,
|
||||
`RELEASE_REPO`, and `RELEASE_BRANCH`; do not put issue placeholders in command
|
||||
text. Shell substitutions, redirects, and pipelines are intentionally not
|
||||
interpreted. Put any trusted shell workflow in a reviewed wrapper script and
|
||||
configure that script as `RELEASE_AGENT_COMMAND` instead.
|
||||
|
||||
Durable state defaults to `.release-engine/state.json`. Full behavior and
|
||||
safety gates are documented in
|
||||
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
|
||||
@@ -0,0 +1,9 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def explicit_local_dashboard_auth_mode(monkeypatch):
|
||||
"""Keep tests intentional now that deployed authentication fails closed."""
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
|
||||
@@ -12,8 +12,8 @@ Convert one eligible Gitea issue into a tested, traceable pull request without d
|
||||
- Repository key (`owner/repo`).
|
||||
- Agent username.
|
||||
- Optional explicit issue number; otherwise deterministic queue selection.
|
||||
- Coding-agent command template.
|
||||
- Test command.
|
||||
- Coding-agent command, parsed into an argument vector.
|
||||
- Test command, parsed into an argument vector.
|
||||
- Local repository path and durable state-file path.
|
||||
|
||||
## State machine
|
||||
@@ -39,7 +39,10 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
|
||||
## Execution
|
||||
|
||||
- Branch format: `<agent>/<issue>-<slug>`.
|
||||
- Coding command receives issue number, title, body, repo, and branch through template fields and environment variables.
|
||||
- Coding and test commands are parsed with POSIX argument quoting and executed directly without a shell.
|
||||
- Issue number, title, body, repo, and branch are supplied to the coding agent only through `RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`, `RELEASE_REPO`, and `RELEASE_BRANCH` environment variables. Gitea-controlled content is never interpolated into executable syntax.
|
||||
- Shell operators, substitutions, and pipelines are not interpreted. Operators that are intentionally required must live in a separately reviewed wrapper script configured as the command.
|
||||
- Empty or malformed commands abort before the issue claim.
|
||||
- Non-zero coding-agent exit blocks tests and PR creation.
|
||||
- Tests run using the configured command; stdout/stderr and exit code become evidence.
|
||||
|
||||
|
||||
@@ -14,17 +14,17 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
function write(items, mirror = true) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
coordinator?.notify('authored');
|
||||
if (backgroundSync?.reconcile) {
|
||||
if (mirror && backgroundSync?.reconcile) {
|
||||
Promise.resolve(backgroundSync.reconcile(items, 'authored'))
|
||||
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
|
||||
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(message) {
|
||||
function enqueue(message, mirror = true) {
|
||||
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
|
||||
@@ -47,10 +47,24 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
items.push(item);
|
||||
write(items);
|
||||
write(items, mirror);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function enqueueDurably(message) {
|
||||
const item = enqueue(message, false);
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
try {
|
||||
await backgroundSync.reconcile(read(), 'authored');
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function update(id, changes) {
|
||||
let updated = null;
|
||||
write(read().map(item => {
|
||||
@@ -182,7 +196,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueue, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
|
||||
@@ -8,7 +8,14 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
request.result.createObjectStore('issues', { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
databasePromise = undefined;
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return databasePromise;
|
||||
@@ -17,7 +24,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return async work => {
|
||||
const transact = async work => {
|
||||
const db = await database();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('issues', 'readwrite');
|
||||
@@ -38,6 +45,13 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
});
|
||||
});
|
||||
};
|
||||
transact.close = async () => {
|
||||
if (!databasePromise) return;
|
||||
const db = await databasePromise;
|
||||
db.close();
|
||||
databasePromise = undefined;
|
||||
};
|
||||
return transact;
|
||||
}
|
||||
|
||||
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
|
||||
@@ -48,6 +62,7 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
const existing = await records.getAll();
|
||||
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
|
||||
for (const current of existing) {
|
||||
if (current.recordType === 'receipt-preference') continue;
|
||||
const currentLane = current.outboxLane || 'issue';
|
||||
if (currentLane !== outboxLane) continue;
|
||||
const replacement = incoming.get(current.id);
|
||||
@@ -81,6 +96,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
});
|
||||
}
|
||||
|
||||
async function claimBatch(ownerLogin, limit = 70) {
|
||||
return transact(async records => {
|
||||
const timestamp = Number(now());
|
||||
const eligible = (await records.getAll()).filter(candidate =>
|
||||
candidate.recordType !== 'receipt-preference' &&
|
||||
candidate.ownerLogin === ownerLogin &&
|
||||
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
||||
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
||||
const lanes = {
|
||||
issue: eligible.filter(item => (item.outboxLane || 'issue') !== 'authored'),
|
||||
authored: eligible.filter(item => item.outboxLane === 'authored'),
|
||||
};
|
||||
const selected = [];
|
||||
const maximum = Math.max(0, Number(limit) || 0);
|
||||
while (selected.length < maximum && (lanes.issue.length || lanes.authored.length)) {
|
||||
if (lanes.issue.length) selected.push(lanes.issue.shift());
|
||||
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift());
|
||||
}
|
||||
const claimed = selected.map(item => ({
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs,
|
||||
}));
|
||||
for (const item of claimed) await records.put(item);
|
||||
return claimed;
|
||||
});
|
||||
}
|
||||
|
||||
async function claim(id, ownerLogin) {
|
||||
return transact(async records => {
|
||||
const timestamp = Number(now());
|
||||
@@ -112,21 +153,93 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
});
|
||||
}
|
||||
|
||||
function preferenceId(ownerLogin) {
|
||||
return 'receipt-preference:' + String(ownerLogin || '').trim();
|
||||
}
|
||||
|
||||
async function setReceiptPreference(ownerLogin, enabled) {
|
||||
const login = String(ownerLogin || '').trim();
|
||||
if (!login) return;
|
||||
return transact(async records => {
|
||||
const id = preferenceId(login);
|
||||
if (!enabled) return records.delete(id);
|
||||
return records.put({ id, recordType: 'receipt-preference', ownerLogin: login, enabled: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function getReceiptPreference(ownerLogin) {
|
||||
const id = preferenceId(ownerLogin);
|
||||
return transact(async records => Boolean(
|
||||
(await records.getAll()).find(item => item.id === id)?.enabled
|
||||
));
|
||||
}
|
||||
|
||||
return {
|
||||
reconcile,
|
||||
upsert,
|
||||
claim,
|
||||
claimNext,
|
||||
claimBatch,
|
||||
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
|
||||
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
||||
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
|
||||
snapshot: () => transact(records => records.getAll()),
|
||||
snapshot: () => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
|
||||
countBlocked: ownerLogin => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference' &&
|
||||
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
||||
setReceiptPreference,
|
||||
getReceiptPreference,
|
||||
close: () => transact.close?.(),
|
||||
};
|
||||
}
|
||||
|
||||
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
function createBackgroundIssueSync({
|
||||
store, fetchJson, base = '', maxConcurrency = 3, batchSize = 70,
|
||||
batch = work => work(), requestTimeoutMs = 15000,
|
||||
}) {
|
||||
let purgeRequested = false;
|
||||
let activeFlush = null;
|
||||
const activeRequests = new Set();
|
||||
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
activeRequests.add(controller);
|
||||
let timer;
|
||||
const interrupted = new Promise((resolve, reject) => {
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
reject(new Error(purgeRequested
|
||||
? 'Background request canceled.'
|
||||
: 'Background request timed out.'));
|
||||
}, { once: true });
|
||||
timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => fetchJson(url, { ...options, signal: controller.signal })),
|
||||
interrupted,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
activeRequests.delete(controller);
|
||||
}
|
||||
}
|
||||
function receiptFor(item, status, delivered = {}) {
|
||||
if (status === 'attention') {
|
||||
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
|
||||
}
|
||||
if (item.kind === 'update-reply') {
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
||||
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
|
||||
}
|
||||
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
|
||||
}
|
||||
|
||||
function deliveryRequest(item) {
|
||||
if (item.kind === 'update-reply') {
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
@@ -177,14 +290,19 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = await fetchJson(request.url, request.options);
|
||||
const delivered = await requestJson(request.url, request.options);
|
||||
await store.complete(item.id);
|
||||
return item.kind ? { message: delivered } : { issue: delivered };
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status === 401) {
|
||||
await store.release(item.id);
|
||||
throw error;
|
||||
}
|
||||
if (status >= 400 && status < 500) {
|
||||
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
|
||||
return { attention: true, error };
|
||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
||||
}
|
||||
await store.release(item.id);
|
||||
throw error;
|
||||
@@ -192,6 +310,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
}
|
||||
|
||||
async function send(item, currentLogin) {
|
||||
if (purgeRequested) return { blocked: true };
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
await store.upsert(item);
|
||||
const claimed = await store.claim(item.id, currentLogin);
|
||||
@@ -199,30 +318,79 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
return deliver(claimed);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
const identity = await fetchJson(base + 'api/v1/background-identity', {
|
||||
async function runFlush() {
|
||||
const identity = await requestJson(base + 'api/v1/background-identity', {
|
||||
headers: { Accept: 'application/json' }, cache: 'no-store',
|
||||
});
|
||||
const login = String(identity?.login || '').trim();
|
||||
const confirmed = [];
|
||||
const receipts = [];
|
||||
let attention = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention };
|
||||
while (true) {
|
||||
const item = await store.claimNext(login);
|
||||
if (!item) break;
|
||||
const result = await deliver(item);
|
||||
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
|
||||
const collect = result => {
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.message) confirmed.push(result.message);
|
||||
if (result.attention) attention += 1;
|
||||
if (result.receipt) receipts.push(result.receipt);
|
||||
};
|
||||
if (store.claimBatch) {
|
||||
const claimed = await store.claimBatch(login, batchSize);
|
||||
let next = 0;
|
||||
let authenticationError = null;
|
||||
let transientError = null;
|
||||
const worker = async () => {
|
||||
while (!purgeRequested && !authenticationError && next < claimed.length) {
|
||||
const item = claimed[next++];
|
||||
try {
|
||||
collect(await deliver(item));
|
||||
} catch (error) {
|
||||
if (Number(error?.status || 0) === 401) authenticationError = error;
|
||||
else if (!transientError) transientError = error;
|
||||
}
|
||||
}
|
||||
};
|
||||
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, claimed.length));
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
if (purgeRequested) {
|
||||
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
|
||||
throw new Error('Background delivery canceled.');
|
||||
}
|
||||
if (authenticationError) {
|
||||
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
|
||||
throw authenticationError;
|
||||
}
|
||||
if (transientError) throw transientError;
|
||||
} else {
|
||||
while (!purgeRequested) {
|
||||
const item = await store.claimNext(login);
|
||||
if (!item) break;
|
||||
collect(await deliver(item));
|
||||
}
|
||||
}
|
||||
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
||||
return { confirmed, blocked, attention };
|
||||
return { confirmed, blocked, attention, login, receipts };
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (purgeRequested) return Promise.resolve({ confirmed: [], blocked: 0, attention: 0, login: '', receipts: [] });
|
||||
if (activeFlush) return activeFlush;
|
||||
activeFlush = Promise.resolve().then(() => batch(runFlush)).finally(() => { activeFlush = null; });
|
||||
return activeFlush;
|
||||
}
|
||||
|
||||
async function purge() {
|
||||
purgeRequested = true;
|
||||
activeRequests.forEach(controller => controller.abort());
|
||||
if (activeFlush) await activeFlush.catch(() => {});
|
||||
await store.close?.();
|
||||
}
|
||||
|
||||
return {
|
||||
flush, send,
|
||||
flush, send, purge,
|
||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||
snapshot: () => store.snapshot(),
|
||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
||||
getReceiptPreference: ownerLogin => store.getReceiptPreference(ownerLogin),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+66
-11
@@ -5,11 +5,16 @@ function createContextPoller({
|
||||
isHidden = () => false,
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
setDeadlineTimer = setTimeout,
|
||||
clearDeadlineTimer = clearTimeout,
|
||||
intervalMs = 8000,
|
||||
timeoutMs = 12000,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
let activeRequest = null;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
let revisions = {};
|
||||
let retainedSnapshot = null;
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
@@ -25,36 +30,81 @@ function createContextPoller({
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
function abortError() {
|
||||
const error = new Error('Live update superseded.');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
function supersede(request) {
|
||||
if (!request || request.settled) return;
|
||||
request.superseded = true;
|
||||
request.controller.abort();
|
||||
request.rejectDeadline(abortError());
|
||||
}
|
||||
|
||||
function refresh(options = {}) {
|
||||
if (stopped || isHidden()) return Promise.resolve(null);
|
||||
if (inFlight) return inFlight;
|
||||
if (activeRequest && !options.force) return activeRequest.promise;
|
||||
if (activeRequest) supersede(activeRequest);
|
||||
|
||||
const controller = new AbortController();
|
||||
let rejectDeadline;
|
||||
const deadlinePromise = new Promise((resolve, reject) => {
|
||||
rejectDeadline = reject;
|
||||
});
|
||||
const requestState = {
|
||||
controller,
|
||||
deadline: null,
|
||||
promise: null,
|
||||
rejectDeadline,
|
||||
settled: false,
|
||||
superseded: false,
|
||||
};
|
||||
activeRequest = requestState;
|
||||
requestState.deadline = setDeadlineTimer(() => {
|
||||
if (requestState.settled || requestState.superseded) return;
|
||||
controller.abort();
|
||||
const error = new Error(`Live update timed out after ${timeoutMs}ms.`);
|
||||
error.name = 'TimeoutError';
|
||||
rejectDeadline(error);
|
||||
}, timeoutMs);
|
||||
|
||||
let request;
|
||||
try {
|
||||
request = fetchContext();
|
||||
request = fetchContext({ ...revisions }, { signal: controller.signal });
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
inFlight = Promise.resolve(request)
|
||||
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
|
||||
.then((snapshot) => {
|
||||
onSnapshot(snapshot);
|
||||
return snapshot;
|
||||
if (activeRequest !== requestState) return retainedSnapshot;
|
||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||
);
|
||||
retainedSnapshot = retainedSnapshot ? { ...retainedSnapshot, ...snapshot } : { ...snapshot };
|
||||
revisions = { ...revisions, ...(snapshot.revisions || {}) };
|
||||
onSnapshot(retainedSnapshot, changedSections);
|
||||
return retainedSnapshot;
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error);
|
||||
if (activeRequest === requestState && !stopped) onError(error);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
requestState.settled = true;
|
||||
clearDeadlineTimer(requestState.deadline);
|
||||
if (activeRequest !== requestState) return;
|
||||
activeRequest = null;
|
||||
schedule();
|
||||
});
|
||||
return inFlight;
|
||||
return requestState.promise;
|
||||
}
|
||||
|
||||
function setVisible(visible) {
|
||||
cancelTimer();
|
||||
if (!visible) return Promise.resolve(null);
|
||||
return refresh();
|
||||
return refresh({ force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -64,6 +114,11 @@ function createContextPoller({
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelTimer();
|
||||
if (activeRequest) {
|
||||
const request = activeRequest;
|
||||
activeRequest = null;
|
||||
supersede(request);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow-x: hidden; }
|
||||
#bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .35; }
|
||||
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.app-menu { margin-left:auto; }
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
#sign-out-all { min-height:44px; }
|
||||
button:hover { filter: brightness(1.15); }
|
||||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||
.panel > summary h2 { display: inline-block; margin-left: 4px; }
|
||||
.panel[open] > summary { margin-bottom: 8px; }
|
||||
aside, section { position: relative; z-index: 1; }
|
||||
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
|
||||
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
|
||||
.sidebar { display:flex; flex-direction:column; gap: 12px; }
|
||||
.stack { display:flex; flex-direction:column; gap:10px; }
|
||||
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
||||
a { color: #60a5fa; text-decoration: none; }
|
||||
.muted { color: #94a3b8; }
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
||||
.kv .label { color: #94a3b8; }
|
||||
.kv .value { color: #e5e7eb; }
|
||||
.suggestion { padding: 10px; border-radius: 12px; border: 1px solid #1b2d45; background: #0f1d33; margin-bottom: 8px; animation: fadein .35s ease; }
|
||||
.suggestion.high { border-color: #fca5a5; background: #2a1216; }
|
||||
.suggestion.medium { border-color: #fcd34d; background: #2a2012; }
|
||||
.suggestion.low { border-color: #d1fae5; background: #122a1e; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
|
||||
.pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; font-size: 12px; }
|
||||
canvas { display: block; width: 100%; height: 260px; background: linear-gradient(180deg,#0a1525,#060c16); border: 1px solid #1f3a5f; border-radius: 10px; }
|
||||
pre { margin: 0; padding: 8px; background: #0b1220; color: #dbe7f2; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; border:1px solid #1f3a5f }
|
||||
input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||||
#cmd-palette.open { display: block; }
|
||||
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
|
||||
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
|
||||
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
|
||||
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
||||
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
|
||||
.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; }
|
||||
.small { font-size: 12px; color: #94a3b8; }
|
||||
.status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.offline-status { position:relative; z-index:19; padding:10px 16px; border-bottom:1px solid #f59e0b; background:#35210b; color:#fde68a; font-size:13px; text-align:center; }
|
||||
.offline-status[hidden] { display:none; }
|
||||
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
.install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.install-app-card[hidden] { display:none; }
|
||||
.install-app-card p { margin:0; flex:1 1 240px; }
|
||||
.install-app-card button { min-height:44px; }
|
||||
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
|
||||
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
|
||||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||||
.event:last-child { border-bottom: 0; }
|
||||
.my-work { grid-column: 1 / -1; }
|
||||
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.work-settings { width:100%; }
|
||||
.work-settings > summary { display:none; }
|
||||
.work-settings-panel { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.work-filter { min-height: 44px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
|
||||
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.draft-actions button { min-height:44px; width:100%; }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.later-actions button { min-height:44px; width:100%; }
|
||||
.today-actions button { min-height:44px; width:100%; }
|
||||
.detail-defer { grid-column:1/-1; max-width:100%; }
|
||||
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; }
|
||||
.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.mark-update-read { min-height:44px; width:100%; }
|
||||
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
|
||||
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
|
||||
.load-more-notifications[hidden] { display:none; }
|
||||
.load-more-work { min-height:44px; width:100%; margin-top:10px; }
|
||||
.load-more-work[hidden] { display:none; }
|
||||
.retry-work-route { min-height:44px; width:100%; margin-top:10px; }
|
||||
.retry-work-route[hidden] { display:none; }
|
||||
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-bulk button { min-height:44px; width:100%; }
|
||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.review-sheet.open { display:flex; }
|
||||
.review-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.review-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.review-sheet-body { white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.review-file, .review-history { padding:8px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||
.review-file-toggle { min-height:44px; width:100%; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; text-align:left; }
|
||||
.review-file-toggle strong { overflow-wrap:anywhere; }
|
||||
.review-file.reviewed { opacity:.72; }
|
||||
.review-file.reviewed .review-file-toggle { border-color:#22c55e; }
|
||||
.review-mark { min-height:44px; width:100%; margin-top:8px; }
|
||||
.review-mark[aria-pressed="true"] { border-color:#22c55e; background:#123c2a; }
|
||||
.review-note { min-height:88px; margin-top:6px; }
|
||||
.review-feedback { display:grid; gap:8px; }
|
||||
.review-feedback label { display:grid; gap:6px; }
|
||||
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.review-handoff button { min-height:44px; }
|
||||
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
|
||||
.review-handoff-link[hidden] { display:none; }
|
||||
.review-merge-continuation { position:sticky; bottom:0; z-index:5; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); }
|
||||
#continue-review-to-merge { min-height:44px; width:100%; }
|
||||
#continue-review-to-merge[hidden] { display:none; }
|
||||
.review-copy-fallback { min-height:140px; }
|
||||
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
|
||||
.review-progress-actions button { min-height:44px; max-width:100%; }
|
||||
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
|
||||
.review-diff-line { display:block; min-width:max-content; }
|
||||
.review-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; }
|
||||
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
|
||||
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
|
||||
.review-inline-composer[hidden] { display:none; }
|
||||
.review-inline-composer textarea { min-height:96px; width:100%; }
|
||||
.review-inline-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.review-inline-actions button { min-height:44px; }
|
||||
.review-diff-line.hunk { color:#93c5fd; }
|
||||
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
|
||||
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
|
||||
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
|
||||
.review-action { min-height:44px; }
|
||||
.review-retry { min-height:44px; margin-top:10px; }
|
||||
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.update-sheet.open { display:flex; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.update-sheet-header button { min-height:44px; }
|
||||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.update-reply { display:grid; gap:8px; margin-top:16px; }
|
||||
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
|
||||
.update-reply button { min-height:44px; width:100%; }
|
||||
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.issue-sheet.open { display:flex; }
|
||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
|
||||
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
.issue-planning-retry { min-height:44px; width:100%; }
|
||||
#edit-issue-content { min-height:44px; width:100%; }
|
||||
.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; }
|
||||
.issue-edit-form label { display:grid; gap:6px; min-width:0; }
|
||||
.issue-edit-form input, .issue-edit-form textarea { box-sizing:border-box; width:100%; max-width:100%; }
|
||||
.issue-edit-form textarea { min-height:132px; resize:vertical; }
|
||||
.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px; }
|
||||
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
|
||||
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
|
||||
.issue-label-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||
.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-due-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-due-editor input { box-sizing:border-box; width:100%; max-width:100%; }
|
||||
.issue-due-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-due-editor input, .issue-due-editor button { min-height:44px; }
|
||||
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff select, .issue-handoff button { min-height:44px; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav[hidden] { display:none; }
|
||||
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
||||
.work-session-nav button { min-height:44px; width:100%; }
|
||||
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
|
||||
.find-work-list { display:grid; gap:10px; }
|
||||
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.find-work-card button { width:100%; font-weight:700; }
|
||||
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
|
||||
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.search-preview { position:fixed; inset:0; z-index:60; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.search-preview.open { display:flex; }
|
||||
.search-preview-panel { box-sizing:border-box; width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; overflow-wrap:anywhere; }
|
||||
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
|
||||
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
|
||||
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.create-issue-sheet.open { display:flex; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-form label { display:grid; gap:6px; }
|
||||
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
|
||||
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.create-issue-label-option input { width:20px; height:20px; margin:0; }
|
||||
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; }
|
||||
.shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.shared-content-actions button { min-height:44px; }
|
||||
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.pull-sheet.open { display:flex; }
|
||||
.pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.pull-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.pull-sheet-header button, .pull-sheet-actions button, .pull-sheet-actions a, .pull-comment-composer button { min-height:44px; }
|
||||
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
|
||||
.conversation-more { min-height:44px; width:100%; margin:8px 0; }
|
||||
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
|
||||
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
|
||||
.pull-review-file { margin-top:8px; }
|
||||
.pull-diff { overflow-x:auto; margin:8px 0; padding:10px; background:#07111f; border-radius:8px; font-size:12px; }
|
||||
.pull-diff-line { display:block; width:max-content; min-width:100%; }
|
||||
.pull-diff-line.added { color:#86efac; background:#123422; }
|
||||
.pull-diff-line.removed { color:#fca5a5; background:#3b161b; }
|
||||
.pull-diff-line.hunk, .pull-diff-note { color:#93c5fd; }
|
||||
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
|
||||
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
|
||||
.pull-review-tools button { min-height:44px; }
|
||||
.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; }
|
||||
.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
|
||||
.pull-review summary h2 { margin:0; font-size:16px; }
|
||||
.pull-review > #merge-pull { min-height:44px; width:100%; margin-top:10px; }
|
||||
.pull-comment-composer textarea { width:100%; min-height:110px; resize:vertical; }
|
||||
.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.mobile-task-dock { display:none; }
|
||||
.mobile-task-dock[hidden] { display:none; }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
|
||||
@media (max-width: 600px) {
|
||||
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
||||
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
|
||||
.app-brand .muted, #clock { display:none; }
|
||||
.app-live-status { margin-left:auto; }
|
||||
.app-menu { position:relative; margin-left:0; }
|
||||
.app-menu > summary { min-width:44px; min-height:44px; display:grid; place-items:center; cursor:pointer; border:1px solid #2a496e; border-radius:10px; list-style:none; }
|
||||
.app-menu > summary::-webkit-details-marker { display:none; }
|
||||
.app-menu:not([open]) > .app-menu-panel { display:none; }
|
||||
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
|
||||
.app-menu-panel button { min-height:44px; width:100%; }
|
||||
.my-work { margin:0; }
|
||||
.my-work-header { align-items:flex-start; }
|
||||
.my-work-actions { width:100%; flex-wrap:nowrap; }
|
||||
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
|
||||
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,7 +11,11 @@ function createDetailDefer({
|
||||
defer(item, preset) {
|
||||
if (!item) return false;
|
||||
const until = laterWork.presetUntil(preset);
|
||||
if (!laterWork.defer(item, until)) return false;
|
||||
const result = laterWork.defer(item, until);
|
||||
if (result !== 'deferred') {
|
||||
announce(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
|
||||
return false;
|
||||
}
|
||||
const inSession = session.active();
|
||||
if (!inSession) close();
|
||||
refresh();
|
||||
|
||||
+69
-3055
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,62 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createInstallApp = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
|
||||
const dismissedKey = 'stackchain.install.dismissed.v1';
|
||||
let deferredPrompt = null;
|
||||
|
||||
function hide() {
|
||||
options.card.hidden = true;
|
||||
}
|
||||
|
||||
function showNativePrompt(event) {
|
||||
event.preventDefault();
|
||||
deferredPrompt = event;
|
||||
options.guidance.hidden = true;
|
||||
options.installButton.hidden = false;
|
||||
options.card.hidden = false;
|
||||
}
|
||||
|
||||
async function install() {
|
||||
if (!deferredPrompt) return;
|
||||
const prompt = deferredPrompt;
|
||||
deferredPrompt = null;
|
||||
options.installButton.disabled = true;
|
||||
await prompt.prompt();
|
||||
const choice = await prompt.userChoice;
|
||||
if (choice.outcome === 'accepted') {
|
||||
hide();
|
||||
options.status.textContent = 'Stackchain was added to your device.';
|
||||
} else {
|
||||
hide();
|
||||
options.installButton.disabled = false;
|
||||
options.status.textContent = 'Installation was not completed.';
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
options.storage.setItem(dismissedKey, '1');
|
||||
hide();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (options.isStandalone() || options.storage.getItem(dismissedKey) === '1') {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
if (options.isIosSafari()) {
|
||||
options.installButton.hidden = true;
|
||||
options.guidance.hidden = false;
|
||||
options.card.hidden = false;
|
||||
}
|
||||
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
|
||||
options.window.addEventListener('appinstalled', () => {
|
||||
deferredPrompt = null;
|
||||
hide();
|
||||
});
|
||||
options.installButton.addEventListener('click', install);
|
||||
options.dismissButton.addEventListener('click', dismiss);
|
||||
}
|
||||
|
||||
return {start};
|
||||
});
|
||||
+52
-10
@@ -13,17 +13,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
function write(items, mirror = true) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
coordinator?.notify('issue');
|
||||
if (backgroundSync?.reconcile) {
|
||||
if (mirror && backgroundSync?.reconcile) {
|
||||
Promise.resolve(backgroundSync.reconcile(items))
|
||||
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
|
||||
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(draft) {
|
||||
function enqueue(draft, mirror = true) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
|
||||
const items = read();
|
||||
@@ -45,27 +45,69 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
|
||||
items.push(item);
|
||||
write(items);
|
||||
write(items, mirror);
|
||||
return item;
|
||||
}
|
||||
|
||||
function update(id, draft) {
|
||||
async function enqueueDurably(draft) {
|
||||
const item = enqueue(draft, false);
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
try {
|
||||
await backgroundSync.reconcile(read());
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function update(id, draft, mirror = true) {
|
||||
let updated = null;
|
||||
write(read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const nextRepository = String(draft?.repository || '');
|
||||
const nextTitle = String(draft?.title || '');
|
||||
const nextBody = String(draft?.body || '');
|
||||
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
||||
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
|
||||
? Number(draft.milestoneId) : undefined;
|
||||
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||
? String(draft.dueDate) : undefined;
|
||||
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
|
||||
updated = {
|
||||
...item,
|
||||
repository: String(draft?.repository || ''), title: String(draft?.title || ''),
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
|
||||
repository: nextRepository, title: nextTitle,
|
||||
body: nextBody, labelIds: nextLabelIds,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||
if (nextDueDate === undefined) delete updated.dueDate;
|
||||
delete updated.error;
|
||||
return updated;
|
||||
}));
|
||||
}), mirror);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function updateDurably(id, draft) {
|
||||
const item = update(id, draft, false);
|
||||
if (!item || !backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
try {
|
||||
await backgroundSync.reconcile(read());
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
if (!items.some(item => item.id === id)) return false;
|
||||
@@ -170,7 +212,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueue, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
return { enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
|
||||
@@ -22,6 +22,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
let handoffRequest = null;
|
||||
let labelRequest = null;
|
||||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
@@ -61,6 +62,11 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
return fetchJson(issuePath(item) + '/handoff-candidates', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
@@ -209,6 +215,26 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
}).finally(() => { releaseRequest = null; });
|
||||
return releaseRequest;
|
||||
},
|
||||
handoff(item, recipient, currentLogin) {
|
||||
if (handoffRequest) return handoffRequest;
|
||||
handoffRequest = fetchJson(issuePath(item) + '/handoff', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient }),
|
||||
}).then(result => {
|
||||
if (
|
||||
result?.number !== item.number ||
|
||||
result?.recipient !== recipient ||
|
||||
!Array.isArray(result.assignees) ||
|
||||
!result.assignees.includes(recipient) ||
|
||||
result.assignees.includes(currentLogin)
|
||||
) {
|
||||
throw new Error('Issue handoff was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { handoffRequest = null; });
|
||||
return handoffRequest;
|
||||
},
|
||||
comment(item, body) {
|
||||
if (commentRequest) return commentRequest;
|
||||
this.saveDraft(item, body);
|
||||
|
||||
@@ -42,11 +42,11 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
const key = storageKey();
|
||||
const id = identity(item);
|
||||
const wake = new Date(until);
|
||||
if (!key || !id || Number.isNaN(wake.getTime()) || wake <= now()) return false;
|
||||
if (!key) return 'unavailable';
|
||||
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
|
||||
const records = read();
|
||||
records[id] = wake.toISOString();
|
||||
write(records);
|
||||
return true;
|
||||
return write(records) ? 'deferred' : 'unavailable';
|
||||
}
|
||||
|
||||
function presetUntil(preset) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createLoginController = factory;
|
||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||
const form = options.form;
|
||||
const status = options.status;
|
||||
const button = options.button;
|
||||
const fetchImpl = options.fetchImpl;
|
||||
const location = options.location;
|
||||
const setIntervalImpl = options.setIntervalImpl || setInterval;
|
||||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||||
let timer = null;
|
||||
|
||||
function showReason(reason) {
|
||||
if (reason !== 'session-expired') return;
|
||||
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
|
||||
}
|
||||
|
||||
function showRetryCountdown(seconds) {
|
||||
let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1);
|
||||
button.disabled = true;
|
||||
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
|
||||
timer = setIntervalImpl(() => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
clearIntervalImpl(timer);
|
||||
timer = null;
|
||||
button.disabled = false;
|
||||
status.textContent = 'You can try signing in again.';
|
||||
return;
|
||||
}
|
||||
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function submit(accessToken) {
|
||||
status.textContent = 'Signing in…';
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl('api/v1/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ access_token: accessToken }),
|
||||
});
|
||||
} catch (_error) {
|
||||
form.reset();
|
||||
status.textContent = 'Sign-in failed. Check your connection and try again.';
|
||||
return;
|
||||
}
|
||||
form.reset();
|
||||
if (response.ok) {
|
||||
location.replace('./');
|
||||
return;
|
||||
}
|
||||
if (response.status === 429) {
|
||||
showRetryCountdown(response.headers.get('Retry-After'));
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Sign-in failed. Check the token and try again.';
|
||||
}
|
||||
|
||||
return { submit, showReason };
|
||||
}));
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const form = document.getElementById('sign-in');
|
||||
const status = document.getElementById('status');
|
||||
const button = document.getElementById('submit-sign-in');
|
||||
const controller = createLoginController({
|
||||
form,
|
||||
status,
|
||||
button,
|
||||
fetchImpl: fetch.bind(window),
|
||||
location: window.location,
|
||||
});
|
||||
controller.showReason(new URLSearchParams(window.location.search).get('reason'));
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
const accessToken = new FormData(form).get('access_token');
|
||||
controller.submit(accessToken);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.mobileLaunch = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const allowedFilters = new Set([
|
||||
'all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft',
|
||||
]);
|
||||
|
||||
function chooseFilter({ saved, today = 0, attention = 0 } = {}) {
|
||||
if (allowedFilters.has(saved)) return saved;
|
||||
if (today > 0) return 'today';
|
||||
if (attention > 0) return 'attention';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function createDisclosure({ disclosure, launcher }) {
|
||||
function onKeydown(event) {
|
||||
if (event.key !== 'Escape' || !disclosure.open) return;
|
||||
event.preventDefault();
|
||||
disclosure.open = false;
|
||||
launcher.focus();
|
||||
}
|
||||
return {
|
||||
start() {
|
||||
disclosure.addEventListener('keydown', onKeydown);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { chooseFilter, createDisclosure };
|
||||
});
|
||||
+41
-7
@@ -14,6 +14,25 @@ function issueDueState(dueDate, now) {
|
||||
};
|
||||
}
|
||||
|
||||
function urgencyReason(item) {
|
||||
if (!item?.is_assigned || item.kind !== 'issue') return '';
|
||||
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||
const priorityLabel = (item.labels || []).find(label =>
|
||||
priorityLabels.includes(String(label).toLowerCase())
|
||||
);
|
||||
if (priorityLabel) return priorityLabel + ' priority';
|
||||
return ['Overdue', 'Due today'].includes(item.due_label) ? item.due_label : '';
|
||||
}
|
||||
|
||||
function attentionReason(item) {
|
||||
if (item?.has_update) return 'Unread update';
|
||||
return urgencyReason(item) || (item?.is_review ? 'Needs your review' : '');
|
||||
}
|
||||
|
||||
function needsAttention(item) {
|
||||
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
|
||||
}
|
||||
|
||||
function buildMyWork(data, now = new Date()) {
|
||||
const login = data.user?.login || '';
|
||||
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
||||
@@ -28,7 +47,7 @@ function buildMyWork(data, now = new Date()) {
|
||||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||
return {
|
||||
const normalized = {
|
||||
...item,
|
||||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
@@ -41,6 +60,9 @@ function buildMyWork(data, now = new Date()) {
|
||||
_priority: priorityLabel ? 0 :
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
|
||||
};
|
||||
normalized.needs_attention = needsAttention(normalized);
|
||||
normalized.attention_reason = attentionReason(normalized);
|
||||
return normalized;
|
||||
});
|
||||
|
||||
const byKey = new Map(work.map((item) => [item.kind + ':' + item.key, item]));
|
||||
@@ -50,6 +72,8 @@ function buildMyWork(data, now = new Date()) {
|
||||
const existing = byKey.get(subjectKind + ':' + key);
|
||||
if (existing) {
|
||||
existing.has_update = true;
|
||||
existing.needs_attention = true;
|
||||
existing.attention_reason = 'Unread update';
|
||||
existing.notification_id = update.id;
|
||||
existing.url = update.url || existing.url;
|
||||
existing.updated_at = update.updated_at || existing.updated_at;
|
||||
@@ -64,6 +88,8 @@ function buildMyWork(data, now = new Date()) {
|
||||
is_review: false,
|
||||
is_assigned: false,
|
||||
has_update: true,
|
||||
needs_attention: true,
|
||||
attention_reason: 'Unread update',
|
||||
notification_id: update.id,
|
||||
reason: 'Unread update',
|
||||
_priority: 1,
|
||||
@@ -84,7 +110,10 @@ function acknowledgeNotification(items, notificationId) {
|
||||
if (item.notification_id !== notificationId) return [item];
|
||||
if (item.kind === 'update') return [];
|
||||
const { notification_id, ...acknowledged } = item;
|
||||
return [{ ...acknowledged, has_update: false }];
|
||||
const next = { ...acknowledged, has_update: false };
|
||||
if ('needs_attention' in item) next.needs_attention = needsAttention(next);
|
||||
if ('attention_reason' in item) next.attention_reason = attentionReason(next);
|
||||
return [next];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -383,11 +412,16 @@ function createNotificationReplier({
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (authoredOutbox && (!status || status >= 500)) {
|
||||
authoredOutbox.enqueue({
|
||||
onStatus('Saving for background delivery…');
|
||||
const admission = await authoredOutbox.enqueueDurably({
|
||||
kind: 'update-reply', notificationId: item.notification_id, body, operationId,
|
||||
});
|
||||
onStatus('Queued for sync when the connection returns.');
|
||||
return { queued: true };
|
||||
if (admission.background) {
|
||||
onStatus('Queued for sync when the connection returns.');
|
||||
return { queued: true };
|
||||
}
|
||||
onStatus('Saved for next launch; background delivery unavailable.');
|
||||
return { queued: false, degraded: true };
|
||||
}
|
||||
onStatus('Could not send reply. Your draft is safe; retry.');
|
||||
return false;
|
||||
@@ -400,7 +434,7 @@ function createNotificationReplier({
|
||||
|
||||
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
let filtered = items;
|
||||
if (selectedFilter === 'attention') filtered = items.filter((item) => item.has_update || item.is_review);
|
||||
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
|
||||
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||
@@ -560,7 +594,7 @@ function summarizeMyWork(items) {
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
attention: items.filter((item) => item.has_update || item.is_review).length,
|
||||
attention: items.filter(needsAttention).length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
|
||||
@@ -139,7 +139,11 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
|
||||
}).then(result => {
|
||||
if (!result?.merged) throw new Error('Pull request merge was not confirmed.');
|
||||
if (!result?.merged) {
|
||||
throw new Error(result?.confirmation_pending && result?.error
|
||||
? result.error
|
||||
: 'Pull request merge was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { mergeRequest = null; });
|
||||
return mergeRequest;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v16';
|
||||
const CACHE = 'stackchain-dashboard-shell-v31';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
BASE + 'static/dashboard.css',
|
||||
BASE + 'static/dashboard.js',
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
@@ -13,11 +15,13 @@ const SHELL = [
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
@@ -29,24 +33,52 @@ const SHELL = [
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
|
||||
async function sessionCsrf() {
|
||||
async function sessionCsrf(signal) {
|
||||
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) return '';
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
return typeof payload.csrf_token === 'string' ? payload.csrf_token : '';
|
||||
}
|
||||
|
||||
let batchedCsrf = null;
|
||||
let batchedCsrfController = null;
|
||||
async function withSessionCsrf(work) {
|
||||
batchedCsrfController = new AbortController();
|
||||
batchedCsrf = sessionCsrf(batchedCsrfController.signal);
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
batchedCsrfController.abort();
|
||||
batchedCsrf = null;
|
||||
batchedCsrfController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const requestOptions = { ...options };
|
||||
const method = String(options.method || 'GET').toUpperCase();
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
const csrf = await sessionCsrf();
|
||||
const cancelBatchedCsrf = () => batchedCsrfController?.abort();
|
||||
if (batchedCsrf && options.signal) {
|
||||
if (options.signal.aborted) cancelBatchedCsrf();
|
||||
else options.signal.addEventListener('abort', cancelBatchedCsrf, { once: true });
|
||||
}
|
||||
let csrf;
|
||||
try {
|
||||
csrf = await (batchedCsrf || sessionCsrf(options.signal));
|
||||
} finally {
|
||||
options.signal?.removeEventListener('abort', cancelBatchedCsrf);
|
||||
}
|
||||
if (options.signal?.aborted) throw new Error('Background request aborted.');
|
||||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||
requestOptions.headers = headers;
|
||||
}
|
||||
@@ -61,9 +93,26 @@ async function fetchJson(url, options = {}) {
|
||||
}
|
||||
|
||||
const issueSync = self.__issueSync || createBackgroundIssueSync({
|
||||
store: createIssueSyncStore(), fetchJson, base: BASE,
|
||||
store: createIssueSyncStore(), fetchJson, base: BASE, batch: withSessionCsrf,
|
||||
});
|
||||
|
||||
async function flushAndNotify() {
|
||||
const result = await issueSync.flush();
|
||||
if (!result?.login || !result.receipts?.length ||
|
||||
!await issueSync.getReceiptPreference?.(result.login)) return;
|
||||
for (const receipt of result.receipts) {
|
||||
const needsAttention = receipt.status === 'attention';
|
||||
const title = needsAttention
|
||||
? 'Queued work needs attention'
|
||||
: receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent';
|
||||
await self.registration.showNotification(title, {
|
||||
body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
||||
tag: 'stackchain-delivery-' + receipt.id,
|
||||
data: { route: receipt.route },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
@@ -76,7 +125,33 @@ self.addEventListener('activate', event => {
|
||||
});
|
||||
|
||||
self.addEventListener('sync', event => {
|
||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(issueSync.flush());
|
||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
||||
});
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
const route = String(event.notification.data?.route || '');
|
||||
if (!route.startsWith('#/my-work/')) return;
|
||||
const target = new URL(BASE + route, self.location.origin).href;
|
||||
event.waitUntil((async () => {
|
||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (!client) return self.clients.openWindow(target);
|
||||
if (client.navigate) await client.navigate(target);
|
||||
return client.focus();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
|
||||
+71
-5
@@ -12,23 +12,35 @@
|
||||
sessionStorage: root.sessionStorage,
|
||||
indexedDB: root.indexedDB,
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
MessageChannel: root.MessageChannel,
|
||||
location: root.location,
|
||||
confirmAction: message => root.confirm(message),
|
||||
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
||||
onClearError: error => {
|
||||
root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message }));
|
||||
root.alert('Signed out, but private queued work could not be cleared. Close other Stackchain tabs and clear this site’s data.');
|
||||
},
|
||||
});
|
||||
root.fetch = boundary.fetch;
|
||||
const attach = () => {
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
boundary.resumeQueuedWork();
|
||||
};
|
||||
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
||||
else attach();
|
||||
root.stackchainSession = boundary;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, location,
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
|
||||
onExpired = () => {},
|
||||
onClearError = () => {},
|
||||
}) {
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
let expirationStarted = false;
|
||||
|
||||
function csrfToken() {
|
||||
const entry = String(cookie?.() || '').split(';')
|
||||
@@ -52,7 +64,11 @@
|
||||
requestOptions.headers = headers;
|
||||
}
|
||||
const response = await fetchImpl(input, requestOptions);
|
||||
if (response.status === 401) onExpired();
|
||||
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
|
||||
expirationStarted = true;
|
||||
onExpired();
|
||||
location.replace(base + 'login?reason=session-expired');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -68,11 +84,44 @@
|
||||
} catch (_error) { /* Cookie invalidation still protects server data. */ }
|
||||
}
|
||||
|
||||
async function stopWorkerOutbox() {
|
||||
const registration = await serviceWorker?.ready;
|
||||
if (!registration?.active || !MessageChannel) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
|
||||
channel.port1.onmessage = event => {
|
||||
clearTimeout(timeout);
|
||||
if (event.data?.ok) resolve();
|
||||
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
|
||||
};
|
||||
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrivateOutbox() {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
async function clearPrivateDeviceData() {
|
||||
removeDashboardStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
||||
try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
catch (_error) { /* Continue clearing other dashboard state. */ }
|
||||
try {
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateOutbox();
|
||||
} catch (_error) {
|
||||
const error = new Error('Could not clear private queued work from this device.');
|
||||
onClearError(error);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));
|
||||
@@ -88,5 +137,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
return { fetch: sessionFetch, signOut, clearPrivateDeviceData };
|
||||
async function signOutAllDevices() {
|
||||
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Could not sign out all devices');
|
||||
await clearPrivateDeviceData();
|
||||
location.assign(base + 'login');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function resumeQueuedWork() {
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({ type: 'stackchain-resume-outbox' });
|
||||
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
|
||||
}
|
||||
|
||||
return { fetch: sessionFetch, signOut, signOutAllDevices, clearPrivateDeviceData, resumeQueuedWork };
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
const prefix = 'stackchain.today-work.v1.';
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function read() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return [];
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || '[]');
|
||||
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(ids) {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return false;
|
||||
try {
|
||||
if (ids.length) storage.setItem(key, JSON.stringify(ids));
|
||||
else storage.removeItem(key);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function add(item) {
|
||||
if (!storageKey()) return 'unavailable';
|
||||
const id = identity(item);
|
||||
if (!id) return 'unavailable';
|
||||
const ids = read();
|
||||
if (ids.includes(id)) return 'exists';
|
||||
if (ids.length >= limit) return 'full';
|
||||
ids.push(id);
|
||||
return write(ids) ? 'added' : 'unavailable';
|
||||
}
|
||||
|
||||
function remove(item) {
|
||||
const id = identity(item);
|
||||
const ids = read();
|
||||
const next = ids.filter(candidate => candidate !== id);
|
||||
return next.length !== ids.length && write(next);
|
||||
}
|
||||
|
||||
function move(item, direction) {
|
||||
const ids = read();
|
||||
const index = ids.indexOf(identity(item));
|
||||
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
|
||||
if (index < 0 || target < 0 || target >= ids.length) return false;
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
return write(ids);
|
||||
}
|
||||
|
||||
function reconcile(items, { pruneMissing = false } = {}) {
|
||||
const available = new Map((items || []).map(item => [identity(item), item]));
|
||||
const ids = read();
|
||||
const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids;
|
||||
if (retained.length !== ids.length) write(retained);
|
||||
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
|
||||
}
|
||||
|
||||
function contains(item) {
|
||||
return read().includes(identity(item));
|
||||
}
|
||||
|
||||
function position(item) {
|
||||
const ids = read();
|
||||
const index = ids.indexOf(identity(item));
|
||||
return {
|
||||
can_up: index > 0,
|
||||
can_down: index >= 0 && index < ids.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, add, remove, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
@@ -0,0 +1,65 @@
|
||||
function createUnfiledCaptures({
|
||||
storage,
|
||||
getCaptureLogin = () => '',
|
||||
getCurrentLogin = () => '',
|
||||
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
now = () => Date.now(),
|
||||
maxItems = 20,
|
||||
}) {
|
||||
const storageKey = 'stackchain.unfiled-issues.v1';
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
|
||||
typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string'
|
||||
);
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
storage?.setItem(storageKey, JSON.stringify({version:1, items}));
|
||||
}
|
||||
|
||||
function list() {
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt))
|
||||
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
if (!title) throw new Error('Add a title before saving.');
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
|
||||
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
|
||||
write(items);
|
||||
return item;
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
const remaining = items.filter(item => item.id !== id);
|
||||
if (remaining.length === items.length) return false;
|
||||
write(remaining);
|
||||
return true;
|
||||
}
|
||||
|
||||
function resume(id, confirmedLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) throw new Error('This capture is no longer available.');
|
||||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||
throw new Error('Reconnect with the account that saved this capture.');
|
||||
}
|
||||
discard(id);
|
||||
return {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
}
|
||||
|
||||
return {list, save, discard, resume};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
+78
-13
@@ -1,8 +1,10 @@
|
||||
"""Signed, short-lived single-operator sessions for the dashboard boundary."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
@@ -11,13 +13,19 @@ from dataclasses import dataclass
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from src.session_store import SessionStore, SessionStoreError
|
||||
|
||||
SESSION_COOKIE = "stackchain_session"
|
||||
CSRF_COOKIE = "stackchain_csrf"
|
||||
DEFAULT_TTL_SECONDS = 8 * 60 * 60
|
||||
MIN_SECRET_LENGTH = 24
|
||||
OPERATOR_MODE = "operator"
|
||||
INSECURE_LOCAL_MODE = "insecure-local"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Session:
|
||||
session_id: str
|
||||
csrf: str
|
||||
expires_at: int
|
||||
|
||||
@@ -26,17 +34,42 @@ def access_token() -> str:
|
||||
return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")
|
||||
|
||||
|
||||
def mode() -> str:
|
||||
return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower()
|
||||
|
||||
|
||||
def configuration_error() -> str | None:
|
||||
configured_mode = mode()
|
||||
if configured_mode == INSECURE_LOCAL_MODE:
|
||||
return None
|
||||
if configured_mode != OPERATOR_MODE:
|
||||
return "unsupported authentication mode"
|
||||
operator_secret = access_token()
|
||||
signing_secret = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
|
||||
if len(operator_secret) < MIN_SECRET_LENGTH or len(signing_secret) < MIN_SECRET_LENGTH:
|
||||
return "operator mode requires two sufficiently long secrets"
|
||||
if hmac.compare_digest(operator_secret, signing_secret):
|
||||
return "operator and session secrets must be independent"
|
||||
return None
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(access_token())
|
||||
return mode() == OPERATOR_MODE and configuration_error() is None
|
||||
|
||||
|
||||
def is_loopback_request(request: Request) -> bool:
|
||||
host = request.client.host if request.client else ""
|
||||
try:
|
||||
return ipaddress.ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return host == "localhost"
|
||||
|
||||
|
||||
def _secret() -> bytes:
|
||||
configured = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
|
||||
if configured:
|
||||
return configured.encode()
|
||||
return hmac.new(
|
||||
access_token().encode(), b"stackchain-dashboard-session-signing", hashlib.sha256
|
||||
).digest()
|
||||
if configuration_error() is not None or mode() != OPERATOR_MODE:
|
||||
raise RuntimeError("Dashboard authentication is not configured")
|
||||
return configured.encode()
|
||||
|
||||
|
||||
def _encode(raw: bytes) -> str:
|
||||
@@ -47,17 +80,31 @@ def _decode(value: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
|
||||
|
||||
def _session_store(now: int | None = None) -> SessionStore:
|
||||
database = os.getenv(
|
||||
"STACKCHAIN_SESSION_DB",
|
||||
os.path.join(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"), "sessions.sqlite3"),
|
||||
)
|
||||
current = time.time if now is None else lambda: now
|
||||
return SessionStore(database, clock=current)
|
||||
|
||||
|
||||
def issue_session(now: int | None = None) -> tuple[str, Session]:
|
||||
issued_at = int(time.time() if now is None else now)
|
||||
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
||||
session = Session(csrf=secrets.token_urlsafe(24), expires_at=issued_at + max(1, ttl))
|
||||
session = Session(
|
||||
session_id=secrets.token_urlsafe(32),
|
||||
csrf=secrets.token_urlsafe(24),
|
||||
expires_at=issued_at + max(1, ttl),
|
||||
)
|
||||
payload = json.dumps(
|
||||
{"csrf": session.csrf, "exp": session.expires_at},
|
||||
{"csrf": session.csrf, "exp": session.expires_at, "sid": session.session_id},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode()
|
||||
encoded = _encode(payload)
|
||||
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||
_session_store(now).activate(session.session_id, session.expires_at)
|
||||
return f"{encoded}.{signature}", session
|
||||
|
||||
|
||||
@@ -70,17 +117,35 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(_decode(encoded))
|
||||
session = Session(csrf=payload["csrf"], expires_at=int(payload["exp"]))
|
||||
session = Session(
|
||||
session_id=payload["sid"],
|
||||
csrf=payload["csrf"],
|
||||
expires_at=int(payload["exp"]),
|
||||
)
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||
return None
|
||||
current = int(time.time() if now is None else now)
|
||||
if session.expires_at <= current or not isinstance(session.csrf, str) or not session.csrf:
|
||||
if (
|
||||
session.expires_at <= current
|
||||
or not isinstance(session.session_id, str)
|
||||
or not session.session_id
|
||||
or not isinstance(session.csrf, str)
|
||||
or not session.csrf
|
||||
):
|
||||
return None
|
||||
return session
|
||||
return session if _session_store(now).is_active(session.session_id, session.expires_at) else None
|
||||
|
||||
|
||||
def request_session(request: Request) -> Session | None:
|
||||
return verify_session(request.cookies.get(SESSION_COOKIE))
|
||||
def revoke_session(session: Session) -> None:
|
||||
_session_store().revoke(session.session_id)
|
||||
|
||||
|
||||
async def revoke_all_sessions() -> None:
|
||||
await asyncio.to_thread(_session_store().revoke_all)
|
||||
|
||||
|
||||
async def request_session(request: Request) -> Session | None:
|
||||
return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))
|
||||
|
||||
|
||||
def cookie_path(request: Request) -> str:
|
||||
|
||||
@@ -1038,6 +1038,90 @@ async def release_assigned_issue(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def issue_handoff_candidates(repository: str) -> list[dict]:
|
||||
user, response = await asyncio.gather(
|
||||
current_user(),
|
||||
_get_client().get(
|
||||
f"/api/v1/repos/{repository}/assignees", headers=_auth()
|
||||
),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea assignees response was not a list")
|
||||
current_login = user.get("login") if isinstance(user, dict) else None
|
||||
candidates = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
login = item.get("login")
|
||||
if not isinstance(login, str) or not login or login == current_login:
|
||||
continue
|
||||
full_name = item.get("full_name")
|
||||
candidates.append({
|
||||
"login": login,
|
||||
"name": full_name if isinstance(full_name, str) and full_name else login,
|
||||
})
|
||||
return candidates
|
||||
|
||||
|
||||
async def handoff_assigned_issue(
|
||||
repository: str, number: int, recipient: str
|
||||
) -> dict:
|
||||
login, issue = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or issue.get("pull_request") is not None
|
||||
or not _login_in_users(login, issue.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("Issue is not assigned to the current user")
|
||||
|
||||
eligible = {
|
||||
item["login"] for item in await issue_handoff_candidates(repository)
|
||||
}
|
||||
if recipient not in eligible:
|
||||
raise IssueNotAvailableError("Handoff recipient is not eligible")
|
||||
assignees_value = issue.get("assignees")
|
||||
assignees = assignees_value if isinstance(assignees_value, list) else []
|
||||
desired = [
|
||||
item["login"] for item in assignees
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("login"), str)
|
||||
and item["login"] != login
|
||||
]
|
||||
if recipient not in desired:
|
||||
desired.append(recipient)
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
headers=_auth(),
|
||||
json={"assignees": desired},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
|
||||
confirmed_assignees = [
|
||||
item["login"] for item in confirmed_value
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
] if isinstance(confirmed_value, list) else []
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or login in confirmed_assignees
|
||||
or recipient not in confirmed_assignees
|
||||
or set(confirmed_assignees) != set(desired)
|
||||
):
|
||||
raise ValueError("Gitea did not confirm issue handoff")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"assignees": confirmed_assignees,
|
||||
"recipient": recipient,
|
||||
}
|
||||
|
||||
|
||||
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
@@ -1470,6 +1554,21 @@ async def pull_completion_review(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def is_pull_merged_at_head(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> bool:
|
||||
pull = await fetch(f"repos/{repository}/pulls/{number}")
|
||||
if not isinstance(pull, dict):
|
||||
return False
|
||||
head_value = pull.get("head")
|
||||
head = head_value if isinstance(head_value, dict) else {}
|
||||
return (
|
||||
pull.get("merged") is True
|
||||
and pull.get("state") == "closed"
|
||||
and head.get("sha") == expected_head_sha
|
||||
)
|
||||
|
||||
|
||||
async def merge_assigned_pull(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> dict:
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Durable throttling state for operator sign-in attempts."""
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import math
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class LoginAttemptStoreError(RuntimeError):
|
||||
"""Raised when sign-in throttling state cannot be accessed safely."""
|
||||
|
||||
|
||||
def client_source(peer_host: str, forwarded_for: str, trusted_proxy_cidrs: str) -> str:
|
||||
"""Resolve a canonical client IP without trusting arbitrary forwarding headers."""
|
||||
try:
|
||||
peer = ipaddress.ip_address(peer_host)
|
||||
trusted = [
|
||||
ipaddress.ip_network(value.strip())
|
||||
for value in trusted_proxy_cidrs.split(",")
|
||||
if value.strip()
|
||||
]
|
||||
except ValueError:
|
||||
return peer_host
|
||||
if not any(peer in network for network in trusted):
|
||||
return str(peer)
|
||||
try:
|
||||
forwarded = [
|
||||
ipaddress.ip_address(value.strip())
|
||||
for value in forwarded_for.split(",")
|
||||
if value.strip()
|
||||
]
|
||||
except ValueError:
|
||||
return str(peer)
|
||||
for address in reversed(forwarded):
|
||||
if not any(address in network for network in trusted):
|
||||
return str(address)
|
||||
return str(forwarded[0]) if forwarded else str(peer)
|
||||
|
||||
|
||||
class LoginAttemptStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
clock: Callable[[], float],
|
||||
max_failures: int,
|
||||
window_seconds: int,
|
||||
max_entries: int = 10_000,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self.max_failures = max(1, max_failures)
|
||||
self.window_seconds = max(1, window_seconds)
|
||||
self.max_entries = max(1, max_entries)
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
|
||||
@staticmethod
|
||||
def _digest(source: str) -> str:
|
||||
return hashlib.sha256(source.encode()).hexdigest()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
source_hash TEXT PRIMARY KEY,
|
||||
failures INTEGER NOT NULL,
|
||||
window_started_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def retry_after(self, source: str) -> int:
|
||||
now = self.clock()
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
|
||||
(self._digest(source),),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
if row is None or row[0] < self.max_failures:
|
||||
return 0
|
||||
remaining = row[1] + self.window_seconds - now
|
||||
return max(0, math.ceil(remaining))
|
||||
|
||||
def record_failure(self, source: str) -> None:
|
||||
now = self.clock()
|
||||
source_hash = self._digest(source)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"DELETE FROM login_attempts WHERE window_started_at + ? <= ?",
|
||||
(self.window_seconds, now),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
|
||||
(source_hash,),
|
||||
).fetchone()
|
||||
if row is None or row[1] + self.window_seconds <= now:
|
||||
connection.execute(
|
||||
"INSERT OR REPLACE INTO login_attempts VALUES (?, 1, ?)",
|
||||
(source_hash, now),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE login_attempts SET failures = failures + 1 WHERE source_hash = ?",
|
||||
(source_hash,),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM login_attempts
|
||||
WHERE source_hash NOT IN (
|
||||
SELECT source_hash FROM login_attempts
|
||||
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
|
||||
)
|
||||
""",
|
||||
(self.max_entries,),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def clear(self, source: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM login_attempts WHERE source_hash = ?",
|
||||
(self._digest(source),),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
+297
-12
@@ -28,6 +28,7 @@ from src.gitea_proxy import (
|
||||
repos,
|
||||
)
|
||||
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
|
||||
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
|
||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||
from src.suggestion_engine import compute
|
||||
from src.views import router as frontend_router
|
||||
@@ -97,6 +98,9 @@ _live_section_retry_at: dict[str, float | None] = {
|
||||
section: None for section in LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
_live_snapshot_refreshing_sections: set[str] = set()
|
||||
_live_section_revisions: dict[str, int] = {
|
||||
section: 0 for section in LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
_read_notification_ids: set[int] = set()
|
||||
_authored_action_operations: dict[
|
||||
str, tuple[tuple[Any, ...], asyncio.Task, float]
|
||||
@@ -129,6 +133,20 @@ class DashboardSignIn(BaseModel):
|
||||
access_token: str = Field(min_length=1, max_length=1_024)
|
||||
|
||||
|
||||
def _login_attempt_store() -> LoginAttemptStore:
|
||||
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
|
||||
return LoginAttemptStore(
|
||||
os.getenv(
|
||||
"STACKCHAIN_LOGIN_ATTEMPT_DB",
|
||||
os.path.join(state_dir, "login-attempts.sqlite3"),
|
||||
),
|
||||
clock=time.time,
|
||||
max_failures=int(os.getenv("STACKCHAIN_LOGIN_MAX_FAILURES", "5")),
|
||||
window_seconds=int(os.getenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "300")),
|
||||
max_entries=int(os.getenv("STACKCHAIN_LOGIN_MAX_ENTRIES", "10000")),
|
||||
)
|
||||
|
||||
|
||||
class NotificationReadBatch(BaseModel):
|
||||
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
|
||||
|
||||
@@ -224,6 +242,12 @@ class IssueMilestoneUpdate(BaseModel):
|
||||
milestone_id: PositiveInt | None = None
|
||||
|
||||
|
||||
class IssueHandoff(BaseModel):
|
||||
recipient: str = Field(
|
||||
min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$"
|
||||
)
|
||||
|
||||
|
||||
class PullReviewComment(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
@@ -430,16 +454,41 @@ app.include_router(frontend_router)
|
||||
|
||||
@app.middleware("http")
|
||||
async def require_operator_session(request: Request, call_next):
|
||||
if not dashboard_auth.enabled():
|
||||
path = dashboard_auth.application_path(request)
|
||||
if path == "/healthz":
|
||||
return await call_next(request)
|
||||
|
||||
if dashboard_auth.configuration_error() is not None:
|
||||
return JSONResponse(
|
||||
{"detail": "Dashboard authentication is not configured"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
if dashboard_auth.mode() == dashboard_auth.INSECURE_LOCAL_MODE:
|
||||
if not dashboard_auth.is_loopback_request(request):
|
||||
return JSONResponse(
|
||||
{"detail": "Insecure local mode requires a loopback client"},
|
||||
status_code=403,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
path = dashboard_auth.application_path(request)
|
||||
public = (
|
||||
path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"}
|
||||
or path.startswith("/static/")
|
||||
or (path == "/api/v1/session" and request.method == "POST")
|
||||
)
|
||||
session = dashboard_auth.request_session(request)
|
||||
session = None
|
||||
if not public:
|
||||
try:
|
||||
session = await dashboard_auth.request_session(request)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if not public and session is None:
|
||||
if path.startswith("/api/"):
|
||||
return JSONResponse(
|
||||
@@ -490,6 +539,37 @@ async def prevent_live_api_caching(request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
CONTENT_SECURITY_POLICY = "; ".join(
|
||||
(
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"img-src 'self' data:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"worker-src 'self'",
|
||||
"manifest-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def enforce_browser_security_boundary(request: Request, call_next):
|
||||
"""Apply one browser trust boundary, including to auth short-circuits."""
|
||||
response = await call_next(request)
|
||||
response.headers["Content-Security-Policy"] = CONTENT_SECURITY_POLICY
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["Permissions-Policy"] = (
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
||||
)
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def health() -> dict[str, str]:
|
||||
"""Return process liveness without depending on Gitea."""
|
||||
@@ -498,12 +578,59 @@ def health() -> dict[str, str]:
|
||||
|
||||
@app.post("/api/v1/session")
|
||||
async def sign_in(payload: DashboardSignIn, request: Request, response: Response):
|
||||
peer_host = request.client.host if request.client is not None else "unknown"
|
||||
source = client_source(
|
||||
peer_host,
|
||||
request.headers.get("x-forwarded-for", ""),
|
||||
os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""),
|
||||
)
|
||||
attempts = _login_attempt_store()
|
||||
try:
|
||||
retry_after = attempts.retry_after(source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if retry_after:
|
||||
return JSONResponse(
|
||||
{"detail": "Too many sign-in attempts"},
|
||||
status_code=429,
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Retry-After": str(retry_after),
|
||||
},
|
||||
)
|
||||
configured_token = dashboard_auth.access_token()
|
||||
if not configured_token or not hmac.compare_digest(
|
||||
payload.access_token, configured_token
|
||||
):
|
||||
try:
|
||||
attempts.record_failure(source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid access token")
|
||||
signed, session = dashboard_auth.issue_session()
|
||||
try:
|
||||
attempts.clear(source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
signed, session = dashboard_auth.issue_session()
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
path = dashboard_auth.cookie_path(request)
|
||||
max_age = max(1, session.expires_at - int(time.time()))
|
||||
response.set_cookie(
|
||||
@@ -530,7 +657,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
|
||||
@app.get("/api/v1/session")
|
||||
async def session_status(request: Request):
|
||||
session = dashboard_auth.request_session(request)
|
||||
session = request.state.dashboard_session
|
||||
return {
|
||||
"authenticated": session is not None,
|
||||
"csrf_token": session.csrf if session is not None else "",
|
||||
@@ -539,6 +666,15 @@ async def session_status(request: Request):
|
||||
|
||||
@app.delete("/api/v1/session")
|
||||
async def sign_out(request: Request, response: Response):
|
||||
session = request.state.dashboard_session
|
||||
try:
|
||||
dashboard_auth.revoke_session(session)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
path = dashboard_auth.cookie_path(request)
|
||||
response.delete_cookie(
|
||||
dashboard_auth.SESSION_COOKIE,
|
||||
@@ -558,6 +694,39 @@ async def sign_out(request: Request, response: Response):
|
||||
return {"authenticated": False, "clear_private_device_data": True}
|
||||
|
||||
|
||||
@app.delete("/api/v1/sessions")
|
||||
async def sign_out_all_devices(request: Request, response: Response):
|
||||
try:
|
||||
await dashboard_auth.revoke_all_sessions()
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
path = dashboard_auth.cookie_path(request)
|
||||
response.delete_cookie(
|
||||
dashboard_auth.SESSION_COOKIE,
|
||||
path=path,
|
||||
secure=True,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
)
|
||||
response.delete_cookie(
|
||||
dashboard_auth.CSRF_COOKIE,
|
||||
path=path,
|
||||
secure=True,
|
||||
httponly=False,
|
||||
samesite="strict",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return {
|
||||
"authenticated": False,
|
||||
"all_sessions_revoked": True,
|
||||
"clear_private_device_data": True,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/readyz")
|
||||
async def readiness():
|
||||
"""Return readiness after verifying the configured Gitea connection."""
|
||||
@@ -980,8 +1149,19 @@ async def _refresh_live_snapshot(sections: set[str]) -> dict:
|
||||
_live_section_retry_at[section] = None
|
||||
else:
|
||||
_record_live_section_failure(section)
|
||||
result = _merge_live_snapshot(_live_snapshot_value, refreshed)
|
||||
previous = _live_snapshot_value
|
||||
result = _merge_live_snapshot(previous, refreshed)
|
||||
result = _without_read_notifications(result)
|
||||
for section in sections:
|
||||
if section not in refreshed_states:
|
||||
continue
|
||||
changed = previous is None or previous.get(section) != result.get(section)
|
||||
if section == "notifications":
|
||||
changed = changed or previous is None or previous.get(
|
||||
"notification_pagination"
|
||||
) != result.get("notification_pagination")
|
||||
if changed:
|
||||
_live_section_revisions[section] += 1
|
||||
_live_snapshot_value = result
|
||||
successful_times = [value for value in _live_section_created_at.values() if value is not None]
|
||||
_live_snapshot_created_at = max(successful_times) if successful_times else None
|
||||
@@ -1004,7 +1184,13 @@ def _start_live_snapshot_refresh(sections: set[str]) -> asyncio.Task:
|
||||
return _live_snapshot_task
|
||||
|
||||
|
||||
def _live_snapshot_payload(value: dict, *, stale: bool, revalidating: bool) -> dict:
|
||||
def _live_snapshot_payload(
|
||||
value: dict,
|
||||
*,
|
||||
stale: bool,
|
||||
revalidating: bool,
|
||||
known_revisions: dict[str, int | None] | None = None,
|
||||
) -> dict:
|
||||
payload = dict(value)
|
||||
now = time.monotonic()
|
||||
section_freshness = {}
|
||||
@@ -1036,6 +1222,13 @@ def _live_snapshot_payload(value: dict, *, stale: bool, revalidating: bool) -> d
|
||||
"retry_in_seconds": min(retries, default=0),
|
||||
"sections": section_freshness,
|
||||
}
|
||||
payload["revisions"] = dict(_live_section_revisions)
|
||||
for section, known_revision in (known_revisions or {}).items():
|
||||
if known_revision is None or known_revision != _live_section_revisions[section]:
|
||||
continue
|
||||
payload.pop(section, None)
|
||||
if section == "notifications":
|
||||
payload.pop("notification_pagination", None)
|
||||
return payload
|
||||
|
||||
|
||||
@@ -1053,6 +1246,8 @@ def _remove_notification_from_live_snapshot(thread_id: int) -> None:
|
||||
for notification in retained_notifications
|
||||
if not isinstance(notification, dict) or notification.get("id") != thread_id
|
||||
]
|
||||
if updated["notifications"] != retained_notifications:
|
||||
_live_section_revisions["notifications"] += 1
|
||||
_live_snapshot_value = updated
|
||||
|
||||
|
||||
@@ -1078,9 +1273,18 @@ def _without_read_notifications(snapshot: dict) -> dict:
|
||||
|
||||
|
||||
@app.get("/api/v1/live")
|
||||
async def live_snapshot() -> JSONResponse:
|
||||
async def live_snapshot(
|
||||
context_revision: int | None = Query(default=None, ge=0),
|
||||
events_revision: int | None = Query(default=None, ge=0),
|
||||
notifications_revision: int | None = Query(default=None, ge=0),
|
||||
) -> JSONResponse:
|
||||
"""Return a freshness-bounded snapshot and share identical upstream loads."""
|
||||
global _live_snapshot_task, _live_snapshot_value, _live_snapshot_created_at
|
||||
known_revisions = {
|
||||
"context": context_revision,
|
||||
"events": events_revision,
|
||||
"notifications": notifications_revision,
|
||||
}
|
||||
now = time.monotonic()
|
||||
due_sections = _due_live_sections(now)
|
||||
if (
|
||||
@@ -1095,19 +1299,28 @@ async def live_snapshot() -> JSONResponse:
|
||||
for state in _live_snapshot_value.get("sections", {}).values()
|
||||
),
|
||||
revalidating=False,
|
||||
known_revisions=known_revisions,
|
||||
)
|
||||
)
|
||||
task = _start_live_snapshot_refresh(due_sections)
|
||||
if _live_snapshot_value is not None:
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(
|
||||
_live_snapshot_value, stale=True, revalidating=True
|
||||
_live_snapshot_value,
|
||||
stale=True,
|
||||
revalidating=True,
|
||||
known_revisions=known_revisions,
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await asyncio.shield(task)
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(result, stale=False, revalidating=False)
|
||||
_live_snapshot_payload(
|
||||
result,
|
||||
stale=False,
|
||||
revalidating=False,
|
||||
known_revisions=known_revisions,
|
||||
)
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
@@ -1382,6 +1595,61 @@ async def release_assigned_issue(
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff-candidates")
|
||||
async def issue_handoff_candidates(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_candidates():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await gitea_proxy.issue_handoff_candidates(repository)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
load_candidates(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Teammates could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff")
|
||||
async def handoff_assigned_issue(
|
||||
handoff: IssueHandoff,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.handoff_assigned_issue(
|
||||
repository, number, handoff.recipient
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": "The issue or recipient changed. Reload before handing off."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The handoff could not be confirmed. It remains in My Work; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
|
||||
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
|
||||
repository = f"{owner}/{repo}"
|
||||
@@ -1972,9 +2240,26 @@ async def merge_assigned_pull(
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
merged = await asyncio.wait_for(
|
||||
gitea_proxy.is_pull_merged_at_head(
|
||||
repository, number, submission.expected_head_sha
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
merged = False
|
||||
if merged:
|
||||
return {"number": number, "merged": True, "state": "closed"}
|
||||
return JSONResponse(
|
||||
{"error": "The pull request could not be merged. It remains in My Work; please retry."},
|
||||
status_code=503,
|
||||
{
|
||||
"number": number,
|
||||
"merged": False,
|
||||
"state": "unknown",
|
||||
"confirmation_pending": True,
|
||||
"error": "Merge confirmation is pending. Check its status before retrying.",
|
||||
},
|
||||
status_code=202,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
+20
-15
@@ -63,7 +63,7 @@ class Client(Protocol):
|
||||
|
||||
|
||||
class Runner(Protocol):
|
||||
def run(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> CommandResult: ...
|
||||
def run(self, command: list[str], cwd: Path, env: dict[str, str] | None = None) -> CommandResult: ...
|
||||
|
||||
|
||||
class StateStore:
|
||||
@@ -113,8 +113,18 @@ def branch_name(agent: str, issue: Issue) -> str:
|
||||
return f"{actor}/{issue.number}-{slug or 'issue'}"
|
||||
|
||||
|
||||
def parse_command(command: str, name: str) -> list[str]:
|
||||
try:
|
||||
arguments = shlex.split(command)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"invalid {name}: {exc}") from exc
|
||||
if not arguments:
|
||||
raise ValueError(f"invalid {name}: command is empty")
|
||||
return arguments
|
||||
|
||||
|
||||
class ShellRunner:
|
||||
def run(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> CommandResult:
|
||||
def run(self, command: list[str], cwd: Path, env: dict[str, str] | None = None) -> CommandResult:
|
||||
merged_env = os.environ.copy()
|
||||
if env:
|
||||
merged_env.update(env)
|
||||
@@ -122,7 +132,7 @@ class ShellRunner:
|
||||
command,
|
||||
cwd=str(cwd),
|
||||
env=merged_env,
|
||||
shell=True,
|
||||
shell=False,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
@@ -220,6 +230,9 @@ class ReleaseEngine:
|
||||
"agent": self.config.agent,
|
||||
}
|
||||
|
||||
agent_command = parse_command(self.config.agent_command, "agent command")
|
||||
test_command = parse_command(self.config.test_command, "test command")
|
||||
|
||||
self.client.claim_issue(self.config.repo, issue.number, self.config.agent)
|
||||
verified = self.client.get_issue(self.config.repo, issue.number)
|
||||
if verified.assignee != self.config.agent:
|
||||
@@ -228,19 +241,11 @@ class ReleaseEngine:
|
||||
)
|
||||
self.store.save(RunState(issue.number, branch, "claimed"))
|
||||
|
||||
branch_result = self.runner.run(f"git checkout -B {shlex.quote(branch)}", self.config.repo_path)
|
||||
branch_result = self.runner.run(["git", "checkout", "-B", branch], self.config.repo_path)
|
||||
if branch_result.returncode != 0:
|
||||
raise RuntimeError(f"branch creation failed: {branch_result.stderr.strip()}")
|
||||
|
||||
context = {
|
||||
"issue_number": issue.number,
|
||||
"issue_title": issue.title,
|
||||
"issue_body": issue.body,
|
||||
"repo": self.config.repo,
|
||||
"branch": branch,
|
||||
}
|
||||
command = self.config.agent_command.format(**context)
|
||||
agent_result = self.runner.run(command, self.config.repo_path, {
|
||||
agent_result = self.runner.run(agent_command, self.config.repo_path, {
|
||||
"RELEASE_ISSUE_NUMBER": str(issue.number),
|
||||
"RELEASE_ISSUE_TITLE": issue.title,
|
||||
"RELEASE_ISSUE_BODY": issue.body,
|
||||
@@ -252,14 +257,14 @@ class ReleaseEngine:
|
||||
raise RuntimeError(f"coding agent failed: {agent_result.stderr.strip()}")
|
||||
self.store.save(RunState(issue.number, branch, "agent_complete"))
|
||||
|
||||
test_result = self.runner.run(self.config.test_command, self.config.repo_path)
|
||||
test_result = self.runner.run(test_command, self.config.repo_path)
|
||||
evidence = (test_result.stdout + "\n" + test_result.stderr).strip()[-4000:]
|
||||
if test_result.returncode != 0:
|
||||
self.store.save(RunState(issue.number, branch, "tests_failed", evidence))
|
||||
raise RuntimeError(f"tests failed: {evidence}")
|
||||
self.store.save(RunState(issue.number, branch, "tests_passed", evidence))
|
||||
|
||||
push_result = self.runner.run(f"git push -u origin {shlex.quote(branch)}", self.config.repo_path)
|
||||
push_result = self.runner.run(["git", "push", "-u", "origin", branch], self.config.repo_path)
|
||||
if push_result.returncode != 0:
|
||||
self.store.save(RunState(issue.number, branch, "push_failed", push_result.stderr[-2000:]))
|
||||
raise RuntimeError(f"push failed: {push_result.stderr.strip()}")
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Durable active-session registry used to revoke signed operator sessions."""
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class SessionStoreError(RuntimeError):
|
||||
"""Raised when session state cannot be read or changed safely."""
|
||||
|
||||
|
||||
class SessionStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
clock: Callable[[], float],
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
|
||||
@staticmethod
|
||||
def _digest(session_id: str) -> str:
|
||||
return hashlib.sha256(session_id.encode()).hexdigest()
|
||||
|
||||
def _connect(self, *, initialize: bool = False) -> sqlite3.Connection:
|
||||
try:
|
||||
if initialize:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(
|
||||
self.path, timeout=self.lock_timeout_seconds
|
||||
)
|
||||
else:
|
||||
connection = sqlite3.connect(
|
||||
f"{self.path.resolve().as_uri()}?mode=rw",
|
||||
timeout=self.lock_timeout_seconds,
|
||||
uri=True,
|
||||
)
|
||||
if initialize:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS active_sessions (
|
||||
session_hash TEXT PRIMARY KEY,
|
||||
expires_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def activate(self, session_id: str, expires_at: int) -> None:
|
||||
try:
|
||||
with self._connect(initialize=True) as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM active_sessions WHERE expires_at <= ?", (int(self.clock()),)
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO active_sessions(session_hash, expires_at) VALUES (?, ?)",
|
||||
(self._digest(session_id), expires_at),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def is_active(self, session_id: str, expires_at: int) -> bool:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT expires_at FROM active_sessions WHERE session_hash = ?",
|
||||
(self._digest(session_id),),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
return row is not None and row[0] == expires_at and expires_at > now
|
||||
|
||||
def revoke(self, session_id: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM active_sessions WHERE session_hash = ?",
|
||||
(self._digest(session_id),),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all(self) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM active_sessions")
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
+3
-3
@@ -11,10 +11,10 @@ SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
|
||||
LOGIN_HTML = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sign in · Stackchain Dashboard</title>
|
||||
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
|
||||
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
|
||||
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
|
||||
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button>Sign in</button><p id="status" role="status"></p></form></main>
|
||||
<script>document.getElementById('sign-in').addEventListener('submit',async(event)=>{event.preventDefault();const status=document.getElementById('status');status.textContent='Signing in…';const access_token=new FormData(event.currentTarget).get('access_token');const response=await fetch('api/v1/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({access_token})});if(response.ok){location.replace('./');return}status.textContent='Sign-in failed. Check the token and try again.';event.currentTarget.reset()});</script></body></html>"""
|
||||
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main>
|
||||
<script src="static/login.js"></script></body></html>"""
|
||||
|
||||
|
||||
class RevalidatingHTMLResponse(HTMLResponse):
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.views import dashboard as dashboard_html
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
|
||||
|
||||
def dashboard_bundle_text() -> str:
|
||||
"""Return the HTML, CSS, and bootstrap sources that make up the dashboard."""
|
||||
return "\n".join(
|
||||
(
|
||||
(FRONTEND / "index.html").read_text(),
|
||||
(FRONTEND / "dashboard.css").read_text(),
|
||||
(FRONTEND / "dashboard.js").read_text(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def dashboard() -> str:
|
||||
# Exercise the real view lookup before adding its separately served assets.
|
||||
html = await dashboard_html()
|
||||
return "\n".join(
|
||||
(
|
||||
html,
|
||||
(FRONTEND / "dashboard.css").read_text(),
|
||||
(FRONTEND / "dashboard.js").read_text(),
|
||||
)
|
||||
)
|
||||
@@ -2,12 +2,11 @@ import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
DASHBOARD_HTML = Path(__file__).parent.parent / "frontend" / "index.html"
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
html = DASHBOARD_HTML.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
|
||||
|
||||
assert api_paths
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OUTBOX = Path(__file__).parents[1] / "frontend" / "authored-outbox.js"
|
||||
@@ -182,6 +182,35 @@ setTimeout(() => {{
|
||||
]
|
||||
|
||||
|
||||
def test_authored_outbox_waits_for_durable_background_admission():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let release; const gate = new Promise(resolve => release = resolve); const events = [];
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
backgroundSync: {{
|
||||
reconcile: async (_items, lane) => {{ events.push('mirror:' + lane); await gate; events.push('committed'); }},
|
||||
requestSync: async () => events.push('registered'),
|
||||
}},
|
||||
}});
|
||||
const admission = outbox.enqueueDurably({{
|
||||
kind:'issue-comment',repository:'o/r',number:1,body:'Do not lose',operationId:'message-1'
|
||||
}}).then(result => {{ events.push('confirmed'); return result; }});
|
||||
Promise.resolve().then(async () => {{
|
||||
const pending = events.slice(); release(); const result = await admission;
|
||||
process.stdout.write(JSON.stringify({{pending,events,result,items:outbox.list()}}));
|
||||
}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["pending"] == ["mirror:authored"]
|
||||
assert output["events"] == ["mirror:authored", "committed", "registered", "confirmed"]
|
||||
assert output["result"]["durability"] == "background"
|
||||
assert output["items"][0]["operationId"] == "message-1"
|
||||
|
||||
|
||||
def test_authored_outbox_foreground_send_uses_atomic_background_delivery():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -217,7 +246,9 @@ async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
assert "const authoredOutbox = createAuthoredOutbox({" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
||||
assert "flushAuthoredOutbox();" in html
|
||||
assert "authoredOutbox.enqueue" in html
|
||||
assert "await authoredOutbox.enqueueDurably" in html
|
||||
assert "Saving for background delivery…" in html
|
||||
assert "Saved for next launch; background delivery unavailable." in html
|
||||
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
|
||||
assert "authoredOutbox.discard(item.outbox_id)" in html
|
||||
assert "if (result?.queued)" in html
|
||||
|
||||
@@ -6,7 +6,7 @@ import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
SYNC = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
|
||||
@@ -64,6 +64,59 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const queued = [
|
||||
{{id:'capture-1',operationId:'capture-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Secret title',body:'Secret body',labelIds:[]}},
|
||||
{{id:'reply-1',operationId:'reply-1',ownerLogin:'timmy',status:'queued',kind:'pull-comment',repository:'stackchain/web',number:8,body:'Secret reply'}},
|
||||
{{id:'bad-1',operationId:'bad-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Bad',body:'Secret failure',labelIds:[]}},
|
||||
];
|
||||
const store = {{
|
||||
claimNext: async () => queued.shift() || null,
|
||||
complete: async () => {{}}, release: async () => {{}}, fail: async () => {{}},
|
||||
countBlocked: async () => 0,
|
||||
}};
|
||||
let issueMutations = 0;
|
||||
const fetchJson = async (url) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
if (url.endsWith('/issues') && issueMutations++ === 0) return {{number:44,repository:'stackchain/api',title:'Secret title'}};
|
||||
if (url.includes('/pulls/8/comments')) return {{id:91,body:'Secret reply'}};
|
||||
const error = new Error('Sensitive validation detail'); error.status = 422; throw error;
|
||||
}};
|
||||
(async () => {{
|
||||
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["login"] == "timmy"
|
||||
assert output["receipts"] == [
|
||||
{
|
||||
"id": "capture-1",
|
||||
"status": "confirmed",
|
||||
"kind": "issue",
|
||||
"route": "#/my-work/issue/stackchain/api/44",
|
||||
},
|
||||
{
|
||||
"id": "reply-1",
|
||||
"status": "confirmed",
|
||||
"kind": "message",
|
||||
"route": "#/my-work/pull/stackchain/web/8",
|
||||
},
|
||||
{
|
||||
"id": "bad-1",
|
||||
"status": "attention",
|
||||
"kind": "issue",
|
||||
"route": "#/my-work/drafts",
|
||||
},
|
||||
]
|
||||
serialized = json.dumps(output["receipts"])
|
||||
assert "Secret" not in serialized
|
||||
assert "Sensitive" not in serialized
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("item", "expected_url"),
|
||||
[
|
||||
@@ -141,6 +194,80 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
]
|
||||
|
||||
|
||||
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const state={{opens:0,closes:0}};
|
||||
const indexedDB={{lastDb:null,open:()=>{{
|
||||
state.opens += 1;
|
||||
const request={{result:null,onsuccess:null,onerror:null,onupgradeneeded:null}};
|
||||
const db={{
|
||||
objectStoreNames:{{contains:()=>true}},
|
||||
close:()=>{{state.closes += 1;}},
|
||||
transaction:()=>{{
|
||||
const tx={{oncomplete:null,onerror:null,onabort:null,error:null,objectStore:()=>({{
|
||||
getAll:()=>{{const get={{onsuccess:null,onerror:null,result:[]}};queueMicrotask(()=>get.onsuccess?.());return get;}},
|
||||
}})}};
|
||||
setTimeout(()=>tx.oncomplete?.(),0);
|
||||
return tx;
|
||||
}},
|
||||
}};
|
||||
indexedDB.lastDb=db;request.result=db;queueMicrotask(()=>request.onsuccess?.());return request;
|
||||
}}}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{indexedDB}});
|
||||
await store.snapshot();
|
||||
indexedDB.lastDb.onversionchange?.();
|
||||
await store.snapshot();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"opens": 2, "closes": 1}
|
||||
|
||||
|
||||
def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
await store.setReceiptPreference('timmy', true);
|
||||
await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
|
||||
const result={{
|
||||
timmy:await store.getReceiptPreference('timmy'),
|
||||
alexander:await store.getReceiptPreference('alexander'),
|
||||
snapshot:await store.snapshot(),
|
||||
blocked:await store.countBlocked('alexander'),
|
||||
}};
|
||||
await store.setReceiptPreference('timmy', false);
|
||||
result.disabled=await store.getReceiptPreference('timmy');
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"timmy": True,
|
||||
"alexander": False,
|
||||
"snapshot": [
|
||||
{
|
||||
"id": "issue",
|
||||
"ownerLogin": "timmy",
|
||||
"status": "queued",
|
||||
"outboxLane": "issue",
|
||||
}
|
||||
],
|
||||
"blocked": 1,
|
||||
"disabled": False,
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_leaves_another_accounts_issue_queued():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -166,6 +293,44 @@ const fetchJson = async url => {{
|
||||
assert output["result"]["blocked"] == 1
|
||||
|
||||
|
||||
def test_session_expiry_during_delivery_releases_claim_without_attention():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const queued = [
|
||||
{{id:'capture-auth',operationId:'stable-key',ownerLogin:'timmy',repository:'o/r',title:'Keep me',body:'',labelIds:[]}},
|
||||
{{id:'capture-later',operationId:'later-key',ownerLogin:'timmy',repository:'o/r',title:'Do not try yet',body:'',labelIds:[]}},
|
||||
];
|
||||
const state = {{released:[],failed:[],mutationKeys:[]}};
|
||||
const store = {{
|
||||
claimNext: async () => queued.shift() || null,
|
||||
release: async id => state.released.push(id),
|
||||
fail: async (id,message) => state.failed.push({{id,message}}),
|
||||
countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
state.mutationKeys.push(options.headers['Idempotency-Key']);
|
||||
const error = new Error('Authentication required'); error.status = 401; throw error;
|
||||
}};
|
||||
(async () => {{
|
||||
let error = null;
|
||||
try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }}
|
||||
catch (caught) {{ error = {{message:caught.message,status:caught.status}}; }}
|
||||
process.stdout.write(JSON.stringify({{state,error}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"state": {
|
||||
"released": ["capture-auth"],
|
||||
"failed": [],
|
||||
"mutationKeys": ["stable-key"],
|
||||
},
|
||||
"error": {"message": "Authentication required", "status": 401},
|
||||
}
|
||||
|
||||
|
||||
def test_transient_delivery_failure_releases_claim_and_requests_another_sync():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -194,6 +359,203 @@ const fetchJson = async url => {{
|
||||
assert output["error"] == "Gitea unavailable"
|
||||
|
||||
|
||||
def test_stalled_delivery_times_out_releases_claim_and_retries_with_same_idempotency_key():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const item = {{id:'capture-timeout',operationId:'stable-operation',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}};
|
||||
const state = {{queued:true,released:[],completed:[],keys:[],attempts:0}};
|
||||
const store = {{
|
||||
claimNext: async () => state.queued ? (state.queued=false, {{...item}}) : null,
|
||||
complete: async id => state.completed.push(id),
|
||||
release: async id => {{state.released.push(id); state.queued=true;}},
|
||||
fail: async () => {{}}, countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
state.keys.push(options.headers['Idempotency-Key']);
|
||||
state.attempts += 1;
|
||||
if (state.attempts === 1) return new Promise(() => {{}});
|
||||
return {{repository:'o/r',number:9}};
|
||||
}};
|
||||
(async () => {{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson,requestTimeoutMs:10}});
|
||||
let firstError='';
|
||||
try {{ await sync.flush(); }} catch (error) {{ firstError=error.message; }}
|
||||
const second=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{state,firstError,second}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["firstError"] == "Background request timed out."
|
||||
assert output["state"]["released"] == ["capture-timeout"]
|
||||
assert output["state"]["completed"] == ["capture-timeout"]
|
||||
assert output["state"]["keys"] == ["stable-operation", "stable-operation"]
|
||||
assert output["second"]["confirmed"][0]["number"] == 9
|
||||
|
||||
|
||||
def test_purge_cancels_stalled_delivery_without_reclaiming_it():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const items=[
|
||||
{{id:'capture-purge-1',operationId:'capture-purge-1',ownerLogin:'timmy',repository:'o/r',title:'Private',body:'Draft',labelIds:[]}},
|
||||
{{id:'capture-purge-2',operationId:'capture-purge-2',ownerLogin:'timmy',repository:'o/r',title:'Private 2',body:'Draft',labelIds:[]}},
|
||||
];
|
||||
const state={{released:[],closed:0,mutations:0}};
|
||||
let mutationStarted;
|
||||
const started=new Promise(resolve => mutationStarted=resolve);
|
||||
const store={{
|
||||
claimBatch:async()=>items.map(item=>({{...item}})),
|
||||
complete:async()=>{{}},
|
||||
release:async id=>state.released.push(id),
|
||||
fail:async()=>{{}}, countBlocked:async()=>0, close:async()=>{{state.closed+=1;}},
|
||||
}};
|
||||
const fetchJson=async url=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
state.mutations+=1;mutationStarted();return new Promise(()=>{{}});
|
||||
}};
|
||||
(async()=>{{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson,maxConcurrency:1,requestTimeoutMs:1000}});
|
||||
sync.flush().catch(()=>{{}});
|
||||
await started;
|
||||
const outcome=await Promise.race([
|
||||
sync.purge().then(()=> 'purged'),
|
||||
new Promise(resolve=>setTimeout(()=>resolve('blocked'),80)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({{state,outcome}}));
|
||||
process.exit(0);
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["outcome"] == "purged"
|
||||
assert output["state"] == {
|
||||
"released": ["capture-purge-1", "capture-purge-2"],
|
||||
"closed": 1,
|
||||
"mutations": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_bounded_flush_delivers_healthy_records_after_transient_failure_without_same_run_retry():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const items = [
|
||||
{{id:'issue-1',operationId:'issue-1',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'One',body:'',labelIds:[]}},
|
||||
{{id:'message-1',operationId:'message-1',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:1,body:'Reply'}},
|
||||
{{id:'issue-bad',operationId:'issue-bad',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Bad',body:'',labelIds:[]}},
|
||||
{{id:'message-2',operationId:'message-2',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:2,body:'Reply'}},
|
||||
{{id:'issue-2',operationId:'issue-2',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Two',body:'',labelIds:[]}},
|
||||
];
|
||||
const state = {{active:0,maxActive:0,attempted:[],completed:[],released:[],batches:0}};
|
||||
const store = {{
|
||||
claimBatch: async (owner, limit) => items.slice(0, limit),
|
||||
complete: async id => state.completed.push(id),
|
||||
release: async id => state.released.push(id),
|
||||
fail: async () => {{}}, countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
const key = options.headers['Idempotency-Key'];
|
||||
state.attempted.push(key); state.active += 1;
|
||||
state.maxActive = Math.max(state.maxActive, state.active);
|
||||
await new Promise(resolve => setTimeout(resolve, key === 'issue-bad' ? 5 : 20));
|
||||
state.active -= 1;
|
||||
if (key === 'issue-bad') {{ const error=new Error('Temporary outage'); error.status=503; throw error; }}
|
||||
return {{id:key,number:7}};
|
||||
}};
|
||||
(async () => {{
|
||||
let error;
|
||||
try {{ await createBackgroundIssueSync({{
|
||||
store,fetchJson,maxConcurrency:3,batchSize:10,
|
||||
batch: async work => {{ state.batches += 1; return work(); }},
|
||||
}}).flush(); }}
|
||||
catch (caught) {{ error=caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{state,error}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["maxActive"] == 3
|
||||
assert output["state"]["batches"] == 1
|
||||
assert sorted(output["state"]["attempted"]) == [
|
||||
"issue-1", "issue-2", "issue-bad", "message-1", "message-2"
|
||||
]
|
||||
assert len(output["state"]["attempted"]) == len(set(output["state"]["attempted"]))
|
||||
assert sorted(output["state"]["completed"]) == [
|
||||
"issue-1", "issue-2", "message-1", "message-2"
|
||||
]
|
||||
assert output["state"]["released"] == ["issue-bad"]
|
||||
assert output["error"] == "Temporary outage"
|
||||
|
||||
|
||||
def test_bounded_flush_stops_admission_and_releases_unstarted_claims_on_auth_loss():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const items = Array.from({{length:5}}, (_, index) => ({{
|
||||
id:'item-'+index,operationId:'item-'+index,ownerLogin:'timmy',outboxLane:'issue',
|
||||
repository:'o/r',title:'Item',body:'',labelIds:[],
|
||||
}}));
|
||||
const state = {{attempted:[],released:[],completed:[]}};
|
||||
const store = {{
|
||||
claimBatch: async () => items,
|
||||
complete: async id => state.completed.push(id),
|
||||
release: async id => state.released.push(id), fail:async()=>{{}}, countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
const key=options.headers['Idempotency-Key']; state.attempted.push(key);
|
||||
if (key === 'item-0') {{ const error=new Error('Authentication required'); error.status=401; throw error; }}
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
return {{number:1}};
|
||||
}};
|
||||
(async()=>{{
|
||||
let error;
|
||||
try {{ await createBackgroundIssueSync({{store,fetchJson,maxConcurrency:2}}).flush(); }}
|
||||
catch (caught) {{ error={{message:caught.message,status:caught.status}}; }}
|
||||
process.stdout.write(JSON.stringify({{state,error}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["attempted"] == ["item-0", "item-1"]
|
||||
assert sorted(output["state"]["released"]) == ["item-0", "item-2", "item-3", "item-4"]
|
||||
assert output["state"]["completed"] == ["item-1"]
|
||||
assert output["error"] == {"message": "Authentication required", "status": 401}
|
||||
|
||||
|
||||
def test_issue_store_claims_one_finite_batch_fairly_across_outbox_lanes():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100,claimMs:5000}});
|
||||
await store.reconcile([
|
||||
{{id:'issue-1',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'issue-2',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'issue-3',ownerLogin:'timmy',status:'queued'}},
|
||||
], 'issue');
|
||||
await store.reconcile([
|
||||
{{id:'message-1',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'message-2',ownerLogin:'timmy',status:'queued'}},
|
||||
], 'authored');
|
||||
const claimed=await store.claimBatch('timmy', 4);
|
||||
process.stdout.write(JSON.stringify({{claimed,snapshot:await store.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["id"] for item in output["claimed"]] == [
|
||||
"issue-1", "message-1", "issue-2", "message-2"
|
||||
]
|
||||
assert all(item["status"] == "sending" for item in output["claimed"])
|
||||
assert all(item["claimUntil"] == 5100 for item in output["claimed"])
|
||||
assert next(item for item in output["snapshot"] if item["id"] == "issue-3")["status"] == "queued"
|
||||
|
||||
|
||||
def test_permanent_delivery_failure_marks_issue_for_foreground_attention():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -392,3 +754,16 @@ async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback():
|
||||
assert "issueOutbox.reconcileBackground(records);" in html
|
||||
assert "authoredOutbox.reconcileBackground(records);" in html
|
||||
assert "if ('indexedDB' in window)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="delivery-receipts" type="checkbox"' in html
|
||||
assert "deliveryReceipts.addEventListener('change', async () => {" in html
|
||||
assert "await Notification.requestPermission()" in html
|
||||
assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html
|
||||
assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html
|
||||
assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html
|
||||
assert "if (window.location.hash !== '#/my-work/drafts') return;" in html
|
||||
@@ -4,6 +4,8 @@ from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
COMMANDS = FRONTEND / "commands.js"
|
||||
@@ -41,7 +43,7 @@ if (action !== 'refresh') throw new Error(`expected refresh, got ${{action}}`);
|
||||
|
||||
def test_command_script_resolves_inside_dashboard_subpath():
|
||||
parser = ScriptSourceParser()
|
||||
parser.feed((FRONTEND / "index.html").read_text())
|
||||
parser.feed(dashboard_bundle_text())
|
||||
command_source = next(source for source in parser.sources if source.endswith("commands.js"))
|
||||
|
||||
assert urljoin(
|
||||
@@ -152,7 +154,7 @@ if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys sho
|
||||
|
||||
|
||||
def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'role="combobox"' in html
|
||||
assert 'aria-controls="cmd-results"' in html
|
||||
@@ -221,7 +223,7 @@ if (!states.some(state => state.status === 'claimed')) throw new Error('claim co
|
||||
|
||||
|
||||
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
run_item = html.split("function runCommandItem(item)", 1)[1].split(
|
||||
"function renderCommands", 1
|
||||
@@ -233,7 +235,7 @@ def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
|
||||
|
||||
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
|
||||
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
|
||||
|
||||
@@ -63,3 +63,164 @@ const poller = createContextPoller({{
|
||||
"callsWhileHidden": 1,
|
||||
"callsAfterResume": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_context_poller_sends_revisions_and_merges_changed_sections():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const requested = [];
|
||||
const rendered = [];
|
||||
const responses = [
|
||||
{{ context: {{ user: {{ login: 'timmy' }} }}, events: [{{ id: 1 }}], notifications: [],
|
||||
sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
|
||||
revisions: {{ context: 1, events: 1, notifications: 1 }}, freshness: {{ age_seconds: 0 }} }},
|
||||
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
|
||||
revisions: {{ context: 1, events: 2, notifications: 1 }}, events: [{{ id: 2 }}],
|
||||
freshness: {{ age_seconds: 1 }} }},
|
||||
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
|
||||
revisions: {{ context: 1, events: 2, notifications: 1 }}, freshness: {{ age_seconds: 2 }} }},
|
||||
];
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: revisions => {{ requested.push({{ ...revisions }}); return Promise.resolve(responses.shift()); }},
|
||||
onSnapshot: (snapshot, changed) => rendered.push({{
|
||||
context: snapshot.context.user.login,
|
||||
event: snapshot.events[0].id,
|
||||
age: snapshot.freshness.age_seconds,
|
||||
changed,
|
||||
}}),
|
||||
onError: error => {{ throw error; }},
|
||||
setTimer: () => 1,
|
||||
clearTimer: () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
process.stdout.write(JSON.stringify({{ requested, rendered }}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"requested": [
|
||||
{},
|
||||
{"context": 1, "events": 1, "notifications": 1},
|
||||
{"context": 1, "events": 2, "notifications": 1},
|
||||
],
|
||||
"rendered": [
|
||||
{"context": "timmy", "event": 1, "age": 0,
|
||||
"changed": ["context", "events", "notifications"]},
|
||||
{"context": "timmy", "event": 2, "age": 1,
|
||||
"changed": ["events"]},
|
||||
{"context": "timmy", "event": 2, "age": 2, "changed": []},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_context_poller_aborts_a_stalled_request_and_recovers_on_schedule():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const timers = [];
|
||||
const deadlineTimers = [];
|
||||
const errors = [];
|
||||
const snapshots = [];
|
||||
const signals = [];
|
||||
let calls = 0;
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: (revisions, options) => {{
|
||||
calls += 1;
|
||||
signals.push(options.signal);
|
||||
if (calls === 1) return new Promise(() => {{}});
|
||||
return Promise.resolve({{ context: {{ user: {{ login: 'timmy' }} }} }});
|
||||
}},
|
||||
onSnapshot: snapshot => snapshots.push(snapshot.context.user.login),
|
||||
onError: error => errors.push({{ name: error.name, message: error.message }}),
|
||||
setTimer: (callback, delay) => {{
|
||||
const timer = {{ callback, delay, cancelled: false }};
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
}},
|
||||
clearTimer: timer => {{ timer.cancelled = true; }},
|
||||
setDeadlineTimer: (callback, delay) => {{
|
||||
const timer = {{ callback, delay, cancelled: false }};
|
||||
deadlineTimers.push(timer);
|
||||
return timer;
|
||||
}},
|
||||
clearDeadlineTimer: timer => {{ timer.cancelled = true; }},
|
||||
intervalMs: 8000,
|
||||
timeoutMs: 25,
|
||||
}});
|
||||
|
||||
(async () => {{
|
||||
const first = poller.start();
|
||||
const deadline = deadlineTimers.find(timer => timer.delay === 25 && !timer.cancelled);
|
||||
deadline.callback();
|
||||
await first;
|
||||
const retry = timers.find(timer => timer.delay === 8000 && !timer.cancelled);
|
||||
retry.callback();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
firstAborted: signals[0].aborted,
|
||||
errors,
|
||||
snapshots,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": 2,
|
||||
"firstAborted": True,
|
||||
"errors": [
|
||||
{"name": "TimeoutError", "message": "Live update timed out after 25ms."}
|
||||
],
|
||||
"snapshots": ["timmy"],
|
||||
}
|
||||
|
||||
|
||||
def test_forced_refresh_retires_stale_generation_without_late_overwrite():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
let resolveFirst;
|
||||
let firstSignal;
|
||||
const rendered = [];
|
||||
const errors = [];
|
||||
let calls = 0;
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: (revisions, options) => {{
|
||||
calls += 1;
|
||||
if (calls === 1) {{
|
||||
firstSignal = options.signal;
|
||||
return new Promise(resolve => {{ resolveFirst = resolve; }});
|
||||
}}
|
||||
return Promise.resolve({{ context: {{ user: {{ login: 'fresh' }} }} }});
|
||||
}},
|
||||
onSnapshot: snapshot => rendered.push(snapshot.context.user.login),
|
||||
onError: error => errors.push(error.name),
|
||||
setTimer: () => 1,
|
||||
clearTimer: () => {{}},
|
||||
setDeadlineTimer: () => 1,
|
||||
clearDeadlineTimer: () => {{}},
|
||||
}});
|
||||
|
||||
(async () => {{
|
||||
const stale = poller.start();
|
||||
const fresh = poller.refresh({{ force: true }});
|
||||
await fresh;
|
||||
resolveFirst({{ context: {{ user: {{ login: 'stale' }} }} }});
|
||||
await stale;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
firstAborted: firstSignal.aborted,
|
||||
rendered,
|
||||
errors,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": 2,
|
||||
"firstAborted": True,
|
||||
"rendered": ["fresh"],
|
||||
"errors": [],
|
||||
}
|
||||
@@ -1,13 +1,83 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.session_store import SessionStoreError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def access_control(monkeypatch):
|
||||
def access_control(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3")
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
|
||||
called = False
|
||||
|
||||
async def user():
|
||||
nonlocal called
|
||||
called = True
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
health = await client.get("/healthz")
|
||||
responses = [
|
||||
await client.get("/readyz"),
|
||||
await client.get("/login"),
|
||||
await client.get("/"),
|
||||
await client.get("/api/v1/context"),
|
||||
]
|
||||
|
||||
assert health.status_code == 200
|
||||
assert [response.status_code for response in responses] == [503, 503, 503, 503]
|
||||
assert all(response.headers["cache-control"] == "no-store" for response in responses)
|
||||
assert all(response.json() == {
|
||||
"detail": "Dashboard authentication is not configured"
|
||||
} for response in responses)
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_operator_mode_rejects_reused_or_incomplete_secrets(monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "same-secret-with-at-least-thirty-two-characters")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "same-secret-with-at-least-thirty-two-characters")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
reused = await client.get("/login")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
|
||||
incomplete = await client.get("/login")
|
||||
|
||||
assert reused.status_code == 503
|
||||
assert incomplete.status_code == 503
|
||||
assert "same-secret" not in reused.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_insecure_local_mode_is_restricted_to_loopback(monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
|
||||
local_transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 1234))
|
||||
remote_transport = httpx.ASGITransport(app=main.app, client=("203.0.113.9", 1234))
|
||||
async with httpx.AsyncClient(transport=local_transport, base_url="http://test") as client:
|
||||
local = await client.get("/login")
|
||||
async with httpx.AsyncClient(transport=remote_transport, base_url="http://test") as client:
|
||||
remote = await client.get("/login")
|
||||
|
||||
assert local.status_code == 200
|
||||
assert remote.status_code == 403
|
||||
assert remote.json() == {"detail": "Insecure local mode requires a loopback client"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -47,6 +117,43 @@ async def test_sign_in_creates_secure_session_without_echoing_access_token(acces
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_in_throttles_repeated_failures_with_retry_guidance(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
failures = [
|
||||
await client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
for _ in range(3)
|
||||
]
|
||||
blocked = await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
|
||||
assert [response.status_code for response in failures] == [401, 401, 401]
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.json() == {"detail": "Too many sign-in attempts"}
|
||||
assert blocked.headers["retry-after"].isdigit()
|
||||
assert blocked.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_sign_in_clears_prior_failures(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
for _ in range(2):
|
||||
await client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
success = await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
after_success = [
|
||||
await client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
for _ in range(3)
|
||||
]
|
||||
|
||||
assert success.status_code == 200
|
||||
assert [response.status_code for response in after_success] == [401, 401, 401]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_get_reaches_private_api(access_control, monkeypatch):
|
||||
async def user():
|
||||
@@ -71,6 +178,30 @@ async def test_authenticated_get_reaches_private_api(access_control, monkeypatch
|
||||
assert response.json()["user"]["login"] == "timmy"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_status_reuses_the_middleware_validation(access_control, monkeypatch):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
original_store = main.dashboard_auth._session_store()
|
||||
lookups = 0
|
||||
|
||||
class CountingStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
nonlocal lookups
|
||||
lookups += 1
|
||||
return original_store.is_active(session_id, expires_at)
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: CountingStore())
|
||||
response = await client.get("/api/v1/session")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["authenticated"] is True
|
||||
assert lookups == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -145,6 +276,264 @@ async def test_logout_clears_session_and_blocks_private_routes(access_control):
|
||||
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_devices_revokes_every_existing_session(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
|
||||
response = await phone.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
phone_private = await phone.get("/api/v1/background-identity")
|
||||
laptop_private = await laptop.get("/api/v1/background-identity")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"authenticated": False,
|
||||
"all_sessions_revoked": True,
|
||||
"clear_private_device_data": True,
|
||||
}
|
||||
assert phone_private.status_code == 401
|
||||
assert laptop_private.status_code == 401
|
||||
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_devices_rejects_cross_site_requests_without_revoking(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={
|
||||
"Origin": "https://evil.example",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
session = await client.get("/api/v1/session")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.headers.get_list("set-cookie") == []
|
||||
assert session.status_code == 200
|
||||
assert session.json()["authenticated"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_control, monkeypatch):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
return True
|
||||
|
||||
def revoke_all(self):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers.get_list("set-cookie") == []
|
||||
assert "database path" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def user():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
captured = client.cookies["stackchain_session"]
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="https://test",
|
||||
headers={"Cookie": f"stackchain_session={captured}"},
|
||||
) as replay:
|
||||
before = await replay.get("/api/v1/background-identity")
|
||||
|
||||
await client.delete(
|
||||
"/api/v1/session",
|
||||
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="https://test",
|
||||
headers={"Cookie": f"stackchain_session={captured}"},
|
||||
) as replay:
|
||||
after = await replay.get("/api/v1/background-identity")
|
||||
|
||||
assert before.status_code == 200
|
||||
assert after.status_code == 401
|
||||
assert after.headers["cache-control"] == "no-store"
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_registry_latency_does_not_block_the_event_loop(access_control, monkeypatch):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
|
||||
class SlowStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
time.sleep(0.15)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
|
||||
private_request = asyncio.create_task(client.get("/api/v1/session"))
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.01)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
response = await private_request
|
||||
|
||||
assert response.status_code == 200
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_registry_read_failure_fails_closed_before_gitea(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
called = False
|
||||
|
||||
async def user():
|
||||
nonlocal called
|
||||
called = True
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
response = await client.get("/api/v1/background-identity")
|
||||
health = await client.get("/healthz")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert "database path" not in response.text
|
||||
assert called is False
|
||||
assert health.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_in_registry_write_failure_issues_no_cookie(access_control, monkeypatch):
|
||||
class BrokenStore:
|
||||
def activate(self, session_id, expires_at):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers.get_list("set-cookie") == []
|
||||
assert "database path" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_logout_registry_failure_does_not_claim_revocation(access_control, monkeypatch):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
return True
|
||||
|
||||
def revoke(self, session_id):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
response = await client.delete(
|
||||
"/api/v1/session",
|
||||
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers.get_list("set-cookie") == []
|
||||
assert "database path" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_public_routes_skip_session_registry_validation(access_control, monkeypatch):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
signed_in = await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
raise SessionStoreError("public routes must not read the registry")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
login = await client.get("/login")
|
||||
manifest = await client.get("/manifest.webmanifest")
|
||||
static = await client.get("/static/session.js")
|
||||
|
||||
assert signed_in.status_code == 200
|
||||
assert [login.status_code, manifest.status_code, static.status_code] == [200, 200, 200]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_public_routes_remain_available_and_readiness_hides_identity(access_control, monkeypatch):
|
||||
async def user():
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
|
||||
def run_session_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '' }};
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], clearErrors: [], responseStatus: 200 }};
|
||||
const storage = {{
|
||||
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||
get length() {{ return this.values.size; }},
|
||||
@@ -27,13 +27,32 @@ const boundary = createSessionBoundary({{
|
||||
base: '/dashboard/',
|
||||
fetchImpl: async (url, options = {{}}) => {{
|
||||
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }});
|
||||
return new Response('{{}}', {{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }});
|
||||
return new Response('{{}}', {{ status: state.responseStatus, headers: {{ 'Content-Type': 'application/json' }} }});
|
||||
}},
|
||||
localStorage: storage,
|
||||
sessionStorage: storage,
|
||||
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
|
||||
indexedDB: {{ deleteDatabase: name => {{
|
||||
state.deletedDatabases.push(name);
|
||||
const request = {{ onsuccess: null, onerror: null, onblocked: null, error: null }};
|
||||
setTimeout(() => {{
|
||||
if (state.failDeletion) {{ request.error = new Error('database blocked'); request.onerror?.(); }}
|
||||
else {{ state.deletionCompleted = true; request.onsuccess?.(); }}
|
||||
}}, 20);
|
||||
return request;
|
||||
}} }},
|
||||
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
|
||||
location: {{ assign: value => {{ state.assigned = value; }} }},
|
||||
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: (message, ports = []) => {{ state.workerMessages.push(message); ports[0]?.postMessage({{ok:true}}); }} }} }}) }},
|
||||
MessageChannel: class {{ constructor() {{
|
||||
const first = {{ onmessage: null, postMessage: data => queueMicrotask(() => second.onmessage?.({{data}})) }};
|
||||
const second = {{ onmessage: null, postMessage: data => queueMicrotask(() => first.onmessage?.({{data}})) }};
|
||||
this.port1 = first; this.port2 = second;
|
||||
}} }},
|
||||
location: {{
|
||||
assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }},
|
||||
replace: value => state.replaced.push(value),
|
||||
}},
|
||||
onClearError: error => state.clearErrors.push(error.message),
|
||||
confirmAction: message => {{ state.confirmations.push(message); return true; }},
|
||||
}});
|
||||
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
@@ -52,6 +71,39 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
|
||||
|
||||
def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
await Promise.all([
|
||||
boundary.fetch('/dashboard/api/v1/live'),
|
||||
boundary.fetch('https://forge.example/dashboard/api/v1/context'),
|
||||
]);
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["replaced"] == [
|
||||
"/dashboard/login?reason=session-expired"
|
||||
]
|
||||
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
|
||||
assert result["deletedDatabases"] == []
|
||||
assert result["deletedCaches"] == []
|
||||
|
||||
|
||||
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
await boundary.fetch('https://untrusted.example/api/private');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["replaced"] == []
|
||||
|
||||
|
||||
def test_sign_out_clears_only_dashboard_private_device_state():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -70,6 +122,52 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
assert result["assignedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_sign_out_stays_on_page_and_reports_failed_private_outbox_deletion():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.failDeletion = true;
|
||||
try { await boundary.signOut(); } catch (error) { state.signOutError = error.message; }
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["assigned"] == ""
|
||||
assert result["clearErrors"] == ["Could not clear private queued work from this device."]
|
||||
assert result["signOutError"] == "Could not clear private queued work from this device."
|
||||
|
||||
|
||||
def test_sign_out_all_devices_requires_confirmation_and_uses_global_endpoint():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.signOutAllDevices();
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["confirmations"] == [
|
||||
"Sign out every device? You will need to sign in again everywhere."
|
||||
]
|
||||
request = result["requests"][0]
|
||||
assert request["url"] == "/dashboard/api/v1/sessions"
|
||||
assert request["method"] == "DELETE"
|
||||
assert request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
|
||||
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.resumeQueuedWork();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["workerMessages"] == [{"type": "stackchain-resume-outbox"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -79,3 +177,5 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
assert '<script src="static/session.js"></script>' in html
|
||||
assert html.index('static/session.js') < html.index('static/markdown.js')
|
||||
assert '<button id="sign-out" type="button">Sign out & clear this device</button>' in html
|
||||
assert '<button id="sign-out-all" type="button">Sign out all devices</button>' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.js"
|
||||
|
||||
@@ -6,13 +6,14 @@ import pytest
|
||||
|
||||
from src import main
|
||||
from src import gitea_proxy
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
DASHBOARD = Path("frontend/index.html")
|
||||
|
||||
|
||||
def test_dashboard_has_realtime_gitea_event_stream_widget():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "Gitea event stream" in html
|
||||
assert "id=\"gitea-events\"" in html
|
||||
@@ -25,14 +26,14 @@ def test_dashboard_has_realtime_gitea_event_stream_widget():
|
||||
|
||||
|
||||
def test_event_stream_reports_when_activity_was_refreshed():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'id="gitea-events-status"' in html
|
||||
assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html
|
||||
|
||||
|
||||
def test_event_stream_reports_refresh_failure_without_erasing_visible_events():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "setEventStreamStatus('Update failed · showing last activity')" in html
|
||||
assert "qs('#gitea-events').innerHTML = '<div class=\"muted\">Event stream unavailable.</div>'" not in html
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
INSTALL_APP = Path(__file__).resolve().parents[1] / "frontend" / "install-app.js"
|
||||
|
||||
|
||||
def run_install_scenario(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createInstallApp = require({json.dumps(str(INSTALL_APP))});
|
||||
class FakeTarget {{
|
||||
constructor() {{ this.listeners = {{}}; }}
|
||||
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
|
||||
dispatch(name, event = {{}}) {{ for (const callback of this.listeners[name] || []) callback(event); }}
|
||||
}}
|
||||
class FakeButton extends FakeTarget {{
|
||||
constructor() {{ super(); this.disabled = false; }}
|
||||
click() {{ this.dispatch('click', {{currentTarget:this}}); }}
|
||||
}}
|
||||
(async () => {{
|
||||
{scenario}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_native_install_prompt_is_revealed_by_eligibility_and_invoked_once():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const install = new FakeButton();
|
||||
const dismiss = new FakeButton();
|
||||
const guidance = {hidden:true};
|
||||
const status = {textContent:''};
|
||||
let promptCalls = 0;
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:install, dismissButton:dismiss,
|
||||
guidance, status, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
windowTarget.dispatch('beforeinstallprompt', {
|
||||
preventDefault(){},
|
||||
prompt:async () => { promptCalls += 1; },
|
||||
userChoice:Promise.resolve({outcome:'accepted'}),
|
||||
});
|
||||
const shown = !card.hidden;
|
||||
install.click();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
install.click();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({shown, promptCalls, hidden:card.hidden, status:status.textContent}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"shown": True,
|
||||
"promptCalls": 1,
|
||||
"hidden": True,
|
||||
"status": "Stackchain was added to your device.",
|
||||
}
|
||||
|
||||
|
||||
def test_declined_native_install_hides_the_spent_choice_and_announces_it():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const install = new FakeButton();
|
||||
const status = {textContent:''};
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:install, dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
windowTarget.dispatch('beforeinstallprompt', {
|
||||
preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'dismissed'}),
|
||||
});
|
||||
install.click();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({hidden:card.hidden, disabled:install.disabled, status:status.textContent}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"hidden": True,
|
||||
"disabled": False,
|
||||
"status": "Installation was not completed.",
|
||||
}
|
||||
|
||||
|
||||
def test_ios_safari_gets_manual_add_to_home_screen_guidance():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const install = new FakeButton(); install.hidden = false;
|
||||
const dismiss = new FakeButton();
|
||||
const guidance = {hidden:true};
|
||||
const status = {textContent:''};
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:install, dismissButton:dismiss,
|
||||
guidance, status, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>true,
|
||||
});
|
||||
app.start();
|
||||
process.stdout.write(JSON.stringify({cardHidden:card.hidden, installHidden:install.hidden, guidanceHidden:guidance.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"cardHidden": False,
|
||||
"installHidden": True,
|
||||
"guidanceHidden": False,
|
||||
}
|
||||
|
||||
|
||||
def test_standalone_launch_never_promotes_installation():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>true, isIosSafari:()=>true,
|
||||
});
|
||||
app.start();
|
||||
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
|
||||
process.stdout.write(JSON.stringify({hidden:card.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"hidden": True}
|
||||
|
||||
|
||||
def test_dismissal_is_remembered_and_suppresses_later_promotion():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key => values.get(key) || null, setItem:(key, value) => values.set(key, value)};
|
||||
const firstWindow = new FakeTarget();
|
||||
const firstCard = {hidden:true};
|
||||
const firstDismiss = new FakeButton();
|
||||
const first = createInstallApp({
|
||||
window:firstWindow, card:firstCard, installButton:new FakeButton(), dismissButton:firstDismiss,
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage,
|
||||
isStandalone:()=>false, isIosSafari:()=>true,
|
||||
});
|
||||
first.start();
|
||||
firstDismiss.click();
|
||||
const secondCard = {hidden:true};
|
||||
const second = createInstallApp({
|
||||
window:new FakeTarget(), card:secondCard, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage,
|
||||
isStandalone:()=>false, isIosSafari:()=>true,
|
||||
});
|
||||
second.start();
|
||||
process.stdout.write(JSON.stringify({firstHidden:firstCard.hidden, remembered:values.size, secondHidden:secondCard.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True}
|
||||
|
||||
|
||||
def test_appinstalled_hides_an_visible_native_promotion():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
|
||||
const shown = !card.hidden;
|
||||
windowTarget.dispatch('appinstalled');
|
||||
process.stdout.write(JSON.stringify({shown, hidden:card.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"shown": True, "hidden": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_touch_safe_install_promotion():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="install-app-card"' in html
|
||||
assert 'id="install-app"' in html
|
||||
assert 'id="dismiss-install-app"' in html
|
||||
assert 'id="install-app-guidance"' in html
|
||||
assert 'Share, then choose <strong>Add to Home Screen</strong>' in html
|
||||
assert '.install-app-card button { min-height:44px;' in html
|
||||
assert '<script src="static/install-app.js"></script>' in html
|
||||
assert 'createInstallApp({' in html
|
||||
assert "window.matchMedia('(display-mode: standalone)').matches" in html
|
||||
assert "navigator.standalone === true" in html
|
||||
assert "isIosSafari" in html
|
||||
@@ -955,6 +955,76 @@ async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_
|
||||
assert result["available"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_handoff_candidates_exclude_current_and_malformed_users():
|
||||
async def handler(request):
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
assert request.url.path == "/api/v1/repos/stackchain/api/assignees"
|
||||
return httpx.Response(200, json=[
|
||||
{"login": "timmy", "full_name": "Timmy"},
|
||||
{"login": "alex", "full_name": "Alexander"},
|
||||
{"login": "casey", "full_name": ""},
|
||||
{"login": ""},
|
||||
{"full_name": "Missing login"},
|
||||
"malformed",
|
||||
])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.issue_handoff_candidates("stackchain/api")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == [
|
||||
{"login": "alex", "name": "Alexander"},
|
||||
{"login": "casey", "name": "casey"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/assignees"):
|
||||
return httpx.Response(200, json=[
|
||||
{"login": "alex", "full_name": "Alexander"},
|
||||
{"login": "casey", "full_name": "Casey"},
|
||||
])
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "pull_request": None,
|
||||
"assignees": [{"login": "timmy"}, {"login": "casey"}],
|
||||
})
|
||||
assert request.content == b'{"assignees":["casey","alex"]}'
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open",
|
||||
"assignees": [{"login": "casey"}, {"login": "alex"}],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.handoff_assigned_issue("stackchain/api", 17, "alex")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "state": "open",
|
||||
"assignees": ["casey", "alex"], "recipient": "alex",
|
||||
}
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/assignees"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
|
||||
calls = []
|
||||
@@ -981,6 +1051,94 @@ async def test_release_issue_endpoint_invalidates_available_work_and_returns_con
|
||||
assert main._available_issue_snapshot_created_at is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
calls.append(("assigned", repository, number))
|
||||
return True
|
||||
|
||||
async def candidates(repository):
|
||||
calls.append(("candidates", repository))
|
||||
return [{"login": "alex", "name": "Alexander"}]
|
||||
|
||||
async def handoff(repository, number, recipient):
|
||||
calls.append(("handoff", repository, number, recipient))
|
||||
return {
|
||||
"repository": repository, "number": number, "state": "open",
|
||||
"assignees": [recipient], "recipient": recipient,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
|
||||
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
listed = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
|
||||
)
|
||||
transferred = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/handoff",
|
||||
json={"recipient": "alex"},
|
||||
)
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.json() == [{"login": "alex", "name": "Alexander"}]
|
||||
assert transferred.status_code == 200
|
||||
assert transferred.json()["recipient"] == "alex"
|
||||
assert listed.headers["cache-control"] == "no-store"
|
||||
assert transferred.headers["cache-control"] == "no-store"
|
||||
assert calls == [
|
||||
("assigned", "stackchain/api", 17),
|
||||
("candidates", "stackchain/api"),
|
||||
("handoff", "stackchain/api", 17, "alex"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch):
|
||||
async def handoff(_repository, _number, _recipient):
|
||||
raise gitea_proxy.IssueNotAvailableError("stale")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/handoff",
|
||||
json={"recipient": "alex"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": "The issue or recipient changed. Reload before handing off."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_handoff_candidates_reject_an_issue_not_assigned_to_operator(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return False
|
||||
|
||||
async def candidates(_repository):
|
||||
nonlocal called
|
||||
called = True
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
|
||||
async def assigned(repository, number):
|
||||
|
||||
+106
-4
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.js"
|
||||
@@ -110,9 +110,10 @@ const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
let invalid = true;
|
||||
let operationSequence = 0;
|
||||
const calls = [];
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'stable-edit',
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'edit-' + (++operationSequence),
|
||||
fetchJson:async (_url, options) => {{
|
||||
calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }}
|
||||
@@ -132,12 +133,46 @@ outbox.flush('timmy').then(async () => {{
|
||||
|
||||
assert output["attention"]["status"] == "attention"
|
||||
assert output["attention"]["error"] == "Title is invalid"
|
||||
assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"]
|
||||
assert [call["key"] for call in output["calls"]] == ["edit-1", "edit-2"]
|
||||
assert output["calls"][1]["body"]["title"] == "Fixed title"
|
||||
assert output["result"]["confirmed"][0]["number"] == 42
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_issue_outbox_rotates_operation_id_only_when_delivery_payload_changes():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let sequence = 0;
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'payload-' + (++sequence),
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
repository:'stackchain/api',title:'Plan',body:'Context',labelIds:[1],milestoneId:2,dueDate:'2026-08-09'
|
||||
}});
|
||||
const unchanged = outbox.update(queued.id, {{...queued}});
|
||||
const fields = [
|
||||
['repository', 'stackchain/web'], ['title', 'Revised'], ['body', 'More context'],
|
||||
['labelIds', [1, 3]], ['milestoneId', 4], ['dueDate', '2026-08-10'],
|
||||
];
|
||||
const edits = [];
|
||||
for (const [field, value] of fields) {{
|
||||
const current = outbox.list()[0];
|
||||
edits.push(outbox.update(queued.id, {{...current, [field]:value}}));
|
||||
}}
|
||||
process.stdout.write(JSON.stringify({{queued,unchanged,edits,final:outbox.list()[0]}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["unchanged"]["operationId"] == output["queued"]["operationId"] == "payload-1"
|
||||
assert [item["operationId"] for item in output["edits"]] == [
|
||||
"payload-2", "payload-3", "payload-4", "payload-5", "payload-6", "payload-7"
|
||||
]
|
||||
assert output["final"]["milestoneId"] == 4
|
||||
assert output["final"]["dueDate"] == "2026-08-10"
|
||||
|
||||
|
||||
def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -186,6 +221,70 @@ setTimeout(() => process.stdout.write(JSON.stringify(state)), 0);
|
||||
assert output["syncs"] == 1
|
||||
|
||||
|
||||
def test_issue_outbox_confirms_durable_admission_only_after_mirror_and_sync():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let releaseMirror; let releaseSync;
|
||||
const mirrorGate = new Promise(resolve => releaseMirror = resolve);
|
||||
const syncGate = new Promise(resolve => releaseSync = resolve);
|
||||
const events = [];
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'durable-1',
|
||||
backgroundSync: {{
|
||||
reconcile: async items => {{ events.push('mirror-start'); await mirrorGate; events.push('mirror-committed'); }},
|
||||
requestSync: async () => {{ events.push('sync-start'); await syncGate; events.push('sync-registered'); }},
|
||||
}},
|
||||
}});
|
||||
const admission = outbox.enqueueDurably({{repository:'stackchain/api',title:'Keep this',body:'Draft'}})
|
||||
.then(result => {{ events.push('confirmed'); return result; }});
|
||||
Promise.resolve().then(async () => {{
|
||||
const beforeMirror = events.slice();
|
||||
releaseMirror(); await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const beforeSync = events.slice();
|
||||
releaseSync();
|
||||
const result = await admission;
|
||||
process.stdout.write(JSON.stringify({{beforeMirror,beforeSync,events,result,items:outbox.list()}}));
|
||||
}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["beforeMirror"] == ["mirror-start"]
|
||||
assert output["beforeSync"] == ["mirror-start", "mirror-committed", "sync-start"]
|
||||
assert output["events"][-1] == "confirmed"
|
||||
assert output["result"]["background"] is True
|
||||
assert output["result"]["item"]["id"] == "durable-1"
|
||||
assert output["items"][0]["title"] == "Keep this"
|
||||
|
||||
|
||||
def test_issue_outbox_reports_degraded_admission_without_losing_foreground_item():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'foreground-1',
|
||||
backgroundSync: {{
|
||||
reconcile: async () => {{}},
|
||||
requestSync: async () => {{ throw new Error('Background Sync unavailable'); }},
|
||||
}},
|
||||
}});
|
||||
outbox.enqueueDurably({{repository:'stackchain/api',title:'Retain me',body:'Draft'}}).then(result =>
|
||||
process.stdout.write(JSON.stringify({{
|
||||
background:result.background, durability:result.durability,
|
||||
error:result.error.message, item:result.item, persisted:outbox.list()
|
||||
}}))
|
||||
);
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["background"] is False
|
||||
assert output["durability"] == "foreground-only"
|
||||
assert output["error"] == "Background Sync unavailable"
|
||||
assert output["persisted"] == [output["item"]]
|
||||
|
||||
|
||||
def test_foreground_delivery_uses_same_atomic_background_claim():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -263,7 +362,10 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio
|
||||
|
||||
assert '<script src="static/issue-outbox.js"></script>' in html
|
||||
assert "const issueOutbox = createIssueOutbox({" in html
|
||||
assert "issueOutbox.enqueue(captureDraft)" in html
|
||||
assert "await issueOutbox.enqueueDurably(captureDraft)" in html
|
||||
assert "Saving for background delivery…" in html
|
||||
assert "Saved for next launch; background delivery unavailable." in html
|
||||
assert "throw new Error('Background Sync unavailable')" in html
|
||||
assert "issueOutbox.retry(queued.id, activeFlushLogin)" in html
|
||||
assert "navigator.onLine" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
||||
|
||||
@@ -21,6 +21,9 @@ def reset_live_snapshot_task():
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_snapshot_refreshing_sections = set()
|
||||
main._live_section_revisions = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
yield
|
||||
main._live_snapshot_task = None
|
||||
main._live_snapshot_value = None
|
||||
@@ -35,6 +38,9 @@ def reset_live_snapshot_task():
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_snapshot_refreshing_sections = set()
|
||||
main._live_section_revisions = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
|
||||
|
||||
def payload(response):
|
||||
@@ -84,6 +90,43 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
|
||||
assert result["sections"] == {
|
||||
"context": "fresh", "events": "fresh", "notifications": "fresh"
|
||||
}
|
||||
assert result["revisions"] == {
|
||||
"context": 1, "events": 1, "notifications": 1
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(_authenticated_user):
|
||||
return [{"type": "push"}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", events)
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
first = payload(await main.live_snapshot())
|
||||
second = payload(await main.live_snapshot(
|
||||
context_revision=first["revisions"]["context"],
|
||||
events_revision=first["revisions"]["events"],
|
||||
notifications_revision=first["revisions"]["notifications"],
|
||||
))
|
||||
|
||||
assert "context" not in second
|
||||
assert "events" not in second
|
||||
assert "notifications" not in second
|
||||
assert "notification_pagination" not in second
|
||||
assert second["sections"] == first["sections"]
|
||||
assert second["revisions"] == first["revisions"]
|
||||
assert "freshness" in second
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import sqlite3
|
||||
|
||||
from src.login_attempt_store import LoginAttemptStore, client_source
|
||||
|
||||
|
||||
def test_forwarded_client_is_used_only_for_explicitly_trusted_proxies():
|
||||
trusted = "127.0.0.0/8, 10.0.0.0/8"
|
||||
|
||||
assert client_source("198.51.100.4", "203.0.113.7", trusted) == "198.51.100.4"
|
||||
assert (
|
||||
client_source("127.0.0.1", "203.0.113.7, 10.1.2.3", trusted)
|
||||
== "203.0.113.7"
|
||||
)
|
||||
assert client_source("127.0.0.1", "not-an-ip", trusted) == "127.0.0.1"
|
||||
|
||||
|
||||
def test_failure_budget_is_shared_across_store_instances_and_expires(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "login-attempts.sqlite3"
|
||||
first = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
second = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
assert first.retry_after("203.0.113.7") == 0
|
||||
first.record_failure("203.0.113.7")
|
||||
second.record_failure("203.0.113.7")
|
||||
first.record_failure("203.0.113.7")
|
||||
|
||||
assert second.retry_after("203.0.113.7") == 60
|
||||
now[0] = 1_060.0
|
||||
assert first.retry_after("203.0.113.7") == 0
|
||||
|
||||
|
||||
def test_recording_a_failure_prunes_expired_source_records(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
store.record_failure("203.0.113.1")
|
||||
store.record_failure("203.0.113.2")
|
||||
|
||||
now[0] = 1_060.0
|
||||
store.record_failure("203.0.113.3")
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: 1_000.0,
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
max_entries=2,
|
||||
)
|
||||
|
||||
for address in ("203.0.113.1", "203.0.113.2", "203.0.113.3"):
|
||||
store.record_failure(address)
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
|
||||
@@ -0,0 +1,87 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import login
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOGIN_JS = ROOT / "frontend" / "login.js"
|
||||
|
||||
|
||||
def test_expired_session_reason_explains_preserved_private_work():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const status = {{ textContent: '' }};
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: () => {{}} }},
|
||||
}});
|
||||
controller.showReason('session-expired');
|
||||
const expired = status.textContent;
|
||||
controller.showReason('https://evil.example/redirect');
|
||||
process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert state["expired"] == (
|
||||
"Your session expired. Private drafts remain on this device. "
|
||||
"Sign in to continue."
|
||||
)
|
||||
assert state["ignored"] == state["expired"]
|
||||
|
||||
|
||||
def test_rate_limited_login_disables_submit_and_counts_down():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const state = {{ reset: 0, interval: null }};
|
||||
const status = {{ textContent: '' }};
|
||||
const button = {{ disabled: false }};
|
||||
const form = {{ reset: () => state.reset += 1 }};
|
||||
const controller = createLoginController({{
|
||||
form, status, button,
|
||||
fetchImpl: async () => new Response(JSON.stringify({{ detail: 'Too many sign-in attempts' }}), {{ status: 429, headers: {{ 'Retry-After': '2' }} }}),
|
||||
location: {{ replace: () => {{}} }},
|
||||
setIntervalImpl: callback => {{ state.interval = callback; return 1; }},
|
||||
clearIntervalImpl: () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
await controller.submit('never-store-this-token');
|
||||
state.initial = {{ disabled: button.disabled, status: status.textContent, reset: state.reset }};
|
||||
state.interval();
|
||||
state.afterTick = {{ disabled: button.disabled, status: status.textContent }};
|
||||
state.interval();
|
||||
state.finished = {{ disabled: button.disabled, status: status.textContent }};
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert state["initial"] == {
|
||||
"disabled": True,
|
||||
"status": "Too many attempts. Try again in 2 seconds.",
|
||||
"reset": 1,
|
||||
}
|
||||
assert state["afterTick"]["disabled"] is True
|
||||
assert state["finished"] == {
|
||||
"disabled": False,
|
||||
"status": "You can try signing in again.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_page_loads_rate_limit_controller():
|
||||
html = await login()
|
||||
|
||||
assert '<script src="static/login.js"></script>' in html
|
||||
assert "main{box-sizing:border-box" in html
|
||||
assert '<p id="status" role="status" aria-live="polite"></p>' in html
|
||||
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MOBILE_LAUNCH = Path(__file__).resolve().parents[1] / "frontend" / "mobile-launch.js"
|
||||
|
||||
|
||||
def test_mobile_launch_selects_priority_queue_without_overriding_saved_choice():
|
||||
script = f"""
|
||||
const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
today: mobileLaunch.chooseFilter({{saved:null, today:2, attention:4}}),
|
||||
attention: mobileLaunch.chooseFilter({{saved:null, today:0, attention:4}}),
|
||||
all: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0}}),
|
||||
saved: mobileLaunch.chooseFilter({{saved:'review', today:2, attention:4}}),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"today": "today",
|
||||
"attention": "attention",
|
||||
"all": "all",
|
||||
"saved": "review",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_disclosure_closes_on_escape_and_restores_launcher_focus():
|
||||
script = f"""
|
||||
const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))});
|
||||
const listeners = {{}};
|
||||
const disclosure = {{open:true, addEventListener(name, fn) {{ listeners[name] = fn; }}}};
|
||||
const launcher = {{focuses:0, focus() {{ this.focuses += 1; }}}};
|
||||
const controller = mobileLaunch.createDisclosure({{disclosure, launcher}});
|
||||
controller.start();
|
||||
let prevented = false;
|
||||
listeners.keydown({{key:'Escape', preventDefault() {{ prevented = true; }}}});
|
||||
process.stdout.write(JSON.stringify({{open:disclosure.open, focuses:launcher.focuses, prevented}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"open": False, "focuses": 1, "prevented": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_launch_progressively_discloses_secondary_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="app-brand"' in html
|
||||
assert 'class="app-live-status"' in html
|
||||
assert 'class="app-menu"' in html
|
||||
assert 'id="app-menu-toggle"' in html
|
||||
assert 'class="work-settings"' in html
|
||||
assert 'id="work-settings-toggle"' in html
|
||||
assert 'id="active-work-queue"' in html
|
||||
assert '<script src="static/mobile-launch.js"></script>' in html
|
||||
service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "BASE + 'static/mobile-launch.js'" in service_worker
|
||||
assert "mobileLaunch.chooseFilter" in html
|
||||
assert "mobileLaunch.createDisclosure" in html
|
||||
assert "@media (max-width: 600px)" in html
|
||||
assert "header { min-height:56px; max-height:64px;" in html
|
||||
assert ".work-settings:not([open]) > .work-settings-panel { display:none; }" in html
|
||||
assert ".app-menu:not([open]) > .app-menu-panel { display:none; }" in html
|
||||
assert ".my-work-actions button { min-height:44px;" in html
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
|
||||
+203
-7
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
@@ -313,6 +313,58 @@ process.stdout.write(JSON.stringify(queue.map(item => ({{title:item.title,reason
|
||||
]
|
||||
|
||||
|
||||
def test_attention_includes_only_assigned_deadline_and_priority_critical_work():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
"issues": [
|
||||
{"number": 1, "title": "Assigned P0", "repository": "stackchain/api",
|
||||
"labels": ["P0"], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
|
||||
{"number": 2, "title": "Assigned overdue", "repository": "stackchain/api",
|
||||
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z"},
|
||||
{"number": 3, "title": "Assigned due today", "repository": "stackchain/api",
|
||||
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-07T23:59:59Z"},
|
||||
{"number": 4, "title": "Assigned future", "repository": "stackchain/api",
|
||||
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
|
||||
{"number": 5, "title": "Unassigned P0", "repository": "stackchain/api",
|
||||
"labels": ["critical"], "assignees": [], "due_date": "2026-08-10T23:59:59Z"},
|
||||
{"number": 6, "title": "Unassigned overdue", "repository": "stackchain/api",
|
||||
"labels": [], "assignees": [], "due_date": "2026-08-06T23:59:59Z"},
|
||||
],
|
||||
"pull_requests": [
|
||||
{"number": 7, "title": "Updated review", "repository": "stackchain/web",
|
||||
"labels": [], "assignees": [], "work_reasons": ["review_requested"]},
|
||||
],
|
||||
"notifications": [
|
||||
{"id": 70, "number": 7, "repository": "stackchain/web", "unread": True,
|
||||
"subject_type": "PullRequest", "url": "https://forge.example/web/pulls/7"},
|
||||
],
|
||||
}
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'));
|
||||
const attention = buildMyWork.filterMyWork(items, 'attention');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
titles: attention.map(item => item.title),
|
||||
count: buildMyWork.countMyWork(items).attention,
|
||||
reasons: Object.fromEntries(attention.map(item => [item.title, item.attention_reason])),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"titles": ["Assigned P0", "Updated review", "Assigned overdue", "Assigned due today"],
|
||||
"count": 4,
|
||||
"reasons": {
|
||||
"Assigned P0": "P0 priority",
|
||||
"Updated review": "Unread update",
|
||||
"Assigned overdue": "Overdue",
|
||||
"Assigned due today": "Due today",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
@@ -418,6 +470,58 @@ controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
|
||||
assert result.stdout == "Issue release was not confirmed."
|
||||
|
||||
|
||||
def test_issue_handoff_is_single_flight_and_requires_confirmed_transfer():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let calls = [];
|
||||
let finish;
|
||||
const controller = createIssueSheet({{
|
||||
fetchJson: (url, options) => {{
|
||||
calls.push({{url, options}});
|
||||
return new Promise(resolve => {{ finish = resolve; }});
|
||||
}},
|
||||
storage: null,
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:17}};
|
||||
const first = controller.handoff(item, 'alex', 'timmy');
|
||||
const duplicate = controller.handoff(item, 'alex', 'timmy');
|
||||
finish({{number:17, repository:'stackchain/api', recipient:'alex', assignees:['alex']}});
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, same: first === duplicate, results
|
||||
}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["same"] is True
|
||||
assert len(output["calls"]) == 1
|
||||
assert output["calls"][0]["url"].endswith("/issues/17/handoff")
|
||||
assert output["calls"][0]["options"]["method"] == "PATCH"
|
||||
assert json.loads(output["calls"][0]["options"]["body"]) == {"recipient": "alex"}
|
||||
assert output["results"][0]["assignees"] == ["alex"]
|
||||
|
||||
|
||||
def test_issue_handoff_candidates_use_the_assigned_issue_route():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let call;
|
||||
const controller = createIssueSheet({{
|
||||
fetchJson: async (url, options) => {{ call = {{url, options}}; return [{{login:'alex', name:'Alexander'}}]; }},
|
||||
storage: null,
|
||||
}});
|
||||
controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
|
||||
.then(result => process.stdout.write(JSON.stringify({{call, result}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["call"]["url"].endswith("/issues/17/handoff-candidates")
|
||||
assert output["call"]["options"]["headers"]["Accept"] == "application/json"
|
||||
assert output["result"] == [{"login": "alex", "name": "Alexander"}]
|
||||
|
||||
|
||||
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
@@ -524,6 +628,19 @@ async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_b
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_sheet_exposes_touch_safe_teammate_handoff():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="issue-handoff-recipient"' in html
|
||||
assert 'id="load-issue-handoff"' in html
|
||||
assert 'id="confirm-issue-handoff"' in html
|
||||
assert 'id="issue-handoff-status" class="small" aria-live="assertive"' in html
|
||||
assert '.issue-handoff select, .issue-handoff button { min-height:44px;' in html
|
||||
assert "issueController.loadHandoffCandidates(selectedIssue)" in html
|
||||
assert "issueController.handoff(selectedIssue, recipient" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
|
||||
html = await dashboard()
|
||||
@@ -708,7 +825,7 @@ process.stdout.write(JSON.stringify({{
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"deferred": True,
|
||||
"deferred": "deferred",
|
||||
"timmy": {
|
||||
"active": 0,
|
||||
"later": [{
|
||||
@@ -721,6 +838,33 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_reports_invalid_identity_and_failed_storage_without_false_success():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
||||
let login = '';
|
||||
const item = {{kind:'issue',repository:'stackchain/api',number:17}};
|
||||
const unavailable = createLaterWork({{
|
||||
storage: {{getItem:() => null,setItem:() => {{ throw new Error('quota'); }},removeItem:() => {{}}}},
|
||||
getLogin:() => login, now:() => new Date('2026-08-08T12:00:00Z'),
|
||||
}});
|
||||
const noIdentity = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
|
||||
login = 'timmy';
|
||||
const invalid = unavailable.defer(item, new Date('2026-08-08T11:00:00Z'));
|
||||
const failedWrite = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
|
||||
process.stdout.write(JSON.stringify({{noIdentity, invalid, failedWrite}}));
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"noIdentity": "unavailable",
|
||||
"invalid": "invalid",
|
||||
"failedWrite": "unavailable",
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
||||
@@ -847,7 +991,7 @@ let sessionActive = false;
|
||||
const controller = createDetailDefer({{
|
||||
laterWork: {{
|
||||
presetUntil:preset => new Date(preset === 'today' ? '2026-08-08T16:00:00Z' : '2026-08-09T09:00:00Z'),
|
||||
defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return true; }},
|
||||
defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return 'deferred'; }},
|
||||
}},
|
||||
session: {{ active:() => sessionActive }},
|
||||
close:() => calls.push(['close']),
|
||||
@@ -883,6 +1027,32 @@ process.stdout.write(JSON.stringify({{outside,session,calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_detail_defer_reports_storage_failure_without_closing_or_refreshing():
|
||||
script = f"""
|
||||
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
|
||||
const calls = [];
|
||||
const controller = createDetailDefer({{
|
||||
laterWork: {{
|
||||
presetUntil:() => new Date('2026-08-08T16:00:00Z'),
|
||||
defer:() => 'unavailable',
|
||||
}},
|
||||
session: {{active:() => false}},
|
||||
close:() => calls.push('close'), refresh:() => calls.push('refresh'),
|
||||
focus:() => calls.push('focus'), announce:message => calls.push(message),
|
||||
}});
|
||||
const saved = controller.defer({{kind:'issue',repository:'stackchain/api',number:17}}, 'today');
|
||||
process.stdout.write(JSON.stringify({{saved,calls}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"saved": False,
|
||||
"calls": ["Could not save Later on this device."],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation():
|
||||
html = await dashboard()
|
||||
@@ -906,16 +1076,16 @@ async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
|
||||
assert 'data-work-filter="later"' in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert 'const laterWork = createLaterWork({' in html
|
||||
assert 'getLogin: () => confirmedOwnerLogin' in html
|
||||
assert 'getLogin: () => planningOwnerLogin' in html
|
||||
assert 'laterWork.partition(lastMyWork,' in html
|
||||
assert 'data-later-preset="today"' in html
|
||||
assert 'data-later-preset="tomorrow"' in html
|
||||
assert 'data-later-restore' in html
|
||||
assert "Deferred until ' + escapeHtml(fmt(item.deferred_until))" in html
|
||||
assert "laterWork.defer(item, laterWork.presetUntil(button.dataset.laterPreset))" in html
|
||||
assert "const result = laterWork.defer(item, until)" in html
|
||||
assert 'laterWork.restore(item)' in html
|
||||
assert '.later-actions button { min-height:44px;' in html
|
||||
assert 'Deferred work stays unread and unchanged in Gitea.' in html
|
||||
assert "'Deferred until ' + fmt(until)" in html
|
||||
|
||||
|
||||
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
||||
@@ -2476,6 +2646,31 @@ Promise.all([first, duplicate]).then(async comments => {{
|
||||
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
|
||||
|
||||
|
||||
def test_pull_sheet_surfaces_pending_merge_confirmation_guidance():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const controller = createPullSheet({{
|
||||
storage: null,
|
||||
fetchJson: () => Promise.resolve({{
|
||||
number: 7,
|
||||
merged: false,
|
||||
state: 'unknown',
|
||||
confirmation_pending: true,
|
||||
error: 'Merge confirmation is pending. Check its status before retrying.',
|
||||
}}),
|
||||
}});
|
||||
controller.merge({{repository:'stackchain/api', number:7}}, 'abc123')
|
||||
.then(() => process.stdout.write(JSON.stringify({{resolved:true}})))
|
||||
.catch(error => process.stdout.write(JSON.stringify({{resolved:false, message:error.message}})));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"resolved": False,
|
||||
"message": "Merge confirmation is pending. Check its status before retrying.",
|
||||
}
|
||||
|
||||
|
||||
def test_pull_sheet_enables_merge_only_for_current_safe_state():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
@@ -2912,13 +3107,14 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
|
||||
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
|
||||
assert 'data-work-count="all"' in html
|
||||
assert 'data-work-count="today"' in html
|
||||
assert 'data-work-count="attention"' in html
|
||||
assert 'data-work-count="issue"' in html
|
||||
assert 'data-work-count="pull"' in html
|
||||
assert 'data-work-count="review"' in html
|
||||
assert 'data-work-count="update"' in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert "['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert 'data-work-count="draft"' in html
|
||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OFFLINE_WORK = Path(__file__).parents[1] / "frontend" / "offline-work.js"
|
||||
@@ -112,4 +112,5 @@ async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snap
|
||||
assert "Outage · saved " in html
|
||||
assert "Server unavailable · showing private My Work saved " in html
|
||||
assert "Live details and actions will return automatically." in html
|
||||
assert "function renderLiveSnapshot(snapshot) {\n setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
|
||||
assert "function renderLiveSnapshot(snapshot, changedSections" in html
|
||||
assert "setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
DASHBOARD = Path("frontend/index.html")
|
||||
|
||||
|
||||
def test_dashboard_panels_are_independently_collapsible():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
for panel_key in ("context", "ai-stream", "issues", "pull-requests", "widgets", "layout", "markdown"):
|
||||
assert f'<details class="panel' in html
|
||||
@@ -13,7 +15,7 @@ def test_dashboard_panels_are_independently_collapsible():
|
||||
|
||||
|
||||
def test_panel_open_state_is_restored_and_saved_in_local_storage():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'const PANEL_STATE_KEY = "stackchain.panel-state.v1"' in html
|
||||
assert "localStorage.getItem(PANEL_STATE_KEY)" in html
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -247,6 +249,108 @@ async def test_assigned_pull_merge_requires_current_eligible_head(monkeypatch):
|
||||
assert calls == [("stackchain/api", 7, "abc123")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_merge_reconciles_acceptance_before_timeout(monkeypatch):
|
||||
calls = []
|
||||
merged = False
|
||||
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
async def merge(repository, number, expected_head_sha):
|
||||
nonlocal merged
|
||||
calls.append(("merge", repository, number, expected_head_sha))
|
||||
merged = True
|
||||
await asyncio.sleep(0.05)
|
||||
return {"number": number, "merged": True, "state": "closed"}
|
||||
|
||||
async def confirm(repository, number, expected_head_sha):
|
||||
calls.append(("confirm", repository, number, expected_head_sha))
|
||||
return merged
|
||||
|
||||
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_pull_merged_at_head", confirm, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"number": 7, "merged": True, "state": "closed"}
|
||||
assert calls == [
|
||||
("merge", "stackchain/api", 7, "abc123"),
|
||||
("confirm", "stackchain/api", 7, "abc123"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_merge_confirmation_requires_merged_expected_head():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
return httpx.Response(200, json={
|
||||
"number": 7,
|
||||
"state": "closed",
|
||||
"merged": True,
|
||||
"head": {"sha": "abc123"},
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
confirmed = await gitea_proxy.is_pull_merged_at_head(
|
||||
"stackchain/api", 7, "abc123"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert confirmed is True
|
||||
assert requests == [("GET", "/api/v1/repos/stackchain/api/pulls/7")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_merge_reports_unresolved_confirmation_without_false_state(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
async def merge(repository, number, expected_head_sha):
|
||||
calls.append(("merge", repository, number, expected_head_sha))
|
||||
raise httpx.ReadTimeout("upstream response was lost")
|
||||
|
||||
async def confirm(repository, number, expected_head_sha):
|
||||
calls.append(("confirm", repository, number, expected_head_sha))
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_pull_merged_at_head", confirm)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.json() == {
|
||||
"number": 7,
|
||||
"merged": False,
|
||||
"state": "unknown",
|
||||
"confirmation_pending": True,
|
||||
"error": "Merge confirmation is pending. Check its status before retrying.",
|
||||
}
|
||||
assert calls == [
|
||||
("merge", "stackchain/api", 7, "abc123"),
|
||||
("confirm", "stackchain/api", 7, "abc123"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_merge_returns_conflict_without_mutating_stale_head(monkeypatch):
|
||||
async def assigned(repository, number):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
@@ -12,12 +13,34 @@ from src.release_engine import (
|
||||
Issue,
|
||||
ReleaseEngine,
|
||||
RunState,
|
||||
ShellRunner,
|
||||
StateStore,
|
||||
branch_name,
|
||||
select_issue,
|
||||
)
|
||||
|
||||
|
||||
def test_shell_runner_treats_metacharacters_as_literal_arguments(tmp_path):
|
||||
output = tmp_path / "argument.txt"
|
||||
injected = tmp_path / "injected.txt"
|
||||
payload = f"release title; touch {injected}"
|
||||
|
||||
result = ShellRunner().run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from pathlib import Path; import sys; Path(sys.argv[1]).write_text(sys.argv[2])",
|
||||
str(output),
|
||||
payload,
|
||||
],
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert output.read_text() == payload
|
||||
assert not injected.exists()
|
||||
|
||||
|
||||
def test_stale_token_error_explains_how_to_restore_cron_authentication():
|
||||
class UnauthorizedHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
@@ -70,9 +93,11 @@ class FakeRunner:
|
||||
def __init__(self, results):
|
||||
self.results = list(results)
|
||||
self.commands = []
|
||||
self.environments = []
|
||||
|
||||
def run(self, command, cwd, env=None):
|
||||
self.commands.append(command)
|
||||
self.environments.append(env)
|
||||
return self.results.pop(0)
|
||||
|
||||
|
||||
@@ -91,7 +116,7 @@ def config(tmp_path):
|
||||
agent="timmy",
|
||||
repo_path=tmp_path,
|
||||
state_path=tmp_path / "state.json",
|
||||
agent_command="agent --issue {issue_number}",
|
||||
agent_command="agent --issue-from-env",
|
||||
test_command="pytest -q",
|
||||
)
|
||||
|
||||
@@ -121,6 +146,24 @@ def test_claim_verification_blocks_execution(tmp_path):
|
||||
assert runner.commands == []
|
||||
|
||||
|
||||
def test_empty_agent_command_fails_before_claim(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
cfg = EngineConfig(
|
||||
repo="stackchain/stackchain-dashboard",
|
||||
agent="timmy",
|
||||
repo_path=tmp_path,
|
||||
state_path=tmp_path / "state.json",
|
||||
agent_command=" ",
|
||||
test_command="pytest -q",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="invalid agent command: command is empty"):
|
||||
ReleaseEngine(cfg, client, FakeRunner([])).execute(issue_number=14)
|
||||
|
||||
assert client.claimed == []
|
||||
|
||||
|
||||
def test_agent_failure_blocks_tests_and_pr(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
@@ -134,12 +177,41 @@ def test_agent_failure_blocks_tests_and_pr(tmp_path):
|
||||
engine.execute(issue_number=14)
|
||||
|
||||
assert runner.commands == [
|
||||
"git checkout -B timmy/14-engine",
|
||||
"agent --issue 14",
|
||||
["git", "checkout", "-B", "timmy/14-engine"],
|
||||
["agent", "--issue-from-env"],
|
||||
]
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
def test_issue_content_reaches_agent_only_through_environment(tmp_path):
|
||||
title = "$(touch /tmp/title-injected); `id`"
|
||||
body = "line one\n&& touch /tmp/body-injected > stolen"
|
||||
issue = Issue(14, title, body, "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(1, "", "agent stopped"),
|
||||
])
|
||||
cfg = EngineConfig(
|
||||
repo="stackchain/stackchain-dashboard",
|
||||
agent="timmy",
|
||||
repo_path=tmp_path,
|
||||
state_path=tmp_path / "state.json",
|
||||
agent_command="agent --work",
|
||||
test_command="pytest -q",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="coding agent failed"):
|
||||
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
|
||||
|
||||
assert runner.commands == [
|
||||
["git", "checkout", "-B", "timmy/14-touch-tmp-title-injected-id"],
|
||||
["agent", "--work"],
|
||||
]
|
||||
assert runner.environments[1]["RELEASE_ISSUE_TITLE"] == title
|
||||
assert runner.environments[1]["RELEASE_ISSUE_BODY"] == body
|
||||
|
||||
|
||||
def test_failed_tests_block_push_and_pr(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
@@ -153,7 +225,7 @@ def test_failed_tests_block_push_and_pr(tmp_path):
|
||||
with pytest.raises(RuntimeError, match="tests failed"):
|
||||
engine.execute(issue_number=14)
|
||||
|
||||
assert "git push" not in " ".join(runner.commands)
|
||||
assert not any(command[:2] == ["git", "push"] for command in runner.commands)
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
@@ -171,7 +243,7 @@ def test_passing_run_pushes_and_opens_linked_pr(tmp_path):
|
||||
result = engine.execute(issue_number=14)
|
||||
|
||||
assert result["pr_number"] == 44
|
||||
assert runner.commands[-1].startswith("git push -u origin")
|
||||
assert runner.commands[-1] == ["git", "push", "-u", "origin", "timmy/14-engine-mvp"]
|
||||
assert len(client.created_prs) == 1
|
||||
body = client.created_prs[0][4]
|
||||
assert "Closes #14" in body
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
REQUIRED_HEADERS = {
|
||||
"x-content-type-options": "nosniff",
|
||||
"referrer-policy": "no-referrer",
|
||||
"x-frame-options": "DENY",
|
||||
}
|
||||
|
||||
|
||||
def assert_browser_security_boundary(response: httpx.Response) -> None:
|
||||
for name, value in REQUIRED_HEADERS.items():
|
||||
assert response.headers[name] == value
|
||||
assert "camera=()" in response.headers["permissions-policy"]
|
||||
policy = response.headers["content-security-policy"]
|
||||
assert "default-src 'self'" in policy
|
||||
assert "script-src 'self'" in policy
|
||||
assert "object-src 'none'" in policy
|
||||
assert "frame-ancestors 'none'" in policy
|
||||
script_policy = next(part for part in policy.split(";") if "script-src" in part)
|
||||
assert "'unsafe-inline'" not in script_policy
|
||||
assert "'unsafe-eval'" not in script_policy
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_security_boundary_covers_pages_health_static_and_api_errors(monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "access-token-with-at-least-thirty-two-characters")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "session-secret-with-at-least-thirty-two-characters")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
responses = [
|
||||
await client.get("/healthz"),
|
||||
await client.get("/login"),
|
||||
await client.get("/static/dashboard.js"),
|
||||
await client.get("/api/v1/context"),
|
||||
]
|
||||
|
||||
assert [response.status_code for response in responses] == [200, 200, 200, 401]
|
||||
for response in responses:
|
||||
assert_browser_security_boundary(response)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_security_boundary_covers_fail_closed_authentication_response(monkeypatch):
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.get("/login")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert_browser_security_boundary(response)
|
||||
@@ -13,21 +13,30 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, failFetch: false, fetchStatus: 200, cachedBody: null }};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
||||
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
|
||||
}};
|
||||
const context = {{
|
||||
URL, Request, Response,
|
||||
URL, Request, Response, Headers, AbortController,
|
||||
console,
|
||||
self: {{
|
||||
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{ claim: async () => {{ state.claimed = true; }} }},
|
||||
__issueSync: {{ flush: async () => {{ state.backgroundFlushes += 1; }} }},
|
||||
clients: {{
|
||||
claim: async () => {{ state.claimed = true; }},
|
||||
matchAll: async () => state.clientList || [],
|
||||
openWindow: async url => {{ state.opened.push(url); }},
|
||||
}},
|
||||
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
|
||||
__issueSync: {{
|
||||
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
|
||||
purge: async () => {{ state.outboxPurges += 1; }},
|
||||
getReceiptPreference: async login => state.receiptLogin === login,
|
||||
}},
|
||||
}},
|
||||
importScripts: () => {{}},
|
||||
caches: {{
|
||||
@@ -43,7 +52,7 @@ const context = {{
|
||||
}},
|
||||
}};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8'), context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf;', context);
|
||||
async function dispatch(name, request) {{
|
||||
let pending;
|
||||
let response;
|
||||
@@ -60,6 +69,19 @@ async function dispatchSync(tag) {{
|
||||
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchMessage(data, ports = []) {{
|
||||
let pending;
|
||||
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchNotificationClick(route) {{
|
||||
let pending;
|
||||
listeners.notificationclick({{
|
||||
notification: {{data: {{route}}, close: () => {{ state.notificationClosed = true; }}}},
|
||||
waitUntil: promise => {{ pending = promise; }},
|
||||
}});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
(async () => {{
|
||||
{scenario}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
@@ -70,10 +92,13 @@ async function dispatchSync(tag) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_operator_session_boundary_ships_in_a_new_shell_cache():
|
||||
def test_session_expiry_recovery_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v16" in source
|
||||
assert "stackchain-dashboard-shell-v31" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
@@ -88,14 +113,116 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(
|
||||
assert result["backgroundFlushes"] == 1
|
||||
|
||||
|
||||
def test_authenticated_page_message_resumes_queued_background_delivery():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchMessage({type:'stackchain-resume-outbox'});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["backgroundFlushes"] == 1
|
||||
|
||||
|
||||
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
let mutations=0;
|
||||
context.fetch=(request, options={})=>{
|
||||
if(String(request).includes('/api/v1/session')){
|
||||
return new Promise((resolve,reject)=>options.signal?.addEventListener('abort',()=>reject(new Error('aborted')), {once:true}));
|
||||
}
|
||||
mutations+=1;
|
||||
return Promise.resolve(new Response('{}',{status:200,headers:{'Content-Type':'application/json'}}));
|
||||
};
|
||||
const controller=new AbortController();
|
||||
const request=context.self.__testWithSessionCsrf(() =>
|
||||
context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST',signal:controller.signal})
|
||||
).then(()=> 'completed', error=>error.message);
|
||||
controller.abort();
|
||||
const outcome=await Promise.race([request,new Promise(resolve=>setTimeout(()=>resolve('blocked'),40))]);
|
||||
process.stdout.write(JSON.stringify({outcome,mutations}));
|
||||
process.exit(0);
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"outcome": "aborted", "mutations": 0}
|
||||
|
||||
|
||||
def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const replies = [];
|
||||
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage: value => replies.push(value)}]);
|
||||
process.stdout.write(JSON.stringify({state,replies}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["state"]["outboxPurges"] == 1
|
||||
assert result["replies"] == [{"ok": True}]
|
||||
|
||||
|
||||
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.receiptLogin = 'timmy';
|
||||
state.flushResult = {login:'timmy', receipts:[
|
||||
{id:'capture-1',status:'confirmed',kind:'issue',route:'#/my-work/issue/stackchain/api/44'},
|
||||
{id:'bad-1',status:'attention',kind:'message',route:'#/my-work/drafts'},
|
||||
]};
|
||||
state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}];
|
||||
await dispatchSync('stackchain-issue-outbox-v1');
|
||||
await dispatchNotificationClick('#/my-work/issue/stackchain/api/44');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == [
|
||||
{
|
||||
"title": "Queued issue created",
|
||||
"options": {
|
||||
"body": "Tap to open it in Stackchain.",
|
||||
"tag": "stackchain-delivery-capture-1",
|
||||
"data": {"route": "#/my-work/issue/stackchain/api/44"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"title": "Queued work needs attention",
|
||||
"options": {
|
||||
"body": "Tap to review it in Drafts.",
|
||||
"tag": "stackchain-delivery-bad-1",
|
||||
"data": {"route": "#/my-work/drafts"},
|
||||
},
|
||||
},
|
||||
]
|
||||
assert result["focused"] == [
|
||||
"https://forge.example/dashboard/#/my-work/issue/stackchain/api/44"
|
||||
]
|
||||
assert result["opened"] == []
|
||||
assert result["notificationClosed"] is True
|
||||
|
||||
|
||||
def test_background_mutations_obtain_session_bound_csrf_proof():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "async function sessionCsrf()" in source
|
||||
assert "async function sessionCsrf(signal)" in source
|
||||
assert "BASE + 'api/v1/session'" in source
|
||||
assert "signal," in source
|
||||
assert "headers.set('X-CSRF-Token', csrf)" in source
|
||||
|
||||
|
||||
def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "async function withSessionCsrf(work)" in source
|
||||
assert "batchedCsrfController = new AbortController()" in source
|
||||
assert "batchedCsrf = sessionCsrf(batchedCsrfController.signal)" in source
|
||||
assert "batchedCsrfController.abort()" in source
|
||||
assert "await (batchedCsrf || sessionCsrf(options.signal))" in source
|
||||
assert "if (options.signal?.aborted)" in source
|
||||
assert "batch: withSessionCsrf" in source
|
||||
|
||||
|
||||
def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -109,6 +236,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
assert set(result["added"]) == {
|
||||
"/dashboard/",
|
||||
"/dashboard/manifest.webmanifest",
|
||||
"/dashboard/static/dashboard.css",
|
||||
"/dashboard/static/dashboard.js",
|
||||
"/dashboard/static/icons/stackchain-192.png",
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/session.js",
|
||||
@@ -117,11 +246,13 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/search-preview.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/unfiled-captures.js",
|
||||
"/dashboard/static/outbox-coordinator.js",
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/detail-defer.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
@@ -133,6 +264,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/work-route.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/background-issue-sync.js",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from src import session_store
|
||||
from src.session_store import SessionStore, SessionStoreError
|
||||
|
||||
|
||||
def test_revocation_is_durable_and_scoped_to_one_session(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
first = SessionStore(database, clock=lambda: now[0])
|
||||
first.activate("first-session-secret", 2_000)
|
||||
first.activate("second-session-secret", 2_000)
|
||||
|
||||
reconstructed = SessionStore(database, clock=lambda: now[0])
|
||||
reconstructed.revoke("first-session-secret")
|
||||
|
||||
assert reconstructed.is_active("first-session-secret", 2_000) is False
|
||||
assert reconstructed.is_active("second-session-secret", 2_000) is True
|
||||
assert b"first-session-secret" not in database.read_bytes()
|
||||
assert b"second-session-secret" not in database.read_bytes()
|
||||
|
||||
|
||||
def test_revoke_all_is_durable_and_does_not_block_future_sessions(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
first = SessionStore(database, clock=lambda: now[0])
|
||||
first.activate("phone-session", 2_000)
|
||||
first.activate("laptop-session", 2_000)
|
||||
|
||||
first.revoke_all()
|
||||
|
||||
reconstructed = SessionStore(database, clock=lambda: now[0])
|
||||
assert reconstructed.is_active("phone-session", 2_000) is False
|
||||
assert reconstructed.is_active("laptop-session", 2_000) is False
|
||||
reconstructed.activate("new-session", 2_000)
|
||||
assert reconstructed.is_active("new-session", 2_000) is True
|
||||
|
||||
|
||||
def test_validation_does_not_create_a_missing_registry(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
store = SessionStore(database, clock=lambda: 1_000.0)
|
||||
|
||||
with pytest.raises(SessionStoreError):
|
||||
store.is_active("unknown-session", 2_000)
|
||||
|
||||
assert database.exists() is False
|
||||
|
||||
|
||||
def test_validation_executes_only_a_read_query(tmp_path, monkeypatch):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate("active-session", 2_000)
|
||||
statements = []
|
||||
connect = sqlite3.connect
|
||||
|
||||
def traced_connect(*args, **kwargs):
|
||||
connection = connect(*args, **kwargs)
|
||||
connection.set_trace_callback(statements.append)
|
||||
return connection
|
||||
|
||||
monkeypatch.setattr(session_store.sqlite3, "connect", traced_connect)
|
||||
|
||||
assert store.is_active("active-session", 2_000) is True
|
||||
assert [statement.split()[0].upper() for statement in statements] == ["SELECT"]
|
||||
|
||||
|
||||
def test_expired_sessions_are_rejected_without_writing_during_validation(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
store.activate("expiring-session", 1_001)
|
||||
|
||||
now[0] = 1_001.0
|
||||
|
||||
assert store.is_active("expiring-session", 1_001) is False
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -22,7 +22,9 @@ async def test_one_live_snapshot_updates_work_and_activity_on_one_timer():
|
||||
html = await dashboard()
|
||||
|
||||
assert "fetch('api/v1/live'" in html
|
||||
assert "renderLiveSnapshot(snapshot)" in html
|
||||
assert "onSnapshot: renderLiveSnapshot" in html
|
||||
assert "section + '_revision'" in html
|
||||
assert "workChanged = contextChanged || notificationsChanged" in html
|
||||
assert "renderContextSnapshot(snapshot.context)" in html
|
||||
assert "paintEventStream(snapshot.events)" in html
|
||||
assert "loadEventStream" not in html
|
||||
@@ -55,3 +57,21 @@ async def test_dashboard_reports_each_live_section_from_its_own_freshness():
|
||||
assert "const notificationsFresh = Array.isArray(snapshot.notifications)" not in html
|
||||
assert "Unread updates unavailable · showing last known updates" in html
|
||||
assert "Activity refresh failed · showing last activity" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_operator_refresh_and_reconnect_supersede_a_stalled_live_request():
|
||||
html = await dashboard()
|
||||
|
||||
assert "async function fetchLiveSnapshot(revisions = {}, { signal } = {})" in html
|
||||
assert "headers: { Accept: 'application/json' },\n signal," in html
|
||||
assert "function load() { return contextPoller.refresh({ force: true }); }" in html
|
||||
assert "contextPoller.refresh({ force: true });" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stalled_refresh_keeps_the_last_snapshot_with_specific_guidance():
|
||||
html = await dashboard()
|
||||
|
||||
assert "e.name === 'TimeoutError'" in html
|
||||
assert "Update delayed · showing last snapshot" in html
|
||||
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
def test_today_queue_is_account_scoped_ordered_unique_and_bounded():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
let login = 'timmy';
|
||||
const queue = createTodayWork({{storage, getLogin: () => login, limit: 3}});
|
||||
const issue = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const first = queue.add(issue(1));
|
||||
const duplicate = queue.add(issue(1));
|
||||
queue.add(issue(2));
|
||||
queue.add(issue(3));
|
||||
const full = queue.add(issue(4));
|
||||
queue.move(issue(3), 'up');
|
||||
queue.move(issue(3), 'up');
|
||||
const timmy = queue.reconcile([issue(1), issue(2), issue(3), issue(4)]).map(item => item.number);
|
||||
const persisted = createTodayWork({{storage, getLogin: () => login, limit: 3}})
|
||||
.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
|
||||
login = 'alexander';
|
||||
const isolated = queue.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
|
||||
process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted, isolated}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"first": "added",
|
||||
"duplicate": "exists",
|
||||
"full": "full",
|
||||
"timmy": [3, 1, 2],
|
||||
"persisted": [3, 1, 2],
|
||||
"isolated": [],
|
||||
}
|
||||
|
||||
|
||||
def test_today_queue_exposes_reorder_boundaries_for_touch_controls():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
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 queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number}});
|
||||
queue.add(item(1)); queue.add(item(2)); queue.add(item(3));
|
||||
process.stdout.write(JSON.stringify([
|
||||
queue.position(item(1)), queue.position(item(2)), queue.position(item(3)), queue.position(item(9))
|
||||
]));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == [
|
||||
{"can_up": False, "can_down": True},
|
||||
{"can_up": True, "can_down": True},
|
||||
{"can_up": True, "can_down": False},
|
||||
{"can_up": False, "can_down": False},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/today-work.js"></script>' in html
|
||||
assert 'data-work-filter="today"' in html
|
||||
assert 'data-work-count="today"' in html
|
||||
assert "const todayWork = createTodayWork({" in html
|
||||
assert "const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');" in html
|
||||
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
|
||||
assert 'data-today-add' in html
|
||||
assert 'data-today-remove' in html
|
||||
assert 'data-today-move="up"' in html
|
||||
assert 'data-today-move="down"' in html
|
||||
assert 'Today is limited to 5 items' in html
|
||||
assert '.today-actions button' in html and 'min-height:44px' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_retained_authenticated_context_keeps_local_planning_separate_from_fresh_delivery():
|
||||
html = await dashboard()
|
||||
|
||||
assert "let planningOwnerLogin = '';" in html
|
||||
assert html.count("getLogin: () => planningOwnerLogin") == 2
|
||||
assert "const retainedPlanningLogin = !snapshot.context.error ?" in html
|
||||
assert "planningOwnerLogin = retainedPlanningLogin;" in html
|
||||
assert "button.disabled = !planningOwnerLogin;" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
||||
assert "getOwnerLogin: () => confirmedOwnerLogin" in html
|
||||
assert "Planning is unavailable until your operator identity is restored." in html
|
||||
assert "Could not save Today on this device." in html
|
||||
assert "Could not save Later on this device." in html
|
||||
assert "data-planning-disabled" in html
|
||||
@@ -0,0 +1,111 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_unfiled_captures_keep_twenty_newest_account_bound_notes():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let sequence = 0;
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage,
|
||||
getCaptureLogin:()=>'timmy',
|
||||
getCurrentLogin:()=>'',
|
||||
createId:()=>String(++sequence),
|
||||
now:()=>1000 + sequence,
|
||||
}});
|
||||
for (let index = 1; index <= 22; index += 1) {{
|
||||
captures.save({{title:'Note ' + index, body:'Context ' + index}});
|
||||
}}
|
||||
const offline = captures.list();
|
||||
const restored = createUnfiledCaptures({{
|
||||
storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy'
|
||||
}}).list();
|
||||
process.stdout.write(JSON.stringify({{offline, restored}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert len(output["offline"]) == 20
|
||||
assert output["offline"][0]["title"] == "Note 22"
|
||||
assert output["offline"][-1]["title"] == "Note 3"
|
||||
assert all(item["quarantined"] for item in output["offline"])
|
||||
assert all(not item["quarantined"] for item in output["restored"])
|
||||
assert output["restored"][0]["ownerLogin"] == "timmy"
|
||||
|
||||
|
||||
def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
let id = 0;
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', createId:()=>String(++id), now:()=>id
|
||||
}});
|
||||
const first = captures.save({{title:'First',body:'One'}});
|
||||
const second = captures.save({{title:'Second',body:'Two'}});
|
||||
let mismatch = '';
|
||||
try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }}
|
||||
const resumed = captures.resume(first.id, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["mismatch"] == "Reconnect with the account that saved this capture."
|
||||
assert output["resumed"] == {"repository": "", "title": "First", "body": "One", "labelIds": []}
|
||||
assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]]
|
||||
|
||||
|
||||
def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const captures = createUnfiledCaptures({{storage,getCaptureLogin:()=>''}});
|
||||
const errors = [];
|
||||
for (const note of [{{title:'',body:'context'}},{{title:'Work',body:'context'}}]) {{
|
||||
try {{ captures.save(note); }} catch (error) {{ errors.push(error.message); }}
|
||||
}}
|
||||
process.stdout.write(JSON.stringify({{errors,size:values.size}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"errors": ["Add a title before saving.", "Offline identity is unavailable. Reconnect once before saving private work."],
|
||||
"size": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume_flow():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/unfiled-captures.js"></script>' in html
|
||||
assert 'id="save-unfiled-issue"' in html
|
||||
assert 'Save for filing' in html
|
||||
assert "createUnfiledCaptures({" in html
|
||||
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
|
||||
assert "unfiledCaptures.save(captureDraft)" in html
|
||||
assert "unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
|
||||
assert "issueCapture.saveDraft(resumed)" in html
|
||||
assert "item.kind === 'unfiled-issue'" in html
|
||||
assert '.create-issue-actions button { min-height:44px;' in html
|
||||
assert '@media(max-width:320px)' in html
|
||||
+16
-3
@@ -1,3 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
@@ -21,15 +23,26 @@ async def test_dashboard_viewport_allows_mobile_zoom():
|
||||
assert "user-scalable=no" not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_bootstrap_uses_csp_compatible_static_assets():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<link rel="stylesheet" href="static/dashboard.css" />' in html
|
||||
assert '<script src="static/dashboard.js"></script>' in html
|
||||
assert "<style>" not in html
|
||||
assert "<script>" not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
|
||||
html = await dashboard()
|
||||
css = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="search-preview" role="dialog" aria-modal="true"' in html
|
||||
assert 'aria-labelledby="search-preview-title"' in html
|
||||
assert 'id="claim-search-result"' in html
|
||||
assert 'id="open-search-result-gitea"' in html
|
||||
assert 'src="static/search-preview.js"' in html
|
||||
assert ".search-preview-panel" in html
|
||||
assert "height:100dvh" in html
|
||||
assert "env(safe-area-inset-bottom)" in html
|
||||
assert ".search-preview-panel" in css
|
||||
assert "height:100dvh" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
WIDGETS = Path(__file__).parents[1] / "frontend" / "widgets.js"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -98,9 +100,10 @@ async def test_dashboard_announces_offline_mode_and_refreshes_after_reconnect():
|
||||
assert 'id="offline-status"' in response.text
|
||||
assert 'role="status"' in response.text
|
||||
assert 'aria-live="polite"' in response.text
|
||||
assert "window.addEventListener('offline'" in response.text
|
||||
assert "window.addEventListener('online'" in response.text
|
||||
assert "contextPoller.refresh()" in response.text
|
||||
bootstrap = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
assert "window.addEventListener('offline'" in bootstrap
|
||||
assert "window.addEventListener('online'" in bootstrap
|
||||
assert "contextPoller.refresh({ force: true })" in bootstrap
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
Reference in new issue
Block a user