Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
163bfad30a | ||
|
|
23c8e57a8e | ||
|
|
350bfc067d | ||
|
|
98aecd8255 | ||
|
|
7d4ff1f095 | ||
|
|
3605c42651 | ||
|
|
d7e2813700 | ||
|
|
fb34b5ffda | ||
|
|
820da7130d | ||
|
|
3bad8a0198 | ||
|
|
8bc5f623ac | ||
|
|
175817e6d8 | ||
|
|
fff7bccf08 | ||
|
|
ee36b8824a | ||
|
|
2c8a1225e0 | ||
|
|
29ceedfc01 | ||
|
|
11a56b21a1 | ||
|
|
3e16dd00d1 | ||
|
|
31f6097afc | ||
|
|
beaf5e0587 | ||
|
|
0a96dfb173 | ||
|
|
7cadb36b41 | ||
|
|
b1890a29ff | ||
|
|
100d38cc5c | ||
|
|
e5a352b418 | ||
|
|
8907263760 | ||
|
|
fdcd0d31ab | ||
|
|
5bef6291e8 | ||
|
|
9724358c89 | ||
|
|
7555cefccb | ||
|
|
04621223fd | ||
|
|
e5016d8b86 | ||
|
|
b455dc6286 | ||
|
|
338b6ebbd0 | ||
|
|
f18c59c7b3 | ||
|
|
e9cab92aff | ||
|
|
877dc475dd | ||
|
|
28e70f6415 | ||
|
|
2a6f2ab12d | ||
|
|
c8809a0728 | ||
|
|
2717f41957 | ||
|
|
b26b57d616 | ||
|
|
1052ce2c84 | ||
|
|
4f0e6544b3 |
No files matched your search
@@ -19,8 +19,8 @@ threads, create and self-assign issues, discover, claim, and release issue assig
|
||||
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
||||
inspect/comment on assigned pull
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
Assigned-issue comments can include one PNG, JPEG, or WebP screenshot; the mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
|
||||
For online delivery, the screenshot uploads before the comment is posted; validation or upload
|
||||
Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
|
||||
For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload
|
||||
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
|
||||
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
|
||||
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
|
||||
@@ -73,6 +73,10 @@ restart, **Resume Today** reopens the saved item (or the next surviving item if
|
||||
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||
one without closing or merging it. **Reply & next** provides the same one-action continuation
|
||||
for the current unread-update conversation. It deliberately leaves the notification unread;
|
||||
an open, unassigned issue update also offers **Take ownership & start**, which checks Today capacity
|
||||
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
|
||||
the session, and opens the issue. The adjacent **Take ownership** action remains available for
|
||||
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
|
||||
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
|
||||
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
@@ -174,6 +178,11 @@ export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
|
||||
export STACKCHAIN_LOGIN_MAX_FAILURES=5
|
||||
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
|
||||
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
|
||||
# Public passkey ceremonies share the same window and durable source ledger.
|
||||
export STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS=10
|
||||
# Bound live one-time challenges even if anonymous clients rotate addresses.
|
||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
|
||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=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.
|
||||
@@ -181,13 +190,16 @@ 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.
|
||||
Token and passkey sign-in failures are scoped to a hashed canonical client address and
|
||||
persisted across workers and restarts. Public passkey option issuance has a separate
|
||||
fixed-window admission budget in the same ledger, and live challenges are bounded per
|
||||
source and globally in the session registry. Once either sign-in budget is exhausted,
|
||||
the server returns `429` with `Retry-After`; a successful sign-in clears that source's
|
||||
failure state. Expired source records and challenges are pruned, and both ledgers are
|
||||
size-bounded. Keep their SQLite files on shared writable storage. `X-Forwarded-For` is
|
||||
ignored unless the immediate peer is inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only
|
||||
networks you operate. Without that setting, a reverse proxy is safely treated as one
|
||||
shared source.
|
||||
|
||||
Inbound API mutations are admitted through a body-size boundary before FastAPI
|
||||
parses JSON: sign-in is capped at 16 KiB and other `POST`, `PUT`, and `PATCH`
|
||||
@@ -208,16 +220,17 @@ session's last explicit activity. Existing two-column registries are migrated in
|
||||
place, their live sessions remain valid, and their idle clock starts at migration.
|
||||
|
||||
The same sheet includes **Security activity**, a reverse-chronological journal of
|
||||
successful token/passkey sign-ins, sign-outs, remote device revocations, issue
|
||||
closures, and pull-request merges. The separate SQLite journal retains at most
|
||||
10,000 events for 90 days and stores only bounded device labels and action targets.
|
||||
It never stores access tokens, cookies, session/CSRF values, credential IDs, raw
|
||||
network addresses, request bodies, or comment content. Keep its database on the
|
||||
same class of persistent, writable storage as the session registry. Before a
|
||||
session revocation or issue closure, the journal durably reserves a pending event;
|
||||
if that reservation fails, the destructive action does not begin. A successful
|
||||
action remains truthfully reported even if its event cannot immediately be finalized,
|
||||
and the activity sheet marks that durable record as **Outcome confirmation pending**.
|
||||
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
|
||||
revocations, issue closures, and pull-request merges. The separate SQLite journal
|
||||
retains at most 10,000 events for 90 days and stores only bounded device labels and
|
||||
action targets. It never stores access tokens, cookies, session/CSRF values,
|
||||
credential IDs, public keys, challenges, attestation data, raw network addresses,
|
||||
request bodies, or comment content. Keep its database on the same class of persistent,
|
||||
writable storage as the session registry. Before passkey credential creation, session
|
||||
revocation, or issue closure, the journal durably reserves a pending event; if that
|
||||
reservation fails, the consequential action does not begin. A successful action
|
||||
remains truthfully reported even if its event cannot immediately be finalized, and
|
||||
the activity sheet marks that durable record as **Outcome confirmation pending**.
|
||||
|
||||
After token bootstrap, **Active devices → Add a passkey for this device** enrolls a
|
||||
WebAuthn credential with required user verification. That device can then sign in
|
||||
@@ -381,8 +394,12 @@ newest 20 comments. Requested-review records also retain the head SHA, CI state,
|
||||
newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines
|
||||
per file. A previously opened unread update is retained by notification
|
||||
identity with allowlisted subject context and its newest 20 conversation messages.
|
||||
This account-bound cache is limited to ten records across all detail kinds; credentials,
|
||||
repository catalogs, events, raw patches, and complete API responses are excluded.
|
||||
The snapshot and each account-bound detail are committed as separate IndexedDB records,
|
||||
so large review previews do not consume the small synchronous localStorage quota or require
|
||||
rewriting the whole cache. Only the opt-in preference remains in localStorage. Existing v1
|
||||
localStorage payloads migrate after a successful IndexedDB commit. The detail cache remains
|
||||
limited to ten records across all detail kinds; credentials, repository catalogs, events, raw
|
||||
patches, and complete API responses are excluded.
|
||||
A cold offline launch labels the saved time. Cached Today details open in the existing
|
||||
phone sheet, where comments can enter the account-bound durable outbox. For issue and
|
||||
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
|
||||
@@ -396,8 +413,8 @@ queued operation and its matching SHA-scoped draft and progress.
|
||||
Cached unread updates use the same phone conversation sheet and replies enter the
|
||||
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
|
||||
Cards without a saved detail explain that reconnection is required. **Clear offline
|
||||
work data** deletes both stores, and opting out or seven-day expiry deletes them
|
||||
automatically.
|
||||
work data**, opt-out, sign-out, remote revocation, and absolute session expiry delete the
|
||||
IndexedDB records; seven-day expiry removes stale records automatically.
|
||||
|
||||
API responses and mutations are never cached by the service worker. New issue captures,
|
||||
issue comments, pull-request comments, and unread-update replies use bounded local
|
||||
|
||||
@@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review', 'issue-close']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
@@ -74,7 +74,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
...(message.kind === 'issue-comment' && message.attachment ? {
|
||||
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
|
||||
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? {
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
@@ -90,6 +91,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
draftFingerprint: String(message.draftFingerprint || ''),
|
||||
progressFingerprint: String(message.progressFingerprint || ''),
|
||||
} : {}),
|
||||
...(message.kind === 'issue-blocker' ? {
|
||||
blockerRepository: String(message.blockerRepository || ''),
|
||||
blockerNumber: Number(message.blockerNumber || 0),
|
||||
present: message.present === true,
|
||||
} : {}),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
@@ -98,6 +104,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
|
||||
async function enqueueDurably(message) {
|
||||
const item = enqueue(message, false);
|
||||
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
|
||||
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
|
||||
write(read().filter(candidate => candidate.id !== item.id), false);
|
||||
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
|
||||
}
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
@@ -157,6 +168,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
if (item.kind === 'issue-close') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
@@ -183,12 +197,49 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
}
|
||||
result = delivery.message;
|
||||
} else {
|
||||
if (item.kind === 'issue-close') {
|
||||
if (item.kind === 'update-reply-read') {
|
||||
if (!item.replyConfirmed) {
|
||||
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
});
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, replyConfirmed: true, status: 'sending', lastAttemptAt: attemptAt,
|
||||
} : candidate), false);
|
||||
}
|
||||
result = await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
} else if (item.kind === 'issue-close') {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
});
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
} else if (item.kind === 'issue-blocker') {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: item.blockerRepository,
|
||||
number: item.blockerNumber,
|
||||
present: item.present,
|
||||
}),
|
||||
});
|
||||
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
|
||||
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
|
||||
Number(candidate?.number) === item.blockerNumber);
|
||||
if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) {
|
||||
throw new Error('Blocker change was not confirmed.');
|
||||
}
|
||||
} else {
|
||||
const body = item.kind === 'pull-review' ? {
|
||||
body: item.body,
|
||||
@@ -297,6 +348,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
error: String(background.error || 'Message needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
if (background?.status === 'authorization') return [{
|
||||
...item,
|
||||
status: 'authorization',
|
||||
error: String(background.error || 'Fresh authorization required').slice(0, 240),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
write(items);
|
||||
|
||||
@@ -85,7 +85,8 @@ function createIssueSyncStore({
|
||||
incoming.set(current.id, replacement);
|
||||
}
|
||||
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && replacement.status === 'attention') ||
|
||||
(['attention', 'authorization'].includes(current.status) &&
|
||||
replacement.status === current.status) ||
|
||||
current.status === 'sent') {
|
||||
incoming.set(current.id, current);
|
||||
}
|
||||
@@ -153,7 +154,8 @@ function createIssueSyncStore({
|
||||
return transact(async records => {
|
||||
const current = (await records.getAll()).find(candidate => candidate.id === item.id);
|
||||
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && item.status === 'attention') ||
|
||||
(['attention', 'authorization'].includes(current.status) &&
|
||||
item.status === current.status) ||
|
||||
current.status === 'sent')) return current;
|
||||
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
|
||||
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
|
||||
@@ -243,6 +245,9 @@ function createIssueSyncStore({
|
||||
status: 'attention', error,
|
||||
...(deliveryState ? { deliveryState } : {}),
|
||||
})),
|
||||
authorization: (id, claimToken, error) => updateClaim(
|
||||
id, claimToken, item => clearClaim(item, { status: 'authorization', error })
|
||||
),
|
||||
snapshot: () => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
|
||||
countBlocked: ownerLogin => transact(async records =>
|
||||
@@ -271,6 +276,9 @@ function createBackgroundIssueSync({
|
||||
const failClaim = (item, error, deliveryState) => store.supportsClaimTokens
|
||||
? store.fail(item.id, item.claimToken, error, deliveryState)
|
||||
: store.fail(item.id, error, deliveryState);
|
||||
const requireAuthorization = (item, error) => store.supportsClaimTokens
|
||||
? store.authorization(item.id, item.claimToken, error)
|
||||
: store.authorization(item.id, error);
|
||||
const checkpointClaim = (item, transform) => store.supportsClaimTokens
|
||||
? store.checkpoint(item.id, item.claimToken, transform) : store.update?.(item.id, transform);
|
||||
|
||||
@@ -311,7 +319,7 @@ function createBackgroundIssueSync({
|
||||
if (item.kind === 'notification-read') {
|
||||
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
|
||||
}
|
||||
if (item.kind === 'update-reply') {
|
||||
if (item.kind === 'update-reply' || item.kind === 'update-reply-read') {
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
@@ -344,6 +352,12 @@ function createBackgroundIssueSync({
|
||||
if (item.kind === 'update-reply') {
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
}
|
||||
if (item.kind === 'update-reply-read') {
|
||||
return {
|
||||
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
|
||||
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
|
||||
};
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-close') {
|
||||
return {
|
||||
@@ -354,6 +368,24 @@ function createBackgroundIssueSync({
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers',
|
||||
options: {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: item.blockerRepository,
|
||||
number: item.blockerNumber,
|
||||
present: item.present === true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||
@@ -420,6 +452,44 @@ function createBackgroundIssueSync({
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
async function deliverReplyRead(item) {
|
||||
let current = item;
|
||||
if (!current.replyConfirmed) {
|
||||
let attachmentMarkdown = current.attachmentMarkdown;
|
||||
if (current.attachment && !attachmentMarkdown) {
|
||||
const uploaded = await requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
||||
body:attachmentMultipart(current.attachment),
|
||||
});
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
||||
current = { ...current, attachmentMarkdown };
|
||||
}
|
||||
const text = String(current.body || '').trim();
|
||||
const replyBody = attachmentMarkdown ?
|
||||
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
|
||||
const options = authoredRequest('', { ...current, body:replyBody }).options;
|
||||
if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
|
||||
await requestStage(
|
||||
current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
||||
options,
|
||||
);
|
||||
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
|
||||
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
|
||||
current = { ...current, replyConfirmed: true };
|
||||
}
|
||||
const request = deliveryRequest(current);
|
||||
return requestStage(current, request.url, request.options);
|
||||
}
|
||||
|
||||
function attachmentMultipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
@@ -480,11 +550,12 @@ function createBackgroundIssueSync({
|
||||
|
||||
async function deliverScreenshotComment(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/attachments',
|
||||
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -505,7 +576,7 @@ function createBackgroundIssueSync({
|
||||
const text = String(item.body || '').trim();
|
||||
return requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/comments',
|
||||
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -517,10 +588,41 @@ function createBackgroundIssueSync({
|
||||
);
|
||||
}
|
||||
|
||||
async function deliverUpdateScreenshotReply(item) {
|
||||
let current = item;
|
||||
let attachmentMarkdown = current.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
||||
body:attachmentMultipart(current.attachment),
|
||||
});
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
||||
current = { ...current, attachmentMarkdown };
|
||||
}
|
||||
const text = String(current.body || '').trim();
|
||||
return requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Content-Type':'application/json',
|
||||
'Idempotency-Key':stageOperationId(current.operationId, 'reply') },
|
||||
body:JSON.stringify({ body:text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
|
||||
});
|
||||
}
|
||||
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = item.attachment && item.kind === 'issue-comment' ?
|
||||
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
|
||||
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
|
||||
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||
@@ -528,6 +630,16 @@ function createBackgroundIssueSync({
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
const dependencies = Array.isArray(delivered?.dependencies) ? delivered.dependencies : [];
|
||||
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
|
||||
Number(candidate?.number) === item.blockerNumber);
|
||||
if (delivered?.number !== item.number || delivered?.dependencies_available !== true || present !== item.present) {
|
||||
const error = new Error('Blocker change was not confirmed.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await completeClaim(item, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
@@ -537,6 +649,15 @@ function createBackgroundIssueSync({
|
||||
await releaseClaim(item);
|
||||
throw error;
|
||||
}
|
||||
if (status === 428 && item.kind === 'pull-review') {
|
||||
const message = String(error?.message || 'Fresh authorization required').slice(0, 240);
|
||||
await requireAuthorization(item, message);
|
||||
return {
|
||||
authorization: true,
|
||||
error,
|
||||
receipt: receiptFor(item, 'authorization'),
|
||||
};
|
||||
}
|
||||
if (status >= 400 && status < 500) {
|
||||
await failClaim(
|
||||
item,
|
||||
@@ -567,11 +688,13 @@ function createBackgroundIssueSync({
|
||||
const confirmed = [];
|
||||
const receipts = [];
|
||||
let attention = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
|
||||
let authorization = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention, authorization, 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.authorization) authorization += 1;
|
||||
if (result.receipt) receipts.push(result.receipt);
|
||||
};
|
||||
if (store.planBatch) {
|
||||
@@ -632,7 +755,7 @@ function createBackgroundIssueSync({
|
||||
}
|
||||
}
|
||||
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
||||
return { confirmed, blocked, attention, login, receipts };
|
||||
return { confirmed, blocked, attention, authorization, login, receipts };
|
||||
}
|
||||
|
||||
function flush() {
|
||||
|
||||
+41
-1
@@ -30,6 +30,8 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
|
||||
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
|
||||
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
|
||||
.authentication-alert { border-color:#d69e2e; background:#241b09; }
|
||||
.authentication-alert strong { color:#f6c453; }
|
||||
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
||||
#load-more-security-activity { width:100%; min-height:44px; }
|
||||
button:hover { filter: brightness(1.15); }
|
||||
@@ -151,6 +153,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.update-selection-controls button { min-height:44px; }
|
||||
.update-selector { min-height:44px; display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
.update-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.my-work-card.selection-active { border-color:#31577f; }
|
||||
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.later-actions button { min-height:44px; width:100%; }
|
||||
.today-actions button { min-height:44px; width:100%; }
|
||||
@@ -232,6 +240,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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; }
|
||||
.ci-checks { max-width:100%; margin:10px 0; border:1px solid #2a496e; border-radius:10px; overflow:hidden; }
|
||||
.ci-checks > summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0 10px; cursor:pointer; }
|
||||
.ci-checks > summary .small { min-width:0; overflow-wrap:anywhere; text-align:right; }
|
||||
.ci-check-refresh { width:100%; min-height:44px; margin-bottom:6px; }
|
||||
.ci-check-list { display:grid; gap:6px; min-width:0; padding:0 8px 8px; }
|
||||
.ci-check { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:8px; border:1px solid #203a5c; border-radius:8px; }
|
||||
.ci-check-failure, .ci-check-error { border-color:#dc2626; background:rgba(127,29,29,.22); }
|
||||
.ci-check-pending { border-color:#d97706; background:rgba(120,53,15,.2); }
|
||||
.ci-check-success { border-color:#15803d; }
|
||||
.ci-check-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
|
||||
.ci-check-copy strong, .ci-check-copy span { min-width:0; overflow-wrap:anywhere; }
|
||||
.ci-check-link { min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid #60a5fa; border-radius:8px; white-space:nowrap; }
|
||||
.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; }
|
||||
@@ -243,8 +263,17 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-reply button { min-height:44px; width:100%; }
|
||||
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.update-reply-actions button { min-height:44px; width:100%; }
|
||||
.update-reply .issue-attachment-preview { width:100%; min-width:0; }
|
||||
@media (max-width:390px) {
|
||||
.update-reply-actions { grid-template-columns:1fr; }
|
||||
.update-reply .issue-attachment-preview { grid-template-columns:56px minmax(0,1fr); padding:8px; }
|
||||
.update-reply .issue-attachment-preview img { width:56px; height:56px; }
|
||||
}
|
||||
.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-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.update-ownership-actions button { min-width:0; width:100%; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions 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); }
|
||||
@@ -258,6 +287,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-blocker-list { display:grid; gap:8px; }
|
||||
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
|
||||
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
|
||||
.issue-blocker-row { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:start; }
|
||||
.issue-blocker-row .issue-blocker { min-width:0; }
|
||||
.issue-blocker-remove { min-height:44px; white-space:normal; }
|
||||
.issue-blocker-manager { display:grid; gap:8px; margin-top:10px; min-width:0; }
|
||||
.issue-blocker-manager :is(input,button) { min-height:44px; width:100%; max-width:100%; box-sizing:border-box; }
|
||||
.issue-blocker-manager [role="listbox"] { display:grid; gap:6px; max-height:240px; overflow:auto; }
|
||||
.issue-blocker-manager [role="option"] { text-align:left; overflow-wrap:anywhere; }
|
||||
#manage-issue-blockers, #start-unblocked-issue { min-height:44px; width:100%; margin-top:10px; }
|
||||
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
|
||||
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
|
||||
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
@@ -433,6 +470,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
|
||||
.my-work-card { min-width:0; overflow-x:hidden; }
|
||||
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
|
||||
.card-planning > summary::-webkit-details-marker { display:none; }
|
||||
@@ -446,8 +484,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-blocker-row { grid-template-columns:1fr; }
|
||||
.issue-blocker-remove { width:100%; }
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
|
||||
#cmd-palette.open { display:flex; flex-direction:column; }
|
||||
|
||||
+584
-121
File diff suppressed because it is too large.
Load diff
+5
-4
@@ -141,7 +141,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
|
||||
.map(item => {
|
||||
const isUpdate = item.kind === 'update-reply';
|
||||
const isUpdate = item.kind === 'update-reply' || item.kind === 'update-reply-read';
|
||||
const isReview = item.kind === 'pull-review';
|
||||
const isClosure = item.kind === 'issue-close';
|
||||
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
|
||||
@@ -161,11 +161,12 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
outbox_kind: item.kind,
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
|
||||
(isClosure ? 'authorization' : 'queued')),
|
||||
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||
(isReview ? 'Queued review' : (isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
||||
authorization_required: isClosure,
|
||||
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
|
||||
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
||||
authorization_required: isClosure || (isReview && item.status === 'authorization'),
|
||||
delivery_state: item.deliveryState,
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
|
||||
const pending = new Map();
|
||||
const loaded = new Set();
|
||||
|
||||
function load(name) {
|
||||
if (loaded.has(name)) return Promise.resolve();
|
||||
if (pending.has(name)) return pending.get(name);
|
||||
const url = urls[name];
|
||||
if (!url) return Promise.reject(new Error('Unknown feature: ' + name));
|
||||
|
||||
const request = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
let timeout;
|
||||
const fail = () => {
|
||||
clearTimeout(timeout);
|
||||
script.remove();
|
||||
pending.delete(name);
|
||||
reject(new Error('Could not load ' + name.replace(/-/g, ' ') + '.'));
|
||||
};
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
clearTimeout(timeout);
|
||||
pending.delete(name);
|
||||
loaded.add(name);
|
||||
resolve();
|
||||
};
|
||||
script.onerror = fail;
|
||||
timeout = setTimeout(fail, timeoutMs);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
pending.set(name, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function run(name, elements, callback) {
|
||||
const trigger = elements?.trigger;
|
||||
const status = elements?.status;
|
||||
const retryLabel = elements?.retryLabel || 'Tap New issue to retry.';
|
||||
if (trigger) trigger.disabled = true;
|
||||
if (status) status.textContent = 'Loading ' + name.replace(/-/g, ' ') + '…';
|
||||
try {
|
||||
await load(name);
|
||||
callback();
|
||||
if (status) status.textContent = '';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) {
|
||||
const label = name === 'issue-capture' ? 'Issue capture' :
|
||||
name === 'pull-workflow' ? 'Pull workspace' : name.replace(/-/g, ' ');
|
||||
status.textContent = label + ' could not load. ' + retryLabel;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (trigger) trigger.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { load, run, ready: name => loaded.has(name) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createFeatureLoader;
|
||||
+53
-2
@@ -98,6 +98,7 @@
|
||||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||
<span class="offline-today-readiness" id="offline-today-readiness" hidden>
|
||||
<span class="small" id="offline-today-status" role="status" aria-live="polite"></span>
|
||||
@@ -115,6 +116,11 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="update-selection-controls" id="update-selection-controls" hidden>
|
||||
<button id="select-updates" type="button">Select updates</button>
|
||||
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
||||
@@ -306,6 +312,16 @@
|
||||
<h2 id="issue-blockers-title">Blocked by</h2>
|
||||
<div class="issue-blocker-list" id="issue-blocker-list"></div>
|
||||
<div id="issue-blocker-status" class="small" aria-live="polite"></div>
|
||||
<button id="manage-issue-blockers" type="button" hidden>Add blocker</button>
|
||||
<button id="start-unblocked-issue" type="button" hidden>Start now</button>
|
||||
<div class="issue-blocker-manager" id="issue-blocker-manager" hidden>
|
||||
<label for="issue-blocker-search">Search open issues</label>
|
||||
<input id="issue-blocker-search" type="search" maxlength="100" autocomplete="off"
|
||||
role="combobox" aria-controls="issue-blocker-results" aria-expanded="false" />
|
||||
<div id="issue-blocker-results" role="listbox" aria-label="Open issue results"></div>
|
||||
<div id="issue-blocker-search-status" class="small" aria-live="polite"></div>
|
||||
<button id="cancel-issue-blocker" type="button">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
<h2>Full conversation</h2>
|
||||
<div id="issue-comments"></div>
|
||||
@@ -516,15 +532,28 @@
|
||||
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
|
||||
<div class="issue-attachment-controls">
|
||||
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<label class="issue-attachment-trigger" for="update-reply-attachment">Attach screenshot</label>
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="update-reply-attachment-preview" hidden>
|
||||
<img id="update-reply-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="update-reply-attachment-meta"></span>
|
||||
<button id="remove-update-reply-attachment" type="button">Remove screenshot</button>
|
||||
</div>
|
||||
<div class="update-reply-actions">
|
||||
<button id="send-update-reply" type="button">Send reply</button>
|
||||
<button id="send-update-reply-next" type="button" hidden>Reply & next</button>
|
||||
<button id="send-update-reply-read-next" type="button">Reply, mark read & next</button>
|
||||
</div>
|
||||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="update-sheet-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<div class="update-ownership-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership & start</button>
|
||||
</div>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
@@ -555,6 +584,15 @@
|
||||
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||
<div class="mention-options" id="pull-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="pull-comment-mention-status" aria-live="polite"></div>
|
||||
<div class="issue-attachment-controls">
|
||||
<input class="visually-hidden" id="pull-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<label class="issue-attachment-trigger" for="pull-attachment">Attach screenshot</label>
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="pull-attachment-preview" hidden>
|
||||
<img id="pull-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="pull-attachment-meta"></span>
|
||||
<button id="remove-pull-attachment" type="button">Remove screenshot</button>
|
||||
</div>
|
||||
<div class="comment-actions">
|
||||
<button id="send-pull-comment" type="button">Post comment</button>
|
||||
<button id="send-pull-comment-next" type="button" hidden>Comment & next</button>
|
||||
@@ -566,6 +604,11 @@
|
||||
<div id="pull-review-status" class="small" aria-live="polite">Expand to load changed files and merge readiness.</div>
|
||||
<button class="pull-retry" id="pull-review-retry" type="button" hidden>Retry review data</button>
|
||||
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Review data not loaded</span></div>
|
||||
<details class="ci-checks" id="pull-checks">
|
||||
<summary><span>Checks</span><span class="small" id="pull-checks-summary">Not loaded</span></summary>
|
||||
<button class="ci-check-refresh" id="refresh-pull-checks" type="button">Refresh checks</button>
|
||||
<div class="ci-check-list" id="pull-check-list"></div>
|
||||
</details>
|
||||
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
|
||||
<div id="pull-files"></div>
|
||||
<button id="merge-pull" type="button" disabled>Merge</button>
|
||||
@@ -597,6 +640,11 @@
|
||||
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
|
||||
<div class="review-sheet-body markdown-content" id="review-sheet-body"></div>
|
||||
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><button class="share-work-route" type="button">Share</button><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
|
||||
<details class="ci-checks" id="review-checks">
|
||||
<summary><span>Checks</span><span class="small" id="review-checks-summary">Not loaded</span></summary>
|
||||
<button class="ci-check-refresh" id="refresh-review-checks" type="button">Refresh checks</button>
|
||||
<div class="ci-check-list" id="review-check-list"></div>
|
||||
</details>
|
||||
<h2>Changed files</h2>
|
||||
<div class="review-display-tools">
|
||||
<button id="review-wrap-lines" type="button" aria-pressed="false" aria-controls="review-files">Wrap lines</button>
|
||||
@@ -676,6 +724,7 @@
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/feature-loader.js"></script>
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
@@ -687,6 +736,7 @@
|
||||
<script src="static/issue-outbox.js"></script>
|
||||
<script src="static/authored-outbox.js"></script>
|
||||
<script src="static/offline-issue-close.js"></script>
|
||||
<script src="static/offline-issue-blocker.js"></script>
|
||||
<script src="static/notification-read-outbox.js"></script>
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
@@ -696,6 +746,7 @@
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
<script src="static/comment-next.js"></script>
|
||||
<script src="static/update-reply-read-next.js"></script>
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
|
||||
@@ -181,6 +181,11 @@
|
||||
let previewUrl = '';
|
||||
let selectionSequence = 0;
|
||||
|
||||
function setBusy(busy) {
|
||||
options.input.disabled = Boolean(busy);
|
||||
options.remove.disabled = Boolean(busy);
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
selectionSequence += 1;
|
||||
if (previewUrl) options.revokeObjectURL(previewUrl);
|
||||
@@ -247,7 +252,7 @@
|
||||
return restored;
|
||||
}
|
||||
|
||||
return Object.assign(controller, { clear: clearPreview, restore: restorePreview });
|
||||
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
|
||||
}
|
||||
|
||||
return { create, mount, multipart, optimizeImage, MAX_BYTES };
|
||||
|
||||
@@ -27,6 +27,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
let milestoneRequest = null;
|
||||
let blockerRequest = null;
|
||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
||||
@@ -67,6 +68,24 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
updateBlocker(item, blocker, remove = false) {
|
||||
if (blockerRequest) return blockerRequest;
|
||||
blockerRequest = fetchJson(issuePath(item) + '/blockers', {
|
||||
method: remove ? 'DELETE' : 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ repository: blocker.repository, number: blocker.number }),
|
||||
}).then(result => {
|
||||
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
|
||||
const present = dependencies.some(candidate =>
|
||||
candidate?.repository === blocker.repository && Number(candidate?.number) === Number(blocker.number)
|
||||
);
|
||||
if (result?.number !== item.number || result?.dependencies_available !== true || present === remove) {
|
||||
throw new Error('Blocker change was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { blockerRequest = null; });
|
||||
return blockerRequest;
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
|
||||
+112
-14
@@ -178,6 +178,62 @@ function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
let active = false;
|
||||
const selected = new Set();
|
||||
const snapshot = () => ({
|
||||
active,
|
||||
ids: Array.from(selected),
|
||||
count: selected.size,
|
||||
limit: maximum,
|
||||
at_limit: selected.size >= maximum,
|
||||
});
|
||||
const publish = () => onChange(snapshot());
|
||||
const select = notificationId => {
|
||||
if (!active || !Number.isInteger(notificationId)) return 'inactive';
|
||||
if (selected.has(notificationId)) return 'already-selected';
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.add(notificationId);
|
||||
publish();
|
||||
return 'selected';
|
||||
};
|
||||
return {
|
||||
start() {
|
||||
if (active) return snapshot();
|
||||
active = true;
|
||||
publish();
|
||||
return snapshot();
|
||||
},
|
||||
select,
|
||||
toggle(notificationId) {
|
||||
if (!selected.has(notificationId)) return select(notificationId);
|
||||
selected.delete(notificationId);
|
||||
publish();
|
||||
return 'deselected';
|
||||
},
|
||||
retain(notificationIds) {
|
||||
const allowed = new Set((notificationIds || []).filter(Number.isInteger));
|
||||
let changed = false;
|
||||
selected.forEach(id => {
|
||||
if (!allowed.has(id)) {
|
||||
selected.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) publish();
|
||||
return snapshot();
|
||||
},
|
||||
cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
publish();
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
@@ -280,6 +336,7 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
|
||||
function createNotificationReader({
|
||||
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
|
||||
acknowledge = null,
|
||||
queueRead = null,
|
||||
loadSaved = () => null,
|
||||
loadConversation = null,
|
||||
@@ -292,6 +349,22 @@ function createNotificationReader({
|
||||
let conversationPager = null;
|
||||
let offlineHydrated = false;
|
||||
|
||||
async function advanceAfterRead(items, current, queueing = false) {
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item => item && item.has_update &&
|
||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
}
|
||||
|
||||
async function open(item, savedDetail = null) {
|
||||
selected = item;
|
||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||
@@ -327,6 +400,10 @@ function createNotificationReader({
|
||||
onConversation(conversationPager.append(comment));
|
||||
return true;
|
||||
},
|
||||
acceptReadAndNext(items, item = selected) {
|
||||
if (!item || selected !== item) return false;
|
||||
return advanceAfterRead(items, item);
|
||||
},
|
||||
async loadOlder() {
|
||||
if (!conversationPager || !selected || offlineHydrated) return false;
|
||||
const pager = conversationPager;
|
||||
@@ -354,19 +431,7 @@ function createNotificationReader({
|
||||
try {
|
||||
if (queueing) await queueRead(current.notification_id);
|
||||
else await markRead(current.notification_id);
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item => item && item.has_update &&
|
||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
return await advanceAfterRead(items, current, queueing);
|
||||
} catch (_error) {
|
||||
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
|
||||
return false;
|
||||
@@ -374,6 +439,21 @@ function createNotificationReader({
|
||||
marking = false;
|
||||
}
|
||||
},
|
||||
async acknowledgeAndNext(items) {
|
||||
if (!selected || marking || offlineHydrated || !acknowledge) return false;
|
||||
const current = selected;
|
||||
marking = true;
|
||||
onStatus('Adding reaction and marking read…');
|
||||
try {
|
||||
await acknowledge(current.notification_id);
|
||||
return await advanceAfterRead(items, current);
|
||||
} catch (_error) {
|
||||
onStatus('Could not acknowledge update. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
marking = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -396,7 +476,7 @@ function createNotificationReplier({
|
||||
}
|
||||
catch (_error) { /* Keep the editable textarea as the fallback. */ }
|
||||
},
|
||||
async submit(item, body) {
|
||||
async submit(item, body, attachment = null) {
|
||||
if (pending) return false;
|
||||
pending = true;
|
||||
this.saveDraft(item, body);
|
||||
@@ -407,6 +487,23 @@ function createNotificationReplier({
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
onStatus('Sending reply…');
|
||||
try {
|
||||
if (attachment) {
|
||||
onStatus('Saving screenshot for durable delivery…');
|
||||
const admission = await authoredOutbox.enqueueDurably({
|
||||
kind:'update-reply', notificationId:item.notification_id, body, operationId, attachment,
|
||||
});
|
||||
const delivery = await authoredOutbox.retry(admission.item.id, admission.item.ownerLogin);
|
||||
if (!delivery.confirmed?.length) {
|
||||
const remaining = delivery.remaining?.find(candidate => candidate.id === admission.item.id);
|
||||
if (remaining?.status === 'attention') return false;
|
||||
onStatus('Queued for sync when the connection returns.');
|
||||
return { queued:true };
|
||||
}
|
||||
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
|
||||
catch (_error) { /* Confirmed delivery is authoritative. */ }
|
||||
onStatus('Reply posted. You can mark this update read when ready.');
|
||||
return delivery.confirmed[0];
|
||||
}
|
||||
const result = await post(item.notification_id, body, operationId);
|
||||
try { storage.removeItem(keyFor(item)); }
|
||||
catch (_error) { /* The posted reply is still authoritative. */ }
|
||||
@@ -741,6 +838,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
buildMyWork.notificationIds = notificationIds;
|
||||
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
||||
buildMyWork.createNotificationSelection = createNotificationSelection;
|
||||
buildMyWork.createNotificationPager = createNotificationPager;
|
||||
buildMyWork.createWorkPager = createWorkPager;
|
||||
buildMyWork.createNotificationReader = createNotificationReader;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
function createOfflineIssueBlocker({
|
||||
enqueueDurably,
|
||||
onQueued = () => {},
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
}) {
|
||||
return async function queueOfflineIssueBlocker(item, blocker, present) {
|
||||
const admission = await enqueueDurably({
|
||||
kind: 'issue-blocker',
|
||||
repository: String(item.repository || ''),
|
||||
number: Number(item.number || 0),
|
||||
blockerRepository: String(blocker.repository || ''),
|
||||
blockerNumber: Number(blocker.number || 0),
|
||||
present: present === true,
|
||||
operationId: String(createOperationId()).slice(0, 128),
|
||||
});
|
||||
onQueued(item, blocker, present === true, admission);
|
||||
return { admission };
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueBlocker;
|
||||
@@ -31,14 +31,18 @@
|
||||
}
|
||||
|
||||
const previousFailed = failed;
|
||||
const readyKeys = new Set(items.filter(item => loadSavedDetail(login, item)).map(itemKey));
|
||||
const savedDetails = new Map();
|
||||
await Promise.all(items.map(async item => {
|
||||
savedDetails.set(itemKey(item), await loadSavedDetail(login, item));
|
||||
}));
|
||||
const readyKeys = new Set(items.filter(item => savedDetails.get(itemKey(item))).map(itemKey));
|
||||
const snapshot = pending => ({
|
||||
total: items.length, ready: readyKeys.size, failed: failed.size, pending,
|
||||
});
|
||||
const candidates = items.filter(item => {
|
||||
const key = itemKey(item);
|
||||
if (onlyFailed && !previousFailed.has(key)) return false;
|
||||
const saved = loadSavedDetail(login, item);
|
||||
const saved = savedDetails.get(key);
|
||||
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
|
||||
});
|
||||
failed = new Set();
|
||||
@@ -51,8 +55,9 @@
|
||||
try {
|
||||
const detail = await loadDetail(item);
|
||||
if (runGeneration !== generation) return;
|
||||
const saved = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
|
||||
if (saved !== false) readyKeys.add(itemKey(item));
|
||||
const saved = await saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
|
||||
if (saved === false) throw new Error('Offline detail was not durably admitted.');
|
||||
readyKeys.add(itemKey(item));
|
||||
} catch (_error) {
|
||||
if (runGeneration === generation) failed.add(itemKey(item));
|
||||
}
|
||||
|
||||
+245
-2
@@ -27,6 +27,54 @@
|
||||
];
|
||||
const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at'];
|
||||
|
||||
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-offline-work-v2') {
|
||||
if (!indexedDB) return null;
|
||||
let databasePromise;
|
||||
function database() {
|
||||
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains('work')) {
|
||||
request.result.createObjectStore('work', { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
db.onversionchange = () => { db.close(); databasePromise = undefined; };
|
||||
resolve(db);
|
||||
};
|
||||
request.onerror = () => reject(request.error || new Error('Offline work database failed to open.'));
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
const requested = request => new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return async work => {
|
||||
const db = await database();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('work', 'readwrite');
|
||||
const store = transaction.objectStore('work');
|
||||
let result;
|
||||
let workError;
|
||||
transaction.oncomplete = () => workError ? undefined : resolve(result);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(workError || transaction.error || new Error('Offline work transaction aborted.'));
|
||||
Promise.resolve(work({
|
||||
get: key => requested(store.get(key)),
|
||||
getAll: () => requested(store.getAll()),
|
||||
put: value => requested(store.put(value)),
|
||||
delete: key => requested(store.delete(key)),
|
||||
clear: () => requested(store.clear()),
|
||||
})).then(value => { result = value; }).catch(error => {
|
||||
workError = error;
|
||||
try { transaction.abort(); } catch (_) { reject(error); }
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function pick(source, fields) {
|
||||
const output = {};
|
||||
fields.forEach(field => {
|
||||
@@ -36,14 +84,155 @@
|
||||
}
|
||||
|
||||
function createOfflineWorkStore({
|
||||
storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10,
|
||||
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
|
||||
maxDetails = 10, initializationTimeoutMs = 1500,
|
||||
}) {
|
||||
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
||||
let migrationPromise;
|
||||
let readinessPromise;
|
||||
let readinessGeneration = 0;
|
||||
let readinessState = transact ? { state:'initializing', reason:'' } : { state:'ready', reason:'' };
|
||||
let recordCache = null;
|
||||
let pendingDurableClear = false;
|
||||
|
||||
function hydrateLegacyCache() {
|
||||
const cache = new Map();
|
||||
try {
|
||||
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
|
||||
if (snapshot) cache.set('snapshot', { ...snapshot, id:'snapshot' });
|
||||
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||
if (Array.isArray(details)) details.forEach(record => {
|
||||
if (!record?.key || !record?.user_login || !record?.data) return;
|
||||
const id = 'detail:' + record.user_login + ':' + record.key;
|
||||
cache.set(id, { ...record, id });
|
||||
});
|
||||
} catch (_) { cache.clear(); }
|
||||
recordCache = cache;
|
||||
}
|
||||
|
||||
function migrateLegacy(generation) {
|
||||
if (!transact) return Promise.resolve(true);
|
||||
if (!migrationPromise) migrationPromise = (async () => {
|
||||
let snapshot = null;
|
||||
let details = [];
|
||||
try {
|
||||
snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
|
||||
const parsedDetails = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||
details = Array.isArray(parsedDetails) ? parsedDetails : [];
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (!snapshot && !details.length) return true;
|
||||
await transact(async records => {
|
||||
if (snapshot && !(await records.get('snapshot'))) {
|
||||
await records.put({ ...snapshot, id:'snapshot' });
|
||||
}
|
||||
for (const record of details.slice(-Math.max(1, maxDetails))) {
|
||||
if (!record?.key || !record?.user_login || !record?.data) continue;
|
||||
const id = 'detail:' + record.user_login + ':' + record.key;
|
||||
if (!(await records.get(id))) await records.put({ ...record, id });
|
||||
}
|
||||
});
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
return true;
|
||||
})().catch(() => false);
|
||||
return migrationPromise;
|
||||
}
|
||||
|
||||
async function initialize(generation) {
|
||||
const migrated = await migrateLegacy(generation);
|
||||
if (!migrated || generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
if (pendingDurableClear) {
|
||||
await transact(async records => { await records.clear(); });
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
pendingDurableClear = false;
|
||||
recordCache = new Map();
|
||||
return true;
|
||||
}
|
||||
return transact(async records => {
|
||||
const currentTime = now().getTime();
|
||||
const valid = [];
|
||||
for (const record of await records.getAll()) {
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const validShape = record?.id === 'snapshot' ?
|
||||
record.version === VERSION && record.user_login && record.data :
|
||||
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data;
|
||||
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) {
|
||||
if (record?.id) await records.delete(record.id);
|
||||
} else valid.push(record);
|
||||
}
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
recordCache = new Map(valid.map(record => [record.id, record]));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function startReady() {
|
||||
const generation = ++readinessGeneration;
|
||||
readinessState = { state:'initializing', reason:'' };
|
||||
const operation = initialize(generation).catch(() => false);
|
||||
readinessPromise = new Promise(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
if (generation !== readinessGeneration || readinessState.state !== 'initializing') return;
|
||||
hydrateLegacyCache();
|
||||
readinessState = { state:'degraded', reason:'deadline' };
|
||||
resolve(false);
|
||||
}, Math.max(1, Number(initializationTimeoutMs) || 1500));
|
||||
operation.then(available => {
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return;
|
||||
clearTimeout(timer);
|
||||
if (available) readinessState = { state:'ready', reason:'' };
|
||||
else {
|
||||
hydrateLegacyCache();
|
||||
readinessState = { state:'degraded', reason:'unavailable' };
|
||||
}
|
||||
resolve(Boolean(available));
|
||||
});
|
||||
});
|
||||
return readinessPromise;
|
||||
}
|
||||
|
||||
function ready() {
|
||||
if (!transact || readinessState.state === 'ready') return Promise.resolve(true);
|
||||
if (readinessState.state === 'degraded') return Promise.resolve(false);
|
||||
return readinessPromise || startReady();
|
||||
}
|
||||
|
||||
function status() {
|
||||
return { ...readinessState };
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (!transact) return Promise.resolve(true);
|
||||
migrationPromise = undefined;
|
||||
readinessPromise = undefined;
|
||||
return startReady();
|
||||
}
|
||||
function enabled() {
|
||||
try { return storage.getItem(ENABLED_KEY) === 'true'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (transact) {
|
||||
try {
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
} catch (_) { /* IndexedDB remains the source of truth. */ }
|
||||
if (readinessState.state === 'degraded') {
|
||||
pendingDurableClear = true;
|
||||
recordCache = new Map();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
migrationPromise = Promise.resolve(true);
|
||||
return transact(async records => {
|
||||
await records.clear();
|
||||
recordCache = new Map();
|
||||
return true;
|
||||
}).catch(() => false);
|
||||
}
|
||||
try {
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
@@ -104,6 +293,22 @@
|
||||
saved_at: now().toISOString(),
|
||||
data,
|
||||
};
|
||||
if (transact) {
|
||||
record.id = 'detail:' + login + ':' + key;
|
||||
return ready().then(available => available ? transact(async records => {
|
||||
const existing = (await records.getAll()).filter(candidate =>
|
||||
candidate?.id?.startsWith('detail:') && candidate.id !== record.id
|
||||
);
|
||||
await records.put(record);
|
||||
const overflow = existing.concat(record).sort((a, b) =>
|
||||
String(a.saved_at).localeCompare(String(b.saved_at))
|
||||
).slice(0, -Math.max(1, maxDetails));
|
||||
for (const candidate of overflow) await records.delete(candidate.id);
|
||||
overflow.forEach(candidate => recordCache.delete(candidate.id));
|
||||
recordCache.set(record.id, record);
|
||||
return true;
|
||||
}) : false).catch(() => false);
|
||||
}
|
||||
const records = readDetails().filter(candidate =>
|
||||
!(candidate?.key === key && candidate?.user_login === login)
|
||||
);
|
||||
@@ -117,6 +322,19 @@
|
||||
const key = detailKey(item);
|
||||
login = String(login || '').trim();
|
||||
if (!login || !key) return null;
|
||||
if (transact) {
|
||||
const id = 'detail:' + login + ':' + key;
|
||||
if (!recordCache) return ready().then(() => loadDetail(login, item));
|
||||
const record = recordCache.get(id);
|
||||
if (!record) return null;
|
||||
const savedAt = Date.parse(record.saved_at || '');
|
||||
if (!record.data || !Number.isFinite(savedAt) || now().getTime() - savedAt > maxAgeMs) {
|
||||
recordCache.delete(id);
|
||||
transact(async records => { await records.delete(id); }).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
const records = readDetails();
|
||||
const currentTime = now().getTime();
|
||||
const valid = records.filter(record => {
|
||||
@@ -142,6 +360,7 @@
|
||||
function save(snapshot) {
|
||||
if (!enabled() || !snapshot?.user?.login) return false;
|
||||
const record = {
|
||||
id: 'snapshot',
|
||||
version: VERSION,
|
||||
user_login: String(snapshot.user.login),
|
||||
saved_at: now().toISOString(),
|
||||
@@ -154,12 +373,36 @@
|
||||
notification_pagination: snapshot.notification_pagination || {},
|
||||
},
|
||||
};
|
||||
if (transact) {
|
||||
return ready().then(available => available ? transact(async records => {
|
||||
await records.put(record);
|
||||
recordCache.set(record.id, record);
|
||||
return true;
|
||||
}) : false).catch(() => false);
|
||||
}
|
||||
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function load(expectedLogin) {
|
||||
if (transact) {
|
||||
if (!recordCache) return ready().then(() => load(expectedLogin));
|
||||
const record = recordCache.get('snapshot');
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
|
||||
!Number.isFinite(savedAt);
|
||||
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
|
||||
if (invalid || expired) {
|
||||
if (record) {
|
||||
recordCache.delete('snapshot');
|
||||
transact(async records => { await records.delete('snapshot'); }).catch(() => {});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (expectedLogin && record.user_login !== expectedLogin) return null;
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
let record;
|
||||
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
|
||||
catch (_) { clear(); return null; }
|
||||
@@ -175,7 +418,7 @@
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
|
||||
return { enabled, setEnabled, save, load, saveDetail, loadDetail, clear };
|
||||
return { enabled, setEnabled, ready, retry, status, save, load, saveDetail, loadDetail, clear };
|
||||
}
|
||||
|
||||
return createOfflineWorkStore;
|
||||
|
||||
@@ -36,11 +36,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrivateOutbox() {
|
||||
function deletePrivateDatabase(name) {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
try { request = indexedDB.deleteDatabase(name); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
@@ -52,7 +52,8 @@
|
||||
removeOwnedStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
|
||||
+15
-3
@@ -5,7 +5,11 @@ function mergeEligibility(detail, reviewState) {
|
||||
if (detail.draft) return { allowed: false, reason: 'Draft pull requests cannot be merged' };
|
||||
if (!detail.mergeable) return { allowed: false, reason: 'Resolve conflicts before merging' };
|
||||
if (detail.ci_state !== 'success') {
|
||||
return { allowed: false, reason: 'CI must succeed before merging' };
|
||||
const blockers = (detail.checks || []).filter(check =>
|
||||
['failure', 'error', 'pending'].includes(check.state)
|
||||
).map(check => check.name).filter(Boolean).slice(0, 3);
|
||||
return { allowed: false, reason: blockers.length ?
|
||||
'CI blocked by ' + blockers.join(', ') : 'CI must succeed before merging' };
|
||||
}
|
||||
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
|
||||
if (reviewState && !reviewState.complete) {
|
||||
@@ -40,6 +44,7 @@ function renderFile(file, index, reviewed, escapeHtml) {
|
||||
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let mergeRequest = null;
|
||||
let checkRequest = null;
|
||||
const reviewRequests = new Map();
|
||||
const reviewCache = new Map();
|
||||
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
|
||||
@@ -64,9 +69,9 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
load(item) {
|
||||
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
|
||||
},
|
||||
loadReview(item, headSha) {
|
||||
loadReview(item, headSha, { refresh = false } = {}) {
|
||||
const key = item.repository + '#' + item.number + ':' + headSha;
|
||||
if (reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
|
||||
if (!refresh && reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
|
||||
if (reviewRequests.has(key)) return reviewRequests.get(key);
|
||||
const request = fetchJson(pathFor(item) + '/review-data', {
|
||||
headers: { Accept: 'application/json' },
|
||||
@@ -78,6 +83,13 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
reviewRequests.set(key, request);
|
||||
return request;
|
||||
},
|
||||
loadChecks(item) {
|
||||
if (checkRequest) return checkRequest;
|
||||
checkRequest = fetchJson(pathFor(item) + '/checks', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).finally(() => { checkRequest = null; });
|
||||
return checkRequest;
|
||||
},
|
||||
conversation(item, initialPage) {
|
||||
const pager = createConversationPager({
|
||||
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||
|
||||
@@ -4,6 +4,7 @@ function createReviewController({
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
}) {
|
||||
let pendingSubmission = null;
|
||||
let pendingChecks = null;
|
||||
|
||||
function endpoint(item) {
|
||||
const [owner, repo] = String(item.repository || '').split('/');
|
||||
@@ -18,6 +19,14 @@ function createReviewController({
|
||||
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
|
||||
}
|
||||
|
||||
function loadChecks(item) {
|
||||
if (pendingChecks) return pendingChecks;
|
||||
pendingChecks = fetchJson(endpoint(item) + '/checks', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).finally(() => { pendingChecks = null; });
|
||||
return pendingChecks;
|
||||
}
|
||||
|
||||
function submit(item, payload) {
|
||||
if (pendingSubmission) return pendingSubmission;
|
||||
const operationKey = 'stackchain.review-submit.v1:' + item.repository + '#' + item.number;
|
||||
@@ -44,7 +53,7 @@ function createReviewController({
|
||||
return pendingSubmission;
|
||||
}
|
||||
|
||||
return { load, submit };
|
||||
return { load, loadChecks, submit };
|
||||
}
|
||||
|
||||
function createWrapPreference({ storage, mobile }) {
|
||||
@@ -355,6 +364,34 @@ function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderChecks(checks, escapeHtml, { offline = false } = {}) {
|
||||
const rank = { error: 0, failure: 0, pending: 1, warning: 2, unknown: 2, success: 3 };
|
||||
const items = (Array.isArray(checks) ? checks : []).filter(check =>
|
||||
check && typeof check.name === 'string' && check.name
|
||||
).map((check, index) => ({ ...check, index })).sort((left, right) =>
|
||||
(rank[left.state] ?? 2) - (rank[right.state] ?? 2) || left.index - right.index
|
||||
);
|
||||
const failed = items.filter(check => ['failure', 'error'].includes(check.state)).length;
|
||||
const pending = items.filter(check => check.state === 'pending').length;
|
||||
const passed = items.filter(check => check.state === 'success').length;
|
||||
const other = items.length - failed - pending - passed;
|
||||
const parts = [
|
||||
failed ? failed + ' failed' : '', pending ? pending + ' pending' : '',
|
||||
passed ? passed + ' passed' : '', other ? other + ' other' : '',
|
||||
].filter(Boolean);
|
||||
const summary = (offline ? 'Last known · ' : '') + (parts.join(' · ') || 'No checks reported');
|
||||
const html = items.map(check => {
|
||||
const url = typeof check.url === 'string' ? check.url : '';
|
||||
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
|
||||
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
|
||||
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
|
||||
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
|
||||
'<span class="small">' + escapeHtml(check.state || 'unknown') +
|
||||
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
|
||||
}).join('');
|
||||
return { summary, html, expanded: failed > 0 };
|
||||
}
|
||||
|
||||
createReviewController.renderDiffFile = renderDiffFile;
|
||||
createReviewController.createWrapPreference = createWrapPreference;
|
||||
createReviewController.parseDiffLines = parseDiffLines;
|
||||
@@ -364,6 +401,7 @@ createReviewController.createDraft = createDraft;
|
||||
createReviewController.formatFeedback = formatFeedback;
|
||||
createReviewController.copyAndContinue = copyAndContinue;
|
||||
createReviewController.prepareMergeContinuation = prepareMergeContinuation;
|
||||
createReviewController.renderChecks = renderChecks;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createReviewController;
|
||||
|
||||
+31
-11
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v83';
|
||||
const CACHE = 'stackchain-dashboard-shell-v87';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -12,6 +12,7 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
@@ -22,6 +23,7 @@ const SHELL = [
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-issue-close.js',
|
||||
BASE + 'static/offline-issue-blocker.js',
|
||||
BASE + 'static/notification-read-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
@@ -31,6 +33,7 @@ const SHELL = [
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
BASE + 'static/update-reply-read-next.js',
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
@@ -121,20 +124,25 @@ async function notifyIdleSession() {
|
||||
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' }));
|
||||
}
|
||||
|
||||
async function storeOfflineLease(expiresAt) {
|
||||
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
|
||||
async function storeOfflineLease(expiresAt, idleExpiresAt) {
|
||||
if (
|
||||
!Number.isInteger(expiresAt) || expiresAt <= 0
|
||||
|| !Number.isInteger(idleExpiresAt) || idleExpiresAt <= 0
|
||||
) return;
|
||||
const cache = await caches.open(CACHE);
|
||||
await cache.put(OFFLINE_LEASE_URL, new Response(
|
||||
JSON.stringify({ expires_at: expiresAt }),
|
||||
JSON.stringify({ expires_at: expiresAt, idle_expires_at: idleExpiresAt }),
|
||||
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
|
||||
));
|
||||
}
|
||||
|
||||
async function validOfflineLease(cache) {
|
||||
async function offlineLeaseState(cache) {
|
||||
const response = await cache.match(OFFLINE_LEASE_URL);
|
||||
const payload = await response?.json?.().catch(() => ({})) || {};
|
||||
return Number.isInteger(payload.expires_at)
|
||||
&& payload.expires_at > Math.floor(Date.now() / 1000);
|
||||
const current = Math.floor(Date.now() / 1000);
|
||||
if (!Number.isInteger(payload.expires_at) || payload.expires_at <= current) return 'expired';
|
||||
if (!Number.isInteger(payload.idle_expires_at) || payload.idle_expires_at <= current) return 'idle';
|
||||
return 'valid';
|
||||
}
|
||||
|
||||
async function expiredOfflineResponse() {
|
||||
@@ -151,8 +159,18 @@ async function expiredOfflineResponse() {
|
||||
);
|
||||
}
|
||||
|
||||
async function idleOfflineResponse() {
|
||||
await notifyIdleSession();
|
||||
return new Response(
|
||||
'Your Stackchain session is locked. Reconnect and sign in.',
|
||||
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
|
||||
async function cachedShellWithValidLease(cache) {
|
||||
if (!await validOfflineLease(cache)) return expiredOfflineResponse();
|
||||
const state = await offlineLeaseState(cache);
|
||||
if (state === 'expired') return expiredOfflineResponse();
|
||||
if (state === 'idle') return idleOfflineResponse();
|
||||
return cache.match(BASE);
|
||||
}
|
||||
|
||||
@@ -204,11 +222,13 @@ async function flushAndNotify() {
|
||||
!await issueSync.getReceiptPreference?.(result.login)) return;
|
||||
for (const receipt of result.receipts) {
|
||||
const needsAttention = receipt.status === 'attention';
|
||||
const title = needsAttention
|
||||
const needsAuthorization = receipt.status === 'authorization';
|
||||
const title = needsAuthorization ? 'Queued review needs authorization' : 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.',
|
||||
body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' :
|
||||
needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
||||
tag: 'stackchain-delivery-' + receipt.id,
|
||||
data: { route: receipt.route },
|
||||
});
|
||||
@@ -236,7 +256,7 @@ self.addEventListener('message', event => {
|
||||
await flushAndNotify();
|
||||
})());
|
||||
if (event.data?.type === 'stackchain-session-lease') {
|
||||
event.waitUntil(storeOfflineLease(event.data.expiresAt));
|
||||
event.waitUntil(storeOfflineLease(event.data.expiresAt, event.data.idleExpiresAt));
|
||||
}
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
|
||||
+91
-16
@@ -142,13 +142,30 @@
|
||||
}
|
||||
try {
|
||||
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
|
||||
if (!append) {
|
||||
page.authentication_alerts.forEach(alert => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event authentication-alert';
|
||||
const formatted = boundary.formatAuthenticationAlert(alert);
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = formatted.title;
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
const labels = {
|
||||
sign_in: 'Signed in',
|
||||
sign_out: 'Signed out',
|
||||
passkey_enrolled: 'Passkey enrolled',
|
||||
device_revoked: 'Device access revoked',
|
||||
all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed',
|
||||
pull_merged: 'Pull request merged',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested',
|
||||
};
|
||||
page.events.forEach(event => {
|
||||
const row = root.document.createElement('article');
|
||||
@@ -225,6 +242,7 @@
|
||||
}) {
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
let expirationStarted = false;
|
||||
let idleLockStarted = false;
|
||||
let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY;
|
||||
let activityHeartbeat = null;
|
||||
|
||||
@@ -244,8 +262,8 @@
|
||||
}
|
||||
|
||||
function handleSessionIdle() {
|
||||
if (expirationStarted) return;
|
||||
expirationStarted = true;
|
||||
if (expirationStarted || idleLockStarted) return;
|
||||
idleLockStarted = true;
|
||||
location.replace(base + 'login?reason=session-idle');
|
||||
}
|
||||
|
||||
@@ -266,16 +284,32 @@
|
||||
return Number.isInteger(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function storedOfflineIdleLease() {
|
||||
const value = Number(localStorage?.getItem?.('stackchain.session-idle-expires-at'));
|
||||
return Number.isInteger(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function scheduleOfflineExpiry(expiresAt) {
|
||||
const delay = Math.max(0, expiresAt * 1000 - now());
|
||||
setTimer(() => handleSessionExpiry(), delay);
|
||||
}
|
||||
|
||||
async function publishOfflineLease(expiresAt) {
|
||||
function scheduleOfflineIdle(idleExpiresAt) {
|
||||
const delay = Math.max(0, idleExpiresAt * 1000 - now());
|
||||
setTimer(() => {
|
||||
if (storedOfflineIdleLease() * 1000 <= now()) handleSessionIdle();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async function publishOfflineLease(expiresAt, idleExpiresAt) {
|
||||
localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt));
|
||||
localStorage?.setItem?.('stackchain.session-idle-expires-at', String(idleExpiresAt));
|
||||
scheduleOfflineExpiry(expiresAt);
|
||||
scheduleOfflineIdle(idleExpiresAt);
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt });
|
||||
registration?.active?.postMessage({
|
||||
type: 'stackchain-session-lease', expiresAt, idleExpiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
@@ -446,7 +480,10 @@
|
||||
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
|
||||
}
|
||||
}
|
||||
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
|
||||
if (
|
||||
response.status === 401 && isSameOrigin(input)
|
||||
&& !expirationStarted && !idleLockStarted
|
||||
) {
|
||||
expirationStarted = true;
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
if (payload.code === 'session_revoked') {
|
||||
@@ -467,12 +504,21 @@
|
||||
const current = now();
|
||||
if (
|
||||
expirationStarted
|
||||
|| idleLockStarted
|
||||
|| activityHeartbeat
|
||||
|| current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs
|
||||
) return Promise.resolve(false);
|
||||
lastActivityHeartbeatAt = current;
|
||||
activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' })
|
||||
.then(response => response.ok)
|
||||
.then(async response => {
|
||||
if (!response.ok) return false;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const expiresAt = storedOfflineLease();
|
||||
if (expiresAt && Number.isInteger(payload.idle_expires_at)) {
|
||||
await publishOfflineLease(expiresAt, payload.idle_expires_at);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.catch(() => false)
|
||||
.finally(() => { activityHeartbeat = null; });
|
||||
return activityHeartbeat;
|
||||
@@ -489,17 +535,27 @@
|
||||
const response = await sessionFetch(base + 'api/v1/session');
|
||||
if (!response.ok) return false;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!payload.authenticated || !Number.isInteger(payload.expires_at)) return false;
|
||||
await publishOfflineLease(payload.expires_at);
|
||||
if (
|
||||
!payload.authenticated
|
||||
|| !Number.isInteger(payload.expires_at)
|
||||
|| !Number.isInteger(payload.idle_expires_at)
|
||||
) return false;
|
||||
await publishOfflineLease(payload.expires_at, payload.idle_expires_at);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
const expiresAt = storedOfflineLease();
|
||||
if (expiresAt * 1000 > now()) {
|
||||
scheduleOfflineExpiry(expiresAt);
|
||||
return true;
|
||||
if (expiresAt * 1000 <= now()) {
|
||||
if (expiresAt) await handleSessionExpiry();
|
||||
return false;
|
||||
}
|
||||
if (expiresAt) await handleSessionExpiry();
|
||||
return false;
|
||||
const idleExpiresAt = storedOfflineIdleLease();
|
||||
if (!idleExpiresAt || idleExpiresAt * 1000 <= now()) {
|
||||
handleSessionIdle();
|
||||
return false;
|
||||
}
|
||||
scheduleOfflineExpiry(expiresAt);
|
||||
scheduleOfflineIdle(idleExpiresAt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,11 +586,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrivateOutbox() {
|
||||
function deletePrivateDatabase(name) {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
try { request = indexedDB.deleteDatabase(name); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
@@ -547,7 +603,8 @@
|
||||
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
||||
try {
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
} catch (_error) {
|
||||
const error = new Error('Could not clear private queued work from this device.');
|
||||
onClearError(error);
|
||||
@@ -622,6 +679,21 @@
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function formatAuthenticationAlert(alert) {
|
||||
const failed = Math.max(0, Number(alert?.failed_count) || 0);
|
||||
const blocked = Math.max(0, Number(alert?.blocked_count) || 0);
|
||||
const method = alert?.method === 'passkey' ? 'passkey' : 'token';
|
||||
const title = `${failed} failed ${method} sign-in${failed === 1 ? '' : 's'}`
|
||||
+ (blocked ? ` · ${blocked} blocked` : '');
|
||||
const formatTime = value => new Date(Number(value) * 1000).toLocaleString(
|
||||
'en-US', { timeZone: 'UTC' },
|
||||
);
|
||||
return {
|
||||
title,
|
||||
detail: `${formatTime(alert?.first_at)} – ${formatTime(alert?.last_at)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function listSecurityEvents(cursor = null) {
|
||||
const query = new URLSearchParams({ limit: '25' });
|
||||
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
|
||||
@@ -630,6 +702,8 @@
|
||||
const payload = await response.json();
|
||||
return {
|
||||
events: Array.isArray(payload.events) ? payload.events : [],
|
||||
authentication_alerts: Array.isArray(payload.authentication_alerts)
|
||||
? payload.authentication_alerts : [],
|
||||
next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null,
|
||||
};
|
||||
}
|
||||
@@ -679,6 +753,7 @@
|
||||
listActiveDevices,
|
||||
listPasskeys,
|
||||
listSecurityEvents,
|
||||
formatAuthenticationAlert,
|
||||
enrollPasskey,
|
||||
revokeActiveDevice,
|
||||
revokePasskey,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
function createUpdateOwnership({
|
||||
claim, addToday, available, start, recover, onClaimed, onState, onStartState,
|
||||
}) {
|
||||
let detail = null;
|
||||
let update = null;
|
||||
let claimedItem = null;
|
||||
@@ -8,6 +10,36 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
onState?.(value);
|
||||
}
|
||||
|
||||
function startState(value) {
|
||||
onStartState?.(value);
|
||||
}
|
||||
|
||||
function claimed(result) {
|
||||
claimedItem = {
|
||||
...result,
|
||||
repository: detail.repository,
|
||||
kind: 'issue',
|
||||
notification_id: update?.notification_id,
|
||||
updated_at: update?.updated_at || result.updated_at,
|
||||
has_update: true,
|
||||
};
|
||||
detail.issue.claimable = false;
|
||||
onClaimed?.(claimedItem);
|
||||
startState({ action: 'hidden', busy: false, message: '' });
|
||||
return claimedItem;
|
||||
}
|
||||
|
||||
function recoverStartedItem(item) {
|
||||
recover?.(item);
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.',
|
||||
});
|
||||
state({ action: 'today', busy: false, message: '' });
|
||||
return 'recovery';
|
||||
}
|
||||
|
||||
function open(nextDetail, nextUpdate) {
|
||||
detail = nextDetail;
|
||||
update = nextUpdate;
|
||||
@@ -18,6 +50,11 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
busy: false,
|
||||
message: '',
|
||||
});
|
||||
startState({
|
||||
action: nextDetail?.issue?.claimable && available && start ? 'start' : 'hidden',
|
||||
busy: false,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
|
||||
function act() {
|
||||
@@ -40,21 +77,13 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
repository: detail.repository,
|
||||
number: detail.issue.number,
|
||||
})).then(result => {
|
||||
claimedItem = {
|
||||
...result,
|
||||
repository: detail.repository,
|
||||
kind: 'issue',
|
||||
notification_id: update?.notification_id,
|
||||
updated_at: update?.updated_at || result.updated_at,
|
||||
has_update: true,
|
||||
};
|
||||
detail.issue.claimable = false;
|
||||
onClaimed?.(claimedItem);
|
||||
claimed(result);
|
||||
state({ action: 'today', busy: false, message: 'Assigned to you. The update is still unread.' });
|
||||
return claimedItem;
|
||||
}).catch(error => {
|
||||
if (error?.status === 409) {
|
||||
detail.issue.claimable = false;
|
||||
startState({ action: 'hidden', busy: false, message: '' });
|
||||
state({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
@@ -72,7 +101,58 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
return request;
|
||||
}
|
||||
|
||||
return { open, act };
|
||||
function startNow() {
|
||||
if (request) return request;
|
||||
if (!detail?.issue?.claimable || !available || !start) return Promise.resolve('hidden');
|
||||
if (!available()) {
|
||||
const message = 'Today is limited to 5 items. Remove one before taking ownership.';
|
||||
startState({ action: 'start', busy: false, message });
|
||||
return Promise.resolve('full');
|
||||
}
|
||||
startState({ action: 'start', busy: true, message: 'Assigning and starting…' });
|
||||
state({ action: 'claim', busy: true, message: '' });
|
||||
request = Promise.resolve(claim({
|
||||
repository: detail.repository,
|
||||
number: detail.issue.number,
|
||||
})).then(result => {
|
||||
const item = claimed(result);
|
||||
const outcome = start(item);
|
||||
if (outcome === 'started') {
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Assigned, added to Today, and ready to work. The update is still unread.',
|
||||
});
|
||||
state({ action: 'today', busy: false, message: '' });
|
||||
return outcome;
|
||||
}
|
||||
return recoverStartedItem(item);
|
||||
}).catch(error => {
|
||||
if (claimedItem) {
|
||||
return recoverStartedItem(claimedItem);
|
||||
}
|
||||
if (error?.status === 409) {
|
||||
detail.issue.claimable = false;
|
||||
state({ action: 'hidden', busy: false, message: '' });
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Someone else claimed or closed this issue. The update is still unread.',
|
||||
});
|
||||
return 'conflict';
|
||||
}
|
||||
state({ action: 'claim', busy: false, message: '' });
|
||||
startState({
|
||||
action: 'start',
|
||||
busy: false,
|
||||
message: (error?.message || 'The issue could not be assigned.') + ' The update and your reply draft are safe; retry.',
|
||||
});
|
||||
return 'retry';
|
||||
}).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { open, act, start:startNow };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateOwnership;
|
||||
@@ -0,0 +1,64 @@
|
||||
function createUpdateReplyReadNext({
|
||||
post, markRead, queue, canQueue, deliver, accept = () => undefined, next = () => undefined,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
|
||||
async function admit(item, body, operationId, replyConfirmed) {
|
||||
const admission = await queue({
|
||||
kind: 'update-reply-read',
|
||||
notificationId: item.notification_id,
|
||||
body,
|
||||
operationId,
|
||||
replyConfirmed,
|
||||
});
|
||||
if (!admission || (!admission.item && admission.durable !== true)) {
|
||||
throw new Error('Reply and acknowledgement were not saved for delivery.');
|
||||
}
|
||||
accept(item);
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: admission.background ? 'queued' : 'saved',
|
||||
next: await next(item),
|
||||
};
|
||||
}
|
||||
|
||||
function submit(item, body, operationId, attachment = null) {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
let replyConfirmed = false;
|
||||
try {
|
||||
if (attachment) {
|
||||
const admission = await queue({
|
||||
kind:'update-reply-read', notificationId:item.notification_id, body,
|
||||
operationId, replyConfirmed:false, attachment,
|
||||
});
|
||||
if (!admission?.item) throw new Error('Reply and screenshot were not saved for delivery.');
|
||||
const outcome = deliver ? await deliver(admission.item) : null;
|
||||
const remaining = outcome?.remaining?.find(candidate => candidate.id === admission.item.id);
|
||||
if (remaining?.status === 'attention') {
|
||||
throw new Error(remaining.error || 'Reply and screenshot need attention.');
|
||||
}
|
||||
accept(item);
|
||||
return { accepted:true, delivery:outcome?.confirmed?.length ? 'posted' : 'queued', next:await next(item) };
|
||||
}
|
||||
try {
|
||||
await post(item, body, operationId);
|
||||
replyConfirmed = true;
|
||||
await markRead(item.notification_id);
|
||||
} catch (error) {
|
||||
if (!canQueue(error)) throw error;
|
||||
return await admit(item, body, operationId, replyConfirmed);
|
||||
}
|
||||
accept(item);
|
||||
return { accepted: true, delivery: 'posted', next: await next(item) };
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
return { submit, busy: () => Boolean(inFlight) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateReplyReadNext;
|
||||
@@ -30,6 +30,7 @@ class Session:
|
||||
session_id: str
|
||||
csrf: str
|
||||
expires_at: int
|
||||
idle_expires_at: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -174,7 +175,14 @@ def verify_session_with_reason(
|
||||
return SessionVerification(None, "session_idle")
|
||||
if status != "active":
|
||||
return SessionVerification(None, "session_revoked")
|
||||
return SessionVerification(session)
|
||||
return SessionVerification(
|
||||
Session(
|
||||
session_id=session.session_id,
|
||||
csrf=session.csrf,
|
||||
expires_at=session.expires_at,
|
||||
idle_expires_at=getattr(status, "idle_expires_at", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
||||
|
||||
+88
-16
@@ -13,6 +13,21 @@ import rjsmin
|
||||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"issue-capture": ("static/create-issue-sheet.js",),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeBundle:
|
||||
runtime_bytes: bytes
|
||||
runtime_gzip_bytes: bytes
|
||||
runtime_digest: str
|
||||
runtime_name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -22,7 +37,9 @@ class FrontendBuild:
|
||||
runtime_gzip_bytes: bytes
|
||||
runtime_digest: str
|
||||
runtime_name: str
|
||||
shell_digest: str
|
||||
page_sources: tuple[str, ...]
|
||||
feature_bundles: dict[str, RuntimeBundle]
|
||||
service_worker_source: str
|
||||
|
||||
|
||||
@@ -37,6 +54,17 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
||||
return minified + f';"source-sha256:{revision}";'.encode()
|
||||
|
||||
|
||||
def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> RuntimeBundle:
|
||||
content = _bundle(frontend_dir, sources)
|
||||
digest = hashlib.sha256(content).hexdigest()[:16]
|
||||
return RuntimeBundle(
|
||||
runtime_bytes=content,
|
||||
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
|
||||
runtime_digest=digest,
|
||||
runtime_name=f"{prefix}-{digest}.js",
|
||||
)
|
||||
|
||||
|
||||
def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
"""Return one internally consistent HTML, runtime, and offline-worker build."""
|
||||
source_html = (frontend_dir / "index.html").read_text()
|
||||
@@ -44,13 +72,21 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
if not sources:
|
||||
raise ValueError("dashboard entry document has no page scripts")
|
||||
|
||||
runtime = _bundle(frontend_dir, sources)
|
||||
runtime_gzip = gzip.compress(runtime, compresslevel=6, mtime=0)
|
||||
digest = hashlib.sha256(runtime).hexdigest()[:16]
|
||||
runtime_name = f"runtime-{digest}.js"
|
||||
feature_source_names = {source for group in FEATURE_SOURCES.values() for source in group}
|
||||
core_sources = tuple(source for source in sources if source not in feature_source_names)
|
||||
core = _runtime(frontend_dir, core_sources, "runtime")
|
||||
feature_bundles = {
|
||||
name: _runtime(frontend_dir, feature_sources, f"feature-{name}")
|
||||
for name, feature_sources in FEATURE_SOURCES.items()
|
||||
}
|
||||
dashboard_html = SCRIPT_TAG.sub("", source_html)
|
||||
feature_metadata = "\n".join(
|
||||
f'<meta name="stackchain-feature-{name}" content="{bundle.runtime_name}">'
|
||||
for name, bundle in feature_bundles.items()
|
||||
)
|
||||
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>", f'<script src="{runtime_name}"></script>\n</body>'
|
||||
"</body>", f'<script src="{core.runtime_name}"></script>\n</body>'
|
||||
)
|
||||
|
||||
worker = (frontend_dir / "service-worker.js").read_text()
|
||||
@@ -59,21 +95,57 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n",
|
||||
" BASE + 'static/dashboard.css',\n"
|
||||
+ f" BASE + '{core.runtime_name}',\n"
|
||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()),
|
||||
)
|
||||
worker = re.sub(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-v\d+';",
|
||||
f"const CACHE = 'stackchain-dashboard-shell-{digest}';",
|
||||
worker,
|
||||
count=1,
|
||||
worker = CACHE_DECLARATION.sub(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||
)
|
||||
|
||||
shell_hasher = hashlib.sha256()
|
||||
shell_inputs = (
|
||||
("dashboard.html", dashboard_html.encode()),
|
||||
(core.runtime_name, core.runtime_bytes),
|
||||
*((bundle.runtime_name, bundle.runtime_bytes) for bundle in feature_bundles.values()),
|
||||
("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()),
|
||||
("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()),
|
||||
(
|
||||
"static/icons/stackchain-192.png",
|
||||
(frontend_dir / "icons/stackchain-192.png").read_bytes(),
|
||||
),
|
||||
(
|
||||
"static/icons/stackchain-512.png",
|
||||
(frontend_dir / "icons/stackchain-512.png").read_bytes(),
|
||||
),
|
||||
(
|
||||
WORKER_RUNTIME_SOURCE,
|
||||
(
|
||||
frontend_dir / WORKER_RUNTIME_SOURCE.removeprefix("static/")
|
||||
).read_bytes(),
|
||||
),
|
||||
("service-worker.js", worker.encode()),
|
||||
)
|
||||
for name, content in shell_inputs:
|
||||
shell_hasher.update(len(name).to_bytes(4, "big"))
|
||||
shell_hasher.update(name.encode())
|
||||
shell_hasher.update(len(content).to_bytes(8, "big"))
|
||||
shell_hasher.update(content)
|
||||
shell_digest = shell_hasher.hexdigest()[:16]
|
||||
worker = worker.replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';",
|
||||
f"const CACHE = 'stackchain-dashboard-shell-{shell_digest}';",
|
||||
1,
|
||||
)
|
||||
|
||||
return FrontendBuild(
|
||||
dashboard_html=dashboard_html,
|
||||
runtime_bytes=runtime,
|
||||
runtime_gzip_bytes=runtime_gzip,
|
||||
runtime_digest=digest,
|
||||
runtime_name=runtime_name,
|
||||
runtime_bytes=core.runtime_bytes,
|
||||
runtime_gzip_bytes=core.runtime_gzip_bytes,
|
||||
runtime_digest=core.runtime_digest,
|
||||
runtime_name=core.runtime_name,
|
||||
shell_digest=shell_digest,
|
||||
page_sources=sources,
|
||||
feature_bundles=feature_bundles,
|
||||
service_worker_source=worker,
|
||||
)
|
||||
)
|
||||
+269
-1
@@ -4,7 +4,7 @@ import re
|
||||
import shlex
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -169,6 +169,10 @@ class IssueEditConflictError(ValueError):
|
||||
"""Raised when an issue changed after the editor loaded it."""
|
||||
|
||||
|
||||
class IssueDependencyInvalidError(ValueError):
|
||||
"""Raised when a requested blocker relationship is not valid."""
|
||||
|
||||
|
||||
def _auth() -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if GITEA_TOKEN:
|
||||
@@ -600,6 +604,51 @@ def _safe_web_url(value: Any) -> str:
|
||||
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
|
||||
|
||||
|
||||
def _safe_gitea_web_url(value: Any) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return ""
|
||||
resolved = urljoin(f"{GITEA_URL}/", value.strip())
|
||||
parsed = urlsplit(resolved)
|
||||
configured = urlsplit(GITEA_URL)
|
||||
base_path = configured.path.rstrip("/")
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or parsed.scheme != configured.scheme
|
||||
or parsed.netloc != configured.netloc
|
||||
or (base_path and parsed.path != base_path and not parsed.path.startswith(f"{base_path}/"))
|
||||
):
|
||||
return ""
|
||||
return resolved
|
||||
|
||||
|
||||
def _normalize_commit_checks(status: Any) -> list[dict]:
|
||||
entries = status.get("statuses") if isinstance(status, dict) else None
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
rank = {"error": 0, "failure": 0, "pending": 1, "warning": 2, "success": 3}
|
||||
checks = []
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = entry.get("context")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
state = entry.get("status", entry.get("state", "unknown"))
|
||||
state = state.lower() if isinstance(state, str) else "unknown"
|
||||
if state not in rank:
|
||||
state = "unknown"
|
||||
description = entry.get("description")
|
||||
checks.append({
|
||||
"name": name.strip()[:120],
|
||||
"state": state,
|
||||
"description": description.strip()[:240] if isinstance(description, str) else "",
|
||||
"url": _safe_gitea_web_url(entry.get("target_url")),
|
||||
"_index": index,
|
||||
})
|
||||
checks.sort(key=lambda check: (rank.get(check["state"], 2), check["_index"]))
|
||||
return [{key: value for key, value in check.items() if key != "_index"} for check in checks[:20]]
|
||||
|
||||
|
||||
def _normalize_notifications(threads: Any) -> list[dict]:
|
||||
if not isinstance(threads, list):
|
||||
raise ValueError("Gitea notification response was not a list")
|
||||
@@ -686,6 +735,60 @@ async def mark_notification_read(thread_id: int) -> None:
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def acknowledge_notification(thread_id: int) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
raise ValueError("Gitea notification thread response was not an object")
|
||||
repository = thread.get("repository")
|
||||
subject = thread.get("subject")
|
||||
if not isinstance(repository, dict) or not isinstance(subject, dict):
|
||||
raise ValueError("Notification does not identify a conversation")
|
||||
repository_name = repository.get("full_name")
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
comment_path = _gitea_api_path(subject.get("latest_comment_url"))
|
||||
subject_match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
|
||||
)
|
||||
comment_match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/issues/comments/(\d+)", comment_path
|
||||
)
|
||||
if (
|
||||
not subject_match
|
||||
or not comment_match
|
||||
or subject_match.group(1) != repository_name
|
||||
or comment_match.group(1) != repository_name
|
||||
or subject.get("type") not in {"Issue", "Pull"}
|
||||
):
|
||||
raise ValueError("Notification has no supported latest comment")
|
||||
|
||||
user = await current_user()
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("Authenticated Gitea user is unavailable")
|
||||
reaction_path = f"/api/v1/{comment_path}/reactions"
|
||||
response = await _get_client().get(reaction_path, headers=_auth())
|
||||
response.raise_for_status()
|
||||
reactions = response.json()
|
||||
existing = any(
|
||||
isinstance(reaction, dict)
|
||||
and reaction.get("content") == "+1"
|
||||
and isinstance(reaction.get("user"), dict)
|
||||
and reaction["user"].get("login") == login
|
||||
for reaction in (reactions if isinstance(reactions, list) else [])
|
||||
)
|
||||
if not existing:
|
||||
response = await _get_client().post(
|
||||
reaction_path, headers=_auth(), json={"content": "+1"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
await mark_notification_read(thread_id)
|
||||
return {
|
||||
"id": thread_id,
|
||||
"reaction": "existing" if existing else "created",
|
||||
"status": "read",
|
||||
}
|
||||
|
||||
|
||||
def _gitea_api_path(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
@@ -714,6 +817,9 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
conversation_match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
|
||||
)
|
||||
comment_match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/issues/comments/(\d+)", comment_path
|
||||
)
|
||||
repository_name = repository.get("full_name")
|
||||
supported_conversation = (
|
||||
conversation_match
|
||||
@@ -789,6 +895,11 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
"url": latest_url,
|
||||
},
|
||||
"issue": issue,
|
||||
"acknowledge_supported": bool(
|
||||
supported_conversation
|
||||
and comment_match
|
||||
and comment_match.group(1) == repository_name
|
||||
),
|
||||
"conversation": conversation,
|
||||
}
|
||||
|
||||
@@ -847,6 +958,43 @@ async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def upload_notification_attachment(
|
||||
thread_id: int, filename: str, content_type: str, content: bytes
|
||||
) -> dict:
|
||||
"""Upload to the exact issue or pull identified by a trusted notification."""
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
raise ValueError("Gitea notification thread response was not an object")
|
||||
repository = thread.get("repository")
|
||||
subject = thread.get("subject")
|
||||
if not isinstance(repository, dict) or not isinstance(subject, dict):
|
||||
raise ValueError("Notification does not identify a conversation")
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path)
|
||||
if (
|
||||
not match
|
||||
or match.group(1) != repository.get("full_name")
|
||||
or subject.get("type") not in {"Issue", "Pull"}
|
||||
):
|
||||
raise ValueError("Notification subject is not a supported conversation")
|
||||
# Gitea stores pull-request assets on its shared issue asset endpoint.
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{match.group(1)}/issues/{match.group(3)}/assets",
|
||||
headers=_auth(), params={"name": filename},
|
||||
files={"attachment": (filename, content, content_type)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
attachment = response.json()
|
||||
if not isinstance(attachment, dict):
|
||||
raise ValueError("Gitea attachment response was not an object")
|
||||
name = attachment.get("name")
|
||||
url = _safe_web_url(attachment.get("browser_download_url"))
|
||||
size = attachment.get("size")
|
||||
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
|
||||
raise ValueError("Gitea did not confirm the attachment")
|
||||
return {"name": name, "url": url, "size": size}
|
||||
|
||||
|
||||
async def close_issue(repository: str, number: int) -> dict:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
@@ -920,6 +1068,33 @@ async def upload_assigned_issue_attachment(
|
||||
return {"name": name, "url": url, "size": size}
|
||||
|
||||
|
||||
async def upload_assigned_pull_attachment(
|
||||
repository: str,
|
||||
number: int,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
content: bytes,
|
||||
) -> dict:
|
||||
if not await is_assigned_pull(repository, number):
|
||||
raise IssueNotAvailableError("Assigned pull request not found")
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/assets",
|
||||
headers=_auth(),
|
||||
params={"name": filename},
|
||||
files={"attachment": (filename, content, content_type)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
attachment = response.json()
|
||||
if not isinstance(attachment, dict):
|
||||
raise ValueError("Gitea attachment response was not an object")
|
||||
name = attachment.get("name")
|
||||
url = _safe_web_url(attachment.get("browser_download_url"))
|
||||
size = attachment.get("size")
|
||||
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
|
||||
raise ValueError("Gitea did not confirm the attachment")
|
||||
return {"name": name, "url": url, "size": size}
|
||||
|
||||
|
||||
async def repo_labels(repository: str) -> list[dict]:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/labels",
|
||||
@@ -1500,6 +1675,73 @@ async def issue_dependencies(repository: str, number: int, limit: int = 20) -> l
|
||||
return dependencies
|
||||
|
||||
|
||||
async def mutate_assigned_issue_dependency(
|
||||
repository: str,
|
||||
number: int,
|
||||
blocker_repository: str,
|
||||
blocker_number: int,
|
||||
remove: bool = False,
|
||||
) -> dict:
|
||||
"""Add or remove one prerequisite and return the canonical open dependency set."""
|
||||
if (repository, number) == (blocker_repository, blocker_number):
|
||||
raise IssueDependencyInvalidError("an issue cannot block itself")
|
||||
|
||||
login, source = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
if (
|
||||
source.get("state") != "open"
|
||||
or isinstance(source.get("pull_request"), dict)
|
||||
or not _login_in_users(login, source.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
|
||||
current = await issue_dependencies(repository, number)
|
||||
currently_present = any(
|
||||
item["repository"] == blocker_repository and item["number"] == blocker_number
|
||||
for item in current
|
||||
)
|
||||
if currently_present != remove:
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"dependencies_available": True,
|
||||
"dependencies": current,
|
||||
}
|
||||
|
||||
if not remove:
|
||||
candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}")
|
||||
if (
|
||||
not isinstance(candidate, dict)
|
||||
or candidate.get("number") != blocker_number
|
||||
or candidate.get("state") != "open"
|
||||
or isinstance(candidate.get("pull_request"), dict)
|
||||
):
|
||||
raise IssueDependencyInvalidError("blocker must be an accessible open issue")
|
||||
owner, repo = blocker_repository.split("/", 1)
|
||||
response = await _get_client().request(
|
||||
"DELETE" if remove else "POST",
|
||||
f"/api/v1/repos/{repository}/issues/{number}/dependencies",
|
||||
headers=_auth(),
|
||||
json={"owner": owner, "repo": repo, "index": blocker_number},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
confirmed = await issue_dependencies(repository, number)
|
||||
present = any(
|
||||
item["repository"] == blocker_repository and item["number"] == blocker_number
|
||||
for item in confirmed
|
||||
)
|
||||
if present == remove:
|
||||
raise ValueError("Gitea did not confirm the dependency change")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"dependencies_available": True,
|
||||
"dependencies": confirmed,
|
||||
}
|
||||
|
||||
|
||||
async def update_assigned_issue(
|
||||
repository: str,
|
||||
number: int,
|
||||
@@ -1714,6 +1956,7 @@ async def pull_completion_review(repository: str, number: int) -> dict:
|
||||
"mergeable": pull.get("mergeable") is True,
|
||||
"merged": pull.get("merged") is True,
|
||||
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
|
||||
"checks": _normalize_commit_checks(status),
|
||||
"files": [
|
||||
{
|
||||
"filename": item.get("filename", ""),
|
||||
@@ -1736,6 +1979,30 @@ async def pull_completion_review(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def pull_check_status(repository: str, number: int) -> dict:
|
||||
"""Load only mutable pull and CI state, without immutable review data."""
|
||||
base = f"repos/{repository}/pulls/{number}"
|
||||
pull = await fetch(base)
|
||||
if not isinstance(pull, dict):
|
||||
raise ValueError("Gitea pull request response was not an object")
|
||||
head_value = pull.get("head")
|
||||
head: dict = head_value if isinstance(head_value, dict) else {}
|
||||
sha_value = head.get("sha")
|
||||
sha = sha_value if isinstance(sha_value, str) else ""
|
||||
status = await fetch(f"repos/{repository}/commits/{sha}/status")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"head_sha": sha,
|
||||
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
|
||||
"draft": pull.get("draft") is True,
|
||||
"mergeable": pull.get("mergeable") is True,
|
||||
"merged": pull.get("merged") is True,
|
||||
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
|
||||
"checks": _normalize_commit_checks(status),
|
||||
}
|
||||
|
||||
|
||||
async def is_pull_merged_at_head(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> bool:
|
||||
@@ -1846,6 +2113,7 @@ async def pull_review_detail(repository: str, number: int) -> dict:
|
||||
"author": user.get("login", ""),
|
||||
"head_sha": sha,
|
||||
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
|
||||
"checks": _normalize_commit_checks(status),
|
||||
"files": normalized_files,
|
||||
"reviews": normalized_reviews,
|
||||
}
|
||||
|
||||
@@ -31,6 +31,19 @@ class LiveSnapshotState:
|
||||
lease_expires_at: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveSnapshotMetadata:
|
||||
"""Shared snapshot coordination fields without the potentially large value."""
|
||||
|
||||
created_at: dict[str, float | None]
|
||||
failure_count: dict[str, int]
|
||||
retry_at: dict[str, float | None]
|
||||
revisions: dict[str, int]
|
||||
generation: str
|
||||
refreshing_sections: set[str]
|
||||
lease_expires_at: float | None
|
||||
|
||||
|
||||
class LiveSnapshotStore:
|
||||
"""A private SQLite snapshot with an expiring, cross-process refresh lease."""
|
||||
|
||||
@@ -145,6 +158,30 @@ class LiveSnapshotStore:
|
||||
lease_expires_at=lease["expires_at"] if lease else None,
|
||||
)
|
||||
|
||||
def load_metadata(self) -> LiveSnapshotMetadata:
|
||||
"""Load freshness and coordination state without reading the snapshot value."""
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT created_at_json, failure_count_json, retry_at_json,
|
||||
revisions_json, generation FROM live_snapshot WHERE singleton = 1"""
|
||||
).fetchone()
|
||||
lease = connection.execute(
|
||||
"SELECT sections_json, expires_at FROM live_refresh_lease "
|
||||
"WHERE singleton = 1 AND expires_at > ?",
|
||||
(now,),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
return LiveSnapshotMetadata(
|
||||
created_at=json.loads(row["created_at_json"]),
|
||||
failure_count=json.loads(row["failure_count_json"]),
|
||||
retry_at=json.loads(row["retry_at_json"]),
|
||||
revisions=json.loads(row["revisions_json"]),
|
||||
generation=row["generation"],
|
||||
refreshing_sections=set(json.loads(lease["sections_json"])) if lease else set(),
|
||||
lease_expires_at=lease["expires_at"] if lease else None,
|
||||
)
|
||||
|
||||
def publish_refresh(
|
||||
self,
|
||||
owner: str,
|
||||
|
||||
+146
-1
@@ -49,6 +49,9 @@ class LoginAttemptStore:
|
||||
window_seconds: int,
|
||||
max_entries: int = 10_000,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
alert_bucket_seconds: int = 60 * 60,
|
||||
alert_retention_seconds: int = 30 * 24 * 60 * 60,
|
||||
max_alert_buckets: int = 720,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
@@ -56,6 +59,9 @@ class LoginAttemptStore:
|
||||
self.window_seconds = max(1, window_seconds)
|
||||
self.max_entries = max(1, max_entries)
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
self.alert_bucket_seconds = max(1, alert_bucket_seconds)
|
||||
self.alert_retention_seconds = max(1, alert_retention_seconds)
|
||||
self.max_alert_buckets = max(1, max_alert_buckets)
|
||||
|
||||
@staticmethod
|
||||
def _digest(source: str) -> str:
|
||||
@@ -74,6 +80,30 @@ class LoginAttemptStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_admissions (
|
||||
bucket TEXT NOT NULL,
|
||||
source_hash TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL,
|
||||
window_started_at REAL NOT NULL,
|
||||
PRIMARY KEY (bucket, source_hash)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_alerts (
|
||||
method TEXT NOT NULL,
|
||||
bucket_started_at INTEGER NOT NULL,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
blocked_count INTEGER NOT NULL DEFAULT 0,
|
||||
first_at INTEGER NOT NULL,
|
||||
last_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (method, bucket_started_at)
|
||||
)
|
||||
"""
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
@@ -97,7 +127,81 @@ class LoginAttemptStore:
|
||||
remaining = row[1] + self.window_seconds - now
|
||||
return max(0, math.ceil(remaining))
|
||||
|
||||
def record_failure(self, source: str) -> None:
|
||||
def admit(self, bucket: str, source: str, *, limit: int) -> int:
|
||||
"""Consume one fixed-window admission or return seconds until retry."""
|
||||
now = self.clock()
|
||||
source_hash = self._digest(source)
|
||||
limit = max(1, limit)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"DELETE FROM login_admissions WHERE window_started_at + ? <= ?",
|
||||
(self.window_seconds, now),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT attempts, window_started_at FROM login_admissions "
|
||||
"WHERE bucket = ? AND source_hash = ?",
|
||||
(bucket, source_hash),
|
||||
).fetchone()
|
||||
if row is not None and row[0] >= limit:
|
||||
return max(1, math.ceil(row[1] + self.window_seconds - now))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO login_admissions VALUES (?, ?, 1, ?)",
|
||||
(bucket, source_hash, now),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE login_admissions SET attempts = attempts + 1 "
|
||||
"WHERE bucket = ? AND source_hash = ?",
|
||||
(bucket, source_hash),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM login_admissions
|
||||
WHERE (bucket, source_hash) NOT IN (
|
||||
SELECT bucket, source_hash FROM login_admissions
|
||||
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
|
||||
)
|
||||
""",
|
||||
(self.max_entries,),
|
||||
)
|
||||
return 0
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def _record_alert(
|
||||
self, connection: sqlite3.Connection, method: str, *, blocked: bool, now: float
|
||||
) -> None:
|
||||
safe_method = method if method in {"token", "passkey"} else "unknown"
|
||||
occurred_at = int(now)
|
||||
bucket = occurred_at - (occurred_at % self.alert_bucket_seconds)
|
||||
failed = 0 if blocked else 1
|
||||
blocked_count = 1 if blocked else 0
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO login_alerts(
|
||||
method, bucket_started_at, failed_count, blocked_count, first_at, last_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(method, bucket_started_at) DO UPDATE SET
|
||||
failed_count = failed_count + excluded.failed_count,
|
||||
blocked_count = blocked_count + excluded.blocked_count,
|
||||
first_at = MIN(first_at, excluded.first_at),
|
||||
last_at = MAX(last_at, excluded.last_at)
|
||||
""",
|
||||
(safe_method, bucket, failed, blocked_count, occurred_at, occurred_at),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM login_alerts WHERE (method, bucket_started_at) NOT IN "
|
||||
"(SELECT method, bucket_started_at FROM login_alerts "
|
||||
"ORDER BY bucket_started_at DESC LIMIT ?)",
|
||||
(self.max_alert_buckets,),
|
||||
)
|
||||
|
||||
def record_failure(self, source: str, *, method: str = "token") -> None:
|
||||
now = self.clock()
|
||||
source_hash = self._digest(source)
|
||||
try:
|
||||
@@ -131,11 +235,52 @@ class LoginAttemptStore:
|
||||
""",
|
||||
(self.max_entries,),
|
||||
)
|
||||
self._record_alert(connection, method, blocked=False, now=now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def record_blocked(self, *, method: str = "token") -> None:
|
||||
now = self.clock()
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._record_alert(connection, method, blocked=True, now=now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def list_alerts(self, *, limit: int = 24) -> list[dict[str, int | str]]:
|
||||
bounded_limit = min(100, max(1, limit))
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM login_alerts WHERE last_at < ?",
|
||||
(now - self.alert_retention_seconds,),
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT method, failed_count, blocked_count, first_at, last_at "
|
||||
"FROM login_alerts ORDER BY bucket_started_at DESC LIMIT ?",
|
||||
(bounded_limit,),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
return [
|
||||
{
|
||||
"method": row[0],
|
||||
"failed_count": row[1],
|
||||
"blocked_count": row[2],
|
||||
"first_at": row[3],
|
||||
"last_at": row[4],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def clear(self, source: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
|
||||
+522
-25
@@ -39,7 +39,12 @@ from src.gitea_proxy import (
|
||||
)
|
||||
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
|
||||
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
|
||||
from src.live_snapshot_store import LiveSnapshotState, LiveSnapshotStore, RefreshLeaseLost
|
||||
from src.live_snapshot_store import (
|
||||
LiveSnapshotMetadata,
|
||||
LiveSnapshotState,
|
||||
LiveSnapshotStore,
|
||||
RefreshLeaseLost,
|
||||
)
|
||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||
from src.passkey_store import PasskeyStore
|
||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
@@ -200,16 +205,20 @@ class DashboardSignIn(BaseModel):
|
||||
return normalized
|
||||
|
||||
|
||||
StepUpAction = Literal[
|
||||
"merge_pull",
|
||||
"submit_pull_review",
|
||||
"close_issue",
|
||||
"revoke_device",
|
||||
"revoke_all_sessions",
|
||||
"enroll_passkey",
|
||||
"revoke_passkey",
|
||||
]
|
||||
|
||||
|
||||
class FreshAuthorization(BaseModel):
|
||||
access_token: str = Field(min_length=1, max_length=1_024)
|
||||
action: Literal[
|
||||
"merge_pull",
|
||||
"close_issue",
|
||||
"revoke_device",
|
||||
"revoke_all_sessions",
|
||||
"enroll_passkey",
|
||||
"revoke_passkey",
|
||||
]
|
||||
action: StepUpAction
|
||||
target: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
@@ -227,9 +236,7 @@ class PasskeyAuthentication(PasskeyCeremony):
|
||||
|
||||
|
||||
class PasskeyAuthorizationTarget(BaseModel):
|
||||
action: Literal[
|
||||
"merge_pull", "close_issue", "revoke_device", "revoke_all_sessions", "revoke_passkey"
|
||||
]
|
||||
action: StepUpAction
|
||||
target: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
@@ -242,7 +249,14 @@ def _passkey_store() -> PasskeyStore:
|
||||
database = os.getenv(
|
||||
"STACKCHAIN_SESSION_DB", os.path.join(state_dir, "sessions.sqlite3")
|
||||
)
|
||||
return PasskeyStore(database, clock=time.time)
|
||||
return PasskeyStore(
|
||||
database,
|
||||
clock=time.time,
|
||||
max_challenges=int(os.getenv("STACKCHAIN_PASSKEY_MAX_CHALLENGES", "10000")),
|
||||
max_challenges_per_source=int(
|
||||
os.getenv("STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE", "10")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _security_event_store() -> SecurityEventStore:
|
||||
@@ -495,6 +509,19 @@ class IssueHandoff(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class IssueBlockerUpdate(BaseModel):
|
||||
repository: str = Field(
|
||||
min_length=3,
|
||||
max_length=255,
|
||||
pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
|
||||
)
|
||||
number: PositiveInt
|
||||
|
||||
|
||||
class IssueBlockerDesiredState(IssueBlockerUpdate):
|
||||
present: bool
|
||||
|
||||
|
||||
class PullReviewComment(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
@@ -920,6 +947,14 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if retry_after:
|
||||
try:
|
||||
await asyncio.to_thread(attempts.record_blocked, method="token")
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return JSONResponse(
|
||||
{"detail": "Too many sign-in attempts"},
|
||||
status_code=429,
|
||||
@@ -1007,7 +1042,10 @@ async def list_security_events(
|
||||
page = await asyncio.to_thread(
|
||||
_security_event_store().list, limit=limit, cursor=cursor
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
authentication_alerts = await asyncio.to_thread(
|
||||
_login_attempt_store().list_alerts, limit=24
|
||||
)
|
||||
except (SecurityEventStoreError, LoginAttemptStoreError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Security activity is temporarily unavailable"
|
||||
)
|
||||
@@ -1025,6 +1063,7 @@ async def list_security_events(
|
||||
}
|
||||
for event in page.events
|
||||
],
|
||||
"authentication_alerts": authentication_alerts,
|
||||
"next_cursor": page.next_cursor,
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
@@ -1156,6 +1195,24 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
|
||||
)
|
||||
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
|
||||
current = next(device for device in devices if device.current)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail="Passkey verification failed") from exc
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"passkey_enrolled",
|
||||
method="passkey",
|
||||
device_label=current.device_label,
|
||||
target="passkey",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Security activity is temporarily unavailable"
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
store.register,
|
||||
credential_id=verified.credential_id,
|
||||
@@ -1165,9 +1222,17 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
|
||||
management_id=current.management_id,
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail="Passkey verification failed") from exc
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Passkey registry is temporarily unavailable"
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return JSONResponse(
|
||||
{"enrolled": True}, status_code=201, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
@@ -1293,6 +1358,34 @@ async def create_passkey_authentication_options(request: Request):
|
||||
credentials = await asyncio.to_thread(store.all)
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=404, detail="No passkeys enrolled")
|
||||
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 = await asyncio.to_thread(attempts.retry_after, source)
|
||||
if not retry_after:
|
||||
retry_after = await asyncio.to_thread(
|
||||
attempts.admit,
|
||||
"passkey_options",
|
||||
source,
|
||||
limit=int(os.getenv("STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS", "10")),
|
||||
)
|
||||
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 passkey sign-in attempts"},
|
||||
status_code=429,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": str(retry_after)},
|
||||
)
|
||||
rp_id, _origin = _passkey_relying_party(request)
|
||||
options, challenge = passkeys.authentication_options(
|
||||
rp_id=rp_id,
|
||||
@@ -1305,6 +1398,7 @@ async def create_passkey_authentication_options(request: Request):
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
source=source,
|
||||
)
|
||||
return JSONResponse(options, headers={"Cache-Control": "no-store"})
|
||||
|
||||
@@ -1391,10 +1485,39 @@ async def verify_passkey_authentication(
|
||||
):
|
||||
if payload.action != "sign_in" or payload.target != "dashboard":
|
||||
raise HTTPException(status_code=400, detail="Invalid passkey sign-in target")
|
||||
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 = await asyncio.to_thread(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 passkey sign-in attempts"},
|
||||
status_code=429,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": str(retry_after)},
|
||||
)
|
||||
try:
|
||||
challenge = passkeys.decode(payload.challenge)
|
||||
credential_id = passkeys.decode(str(payload.credential.get("id", "")))
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
await asyncio.to_thread(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=400, detail="Invalid passkey ceremony")
|
||||
store = _passkey_store()
|
||||
valid = await asyncio.to_thread(
|
||||
@@ -1407,6 +1530,14 @@ async def verify_passkey_authentication(
|
||||
)
|
||||
stored = await asyncio.to_thread(store.get, credential_id)
|
||||
if not valid or stored is None:
|
||||
try:
|
||||
await asyncio.to_thread(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="Passkey sign-in failed")
|
||||
rp_id, origin = _passkey_relying_party(request)
|
||||
try:
|
||||
@@ -1432,7 +1563,24 @@ async def verify_passkey_authentication(
|
||||
except dashboard_auth.SessionStoreError:
|
||||
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
|
||||
except Exception as exc:
|
||||
try:
|
||||
await asyncio.to_thread(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="Passkey sign-in failed") from exc
|
||||
try:
|
||||
await asyncio.to_thread(attempts.clear, source)
|
||||
except LoginAttemptStoreError:
|
||||
await asyncio.to_thread(dashboard_auth.revoke_session, session)
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
@@ -1479,6 +1627,7 @@ async def session_status(request: Request):
|
||||
}
|
||||
if session is not None:
|
||||
payload["expires_at"] = session.expires_at
|
||||
payload["idle_expires_at"] = session.idle_expires_at
|
||||
return payload
|
||||
|
||||
|
||||
@@ -1501,7 +1650,10 @@ async def record_session_activity(request: Request):
|
||||
)
|
||||
return {
|
||||
"active": True,
|
||||
"idle_expires_at": int(time.time()) + dashboard_auth.idle_timeout_seconds(),
|
||||
"idle_expires_at": min(
|
||||
session.expires_at,
|
||||
int(time.time()) + dashboard_auth.idle_timeout_seconds(),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -2343,6 +2495,35 @@ def _apply_shared_live_state(state: LiveSnapshotState) -> None:
|
||||
_live_snapshot_created_at = max(successful) if successful else None
|
||||
|
||||
|
||||
def _apply_shared_live_metadata(state: LiveSnapshotMetadata) -> None:
|
||||
"""Update coordination state while retaining the matching local snapshot value."""
|
||||
global _live_snapshot_created_at
|
||||
global _live_section_created_at, _live_section_failure_count, _live_section_retry_at
|
||||
global _live_section_revisions, _live_revision_generation
|
||||
global _live_snapshot_refreshing_sections
|
||||
_live_section_created_at = state.created_at
|
||||
_live_section_failure_count = state.failure_count
|
||||
_live_section_retry_at = state.retry_at
|
||||
_live_section_revisions = state.revisions
|
||||
_live_revision_generation = state.generation
|
||||
_live_snapshot_refreshing_sections = state.refreshing_sections
|
||||
successful = [value for value in state.created_at.values() if value is not None]
|
||||
_live_snapshot_created_at = max(successful) if successful else None
|
||||
|
||||
|
||||
async def _sync_shared_live_state() -> None:
|
||||
"""Load the large shared value only when this worker's copy is out of date."""
|
||||
metadata = await asyncio.to_thread(_live_snapshot_store.load_metadata)
|
||||
if (
|
||||
_live_snapshot_value is None
|
||||
or metadata.generation != _live_revision_generation
|
||||
or metadata.revisions != _live_section_revisions
|
||||
):
|
||||
_apply_shared_live_state(await asyncio.to_thread(_live_snapshot_store.load))
|
||||
else:
|
||||
_apply_shared_live_metadata(metadata)
|
||||
|
||||
|
||||
def _merge_live_snapshot(previous: dict | None, refreshed: dict) -> dict:
|
||||
if previous is None:
|
||||
return refreshed
|
||||
@@ -2575,7 +2756,7 @@ async def _live_snapshot_response(
|
||||
"events": events_revision,
|
||||
"notifications": notifications_revision,
|
||||
}
|
||||
_apply_shared_live_state(await asyncio.to_thread(_live_snapshot_store.load))
|
||||
await _sync_shared_live_state()
|
||||
now = _live_snapshot_clock()
|
||||
due_sections = _due_live_sections(now)
|
||||
if _live_snapshot_value is not None and not due_sections:
|
||||
@@ -2897,6 +3078,34 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
return JSONResponse({"id": thread_id, "status": "read"})
|
||||
|
||||
|
||||
@app.post("/api/v1/notifications/{thread_id}/acknowledge")
|
||||
async def acknowledge_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.acknowledge_notification(thread_id),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Acknowledging the update timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"error": "This update has no comment that can be acknowledged."},
|
||||
status_code=422,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The update could not be acknowledged. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
await _remove_notifications_from_live_snapshot([thread_id])
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.post("/api/v1/notifications/{thread_id}/reply", status_code=201)
|
||||
async def reply_to_notification(
|
||||
reply: NotificationReply,
|
||||
@@ -2925,6 +3134,60 @@ async def reply_to_notification(
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.post("/api/v1/notifications/{thread_id}/attachments", status_code=201)
|
||||
async def attach_to_notification(
|
||||
request: Request,
|
||||
thread_id: int = PathParam(gt=0),
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
if request.headers.get("content-type", "").lower().startswith("multipart/form-data"):
|
||||
form = await request.form()
|
||||
uploaded = form.get("file")
|
||||
if not isinstance(uploaded, UploadFile):
|
||||
raise ValueError("screenshot file is required")
|
||||
filename = str(uploaded.filename or "")
|
||||
content_type = str(uploaded.content_type or "")
|
||||
content = _validate_binary_attachment(filename, content_type, await uploaded.read())
|
||||
else:
|
||||
attachment = IssueAttachment.model_validate(await request.json())
|
||||
filename = attachment.filename
|
||||
content_type = attachment.content_type
|
||||
content = attachment.content()
|
||||
except (ValueError, ValidationError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
async def upload_attachment():
|
||||
result = await gitea_proxy.upload_notification_attachment(
|
||||
thread_id, filename, content_type, content
|
||||
)
|
||||
safe_name = (
|
||||
result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
|
||||
.replace("\r", " ").replace("\n", " ")
|
||||
)
|
||||
safe_url = result["url"].replace("<", "%3C").replace(">", "%3E")
|
||||
result["markdown"] = f""
|
||||
return result
|
||||
|
||||
try:
|
||||
result = await _run_idempotent_authored_action(
|
||||
upload_attachment(), idempotency_key=idempotency_key,
|
||||
fingerprint=("notification-attachment", thread_id, filename, content_type,
|
||||
hashlib.sha256(content).hexdigest()),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The screenshot could not be uploaded. Your draft is safe; please retry."},
|
||||
status_code=503, headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/claim")
|
||||
async def claim_available_issue(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
@@ -3093,6 +3356,65 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g
|
||||
)
|
||||
|
||||
|
||||
async def _mutate_assigned_issue_blocker(
|
||||
update: IssueBlockerUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
remove: bool,
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.mutate_assigned_issue_dependency(
|
||||
f"{owner}/{repo}", number, update.repository, update.number, remove
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
except gitea_proxy.IssueDependencyInvalidError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=422)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The blocker change could not be confirmed. Reload the issue before retrying."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers")
|
||||
async def add_assigned_issue_blocker(
|
||||
update: IssueBlockerUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
return await _mutate_assigned_issue_blocker(update, owner, repo, number, False)
|
||||
|
||||
|
||||
@app.delete("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers")
|
||||
async def remove_assigned_issue_blocker(
|
||||
update: IssueBlockerUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
return await _mutate_assigned_issue_blocker(update, owner, repo, number, True)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers")
|
||||
async def set_assigned_issue_blocker_state(
|
||||
update: IssueBlockerDesiredState,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
return await _mutate_assigned_issue_blocker(
|
||||
update, owner, repo, number, not update.present
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content")
|
||||
async def update_assigned_issue_content(
|
||||
update: IssueContentUpdate,
|
||||
@@ -3440,6 +3762,67 @@ async def attach_to_assigned_issue(
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/attachments", status_code=201)
|
||||
async def attach_to_assigned_pull(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
if request.headers.get("content-type", "").lower().startswith("multipart/form-data"):
|
||||
form = await request.form()
|
||||
uploaded = form.get("file")
|
||||
if not isinstance(uploaded, UploadFile):
|
||||
raise ValueError("screenshot file is required")
|
||||
filename = str(uploaded.filename or "")
|
||||
content_type = str(uploaded.content_type or "")
|
||||
content = _validate_binary_attachment(filename, content_type, await uploaded.read())
|
||||
else:
|
||||
attachment = IssueAttachment.model_validate(await request.json())
|
||||
filename = attachment.filename
|
||||
content_type = attachment.content_type
|
||||
content = attachment.content()
|
||||
except (ValueError, ValidationError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
async def upload_attachment():
|
||||
result = await gitea_proxy.upload_assigned_pull_attachment(
|
||||
repository, number, filename, content_type, content
|
||||
)
|
||||
safe_name = (
|
||||
result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
|
||||
.replace("\r", " ").replace("\n", " ")
|
||||
)
|
||||
safe_url = result["url"].replace("<", "%3C").replace(">", "%3E")
|
||||
result["markdown"] = f""
|
||||
return result
|
||||
|
||||
try:
|
||||
result = await _run_idempotent_authored_action(
|
||||
upload_attachment(),
|
||||
idempotency_key=idempotency_key,
|
||||
fingerprint=(
|
||||
"pull-attachment", repository, number, filename, content_type,
|
||||
hashlib.sha256(content).hexdigest(),
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError as exc:
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found") from exc
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The screenshot could not be uploaded. Your draft is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/close")
|
||||
async def close_assigned_issue(
|
||||
request: Request,
|
||||
@@ -3595,6 +3978,37 @@ async def review_detail(owner: str, repo: str, number: int):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review/checks")
|
||||
async def requested_review_checks(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_checks():
|
||||
if not await is_requested_review(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Review request not found")
|
||||
return await gitea_proxy.pull_check_status(repository, number)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
load_checks(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Refreshing review checks timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Review checks are temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/detail")
|
||||
async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
|
||||
repository = f"{owner}/{repo}"
|
||||
@@ -3655,6 +4069,37 @@ async def assigned_pull_review_data(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/checks")
|
||||
async def assigned_pull_checks(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_checks():
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned pull request not found")
|
||||
return await gitea_proxy.pull_check_status(repository, number)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
load_checks(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Refreshing pull request checks timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Pull request checks are temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments")
|
||||
async def assigned_pull_conversation(
|
||||
owner: str,
|
||||
@@ -3793,12 +4238,26 @@ async def merge_assigned_pull(
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
|
||||
async def submit_review(
|
||||
submission: PullReviewSubmission,
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if submission.decision != "comment":
|
||||
await _require_step_up(
|
||||
request,
|
||||
step_up_grant,
|
||||
action="submit_pull_review",
|
||||
target=(
|
||||
f"{repository}#{number}@{submission.expected_head_sha}:"
|
||||
f"{submission.decision}"
|
||||
),
|
||||
)
|
||||
|
||||
async def submit_requested_review():
|
||||
if not await is_requested_review(repository, number):
|
||||
@@ -3810,12 +4269,50 @@ async def submit_review(
|
||||
submission.decision,
|
||||
submission.body,
|
||||
)
|
||||
if submission.comments:
|
||||
return await gitea_proxy.submit_pull_review(
|
||||
*args,
|
||||
[comment.model_dump() for comment in submission.comments],
|
||||
)
|
||||
return await gitea_proxy.submit_pull_review(*args)
|
||||
journal = None
|
||||
operation_id = None
|
||||
if submission.decision != "comment":
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
(
|
||||
"pull_review_approved"
|
||||
if submission.decision == "approve"
|
||||
else "pull_review_changes_requested"
|
||||
),
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Security activity is temporarily unavailable",
|
||||
)
|
||||
try:
|
||||
if submission.comments:
|
||||
result = await gitea_proxy.submit_pull_review(
|
||||
*args,
|
||||
[comment.model_dump() for comment in submission.comments],
|
||||
)
|
||||
else:
|
||||
result = await gitea_proxy.submit_pull_review(*args)
|
||||
except (
|
||||
HTTPException,
|
||||
gitea_proxy.StaleReviewError,
|
||||
gitea_proxy.InvalidReviewCommentError,
|
||||
):
|
||||
if journal is not None and operation_id is not None:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
raise
|
||||
if journal is not None and operation_id is not None:
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return result
|
||||
|
||||
try:
|
||||
comment_fingerprint = tuple(
|
||||
|
||||
+46
-3
@@ -20,9 +20,18 @@ class StoredPasskey:
|
||||
|
||||
|
||||
class PasskeyStore:
|
||||
def __init__(self, path: str | Path, *, clock: Callable[[], float]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
clock: Callable[[], float],
|
||||
max_challenges: int = 10_000,
|
||||
max_challenges_per_source: int = 10,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self.max_challenges = max(1, max_challenges)
|
||||
self.max_challenges_per_source = max(1, max_challenges_per_source)
|
||||
|
||||
@staticmethod
|
||||
def _digest(value: bytes | str) -> str:
|
||||
@@ -50,6 +59,7 @@ class PasskeyStore:
|
||||
CREATE TABLE IF NOT EXISTS passkey_challenges (
|
||||
challenge_hash TEXT PRIMARY KEY,
|
||||
session_hash TEXT,
|
||||
source_hash TEXT,
|
||||
purpose TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
@@ -57,6 +67,14 @@ class PasskeyStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(passkey_challenges)")
|
||||
}
|
||||
if "source_hash" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE passkey_challenges ADD COLUMN source_hash TEXT"
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
@@ -69,25 +87,50 @@ class PasskeyStore:
|
||||
purpose: str,
|
||||
action: str,
|
||||
target: str,
|
||||
source: str | None = None,
|
||||
ttl_seconds: int = 120,
|
||||
) -> None:
|
||||
now = int(self.clock())
|
||||
source_hash = self._digest(source or session_id or f"{purpose}:{action}:{target}")
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
|
||||
connection.execute(
|
||||
"INSERT INTO passkey_challenges("
|
||||
"challenge_hash, session_hash, purpose, action, target, expires_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"challenge_hash, session_hash, source_hash, purpose, action, target, expires_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
self._digest(challenge),
|
||||
self._digest(session_id) if session_id else None,
|
||||
source_hash,
|
||||
purpose,
|
||||
action,
|
||||
target,
|
||||
now + max(1, ttl_seconds),
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM passkey_challenges
|
||||
WHERE source_hash = ? AND challenge_hash NOT IN (
|
||||
SELECT challenge_hash FROM passkey_challenges
|
||||
WHERE source_hash = ?
|
||||
ORDER BY expires_at DESC, rowid DESC LIMIT ?
|
||||
)
|
||||
""",
|
||||
(source_hash, source_hash, self.max_challenges_per_source),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
DELETE FROM passkey_challenges
|
||||
WHERE challenge_hash NOT IN (
|
||||
SELECT challenge_hash FROM passkey_challenges
|
||||
ORDER BY expires_at DESC, rowid DESC LIMIT ?
|
||||
)
|
||||
""",
|
||||
(self.max_challenges,),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
|
||||
@@ -17,8 +17,10 @@ def request_body_limit(method: str, path: str) -> int | None:
|
||||
return SESSION_BODY_LIMIT
|
||||
if (
|
||||
normalized_method == "POST"
|
||||
and path.startswith("/api/v1/repos/")
|
||||
and "/issues/" in path
|
||||
and (
|
||||
(path.startswith("/api/v1/repos/") and ("/issues/" in path or "/pulls/" in path))
|
||||
or path.startswith("/api/v1/notifications/")
|
||||
)
|
||||
and path.endswith("/attachments")
|
||||
):
|
||||
return ISSUE_ATTACHMENT_BODY_LIMIT
|
||||
|
||||
+16
-4
@@ -12,6 +12,17 @@ class SessionStoreError(RuntimeError):
|
||||
"""Raised when session state cannot be read or changed safely."""
|
||||
|
||||
|
||||
class SessionStatus(str):
|
||||
"""String-compatible status carrying the server-confirmed idle deadline."""
|
||||
|
||||
idle_expires_at: int | None
|
||||
|
||||
def __new__(cls, value: str, idle_expires_at: int | None = None):
|
||||
instance = super().__new__(cls, value)
|
||||
instance.idle_expires_at = idle_expires_at
|
||||
return instance
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveDevice:
|
||||
management_id: str
|
||||
@@ -180,10 +191,11 @@ class SessionStore:
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
if row is None or row[0] != expires_at or expires_at <= now:
|
||||
return "revoked"
|
||||
if row[1] + max(1, idle_timeout_seconds) <= now:
|
||||
return "idle"
|
||||
return "active"
|
||||
return SessionStatus("revoked")
|
||||
idle_expires_at = min(row[0], row[1] + max(1, idle_timeout_seconds))
|
||||
if idle_expires_at <= now:
|
||||
return SessionStatus("idle", idle_expires_at)
|
||||
return SessionStatus("active", idle_expires_at)
|
||||
|
||||
def touch(
|
||||
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
|
||||
|
||||
@@ -69,3 +69,20 @@ async def runtime_bundle(digest: str, request: Request) -> Response:
|
||||
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/feature-{name}-{digest}.js")
|
||||
async def feature_bundle(name: str, digest: str, request: Request) -> Response:
|
||||
bundle = FRONTEND_BUILD.feature_bundles.get(name)
|
||||
if bundle is None or digest != bundle.runtime_digest:
|
||||
raise HTTPException(status_code=404, detail="Feature revision not found")
|
||||
accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0
|
||||
return Response(
|
||||
bundle.runtime_gzip_bytes if accepts_gzip else bundle.runtime_bytes,
|
||||
media_type="application/javascript",
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Vary": "Accept-Encoding",
|
||||
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
|
||||
},
|
||||
)
|
||||
@@ -77,6 +77,41 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const calls = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
fetchJson: async (url, options) => {{
|
||||
calls.push({{url,method:options.method,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
return {{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
|
||||
}},
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
kind:'issue-blocker',repository:'stackchain/dashboard',number:17,operationId:'blocker-op',
|
||||
blockerRepository:'stackchain/api',blockerNumber:9,present:false,
|
||||
}});
|
||||
const restored = createAuthoredOutbox({{storage}}).list()[0];
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{queued,restored,calls,result,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["blockerRepository"] == "stackchain/api"
|
||||
assert output["queued"]["blockerNumber"] == 9
|
||||
assert output["queued"]["present"] is False
|
||||
assert output["restored"]["present"] is False
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
"method": "PATCH",
|
||||
"key": "blocker-op",
|
||||
"body": {"repository": "stackchain/api", "number": 9, "present": False},
|
||||
}]
|
||||
assert output["result"]["confirmed"][0]["dependencies"] == []
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_exposes_sending_then_preserves_transient_attempt_details():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -480,6 +515,35 @@ outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Review',ope
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authorization_hold_can_resume_in_foreground_with_original_review_identity():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const sent=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createAuthoredOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',
|
||||
backgroundSync:{{reconcile:async()=>{{}},requestSync:async()=>{{}},
|
||||
send:async item=>{{sent.push({{operationId:item.operationId,status:item.status,
|
||||
head:item.expectedHeadSha,decision:item.decision}});return{{message:{{id:91}}}};}}}},
|
||||
}});
|
||||
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,operationId:'review-stable',
|
||||
body:'Ship it',decision:'approve',expectedHeadSha:'abc123'}});
|
||||
outbox.reconcileBackground([{{id:'review-stable',kind:'pull-review',status:'authorization',
|
||||
error:'Fresh authorization required'}}]);
|
||||
(async()=>{{const held=outbox.list()[0];const result=await outbox.retry(held.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{held,sent,result,remaining:outbox.list()}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["held"]["status"] == "authorization"
|
||||
assert output["sent"] == [{
|
||||
"operationId": "review-stable", "status": "authorization",
|
||||
"head": "abc123", "decision": "approve",
|
||||
}]
|
||||
assert output["result"]["confirmed"] == [{"id": 91}]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
html = await dashboard()
|
||||
@@ -549,3 +613,29 @@ const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',background
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output == {"error": "IndexedDB unavailable", "items": []}
|
||||
|
||||
|
||||
def test_pull_screenshot_comment_persists_blob_only_in_indexeddb():
|
||||
script = f"""
|
||||
const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const mirrors=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
|
||||
reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}},
|
||||
}}}});
|
||||
(async()=>{{const blob=new Blob(['PULL-PRIVATE-BYTES'],{{type:'image/webp'}});
|
||||
await outbox.enqueueDurably({{kind:'pull-comment',repository:'stackchain/web',number:31,
|
||||
body:'Mobile proof',operationId:'pull-image-31',
|
||||
attachment:{{filename:'proof.webp',contentType:'image/webp',blob}}}});
|
||||
const local=values.get('stackchain.authored-outbox.v1');const durable=mirrors[0][0].attachment;
|
||||
process.stdout.write(JSON.stringify({{local,metadata:JSON.parse(local).items[0].attachment,
|
||||
durable:{{isBlob:durable.blob instanceof Blob,text:await durable.blob.text()}}}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert "PULL-PRIVATE-BYTES" not in output["local"]
|
||||
assert output["metadata"] == {
|
||||
"filename": "proof.webp", "contentType": "image/webp", "stored": True
|
||||
}
|
||||
assert output["durable"] == {"isBlob": True, "text": "PULL-PRIVATE-BYTES"}
|
||||
@@ -64,6 +64,54 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item = {{
|
||||
id:'blocker-1',operationId:'blocker-1',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',number:17,blockerRepository:'stackchain/api',blockerNumber:9,present:false,
|
||||
}};
|
||||
const state={{calls:[],completed:[]}};
|
||||
const store={{
|
||||
claimNext:async()=>item?{{...item}}:null,
|
||||
complete:async id=>{{state.completed.push(id);item=null;}},
|
||||
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
state.calls.push({{url,method:options.method,key:options.headers?.['Idempotency-Key']||'',body:options.body?JSON.parse(options.body):null}});
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
return{{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
|
||||
}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["calls"][1] == {
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
"method": "PATCH",
|
||||
"key": "blocker-1",
|
||||
"body": {"repository": "stackchain/api", "number": 9, "present": False},
|
||||
}
|
||||
assert output["state"]["completed"] == ["blocker-1"]
|
||||
assert output["result"]["confirmed"][0]["dependencies"] == []
|
||||
|
||||
|
||||
def test_closed_app_sync_keeps_unconfirmed_blocker_change_actionable():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item={{id:'blocker-2',operationId:'blocker-2',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',repository:'o/r',number:1,blockerRepository:'o/b',blockerNumber:2,present:false}};
|
||||
const state={{completed:[],failed:[]}};
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,complete:async id=>{{state.completed.push(id);item=null;}},release:async()=>{{}},fail:async(id,message)=>{{state.failed.push([id,message]);item=null;}},countBlocked:async()=>0}};
|
||||
const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:1,dependencies_available:true,dependencies:[{{repository:'o/b',number:2}}]}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["completed"] == []
|
||||
assert output["state"]["failed"] == [["blocker-2", "Blocker change was not confirmed."]]
|
||||
assert output["result"]["attention"] == 1
|
||||
|
||||
|
||||
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -237,6 +285,71 @@ const fetchJson=async(url,options={{}})=>{{
|
||||
assert output["second"]["confirmed"] == [{"id": 91}]
|
||||
|
||||
|
||||
def test_pull_screenshot_retry_uploads_to_pull_and_reuses_checkpoint_before_one_comment():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'pull-image',operationId:'pull-image',ownerLogin:'timmy',status:'queued',
|
||||
kind:'pull-comment',repository:'stackchain/web',number:31,body:'Mobile proof',
|
||||
attachment:{{filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}}}};
|
||||
const calls=[];let commentAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},
|
||||
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body instanceof FormData?'multipart':JSON.parse(options.body)}});
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
if(commentAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}return{{id:77}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});try{{await sync.flush();}}catch(_error){{}}
|
||||
const checkpoint=item.attachmentMarkdown;const result=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{calls,checkpoint,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["checkpoint"].startswith("![proof.png]")
|
||||
assert [call["url"] for call in output["calls"]] == [
|
||||
"api/v1/repos/stackchain/web/pulls/31/attachments",
|
||||
"api/v1/repos/stackchain/web/pulls/31/comments",
|
||||
"api/v1/repos/stackchain/web/pulls/31/comments",
|
||||
]
|
||||
assert [call["key"] for call in output["calls"]] == [
|
||||
"pull-image:attachment", "pull-image:comment", "pull-image:comment"
|
||||
]
|
||||
assert output["calls"][-1]["body"] == {
|
||||
"body": "Mobile proof\n\n"
|
||||
}
|
||||
assert output["result"]["confirmed"] == [{"id": 77}]
|
||||
|
||||
|
||||
def test_pull_screenshot_comment_timeout_replays_one_upload_with_durable_stage_identity():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'pull-timeout',operationId:'pull-timeout',ownerLogin:'timmy',status:'queued',
|
||||
kind:'pull-comment',repository:'stackchain/web',number:31,body:'Mobile proof',
|
||||
attachment:{{filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}}}};
|
||||
const calls=[];let commentAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},
|
||||
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key']}});
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
if(commentAttempts++===0)return new Promise(()=>{{}});return{{id:78}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson,requestTimeoutMs:5}});
|
||||
let timedOut='';try{{await sync.flush();}}catch(error){{timedOut=error.message;}}
|
||||
const checkpoint=item.attachmentMarkdown;const replay=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{calls,timedOut,checkpoint,replay}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["timedOut"] == "Background request timed out."
|
||||
assert output["checkpoint"].startswith("![proof.png]")
|
||||
assert [call["key"] for call in output["calls"]] == [
|
||||
"pull-timeout:attachment", "pull-timeout:comment", "pull-timeout:comment"
|
||||
]
|
||||
assert sum(call["url"].endswith("/attachments") for call in output["calls"]) == 1
|
||||
assert output["replay"]["confirmed"] == [{"id": 78}]
|
||||
|
||||
|
||||
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -409,6 +522,41 @@ createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdo
|
||||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_holds_consequential_review_for_foreground_authorization():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const item={{id:'review-op',operationId:'review-op',ownerLogin:'timmy',status:'queued',
|
||||
kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
|
||||
decision:'approve',expectedHeadSha:'abc123',comments:[]}};
|
||||
const state={{authorized:[],failed:[],released:[],completed:[]}};let claimed=false;
|
||||
const store={{
|
||||
claimNext:async()=>claimed?null:(claimed=true,item),
|
||||
authorization:async(id,error)=>state.authorized.push({{id,error}}),
|
||||
fail:async(...args)=>state.failed.push(args),release:async id=>state.released.push(id),
|
||||
complete:async id=>state.completed.push(id),countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async url=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
const error=new Error('Fresh authorization required');error.status=428;
|
||||
error.code='step_up_required';throw error;
|
||||
}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"] == {
|
||||
"authorized": [{"id": "review-op", "error": "Fresh authorization required"}],
|
||||
"failed": [], "released": [], "completed": [],
|
||||
}
|
||||
assert output["result"]["attention"] == 0
|
||||
assert output["result"]["authorization"] == 1
|
||||
assert output["result"]["receipts"] == [{
|
||||
"id": "review-op", "status": "authorization", "kind": "message",
|
||||
"route": "#/my-work/review/stackchain/web/8",
|
||||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_leaves_issue_closure_awaiting_foreground_authorization():
|
||||
records = [
|
||||
{
|
||||
|
||||
@@ -292,17 +292,15 @@ async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_current_today_update_offers_reply_and_next_without_marking_read():
|
||||
async def test_unread_update_offers_reply_mark_read_and_next_independent_of_today():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="send-update-reply-next"' in html
|
||||
assert '>Reply & next</button>' in html
|
||||
assert "setUpdateReplyNextVisibility();" in html
|
||||
assert "const updateReplyNext = createCommentNext({" in html
|
||||
assert "notificationReplier.saveDraft(item, '');" in html
|
||||
assert "successMessage: 'Reply saved. Next Today item opened.'" in html
|
||||
assert "qs('#mark-update-read-next').click()" not in html
|
||||
assert 'id="send-update-reply-read-next"' in html
|
||||
assert '>Reply, mark read & next</button>' in html
|
||||
assert "const updateReplyReadNext = createUpdateReplyReadNext({" in html
|
||||
assert "markRead: markNotificationRead" in html
|
||||
assert "next: item => notificationReader.acceptReadAndNext(lastMyWork, item)" in html
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v83" in worker
|
||||
assert "stackchain-dashboard-shell-v87" in worker
|
||||
@@ -154,10 +154,141 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
|
||||
"created_at": activity.json()["events"][0]["created_at"],
|
||||
"status": "completed",
|
||||
}
|
||||
assert activity.json()["events"][1] == {
|
||||
"id": activity.json()["events"][1]["id"],
|
||||
"kind": "passkey_enrolled",
|
||||
"method": "passkey",
|
||||
"device_label": "Phone",
|
||||
"target": "passkey",
|
||||
"created_at": activity.json()["events"][1]["created_at"],
|
||||
"status": "completed",
|
||||
}
|
||||
assert "stackchain_session=" in signed_in.headers["set-cookie"]
|
||||
assert "correct horse battery staple" not in signed_in.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_options_are_source_limited_before_challenge_generation(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS", "2")
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
first_source = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
|
||||
other_source = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
|
||||
|
||||
async with httpx.AsyncClient(transport=first_source, base_url="https://test") as client:
|
||||
first = await client.post("/api/v1/passkeys/authentication/options")
|
||||
second = await client.post("/api/v1/passkeys/authentication/options")
|
||||
blocked = await client.post("/api/v1/passkeys/authentication/options")
|
||||
async with httpx.AsyncClient(transport=other_source, base_url="https://test") as client:
|
||||
available = await client.post("/api/v1/passkeys/authentication/options")
|
||||
|
||||
assert first.status_code == second.status_code == available.status_code == 200
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.json() == {"detail": "Too many passkey sign-in attempts"}
|
||||
assert blocked.headers["retry-after"] == "60"
|
||||
with sqlite3.connect(main._passkey_store().path) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM passkey_challenges WHERE purpose = 'authentication'"
|
||||
).fetchone() == (3,)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_passkey_verification_consumes_the_shared_sign_in_budget(
|
||||
access_control,
|
||||
):
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.9", 1234))
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
failures = []
|
||||
for _attempt in range(3):
|
||||
options = await client.post("/api/v1/passkeys/authentication/options")
|
||||
failures.append(
|
||||
await client.post(
|
||||
"/api/v1/passkeys/authentication/verify",
|
||||
json={
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "d3JvbmctY3JlZGVudGlhbA"},
|
||||
"device_label": "Phone",
|
||||
"action": "sign_in",
|
||||
"target": "dashboard",
|
||||
},
|
||||
)
|
||||
)
|
||||
blocked = await client.post("/api/v1/passkeys/authentication/options")
|
||||
|
||||
assert [response.status_code for response in failures] == [401, 401, 401]
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.headers["retry-after"] == "60"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_passkey_sign_in_clears_prior_source_failures(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class VerifiedAuthentication:
|
||||
new_sign_count = 1
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys,
|
||||
"verify_authentication",
|
||||
lambda **_kwargs: VerifiedAuthentication(),
|
||||
)
|
||||
_signed, _managed_session = await asyncio.to_thread(
|
||||
main.dashboard_auth.issue_session,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
source = "203.0.113.10"
|
||||
attempts = main._login_attempt_store()
|
||||
await asyncio.to_thread(attempts.record_failure, source)
|
||||
await asyncio.to_thread(attempts.record_failure, source)
|
||||
transport = httpx.ASGITransport(app=main.app, client=(source, 1234))
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
options = await client.post("/api/v1/passkeys/authentication/options")
|
||||
signed_in = await client.post(
|
||||
"/api/v1/passkeys/authentication/verify",
|
||||
json={
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
"device_label": "Phone",
|
||||
"action": "sign_in",
|
||||
"target": "dashboard",
|
||||
},
|
||||
)
|
||||
|
||||
assert signed_in.status_code == 200, signed_in.text
|
||||
with sqlite3.connect(attempts.path) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM login_attempts WHERE source_hash = ?",
|
||||
(attempts._digest(source),),
|
||||
).fetchone() == (0,)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use(
|
||||
access_control, monkeypatch
|
||||
@@ -201,6 +332,14 @@ async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use(
|
||||
json={"action": "close_issue", "target": "stackchain/api#7"},
|
||||
headers=headers,
|
||||
)
|
||||
review_options = await client.post(
|
||||
"/api/v1/passkeys/authorization/options",
|
||||
json={
|
||||
"action": "submit_pull_review",
|
||||
"target": "stackchain/api#7@abc123:approve",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
ceremony = {
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
@@ -220,6 +359,8 @@ async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use(
|
||||
)
|
||||
|
||||
assert options.status_code == 200
|
||||
assert review_options.status_code == 200
|
||||
assert review_options.json()["challenge"]
|
||||
assert authorized.status_code == 201
|
||||
assert authorized.json()["grant"]
|
||||
assert authorized.json()["expires_in"] == 90
|
||||
@@ -684,6 +825,44 @@ async def test_sign_in_throttles_repeated_failures_with_retry_guidance(access_co
|
||||
assert blocked.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_security_activity_surfaces_failed_and_blocked_token_sign_ins(access_control):
|
||||
attacker_transport = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
|
||||
operator_transport = httpx.ASGITransport(app=main.app, client=("198.51.100.4", 1234))
|
||||
async with httpx.AsyncClient(
|
||||
transport=attacker_transport, base_url="https://test"
|
||||
) as attacker:
|
||||
failures = [
|
||||
await attacker.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "wrong", "device_label": "Unknown"},
|
||||
)
|
||||
for _ in range(4)
|
||||
]
|
||||
async with httpx.AsyncClient(
|
||||
transport=operator_transport, base_url="https://test"
|
||||
) as operator:
|
||||
signed_in = await operator.post(
|
||||
"/api/v1/session",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"device_label": "Phone",
|
||||
},
|
||||
)
|
||||
activity = await operator.get("/api/v1/security-events")
|
||||
|
||||
assert [response.status_code for response in failures] == [401, 401, 401, 429]
|
||||
assert signed_in.status_code == 200
|
||||
assert activity.status_code == 200
|
||||
assert activity.headers["cache-control"] == "no-store"
|
||||
alerts = activity.json()["authentication_alerts"]
|
||||
assert len(alerts) == 1
|
||||
assert alerts[0]["method"] == "token"
|
||||
assert alerts[0]["failed_count"] == 3
|
||||
assert alerts[0]["blocked_count"] == 1
|
||||
assert alerts[0]["first_at"] <= alerts[0]["last_at"]
|
||||
|
||||
|
||||
@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))
|
||||
@@ -772,9 +951,39 @@ async def test_authenticated_session_status_exposes_csrf_proof_and_offline_lease
|
||||
assert payload["authenticated"] is True
|
||||
assert payload["csrf_token"] == client.cookies["stackchain_csrf"]
|
||||
assert payload["expires_at"] > int(time.time())
|
||||
assert payload["idle_expires_at"] > int(time.time())
|
||||
assert "correct horse battery staple" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_status_reports_server_idle_deadline_without_extending_it(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
expected_last_active = int(time.time()) - 120
|
||||
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE active_sessions SET last_active_at = ?", (expected_last_active,)
|
||||
)
|
||||
|
||||
first = await client.get("/api/v1/session")
|
||||
second = await client.get("/api/v1/session")
|
||||
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
|
||||
stored_last_active = connection.execute(
|
||||
"SELECT last_active_at FROM active_sessions"
|
||||
).fetchone()[0]
|
||||
|
||||
assert first.status_code == 200
|
||||
assert first.json()["idle_expires_at"] == expected_last_active + 900
|
||||
assert second.json()["idle_expires_at"] == expected_last_active + 900
|
||||
assert stored_last_active == expected_last_active
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_session_status_discloses_no_offline_lease(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -815,6 +1024,7 @@ async def test_idle_session_is_rejected_with_distinct_api_and_page_recovery(acce
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_heartbeat_extends_the_server_idle_deadline(access_control, monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", "100")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
@@ -827,10 +1037,11 @@ async def test_activity_heartbeat_extends_the_server_idle_deadline(access_contro
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
status = await client.get("/api/v1/session")
|
||||
|
||||
assert heartbeat.status_code == 200
|
||||
assert heartbeat.json()["active"] is True
|
||||
assert heartbeat.json()["idle_expires_at"] >= int(time.time()) + 899
|
||||
assert heartbeat.json()["idle_expires_at"] == status.json()["expires_at"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -953,6 +1164,91 @@ async def test_other_high_impact_routes_require_fresh_authorization_before_mutat
|
||||
assert laptop_still_active.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_consequential_review_requires_decision_and_head_bound_fresh_authorization(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
calls = []
|
||||
|
||||
async def requested(repository, number):
|
||||
calls.append(("requested", repository, number))
|
||||
return True
|
||||
|
||||
async def submit(repository, number, head, decision, body):
|
||||
calls.append(("submit", repository, number, head, decision, body))
|
||||
return {"id": 91, "state": "APPROVED"}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
payload = {
|
||||
"expected_head_sha": "abc123",
|
||||
"decision": "approve",
|
||||
"body": "Ready to ship.",
|
||||
}
|
||||
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"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
missing = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
wrong_grant = await fresh_grant(
|
||||
client,
|
||||
"submit_pull_review",
|
||||
"stackchain/api#7@different:approve",
|
||||
)
|
||||
mismatched = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": wrong_grant},
|
||||
)
|
||||
grant = await fresh_grant(
|
||||
client,
|
||||
"submit_pull_review",
|
||||
"stackchain/api#7@abc123:approve",
|
||||
)
|
||||
approved = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
replayed = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
comment = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={**payload, "decision": "comment"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert [missing.status_code, mismatched.status_code] == [428, 428]
|
||||
assert missing.json()["detail"] == {
|
||||
"detail": "Fresh authorization required",
|
||||
"code": "step_up_required",
|
||||
"action": "submit_pull_review",
|
||||
"target": "stackchain/api#7@abc123:approve",
|
||||
}
|
||||
assert approved.status_code == 201
|
||||
assert replayed.status_code == 428
|
||||
assert comment.status_code == 201
|
||||
assert calls == [
|
||||
("requested", "stackchain/api", 7),
|
||||
("submit", "stackchain/api", 7, "abc123", "approve", "Ready to ship."),
|
||||
("requested", "stackchain/api", 7),
|
||||
("submit", "stackchain/api", 7, "abc123", "comment", "Ready to ship."),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_operator_can_review_and_revoke_one_remote_device(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -1297,6 +1593,9 @@ async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
|
||||
time.sleep(0.15)
|
||||
return 17
|
||||
|
||||
def record_blocked(self, *, method):
|
||||
assert method == "token"
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.10", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
|
||||
@@ -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: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, timers: [], now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
||||
const storage = {{
|
||||
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||
get length() {{ return this.values.size; }},
|
||||
@@ -59,7 +59,7 @@ const boundary = createSessionBoundary({{
|
||||
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
|
||||
addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }},
|
||||
now: () => state.now,
|
||||
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
|
||||
setTimer: (callback, delay) => {{ state.leaseDelay = delay; state.timers.push({{callback, delay}}); return state.timers.length; }},
|
||||
confirmAction: message => {{ state.confirmations.push(message); return true; }},
|
||||
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
|
||||
}});
|
||||
@@ -260,22 +260,102 @@ process.stdout.write(JSON.stringify(state));
|
||||
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800};
|
||||
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800, idle_expires_at:4102441200};
|
||||
const valid = await boundary.refreshOfflineLease();
|
||||
state.valid = valid;
|
||||
state.savedExpiry = storage.getItem('stackchain.session-expires-at');
|
||||
state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["valid"] is True
|
||||
assert result["savedExpiry"] == "4102444800"
|
||||
assert result["savedIdleExpiry"] == "4102441200"
|
||||
assert result["workerMessages"] == [
|
||||
{"type": "stackchain-session-lease", "expiresAt": 4102444800}
|
||||
{
|
||||
"type": "stackchain-session-lease",
|
||||
"expiresAt": 4102444800,
|
||||
"idleExpiresAt": 4102441200,
|
||||
}
|
||||
]
|
||||
assert result["leaseDelay"] > 0
|
||||
|
||||
|
||||
def test_expired_idle_lease_locks_offline_without_purging_private_work():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
storage.setItem('stackchain.session-expires-at', '200');
|
||||
storage.setItem('stackchain.session-idle-expires-at', '99');
|
||||
state.failFetch = true;
|
||||
const valid = await boundary.refreshOfflineLease();
|
||||
state.valid = valid;
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["valid"] is False
|
||||
assert result["remaining"] == [
|
||||
"stackchain.private",
|
||||
"gitea.preference",
|
||||
"stackchain.session-expires-at",
|
||||
"stackchain.session-idle-expires-at",
|
||||
]
|
||||
assert result["deletedDatabases"] == []
|
||||
assert result["deletedCaches"] == []
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
|
||||
|
||||
|
||||
def test_successful_activity_heartbeat_republishes_only_confirmed_idle_deadline():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
storage.setItem('stackchain.session-expires-at', '4102444800');
|
||||
state.responsePayload = {active:true, idle_expires_at:4102441300};
|
||||
const recorded = await boundary.recordActivity();
|
||||
state.recorded = recorded;
|
||||
state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["recorded"] is True
|
||||
assert result["savedIdleExpiry"] == "4102441300"
|
||||
assert result["workerMessages"] == [
|
||||
{
|
||||
"type": "stackchain-session-lease",
|
||||
"expiresAt": 4102444800,
|
||||
"idleExpiresAt": 4102441300,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_absolute_expiry_still_purges_after_an_idle_lock():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {authenticated:true, expires_at:200, idle_expires_at:150};
|
||||
await boundary.refreshOfflineLease();
|
||||
state.now = 150000;
|
||||
await state.timers[1].callback();
|
||||
state.deletedAtIdle = [...state.deletedDatabases];
|
||||
state.now = 200000;
|
||||
await state.timers[0].callback();
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["deletedAtIdle"] == []
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
"/dashboard/login?reason=session-idle",
|
||||
"/dashboard/login?reason=session-expired",
|
||||
]
|
||||
|
||||
|
||||
def test_expired_stored_lease_purges_private_work_when_session_status_is_offline():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -291,7 +371,9 @@ process.stdout.write(JSON.stringify(state));
|
||||
|
||||
assert result["valid"] is False
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
@@ -419,7 +501,9 @@ process.stdout.write(JSON.stringify(state));
|
||||
"/dashboard/login?reason=session-expired"
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert result["expiredAfterDeletion"] is True
|
||||
@@ -439,7 +523,9 @@ process.stdout.write(JSON.stringify(state));
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
@@ -532,7 +618,9 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert "stackchain.private" in result["removed"]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
assert result["assignedAfterDeletion"] is True
|
||||
@@ -648,6 +736,34 @@ process.stdout.write(JSON.stringify(state));
|
||||
]
|
||||
|
||||
|
||||
def test_security_activity_formats_failed_sign_in_alerts_for_mobile_scanability():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.formatted = boundary.formatAuthenticationAlert({
|
||||
method:'token', failed_count:12, blocked_count:3, first_at:1000, last_at:1060
|
||||
});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["formatted"] == {
|
||||
"title": "12 failed token sign-ins · 3 blocked",
|
||||
"detail": "1/1/1970, 12:16:40 AM – 1/1/1970, 12:17:40 AM",
|
||||
}
|
||||
|
||||
|
||||
def test_security_activity_renders_authentication_alerts_before_journal_events():
|
||||
source = SESSION_JS.read_text()
|
||||
|
||||
alerts = source.index("page.authentication_alerts.forEach")
|
||||
events = source.index("page.events.forEach")
|
||||
assert alerts < events
|
||||
assert "boundary.formatAuthenticationAlert(alert)" in source
|
||||
assert "row.className = 'security-event authentication-alert'" in source
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
assert ".authentication-alert { border-color:#d69e2e;" in css
|
||||
|
||||
|
||||
def test_security_activity_explains_pending_outcome_confirmation():
|
||||
source = SESSION_JS.read_text()
|
||||
|
||||
@@ -655,6 +771,21 @@ def test_security_activity_explains_pending_outcome_confirmation():
|
||||
assert "event.status === 'pending'" in source
|
||||
|
||||
|
||||
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
|
||||
source = SESSION_JS.read_text()
|
||||
|
||||
assert "passkey_enrolled: 'Passkey enrolled'" in source
|
||||
assert "title.textContent = labels[event.kind]" in source
|
||||
assert "details.textContent =" in source
|
||||
|
||||
|
||||
def test_security_activity_labels_consequential_pull_review_decisions():
|
||||
source = SESSION_JS.read_text()
|
||||
|
||||
assert "pull_review_approved: 'Pull request approved'" in source
|
||||
assert "pull_review_changes_requested: 'Changes requested'" in source
|
||||
|
||||
|
||||
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
|
||||
@@ -142,6 +142,27 @@ process.stdout.write(JSON.stringify(item));
|
||||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_held_review_as_awaiting_foreground_authorization():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
|
||||
id:'review-1',kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
|
||||
decision:'approve',expectedHeadSha:'abc',comments:[],ownerLogin:'timmy',
|
||||
status:'authorization',error:'Fresh authorization required',queuedAt:200
|
||||
}}]}})]]);
|
||||
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const inbox=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
|
||||
process.stdout.write(JSON.stringify({{item:inbox.list()[0],partition:inbox.partition()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["item"]["status"] == "authorization"
|
||||
assert output["item"]["label"] == "Review awaiting authorization"
|
||||
assert output["item"]["authorization_required"] is True
|
||||
assert output["partition"]["counts"]["authorization"] == 1
|
||||
assert output["partition"]["retryable"] == []
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_queued_issue_closure_as_awaiting_authorization():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LOADER = Path(__file__).parents[1] / "frontend" / "feature-loader.js"
|
||||
|
||||
|
||||
def run_loader(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const fs=require('fs'); const vm=require('vm');
|
||||
const state={{appends:0, removed:0}};
|
||||
const document={{
|
||||
head:{{appendChild(node){{ state.appends++; state.node=node; }} }},
|
||||
createElement(){{ return {{remove(){{state.removed++;}}}}; }},
|
||||
}};
|
||||
const context={{document, setTimeout, clearTimeout, console}};
|
||||
context.globalThis=context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(LOADER))},'utf8'),context);
|
||||
const createFeatureLoader=context.createFeatureLoader;
|
||||
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_concurrent_feature_requests_share_one_script():
|
||||
result = run_loader("""
|
||||
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
|
||||
const first=loader.load('issue-capture'); const second=loader.load('issue-capture');
|
||||
state.node.onload(); await Promise.all([first,second]);
|
||||
console.log(JSON.stringify({appends:state.appends,same:first===second,ready:loader.ready('issue-capture')}));
|
||||
""")
|
||||
assert result == {"appends": 1, "same": True, "ready": True}
|
||||
|
||||
|
||||
def test_failed_feature_request_can_retry_without_reload():
|
||||
result = run_loader("""
|
||||
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
|
||||
const failed=loader.load('issue-capture').catch(error=>error.message); state.node.onerror();
|
||||
const message=await failed; const retry=loader.load('issue-capture'); state.node.onload(); await retry;
|
||||
console.log(JSON.stringify({appends:state.appends,removed:state.removed,message,ready:loader.ready('issue-capture')}));
|
||||
""")
|
||||
assert result == {"appends": 2, "removed": 1, "message": "Could not load issue capture.", "ready": True}
|
||||
|
||||
|
||||
def test_feature_gate_exposes_loading_failure_and_success_to_mobile_controls():
|
||||
result = run_loader("""
|
||||
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
|
||||
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
|
||||
const failed=loader.run('issue-capture',{trigger,status},()=>{opened++;});
|
||||
const loading={disabled:trigger.disabled,status:status.textContent}; state.node.onerror();
|
||||
const first=await failed;
|
||||
const failedState={disabled:trigger.disabled,status:status.textContent,opened};
|
||||
const retry=loader.run('issue-capture',{trigger,status},()=>{opened++;}); state.node.onload();
|
||||
const second=await retry;
|
||||
console.log(JSON.stringify({loading,failedState,first,second,opened,finalStatus:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"loading": {"disabled": True, "status": "Loading issue capture…"},
|
||||
"failedState": {
|
||||
"disabled": False,
|
||||
"status": "Issue capture could not load. Tap New issue to retry.",
|
||||
"opened": 0,
|
||||
},
|
||||
"first": False,
|
||||
"second": True,
|
||||
"opened": 1,
|
||||
"finalStatus": "",
|
||||
}
|
||||
|
||||
|
||||
def test_pull_workspace_failure_is_accessible_and_retryable_from_same_card():
|
||||
result = run_loader("""
|
||||
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
|
||||
const loader=createFeatureLoader({document, urls:{'pull-workflow':'feature-pull.js'}, timeoutMs:100});
|
||||
const failed=loader.run('pull-workflow',{trigger,status,retryLabel:'Tap the work card to retry.'},()=>{opened++;});
|
||||
state.node.onerror(); const first=await failed;
|
||||
const failedStatus=status.textContent;
|
||||
const retry=loader.run('pull-workflow',{trigger,status,retryLabel:'Tap the work card to retry.'},()=>{opened++;});
|
||||
state.node.onload(); const second=await retry;
|
||||
console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appends}));
|
||||
""")
|
||||
assert result == {
|
||||
"first": False,
|
||||
"second": True,
|
||||
"failedStatus": "Pull workspace could not load. Tap the work card to retry.",
|
||||
"opened": 1,
|
||||
"appends": 2,
|
||||
}
|
||||
@@ -9,7 +9,7 @@ from starlette.requests import Request
|
||||
|
||||
from src import main
|
||||
from src.frontend_bundle import build_frontend
|
||||
from src.views import dashboard, runtime_bundle, service_worker
|
||||
from src.views import dashboard, feature_bundle, runtime_bundle, service_worker
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
@@ -42,6 +42,116 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
assert changed.runtime_bytes != first.runtime_bytes
|
||||
|
||||
|
||||
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
|
||||
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow"}
|
||||
capture = first.feature_bundles["issue-capture"]
|
||||
pull_workflow = first.feature_bundles["pull-workflow"]
|
||||
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||
assert b"function createIssueCapture" in capture.runtime_bytes
|
||||
assert b"function createPullSheet" not in first.runtime_bytes
|
||||
assert b"function createReviewController" not in first.runtime_bytes
|
||||
assert b"function createPullSheet" in pull_workflow.runtime_bytes
|
||||
assert b"function createReviewController" in pull_workflow.runtime_bytes
|
||||
assert len(first.runtime_gzip_bytes) <= 95 * 1024
|
||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
capture_source = changed_frontend / "create-issue-sheet.js"
|
||||
capture_source.write_text(capture_source.read_text() + "\n// feature-only change\n")
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
assert changed.runtime_name == first.runtime_name
|
||||
assert changed.feature_bundles["issue-capture"].runtime_name != capture.runtime_name
|
||||
|
||||
pull_source = changed_frontend / "pull-sheet.js"
|
||||
pull_source.write_text(pull_source.read_text() + "\n// pull-workflow-only change\n")
|
||||
pull_changed = build_frontend(changed_frontend)
|
||||
assert pull_changed.runtime_name == first.runtime_name
|
||||
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
|
||||
build = build_frontend(FRONTEND)
|
||||
capture = build.feature_bundles["issue-capture"]
|
||||
|
||||
request = Request(
|
||||
{"type": "http", "method": "GET", "path": f"/{capture.runtime_name}",
|
||||
"headers": [(b"accept-encoding", b"identity")]}
|
||||
)
|
||||
response = await feature_bundle("issue-capture", capture.runtime_digest, request)
|
||||
|
||||
assert response.body == capture.runtime_bytes
|
||||
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
with pytest.raises(Exception) as rejected:
|
||||
await feature_bundle("issue-capture", "missing", request)
|
||||
assert rejected.value.status_code == 404
|
||||
|
||||
|
||||
def test_css_only_release_gets_a_new_shell_generation(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
(changed_frontend / "dashboard.css").write_text(
|
||||
(changed_frontend / "dashboard.css").read_text() + "\n/* release change */\n"
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
|
||||
assert changed.runtime_name == first.runtime_name
|
||||
assert changed.runtime_bytes == first.runtime_bytes
|
||||
assert changed.shell_digest != first.shell_digest
|
||||
assert f"const CACHE = 'stackchain-dashboard-shell-{changed.shell_digest}';" in (
|
||||
changed.service_worker_source
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"asset",
|
||||
[
|
||||
"manifest.webmanifest",
|
||||
"icons/stackchain-192.png",
|
||||
"icons/stackchain-512.png",
|
||||
"background-issue-sync.js",
|
||||
"service-worker.js",
|
||||
],
|
||||
)
|
||||
def test_every_offline_shell_asset_versions_the_shell_generation(tmp_path, asset):
|
||||
first = build_frontend(FRONTEND)
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
path = changed_frontend / asset
|
||||
path.write_bytes(path.read_bytes() + b"\nrelease-change")
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
|
||||
assert changed.shell_digest != first.shell_digest
|
||||
|
||||
|
||||
def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v87';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
|
||||
assert changed.shell_digest == first.shell_digest
|
||||
assert changed.service_worker_source == first.service_worker_source
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
@@ -182,6 +182,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
"issue": {"number": 7, "assignees": [], "claimable": True},
|
||||
"acknowledge_supported": True,
|
||||
"conversation": {
|
||||
"comments": [{
|
||||
"id": 9,
|
||||
|
||||
@@ -1939,3 +1939,193 @@ async def test_gitea_update_issue_labels_rejects_unconfirmed_label_set():
|
||||
await gitea_proxy.update_issue_labels("stackchain/api", 7, [3])
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_blocker_route_returns_canonical_dependencies(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def mutate(repository, number, blocker_repository, blocker_number, remove=False):
|
||||
calls.append((repository, number, blocker_repository, blocker_number, remove))
|
||||
return {
|
||||
"repository": repository, "number": number, "dependencies_available": True,
|
||||
"dependencies": [] if remove else [{
|
||||
"repository": blocker_repository, "number": blocker_number,
|
||||
"title": "Restore API", "state": "open",
|
||||
}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
added = await client.post(
|
||||
"/api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
json={"repository": "stackchain/api", "number": 9},
|
||||
)
|
||||
removed = await client.request(
|
||||
"DELETE", "/api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
json={"repository": "stackchain/api", "number": 9},
|
||||
)
|
||||
|
||||
assert [added.status_code, removed.status_code] == [200, 200]
|
||||
assert added.headers["cache-control"] == "no-store"
|
||||
assert added.json()["dependencies"][0]["number"] == 9
|
||||
assert removed.json()["dependencies"] == []
|
||||
assert calls == [
|
||||
("stackchain/dashboard", 17, "stackchain/api", 9, False),
|
||||
("stackchain/dashboard", 17, "stackchain/api", 9, True),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_blocker_patch_converges_to_desired_state(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def mutate(repository, number, blocker_repository, blocker_number, remove=False):
|
||||
calls.append((repository, number, blocker_repository, blocker_number, remove))
|
||||
return {
|
||||
"repository": repository, "number": number, "dependencies_available": True,
|
||||
"dependencies": [] if remove else [{"repository": blocker_repository, "number": blocker_number}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
headers={"Idempotency-Key": "offline-blocker-1"},
|
||||
json={"repository": "stackchain/api", "number": 9, "present": False},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["dependencies"] == []
|
||||
assert calls == [("stackchain/dashboard", 17, "stackchain/api", 9, True)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_blocker_route_rejects_invalid_candidate(monkeypatch):
|
||||
async def mutate(*_args, **_kwargs):
|
||||
raise gitea_proxy.IssueDependencyInvalidError("blocker must be an open issue")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, 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/dashboard/issues/17/blockers",
|
||||
json={"repository": "stackchain/api", "number": 9},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {"error": "blocker must be an open issue"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmation():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
path = request.url.path
|
||||
if path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if path == "/api/v1/repos/stackchain/dashboard/issues/17":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
|
||||
})
|
||||
if path == "/api/v1/repos/stackchain/api/issues/9":
|
||||
return httpx.Response(200, json={"number": 9, "state": "open"})
|
||||
if request.method == "GET" and path.endswith("/dependencies"):
|
||||
mutated = any(r.method == "POST" for r in requests)
|
||||
return httpx.Response(200, json=[{
|
||||
"number": 9, "state": "open", "title": "Restore API",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/9",
|
||||
}] if mutated else [])
|
||||
if request.method == "POST" and path.endswith("/dependencies"):
|
||||
return httpx.Response(201, json={})
|
||||
raise AssertionError((request.method, path))
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.mutate_assigned_issue_dependency(
|
||||
"stackchain/dashboard", 17, "stackchain/api", 9
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
mutation = next(request for request in requests if request.method == "POST")
|
||||
assert mutation.url.path == "/api/v1/repos/stackchain/dashboard/issues/17/dependencies"
|
||||
assert json.loads(mutation.content) == {"owner": "stackchain", "repo": "api", "index": 9}
|
||||
assert result["dependencies"] == [{
|
||||
"repository": "stackchain/api", "number": 9, "title": "Restore API",
|
||||
"state": "open", "url": "https://forge.example/stackchain/api/issues/9",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_dependency_retry_returns_canonical_state_without_duplicate_mutation():
|
||||
mutations = []
|
||||
dependency = {
|
||||
"number": 9, "state": "open", "title": "Restore API",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/9",
|
||||
}
|
||||
|
||||
async def handler(request):
|
||||
path = request.url.path
|
||||
if path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if path == "/api/v1/repos/stackchain/dashboard/issues/17":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
|
||||
})
|
||||
if path == "/api/v1/repos/stackchain/api/issues/9":
|
||||
return httpx.Response(200, json={"number": 9, "state": "open"})
|
||||
if request.method == "GET" and path.endswith("/dependencies"):
|
||||
return httpx.Response(200, json=[dependency])
|
||||
if path.endswith("/dependencies"):
|
||||
mutations.append(request.method)
|
||||
return httpx.Response(201, json={})
|
||||
raise AssertionError((request.method, path))
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.mutate_assigned_issue_dependency(
|
||||
"stackchain/dashboard", 17, "stackchain/api", 9
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert mutations == []
|
||||
assert result["dependencies"][0]["number"] == 9
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("candidate,blocker_repository,blocker_number,existing", [
|
||||
({"number": 17, "state": "open"}, "stackchain/dashboard", 17, []),
|
||||
({"number": 9, "state": "closed"}, "stackchain/api", 9, []),
|
||||
({"number": 9, "state": "open", "pull_request": {}}, "stackchain/api", 9, []),
|
||||
])
|
||||
async def test_gitea_dependency_rejects_invalid_candidates(
|
||||
candidate, blocker_repository, blocker_number, existing
|
||||
):
|
||||
async def handler(request):
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path == "/api/v1/repos/stackchain/dashboard/issues/17":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
|
||||
})
|
||||
if request.url.path.endswith("/dependencies"):
|
||||
return httpx.Response(200, json=existing)
|
||||
return httpx.Response(200, json=candidate)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.IssueDependencyInvalidError):
|
||||
await gitea_proxy.mutate_assigned_issue_dependency(
|
||||
"stackchain/dashboard", 17, blocker_repository, blocker_number
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
@@ -406,6 +406,28 @@ Promise.resolve(pending).then(async()=>process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_pending_comment_admission_locks_attachment_replacement_and_remove_controls():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
class Element {{
|
||||
constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
|
||||
addEventListener(type,fn) {{ this.listeners[type]=fn; }}
|
||||
}}
|
||||
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
|
||||
const controller=attachment.mount({{input,preview,image,meta,remove,status,
|
||||
createObjectURL:()=> 'blob:preview',revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
|
||||
controller.setBusy(true);
|
||||
const pending={{input:input.disabled,remove:remove.disabled}};
|
||||
controller.setBusy(false);
|
||||
process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled}}}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"pending": {"input": True, "remove": True},
|
||||
"released": {"input": False, "remove": False},
|
||||
}
|
||||
|
||||
|
||||
def test_issue_comment_actions_upload_binary_multipart_before_posting_and_clear_after_acceptance():
|
||||
source = DASHBOARD.read_text()
|
||||
|
||||
@@ -450,3 +472,50 @@ def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_adv
|
||||
assert "await authoredOutbox.enqueueDurably(message)" in source
|
||||
assert "await issueCommentNext.admit(item, message)" in source
|
||||
assert "navigator.onLine === false" in source
|
||||
|
||||
|
||||
def test_assigned_pull_composer_offers_screenshot_preview_remove_and_reuses_optimizer():
|
||||
html = INDEX.read_text()
|
||||
source = DASHBOARD.read_text()
|
||||
|
||||
assert 'id="pull-attachment"' in html
|
||||
assert 'accept="image/png,image/jpeg,image/webp"' in html
|
||||
assert 'for="pull-attachment"' in html
|
||||
assert 'id="pull-attachment-preview"' in html
|
||||
assert 'id="pull-attachment-image"' in html
|
||||
assert 'id="remove-pull-attachment"' in html
|
||||
assert "const pullAttachmentController = issueAttachment.mount({" in source
|
||||
assert "pullAttachmentController.clear();" in source
|
||||
|
||||
|
||||
def test_retrying_same_pull_load_preserves_screenshot_but_target_change_clears_it():
|
||||
source = DASHBOARD.read_text()
|
||||
open_body = source.split("async function openPullSheet(item, trigger, offlineDetail = null) {", 1)[1].split(
|
||||
"\n function closePullSheet", 1
|
||||
)[0]
|
||||
|
||||
assert "if (!sameWorkTarget(selectedPull, item)) pullAttachmentController.clear();" in open_body
|
||||
|
||||
|
||||
def test_pull_screenshot_comment_uploads_or_durably_admits_before_clearing_draft():
|
||||
source = DASHBOARD.read_text()
|
||||
|
||||
assert "async function queuePullScreenshotComment" in source
|
||||
assert "attachment: await pullAttachmentController.serialize()" in source
|
||||
assert "await authoredOutbox.enqueueDurably(message)" in source
|
||||
assert "navigator.onLine === false" in source
|
||||
assert "Your comment and screenshot are safe; retry." in source
|
||||
|
||||
|
||||
def test_pull_screenshot_foreground_and_replay_share_the_durable_operation_pipeline():
|
||||
source = DASHBOARD.read_text()
|
||||
standard = source.split(
|
||||
"qs('#send-pull-comment').addEventListener('click', async () => {", 1
|
||||
)[1].split("\n });", 1)[0]
|
||||
comment_next = source.split("async function submitCommentAndNext(kind) {", 1)[1].split(
|
||||
"\n }\n qs('#send-issue-comment-next')", 1
|
||||
)[0]
|
||||
|
||||
assert "await queuePullScreenshotComment(item, body, operationId, false, true)" in standard
|
||||
assert "pullAttachmentController.prepareComment" not in standard
|
||||
assert "attachmentController.state() && (kind === 'pull' || navigator.onLine === false)" in comment_next
|
||||
@@ -402,3 +402,73 @@ async def test_attachment_endpoint_rejects_unsafe_or_mismatched_filename(monkeyp
|
||||
|
||||
assert [traversal.status_code, mismatch.status_code] == [422, 422]
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_attachment_endpoint_uploads_binary_to_exact_assigned_pull(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def upload(repository, number, filename, content_type, content):
|
||||
calls.append((repository, number, filename, content_type, content))
|
||||
return {
|
||||
"name": filename,
|
||||
"url": "https://forge.example/attachments/pull.png",
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "upload_assigned_pull_attachment", upload, raising=False
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/web/pulls/31/attachments",
|
||||
files={"file": ("pull.png", PNG_BYTES, "image/png")},
|
||||
headers={"Idempotency-Key": "pull-image-31:attachment"},
|
||||
)
|
||||
replay = await client.post(
|
||||
"/api/v1/repos/stackchain/web/pulls/31/attachments",
|
||||
files={"file": ("pull.png", PNG_BYTES, "image/png")},
|
||||
headers={"Idempotency-Key": "pull-image-31:attachment"},
|
||||
)
|
||||
|
||||
assert response.status_code == replay.status_code == 201
|
||||
assert replay.json() == response.json()
|
||||
assert response.json()["markdown"] == (
|
||||
""
|
||||
)
|
||||
assert calls == [("stackchain/web", 31, "pull.png", "image/png", PNG_BYTES)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_pull_attachment_revalidates_assignment_before_exact_pull_upload():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"state": "open", "assignees": [{"login": "timmy"}],
|
||||
})
|
||||
return httpx.Response(201, json={
|
||||
"name": "pull.png", "size": len(PNG_BYTES),
|
||||
"browser_download_url": "https://forge.example/attachments/pull.png",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.upload_assigned_pull_attachment(
|
||||
"stackchain/web", 31, "pull.png", "image/png", PNG_BYTES
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/web/pulls/31"),
|
||||
("POST", "/api/v1/repos/stackchain/web/issues/31/assets"),
|
||||
]
|
||||
assert PNG_BYTES in requests[-1].content
|
||||
assert result["url"] == "https://forge.example/attachments/pull.png"
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,6 +137,48 @@ async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch
|
||||
assert "freshness" in second
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_warm_unchanged_polls_only_load_shared_metadata(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
first = payload(await main.live_snapshot())
|
||||
full_load = main._live_snapshot_store.load
|
||||
metadata_load = main._live_snapshot_store.load_metadata
|
||||
calls = {"full": 0, "metadata": 0}
|
||||
|
||||
def counted_full_load():
|
||||
calls["full"] += 1
|
||||
return full_load()
|
||||
|
||||
def counted_metadata_load():
|
||||
calls["metadata"] += 1
|
||||
return metadata_load()
|
||||
|
||||
monkeypatch.setattr(main._live_snapshot_store, "load", counted_full_load)
|
||||
monkeypatch.setattr(main._live_snapshot_store, "load_metadata", counted_metadata_load)
|
||||
|
||||
for _ in range(10):
|
||||
response = await main.live_snapshot(
|
||||
context_revision=first["revisions"]["context"],
|
||||
events_revision=first["revisions"]["events"],
|
||||
notifications_revision=first["revisions"]["notifications"],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "context" not in payload(response)
|
||||
|
||||
assert calls == {"full": 0, "metadata": 10}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_token_from_another_worker_never_suppresses_content(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import threading
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -7,6 +8,26 @@ from src import live_snapshot_store
|
||||
from src.live_snapshot_store import LiveSnapshotStore, RefreshLeaseLost
|
||||
|
||||
|
||||
def test_metadata_load_does_not_retrieve_or_decode_snapshot_value(tmp_path):
|
||||
store = LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: 100.0)
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE live_snapshot SET value_json = ? WHERE singleton = 1",
|
||||
("not-json",),
|
||||
)
|
||||
|
||||
metadata = store.load_metadata()
|
||||
|
||||
assert metadata.created_at == {
|
||||
section: None for section in ("context", "events", "notifications")
|
||||
}
|
||||
assert metadata.revisions == {
|
||||
section: 0 for section in ("context", "events", "notifications")
|
||||
}
|
||||
with pytest.raises(ValueError):
|
||||
store.load()
|
||||
|
||||
|
||||
def test_independent_stores_racing_for_refresh_have_one_lease_winner(tmp_path):
|
||||
path = tmp_path / "live.sqlite3"
|
||||
first = LiveSnapshotStore(path, clock=lambda: 100.0)
|
||||
|
||||
@@ -72,3 +72,122 @@ def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
|
||||
|
||||
|
||||
def test_failed_sign_ins_are_aggregated_across_workers_and_survive_throttle_clear(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,
|
||||
alert_bucket_seconds=300,
|
||||
)
|
||||
second = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
alert_bucket_seconds=300,
|
||||
)
|
||||
|
||||
first.record_failure("203.0.113.7", method="token")
|
||||
now[0] = 1_010.0
|
||||
second.record_failure("198.51.100.4", method="token")
|
||||
first.clear("203.0.113.7")
|
||||
|
||||
assert second.list_alerts() == [
|
||||
{
|
||||
"method": "token",
|
||||
"failed_count": 2,
|
||||
"blocked_count": 0,
|
||||
"first_at": 1_000,
|
||||
"last_at": 1_010,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_rate_blocked_sign_ins_increment_a_distinct_privacy_safe_count(tmp_path):
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: 1_000.0,
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
store.record_blocked(method="token")
|
||||
|
||||
assert store.list_alerts() == [
|
||||
{
|
||||
"method": "token",
|
||||
"failed_count": 0,
|
||||
"blocked_count": 1,
|
||||
"first_at": 1_000,
|
||||
"last_at": 1_000,
|
||||
}
|
||||
]
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
columns = [row[1] for row in connection.execute("PRAGMA table_info(login_alerts)")]
|
||||
assert "source_hash" not in columns
|
||||
|
||||
|
||||
def test_expired_sign_in_alert_buckets_are_not_returned(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
alert_retention_seconds=300,
|
||||
)
|
||||
store.record_failure("203.0.113.7")
|
||||
|
||||
now[0] = 1_301.0
|
||||
|
||||
assert store.list_alerts() == []
|
||||
|
||||
|
||||
def test_sign_in_alert_bucket_count_is_bounded_during_rotating_source_floods(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
alert_bucket_seconds=60,
|
||||
max_alert_buckets=2,
|
||||
)
|
||||
for index, timestamp in enumerate((1_000.0, 1_060.0, 1_120.0)):
|
||||
now[0] = timestamp
|
||||
store.record_failure(f"203.0.113.{index}")
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
count = connection.execute("SELECT COUNT(*) FROM login_alerts").fetchone()[0]
|
||||
|
||||
assert count == 2
|
||||
|
||||
|
||||
def test_named_admission_budget_is_atomic_across_instances_and_source_scoped(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.admit("passkey_options", "203.0.113.7", limit=2) == 0
|
||||
assert second.admit("passkey_options", "203.0.113.7", limit=2) == 0
|
||||
assert first.admit("passkey_options", "203.0.113.7", limit=2) == 60
|
||||
assert first.admit("passkey_options", "203.0.113.8", limit=2) == 0
|
||||
|
||||
now[0] = 1_060.0
|
||||
assert second.admit("passkey_options", "203.0.113.7", limit=2) == 0
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v83" in worker
|
||||
assert "stackchain-dashboard-shell-v87" in worker
|
||||
@@ -41,7 +41,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v83" in worker
|
||||
assert "stackchain-dashboard-shell-v87" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
+450
-11
@@ -176,19 +176,137 @@ controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}
|
||||
}
|
||||
|
||||
|
||||
def test_unread_issue_update_claims_and_starts_once_without_marking_it_read():
|
||||
script = f"""
|
||||
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
||||
let claims = 0;
|
||||
let starts = 0;
|
||||
let finishClaim;
|
||||
const reconciled = [];
|
||||
const states = [];
|
||||
const controller = createUpdateOwnership({{
|
||||
available: () => true,
|
||||
claim: () => {{ claims += 1; return new Promise(resolve => {{ finishClaim = resolve; }}); }},
|
||||
addToday: () => 'added',
|
||||
start: item => {{ starts += 1; return item.notification_id === 42 ? 'started' : 'broken'; }},
|
||||
recover: () => {{ throw new Error('recovery should not run'); }},
|
||||
onClaimed: item => reconciled.push(item),
|
||||
onStartState: state => states.push(state),
|
||||
onState: () => {{}},
|
||||
}});
|
||||
controller.open({{
|
||||
repository:'stackchain/api', title:'Retry deploy',
|
||||
issue:{{number:7, assignees:[], claimable:true}},
|
||||
}}, {{notification_id:42, updated_at:'2026-08-08T12:00:00Z'}});
|
||||
const first = controller.start();
|
||||
const second = controller.start();
|
||||
if (first !== second || claims !== 1) throw new Error('start was not single-flight');
|
||||
finishClaim({{number:7, title:'Retry deploy', assignees:['timmy'], state:'open'}});
|
||||
(async () => {{
|
||||
const result = await first;
|
||||
process.stdout.write(JSON.stringify({{result, claims, starts, reconciled, states}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["result"] == "started"
|
||||
assert output["claims"] == 1
|
||||
assert output["starts"] == 1
|
||||
assert len(output["reconciled"]) == 1
|
||||
assert output["reconciled"][0]["notification_id"] == 42
|
||||
assert output["reconciled"][0]["has_update"] is True
|
||||
assert output["states"][-1] == {
|
||||
"action": "hidden",
|
||||
"busy": False,
|
||||
"message": "Assigned, added to Today, and ready to work. The update is still unread.",
|
||||
}
|
||||
|
||||
|
||||
def test_unread_update_start_checks_today_capacity_before_claiming():
|
||||
script = f"""
|
||||
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
||||
let claims = 0;
|
||||
const states = [];
|
||||
const controller = createUpdateOwnership({{
|
||||
available: () => false,
|
||||
claim: () => {{ claims += 1; return Promise.resolve({{number:7}}); }},
|
||||
addToday: () => 'added', start: () => 'started',
|
||||
onStartState: state => states.push(state), onState: () => {{}},
|
||||
}});
|
||||
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
|
||||
(async () => {{
|
||||
const result = await controller.start();
|
||||
process.stdout.write(JSON.stringify({{result, claims, states}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["result"] == "full"
|
||||
assert output["claims"] == 0
|
||||
assert output["states"][-1] == {
|
||||
"action": "start",
|
||||
"busy": False,
|
||||
"message": "Today is limited to 5 items. Remove one before taking ownership.",
|
||||
}
|
||||
|
||||
|
||||
def test_unread_update_opens_owned_issue_when_today_start_throws():
|
||||
script = f"""
|
||||
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
||||
let recovered = null;
|
||||
const states = [];
|
||||
const controller = createUpdateOwnership({{
|
||||
available: () => true,
|
||||
claim: () => Promise.resolve({{number:7, title:'Retry deploy'}}),
|
||||
addToday: () => 'added',
|
||||
start: () => {{ throw new Error('storage failed'); }},
|
||||
recover: item => {{ recovered = item; }},
|
||||
onStartState: state => states.push(state), onState: () => {{}},
|
||||
}});
|
||||
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
|
||||
(async () => {{
|
||||
const result = await controller.start();
|
||||
process.stdout.write(JSON.stringify({{result, recovered, states}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["result"] == "recovery"
|
||||
assert output["recovered"]["number"] == 7
|
||||
assert output["states"][-1] == {
|
||||
"action": "hidden",
|
||||
"busy": False,
|
||||
"message": "Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_sheet_wires_phone_safe_ownership_to_my_work_and_today():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/update-ownership.js"></script>' in html
|
||||
assert 'id="update-ownership-action"' in html
|
||||
assert 'id="update-ownership-start"' in html
|
||||
assert '>Take ownership & start</button>' in html
|
||||
assert 'hidden aria-describedby="update-sheet-status"' in html
|
||||
assert 'const updateOwnership = createUpdateOwnership({' in html
|
||||
assert 'available: () => createAndStart.available()' in html
|
||||
assert 'const outcome = createAndStart.complete(item)' in html
|
||||
assert "openRoutedWork(item, qs('#update-ownership-start'))" in html
|
||||
assert 'updateOwnership.open(detail, selectedUpdate)' in html
|
||||
assert "qs('#update-ownership-action').addEventListener('click'" in html
|
||||
assert "qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start())" in html
|
||||
assert "lastContextSnapshot.issues" in html
|
||||
assert "todayWork.add(item)" in html
|
||||
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
||||
assert '.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden;' in html
|
||||
|
||||
|
||||
def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
|
||||
@@ -693,6 +811,77 @@ controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
|
||||
assert output["result"] == [{"login": "alex", "name": "Alexander"}]
|
||||
|
||||
|
||||
def test_issue_blocker_mutation_is_single_flight_and_requires_canonical_confirmation():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let calls = [];
|
||||
let finish;
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
fetchJson:(url, options) => {{ calls.push({{url, options}}); return new Promise(resolve => finish = resolve); }},
|
||||
}});
|
||||
const item = {{repository:'stackchain/dashboard', number:17}};
|
||||
const blocker = {{repository:'stackchain/api', number:9}};
|
||||
const first = controller.updateBlocker(item, blocker, false);
|
||||
const duplicate = controller.updateBlocker(item, blocker, false);
|
||||
finish({{repository:item.repository, number:item.number, dependencies_available:true,
|
||||
dependencies:[{{...blocker,title:'Restore API',state:'open'}}]}});
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
|
||||
same:first === duplicate, result:results[0]
|
||||
}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["same"] is True
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
"method": "POST",
|
||||
"body": {"repository": "stackchain/api", "number": 9},
|
||||
}]
|
||||
assert output["result"]["dependencies"][0]["number"] == 9
|
||||
|
||||
|
||||
def test_issue_blocker_removal_rejects_unconfirmed_canonical_state():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
fetchJson:async () => ({{dependencies_available:true,
|
||||
dependencies:[{{repository:'stackchain/api',number:9,state:'open'}}]}}),
|
||||
}});
|
||||
controller.updateBlocker(
|
||||
{{repository:'stackchain/dashboard',number:17}},
|
||||
{{repository:'stackchain/api',number:9}}, true
|
||||
).then(() => process.stdout.write('unexpected'))
|
||||
.catch(error => process.stdout.write(error.message));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
assert result.stdout == "Blocker change was not confirmed."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_sheet_manages_blockers_with_search_and_touch_safe_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="manage-issue-blockers"' in html
|
||||
assert 'id="issue-blocker-search" type="search"' in html
|
||||
assert 'id="issue-blocker-results" role="listbox"' in html
|
||||
assert 'id="cancel-issue-blocker"' in html
|
||||
assert "issueController.updateBlocker(selectedIssue, blocker, false)" in html
|
||||
assert "issueController.updateBlocker(selectedIssue, blocker, true)" in html
|
||||
assert "api('api/v1/search?q='" in html
|
||||
assert "result.kind === 'issue' && result.state === 'open'" in html
|
||||
assert "renderPlanIssueDependencies(selectedIssueDetail)" in html
|
||||
assert ".issue-blocker-manager :is(input,button) { min-height:44px;" in html
|
||||
assert "width:100%; max-width:100%; box-sizing:border-box" in html
|
||||
assert "overflow-wrap:anywhere" in html
|
||||
|
||||
|
||||
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
@@ -3123,6 +3312,68 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||
]
|
||||
|
||||
|
||||
def test_notification_selection_tracks_a_bounded_subset_and_clears_on_cancel():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const states = [];
|
||||
const selection = buildMyWork.createNotificationSelection({{
|
||||
limit: 2,
|
||||
onChange: state => states.push(state),
|
||||
}});
|
||||
selection.start();
|
||||
const first = selection.select(42);
|
||||
const duplicate = selection.select(42);
|
||||
selection.toggle(42);
|
||||
selection.select(43);
|
||||
selection.select(44);
|
||||
const second = selection.select(45);
|
||||
selection.cancel();
|
||||
process.stdout.write(JSON.stringify({{first, duplicate, second, states, snapshot:selection.snapshot()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["first"] == "selected"
|
||||
assert output["duplicate"] == "already-selected"
|
||||
assert output["second"] == "limit"
|
||||
assert output["states"] == [
|
||||
{"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
||||
{"active": True, "ids": [42], "count": 1, "limit": 2, "at_limit": False},
|
||||
{"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
||||
{"active": True, "ids": [43], "count": 1, "limit": 2, "at_limit": False},
|
||||
{"active": True, "ids": [43, 44], "count": 2, "limit": 2, "at_limit": True},
|
||||
{"active": False, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
||||
]
|
||||
assert output["snapshot"] == output["states"][-1]
|
||||
|
||||
|
||||
def test_notification_selection_retains_only_failed_ids_after_partial_acknowledgement():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const states = [];
|
||||
const selection = buildMyWork.createNotificationSelection({{
|
||||
onChange: state => states.push(state),
|
||||
}});
|
||||
selection.start();
|
||||
selection.select(42);
|
||||
selection.select(43);
|
||||
selection.select(44);
|
||||
const retained = selection.retain([44, 44, 99]);
|
||||
process.stdout.write(JSON.stringify({{retained, states}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["retained"] == {
|
||||
"active": True, "ids": [44], "count": 1, "limit": 50, "at_limit": False,
|
||||
}
|
||||
assert output["states"][-1] == output["retained"]
|
||||
|
||||
|
||||
def test_notification_pager_is_single_flight_and_merges_unique_updates():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
@@ -3250,6 +3501,68 @@ reader.open(original[0], original).then(() =>
|
||||
assert output["result"]["next"]["notification_id"] == 43
|
||||
|
||||
|
||||
def test_notification_reader_acknowledges_once_and_opens_next_update():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = [
|
||||
{{kind:'update', notification_id:42, has_update:true}},
|
||||
{{kind:'update', notification_id:43, has_update:true}},
|
||||
];
|
||||
let release;
|
||||
const calls = [];
|
||||
const events = [];
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: async id => ({{id}}), markRead: async () => {{}},
|
||||
acknowledge: id => new Promise(resolve => {{ calls.push(id); release = resolve; }}),
|
||||
onOpen: item => events.push(['open', item.notification_id]), onDetail: () => {{}},
|
||||
onItems: next => events.push(['items', next.map(item => item.notification_id)]),
|
||||
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
|
||||
}});
|
||||
(async () => {{
|
||||
await reader.open(items[0]);
|
||||
events.length = 0;
|
||||
const first = reader.acknowledgeAndNext(items);
|
||||
const duplicate = reader.acknowledgeAndNext(items);
|
||||
await Promise.resolve();
|
||||
release({{reaction:'created', status:'read'}});
|
||||
const results = await Promise.all([first, duplicate]);
|
||||
process.stdout.write(JSON.stringify({{calls, events, results}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["calls"] == [42]
|
||||
assert output["events"] == [
|
||||
["status", "Adding reaction and marking read…"],
|
||||
["items", [43]],
|
||||
["open", 43],
|
||||
["status", "Loading update…"],
|
||||
["status", "Update ready."],
|
||||
]
|
||||
assert output["results"][0]["next"]["notification_id"] == 43
|
||||
assert output["results"][1] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
|
||||
html = await dashboard()
|
||||
|
||||
assert (
|
||||
'id="acknowledge-update-next" type="button" hidden '
|
||||
'aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>'
|
||||
) in html
|
||||
assert "qs('#acknowledge-update-next').hidden = true;" in html
|
||||
assert "qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;" in html
|
||||
assert "async function acknowledgeNotification(notificationId)" in html
|
||||
assert "'/acknowledge', { method: 'POST'" in html
|
||||
assert "acknowledge: acknowledgeNotification" in html
|
||||
assert "notificationReader.acknowledgeAndNext(lastMyWork)" in html
|
||||
assert "qs('#acknowledge-update-next').disabled = offline;" in html
|
||||
assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html
|
||||
|
||||
|
||||
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
@@ -3847,7 +4160,9 @@ const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const states = [
|
||||
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
|
||||
{{state:'open', draft:true, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
|
||||
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'failure', head_sha:'abc'}},
|
||||
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'failure', head_sha:'abc', checks:[
|
||||
{{name:'lint', state:'failure'}}, {{name:'release', state:'pending'}}, {{name:'build', state:'success'}}
|
||||
]}},
|
||||
{{state:'open', draft:false, mergeable:false, merged:false, ci_state:'success', head_sha:'abc'}},
|
||||
];
|
||||
process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)));
|
||||
@@ -3856,7 +4171,7 @@ process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)
|
||||
output = json.loads(result.stdout)
|
||||
assert output[0] == {"allowed": True, "reason": "Ready to merge"}
|
||||
assert output[1]["allowed"] is False and "draft" in output[1]["reason"].lower()
|
||||
assert output[2]["allowed"] is False and "CI" in output[2]["reason"]
|
||||
assert output[2] == {"allowed": False, "reason": "CI blocked by lint, release"}
|
||||
assert output[3]["allowed"] is False and "conflict" in output[3]["reason"].lower()
|
||||
|
||||
|
||||
@@ -3935,6 +4250,38 @@ Promise.allSettled([first, concurrent]).then(async failed => {{
|
||||
]
|
||||
|
||||
|
||||
def test_pull_sheet_refreshes_status_only_without_clearing_review_progress():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v)}};
|
||||
const calls = [];
|
||||
const sheet = createPullSheet({{storage, fetchJson: url => {{
|
||||
calls.push(url);
|
||||
return Promise.resolve({{head_sha:'abc123', ci_state:calls.length === 1 ? 'pending' : 'success',
|
||||
checks:[{{name:'lint', state:calls.length === 1 ? 'pending' : 'success'}}],
|
||||
files:[{{filename:'src/api.py'}}]}});
|
||||
}}}});
|
||||
const item = {{repository:'stackchain/api', number:7}};
|
||||
sheet.loadReview(item, 'abc123').then(first => {{
|
||||
sheet.toggleReviewed(item, first, 'src/api.py');
|
||||
return sheet.loadChecks(item).then(refreshed => {{
|
||||
const combined = {{...first, ...refreshed}};
|
||||
process.stdout.write(JSON.stringify({{calls, refreshed, progress:sheet.reviewState(item, combined)}}));
|
||||
}});
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == [
|
||||
"api/v1/repos/stackchain/api/pulls/7/review-data",
|
||||
"api/v1/repos/stackchain/api/pulls/7/checks",
|
||||
]
|
||||
assert output["refreshed"]["ci_state"] == "success"
|
||||
assert output["progress"] == {"reviewed": ["src/api.py"], "total": 1, "complete": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_pull_sheet_puts_reading_before_collapsed_review_controls():
|
||||
html = await dashboard()
|
||||
@@ -3994,7 +4341,10 @@ async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
|
||||
assert "pullConversation.append(comment)" in html
|
||||
assert 'id="pull-comment"' in html and 'maxlength="10000"' in html
|
||||
assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html
|
||||
assert '<script src="static/pull-sheet.js"></script>' in html
|
||||
assert 'stackchain-feature-pull-workflow' in html
|
||||
assert "pullWorkflowFeatures.run('pull-workflow'" in html
|
||||
assert "let pullController = null" in html
|
||||
assert "let reviewController = null" in html
|
||||
assert "pullController.load(item)" in html
|
||||
assert "createPullSheet.renderFile" in html
|
||||
assert "pullController.toggleReviewed" in html
|
||||
@@ -4114,6 +4464,14 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
|
||||
assert '.create-issue-label-option' in html and 'min-height:44px' in html
|
||||
|
||||
|
||||
def test_new_issue_capture_waits_for_retryable_feature_before_opening():
|
||||
dashboard_source = (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert "await issueCaptureFeatures.run('issue-capture'" in dashboard_source
|
||||
assert "if (!issueCapture)" in dashboard_source
|
||||
assert "async function openCreateIssueSheet" in dashboard_source
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
|
||||
html = await dashboard()
|
||||
@@ -4259,18 +4617,26 @@ async def test_mobile_update_sheet_has_persistent_accessible_reply_composer():
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
|
||||
async def test_updates_view_offers_accessible_sticky_mobile_subset_selection():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="bulk-mark-read"' in html
|
||||
assert 'id="bulk-mark-read-bar"' in html
|
||||
assert 'class="my-work-bulk"' in html
|
||||
assert 'id="select-updates"' in html
|
||||
assert 'id="cancel-update-selection"' in html
|
||||
assert 'id="update-selection-status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert 'class="update-selector"' in html
|
||||
assert 'type="checkbox"' in html
|
||||
assert 'Select update ' in html
|
||||
assert '.update-selector { min-height:44px;' in html
|
||||
assert '.my-work-bulk { position:sticky;' in html
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
|
||||
assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
|
||||
assert "'next ' + ids.length + ' of ' + allIds.length + ' loaded updates'" in html
|
||||
assert "'Confirm marking ' + bulkLabel + ' read'" in html
|
||||
assert "const ids = allIds.slice(0, 50)" in html
|
||||
assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
|
||||
assert "'Mark ' + selection.count + ' selected read'" in html
|
||||
assert "'Confirm marking ' + selection.count + ' selected read'" in html
|
||||
assert 'bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids)' in html
|
||||
assert 'notificationSelection.retain(result.failed)' in html
|
||||
assert "notificationSelection.cancel()" in html
|
||||
assert "createBulkNotificationAcknowledger" in html
|
||||
assert "api/v1/notifications/read" in html
|
||||
assert "body: JSON.stringify({ ids })" in html
|
||||
@@ -4287,7 +4653,8 @@ async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile(
|
||||
assert "api/v1/notifications?page=" in html
|
||||
assert "snapshot.notification_pagination" in html
|
||||
assert "notificationPager.loadMore(lastNotifications)" in html
|
||||
assert "Math.min(lastNotifications.length, 50)" in html
|
||||
assert "createNotificationSelection({" in html
|
||||
assert "limit: 50" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -4335,6 +4702,28 @@ controller.load({{ repository: 'stackchain/api', number: 7 }}).then(detail =>
|
||||
}
|
||||
|
||||
|
||||
def test_review_controller_refreshes_checks_through_status_only_path():
|
||||
script = f"""
|
||||
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const calls = [];
|
||||
const controller = createReviewController({{ fetchJson: async (url, options) => {{
|
||||
calls.push({{url, accept:options.headers.Accept}});
|
||||
return {{head_sha:'abc123', ci_state:'success', checks:[]}};
|
||||
}} }});
|
||||
controller.loadChecks({{repository:'stackchain/api', number:7}}).then(status =>
|
||||
process.stdout.write(JSON.stringify({{calls, status}}))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/api/pulls/7/review/checks",
|
||||
"accept": "application/json",
|
||||
}]
|
||||
assert output["status"]["ci_state"] == "success"
|
||||
|
||||
|
||||
def test_review_diff_rows_escape_content_and_toggle_accessibly():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
@@ -4375,6 +4764,53 @@ process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expand
|
||||
assert output["hidden"] is False
|
||||
|
||||
|
||||
def test_review_checks_render_blockers_first_with_safe_touch_links():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const escapeHtml = value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
||||
const result = reviewSheet.renderChecks([
|
||||
{{name:'build', state:'success', description:'Passed'}},
|
||||
{{name:'lint <mobile>', state:'failure', description:'Open & fix', url:'https://forge.example/jobs/4'}},
|
||||
{{name:'release', state:'pending', description:'Waiting'}},
|
||||
], escapeHtml, {{offline:false}});
|
||||
const offline = reviewSheet.renderChecks([
|
||||
{{name:'lint', state:'success', description:'Passed'}},
|
||||
], escapeHtml, {{offline:true}});
|
||||
process.stdout.write(JSON.stringify({{result, offline}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["result"]["summary"] == "1 failed · 1 pending · 1 passed"
|
||||
assert output["result"]["expanded"] is True
|
||||
assert output["result"]["html"].index("lint <mobile>") < output["result"]["html"].index("build")
|
||||
assert 'href="https://forge.example/jobs/4"' in output["result"]["html"]
|
||||
assert 'target="_blank" rel="noopener noreferrer"' in output["result"]["html"]
|
||||
assert "Open job" in output["result"]["html"]
|
||||
assert output["offline"]["summary"].startswith("Last known · ")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_pull_review_sheets_refresh_checks_without_reloading_diffs():
|
||||
html = await dashboard()
|
||||
|
||||
assert html.index('id="pull-checks"') < html.index('id="pull-files"')
|
||||
assert html.index('id="review-checks"') < html.index('id="review-files"')
|
||||
assert 'id="refresh-pull-checks"' in html
|
||||
assert 'id="refresh-review-checks"' in html
|
||||
assert "createReviewController.renderChecks" in html
|
||||
assert "pullController.loadChecks(item)" in html
|
||||
assert "reviewController.loadChecks(item)" in html
|
||||
assert "New commits detected. Reload review data before merging." in html
|
||||
assert "New commits detected. Reload the review before submitting feedback." in html
|
||||
assert "qs('#refresh-review-checks').disabled = offlineReview" in html
|
||||
assert ".ci-check-link" in html and "min-height:44px" in html
|
||||
assert ".ci-check-copy" in html and "overflow-wrap:anywhere" in html
|
||||
|
||||
|
||||
def test_review_wrap_preference_defaults_to_phone_layout_and_persists_explicit_choice():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
@@ -4682,6 +5118,9 @@ async def test_review_attention_drafts_require_revision_instead_of_resending_sta
|
||||
assert "reviewOutbox && item.status === 'attention'" in html
|
||||
assert ">Open current review</button>" in html
|
||||
assert ">Copy feedback</button>" in html
|
||||
assert "reviewOutbox && item.status === 'authorization'" in html
|
||||
assert ">Authorize & send review</button>" in html
|
||||
assert "Review submitted and queued intent cleared." in html
|
||||
assert "item.kind === 'authored-outbox' && !reviewOutbox" in html
|
||||
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol
|
||||
"assignees": [],
|
||||
"claimable": True,
|
||||
}
|
||||
assert result["acknowledge_supported"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -41,6 +41,117 @@ async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monk
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
calls.append(("fetch", path))
|
||||
if path == "notifications/threads/42":
|
||||
return {
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Issue",
|
||||
"url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
|
||||
"latest_comment_url": (
|
||||
"https://forge.example/api/v1/repos/stackchain/api/issues/comments/91"
|
||||
),
|
||||
},
|
||||
}
|
||||
if path == "user":
|
||||
return {"login": "timmy"}
|
||||
raise AssertionError(path)
|
||||
|
||||
class Response:
|
||||
def __init__(self, payload=None):
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
class Client:
|
||||
async def get(self, path, headers):
|
||||
calls.append(("get", path))
|
||||
return Response([])
|
||||
|
||||
async def post(self, path, headers, json):
|
||||
calls.append(("post", path, json))
|
||||
return Response({"content": "+1"})
|
||||
|
||||
async def patch(self, path, headers):
|
||||
calls.append(("patch", path))
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
|
||||
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
|
||||
|
||||
result = await gitea_proxy.acknowledge_notification(42)
|
||||
|
||||
reaction_path = "/api/v1/repos/stackchain/api/issues/comments/91/reactions"
|
||||
assert result == {"id": 42, "reaction": "created", "status": "read"}
|
||||
assert calls == [
|
||||
("fetch", "notifications/threads/42"),
|
||||
("fetch", "user"),
|
||||
("get", reaction_path),
|
||||
("post", reaction_path, {"content": "+1"}),
|
||||
("patch", "/api/v1/notifications/threads/42?to-status=read"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_reuses_existing_operator_reaction_on_retry(monkeypatch):
|
||||
posts = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
if path == "user":
|
||||
return {"login": "timmy"}
|
||||
return {
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Pull",
|
||||
"url": "https://forge.example/api/v1/repos/stackchain/api/pulls/7",
|
||||
"latest_comment_url": (
|
||||
"https://forge.example/api/v1/repos/stackchain/api/issues/comments/91"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
class Response:
|
||||
def __init__(self, payload=None):
|
||||
self.payload = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
class Client:
|
||||
async def get(self, path, headers):
|
||||
return Response([{"content": "+1", "user": {"login": "timmy"}}])
|
||||
|
||||
async def post(self, path, headers, json):
|
||||
posts.append((path, json))
|
||||
return Response()
|
||||
|
||||
async def patch(self, path, headers):
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
|
||||
|
||||
result = await gitea_proxy.acknowledge_notification(42)
|
||||
|
||||
assert result == {"id": 42, "reaction": "existing", "status": "read"}
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeypatch):
|
||||
marked = []
|
||||
@@ -61,6 +172,33 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
|
||||
assert marked == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def acknowledge(thread_id):
|
||||
calls.append(thread_id)
|
||||
return {"id": thread_id, "reaction": "created", "status": "read"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "acknowledge_notification", acknowledge, raising=False)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_live_snapshot_value",
|
||||
{"notifications": [{"id": 42}, {"id": 43}]},
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post("/api/v1/notifications/42/acknowledge")
|
||||
invalid = await client.post("/api/v1/notifications/0/acknowledge")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": 42, "reaction": "created", "status": "read"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert invalid.status_code == 422
|
||||
assert calls == [42]
|
||||
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_snapshot_maintenance_does_not_block_the_event_loop(monkeypatch):
|
||||
async def mark(_thread_id):
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "offline-issue-blocker.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_offline_blocker_reports_queued_only_after_durable_admission():
|
||||
script = f"""
|
||||
const createOfflineIssueBlocker=require({json.dumps(str(MODULE))});
|
||||
const events=[];
|
||||
const queue=createOfflineIssueBlocker({{
|
||||
createOperationId:()=> 'blocker-op',
|
||||
enqueueDurably:async message=>{{events.push(['admit',message]);return{{item:message,durability:'background'}};}},
|
||||
onQueued:(item,blocker,present,admission)=>events.push(['queued',item.number,blocker.number,present,admission.durability]),
|
||||
}});
|
||||
(async()=>{{
|
||||
const result=await queue(
|
||||
{{repository:'stackchain/dashboard',number:17}},
|
||||
{{repository:'stackchain/api',number:9,title:'Restore API'}},
|
||||
false
|
||||
);
|
||||
process.stdout.write(JSON.stringify({{events,result}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["events"] == [
|
||||
["admit", {
|
||||
"kind": "issue-blocker", "repository": "stackchain/dashboard", "number": 17,
|
||||
"blockerRepository": "stackchain/api", "blockerNumber": 9,
|
||||
"present": False, "operationId": "blocker-op",
|
||||
}],
|
||||
["queued", 17, 9, False, "background"],
|
||||
]
|
||||
assert output["result"]["admission"]["durability"] == "background"
|
||||
|
||||
|
||||
def test_offline_blocker_does_not_report_queued_when_durable_admission_fails():
|
||||
script = f"""
|
||||
const createOfflineIssueBlocker=require({json.dumps(str(MODULE))});
|
||||
let queued=false;
|
||||
const queue=createOfflineIssueBlocker({{
|
||||
enqueueDurably:async()=>{{throw new Error('IndexedDB unavailable');}},
|
||||
onQueued:()=>queued=true,
|
||||
}});
|
||||
queue({{repository:'o/r',number:1}},{{repository:'o/b',number:2}},true)
|
||||
.then(()=>process.stdout.write(JSON.stringify({{queued}})))
|
||||
.catch(error=>process.stdout.write(JSON.stringify({{queued,error:error.message}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"queued": False, "error": "IndexedDB unavailable"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_issue_exposes_offline_blocker_queue_in_mobile_sheet():
|
||||
html = await dashboard()
|
||||
dashboard_js = (MODULE.parent / "dashboard.js").read_text()
|
||||
service_worker = (MODULE.parent / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/offline-issue-blocker.js"></script>' in html
|
||||
assert "BASE + 'static/offline-issue-blocker.js'" in service_worker
|
||||
assert "const queueOfflineIssueBlocker = createOfflineIssueBlocker({" in dashboard_js
|
||||
assert "qs('#manage-issue-blockers').hidden = !assignedIssue;" in dashboard_js
|
||||
assert "await queueOfflineIssueBlocker(item, blocker, !remove);" in dashboard_js
|
||||
assert "Blocker removal queued. Today remains blocked until Stackchain confirms delivery." in dashboard_js
|
||||
assert "const candidates = selectedIssueOffline ? workSession.items()" in dashboard_js
|
||||
@@ -56,6 +56,33 @@ process.stdout.write(JSON.stringify({peak, loaded, saved, status}));
|
||||
assert result["status"] == {"total": 5, "ready": 5, "failed": 0, "pending": 0}
|
||||
|
||||
|
||||
def test_readiness_waits_for_durable_async_admission_and_reports_rejection():
|
||||
result = run_scenario("""
|
||||
const events = [];
|
||||
const warmer = createOfflineToday({
|
||||
loadDetail: async () => ({title:'Fetched detail'}),
|
||||
loadSavedDetail: async () => null,
|
||||
saveDetail: async () => {
|
||||
events.push('admission-started');
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
events.push('admission-rejected');
|
||||
return false;
|
||||
},
|
||||
onStatus: status => events.push('ready:' + status.ready + '/failed:' + status.failed),
|
||||
});
|
||||
const status = await warmer.warm('timmy', [{
|
||||
kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'v1',
|
||||
}]);
|
||||
process.stdout.write(JSON.stringify({events, status, failed:warmer.failedKeys()}));
|
||||
""")
|
||||
|
||||
assert result["status"] == {"total": 1, "ready": 0, "failed": 1, "pending": 0}
|
||||
assert result["failed"] == ["issue:stackchain/dashboard:1"]
|
||||
assert result["events"].index("admission-rejected") < result["events"].index(
|
||||
"ready:0/failed:1"
|
||||
)
|
||||
|
||||
|
||||
def test_refreshes_only_changed_revisions_and_keeps_prior_copy_on_failure():
|
||||
result = run_scenario("""
|
||||
const items = [
|
||||
|
||||
+232
-1
@@ -27,6 +27,221 @@ const storage = {{
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def run_async_scenario(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createOfflineWorkStore = require({json.dumps(str(OFFLINE_WORK))});
|
||||
const values = new Map();
|
||||
const records = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
const transaction = async work => work({{
|
||||
get: async key => records.get(key),
|
||||
getAll: async () => [...records.values()],
|
||||
put: async value => records.set(value.id, structuredClone(value)),
|
||||
delete: async key => records.delete(key),
|
||||
clear: async () => records.clear(),
|
||||
}});
|
||||
(async () => {{
|
||||
{scenario}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_indexeddb_commit_persists_snapshot_and_details_without_localstorage_payloads():
|
||||
result = run_async_scenario("""
|
||||
const store = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:00Z')});
|
||||
store.setEnabled(true);
|
||||
const snapshotSaved = await store.save({
|
||||
user:{login:'timmy', full_name:'Timmy'}, issues:[{number:8, title:'Large offline review'}],
|
||||
pull_requests:[], notifications:[],
|
||||
});
|
||||
const item = {kind:'pull', repository:'stackchain/dashboard', number:8, is_review:true};
|
||||
const detailSaved = await store.saveDetail('timmy', item, {
|
||||
title:'Large offline review', head_sha:'abc123',
|
||||
files:[{filename:'large.diff', diff_lines:['+private review line']}],
|
||||
});
|
||||
const restarted = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:01:00Z')});
|
||||
process.stdout.write(JSON.stringify({
|
||||
snapshotSaved, detailSaved,
|
||||
loaded:await restarted.load('timmy'),
|
||||
detail:await restarted.loadDetail('timmy', item),
|
||||
localValues:[...values.values()].join(' '),
|
||||
recordIds:[...records.keys()].sort(),
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result["snapshotSaved"] is True
|
||||
assert result["detailSaved"] is True
|
||||
assert result["loaded"]["user"]["login"] == "timmy"
|
||||
assert result["detail"]["head_sha"] == "abc123"
|
||||
assert "Large offline review" not in result["localValues"]
|
||||
assert "private review line" not in result["localValues"]
|
||||
assert result["recordIds"] == ["detail:timmy:pull:stackchain/dashboard:8", "snapshot"]
|
||||
|
||||
|
||||
def test_legacy_localstorage_payloads_migrate_only_after_indexeddb_commit():
|
||||
result = run_async_scenario("""
|
||||
values.set('stackchain.offline-work.enabled.v1', 'true');
|
||||
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
|
||||
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
|
||||
data:{user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]},
|
||||
}));
|
||||
values.set('stackchain.offline-work.details.v1', JSON.stringify([{
|
||||
key:'issue:stackchain/dashboard:9', user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
|
||||
data:{title:'Migrated issue'},
|
||||
}]));
|
||||
const store = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:01:00Z')});
|
||||
const snapshot = await store.load('timmy');
|
||||
const detail = await store.loadDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:9});
|
||||
process.stdout.write(JSON.stringify({
|
||||
snapshot, detail,
|
||||
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1'),
|
||||
legacyDetails:values.has('stackchain.offline-work.details.v1'),
|
||||
recordIds:[...records.keys()].sort(),
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result["snapshot"]["user"]["login"] == "timmy"
|
||||
assert result["detail"]["title"] == "Migrated issue"
|
||||
assert result["legacySnapshot"] is False
|
||||
assert result["legacyDetails"] is False
|
||||
assert result["recordIds"] == ["detail:timmy:issue:stackchain/dashboard:9", "snapshot"]
|
||||
|
||||
|
||||
def test_failed_migration_keeps_legacy_snapshot_readable_and_untouched():
|
||||
result = run_async_scenario("""
|
||||
values.set('stackchain.offline-work.enabled.v1', 'true');
|
||||
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
|
||||
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
|
||||
data:{user:{login:'timmy'}, issues:[{number:7, title:'Keep me'}], pull_requests:[], notifications:[]},
|
||||
}));
|
||||
let attempts = 0;
|
||||
const flakyTransaction = async work => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error('quota admission failed');
|
||||
return transaction(work);
|
||||
};
|
||||
const store = createOfflineWorkStore({storage, transaction:flakyTransaction, now:() => new Date('2026-08-07T12:01:00Z')});
|
||||
const snapshot = await store.load('timmy');
|
||||
process.stdout.write(JSON.stringify({
|
||||
snapshot, attempts,
|
||||
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1'),
|
||||
recordIds:[...records.keys()],
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result["snapshot"]["issues"][0]["title"] == "Keep me"
|
||||
assert result["legacySnapshot"] is True
|
||||
assert result["recordIds"] == []
|
||||
|
||||
|
||||
def test_ready_atomically_prunes_all_expired_indexeddb_records():
|
||||
result = run_async_scenario("""
|
||||
const writer = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:00Z'), maxAgeMs:1000});
|
||||
writer.setEnabled(true);
|
||||
await writer.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
|
||||
await writer.saveDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:1}, {title:'One'});
|
||||
await writer.saveDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:2}, {title:'Two'});
|
||||
const expired = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:02Z'), maxAgeMs:1000});
|
||||
await expired.ready();
|
||||
process.stdout.write(JSON.stringify({recordIds:[...records.keys()]}));
|
||||
""")
|
||||
|
||||
assert result["recordIds"] == []
|
||||
|
||||
|
||||
def test_ready_degrades_within_deadline_when_indexeddb_never_settles():
|
||||
result = run_async_scenario("""
|
||||
values.set('stackchain.offline-work.enabled.v1', 'true');
|
||||
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
|
||||
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
|
||||
data:{user:{login:'timmy'}, issues:[{number:7, title:'Still available'}], pull_requests:[], notifications:[]},
|
||||
}));
|
||||
const neverTransaction = async () => new Promise(() => {});
|
||||
const store = createOfflineWorkStore({
|
||||
storage, transaction:neverTransaction, initializationTimeoutMs:20,
|
||||
now:() => new Date('2026-08-07T12:01:00Z'),
|
||||
});
|
||||
const started = Date.now();
|
||||
const outcome = await Promise.race([
|
||||
store.ready().then(value => ({value, status:store.status(), saved:store.load('timmy')})),
|
||||
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 100)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({...outcome, elapsed:Date.now() - started,
|
||||
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1')}));
|
||||
""")
|
||||
|
||||
assert result.get("timedOut", False) is False
|
||||
assert result["value"] is False
|
||||
assert result["status"] == {"state": "degraded", "reason": "deadline"}
|
||||
assert result["saved"]["issues"][0]["title"] == "Still available"
|
||||
assert result["legacySnapshot"] is True
|
||||
assert result["elapsed"] < 100
|
||||
|
||||
|
||||
def test_degraded_store_rejects_saves_without_waiting_for_stalled_indexeddb():
|
||||
result = run_async_scenario("""
|
||||
values.set('stackchain.offline-work.enabled.v1', 'true');
|
||||
const neverTransaction = async () => new Promise(() => {});
|
||||
const store = createOfflineWorkStore({storage, transaction:neverTransaction, initializationTimeoutMs:10});
|
||||
await store.ready();
|
||||
const outcome = await Promise.race([
|
||||
store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]})
|
||||
.then(value => ({value})),
|
||||
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 80)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(outcome));
|
||||
""")
|
||||
|
||||
assert result == {"value": False}
|
||||
|
||||
|
||||
def test_degraded_store_clears_legacy_data_without_waiting_for_indexeddb():
|
||||
result = run_async_scenario("""
|
||||
values.set('stackchain.offline-work.snapshot.v1', '{"private":"snapshot"}');
|
||||
values.set('stackchain.offline-work.details.v1', '[{"private":"detail"}]');
|
||||
const neverTransaction = async () => new Promise(() => {});
|
||||
const store = createOfflineWorkStore({storage, transaction:neverTransaction, initializationTimeoutMs:10});
|
||||
await store.ready();
|
||||
const outcome = await Promise.race([
|
||||
store.clear().then(value => ({value, keys:[...values.keys()]})),
|
||||
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 80)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(outcome));
|
||||
""")
|
||||
|
||||
assert result == {"value": True, "keys": []}
|
||||
|
||||
|
||||
def test_retry_recovers_durable_saving_after_initialization_deadline():
|
||||
result = run_async_scenario("""
|
||||
let stalled = true;
|
||||
const recoveringTransaction = async work => stalled ? new Promise(() => {}) : transaction(work);
|
||||
const store = createOfflineWorkStore({storage, transaction:recoveringTransaction, initializationTimeoutMs:10});
|
||||
const initial = await store.ready();
|
||||
stalled = false;
|
||||
const recovered = await store.retry();
|
||||
store.setEnabled(true);
|
||||
const saved = await store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
|
||||
process.stdout.write(JSON.stringify({initial, recovered, saved, status:store.status(), recordIds:[...records.keys()]}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"initial": False,
|
||||
"recovered": True,
|
||||
"saved": True,
|
||||
"status": {"state": "ready", "reason": ""},
|
||||
"recordIds": ["snapshot"],
|
||||
}
|
||||
|
||||
|
||||
def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields():
|
||||
result = run_scenario("""
|
||||
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
|
||||
@@ -213,11 +428,27 @@ async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydr
|
||||
assert '.offline-work-controls button { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_uses_async_indexeddb_offline_store_end_to_end():
|
||||
html = await dashboard()
|
||||
|
||||
assert "createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB })" in html
|
||||
assert "let offlineStorageReady = await offlineWorkStore.ready();" in html
|
||||
assert 'id="retry-offline-storage"' in html
|
||||
assert "Offline saving unavailable · online work remains live." in html
|
||||
assert "await offlineWorkStore.retry()" in html
|
||||
assert "async function hydrateOfflineWork" in html
|
||||
assert "const saved = await offlineWorkStore.load();" in html
|
||||
assert "await offlineWorkStore.save({" in html
|
||||
assert "await offlineWorkStore.clear();" in html
|
||||
assert "Offline saving unavailable · retry after reconnect." in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snapshot():
|
||||
html = await dashboard()
|
||||
|
||||
assert "if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;" in html
|
||||
assert "if (!hasContextSnapshot && await hydrateOfflineWork('outage')) return;" in html
|
||||
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
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import sqlite3
|
||||
|
||||
from src.passkey_store import PasskeyStore
|
||||
|
||||
|
||||
def test_active_challenges_are_bounded_per_source_and_globally_across_instances(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "passkeys.sqlite3"
|
||||
first = PasskeyStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_challenges=3,
|
||||
max_challenges_per_source=2,
|
||||
)
|
||||
second = PasskeyStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_challenges=3,
|
||||
max_challenges_per_source=2,
|
||||
)
|
||||
|
||||
for challenge in (b"first", b"second", b"third"):
|
||||
first.issue_challenge(
|
||||
challenge,
|
||||
session_id=None,
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
source="203.0.113.7",
|
||||
)
|
||||
second.issue_challenge(
|
||||
b"other-one",
|
||||
session_id=None,
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
source="203.0.113.8",
|
||||
)
|
||||
second.issue_challenge(
|
||||
b"other-two",
|
||||
session_id=None,
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
source="203.0.113.8",
|
||||
)
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM passkey_challenges").fetchone() == (3,)
|
||||
counts = connection.execute(
|
||||
"SELECT source_hash, COUNT(*) FROM passkey_challenges GROUP BY source_hash"
|
||||
).fetchall()
|
||||
assert sorted(count for _source, count in counts) == [1, 2]
|
||||
|
||||
now[0] = 1_121.0
|
||||
first.issue_challenge(
|
||||
b"after-expiry",
|
||||
session_id=None,
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
source="203.0.113.9",
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM passkey_challenges").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_existing_challenge_registry_is_migrated_without_losing_live_challenges(tmp_path):
|
||||
database = tmp_path / "passkeys.sqlite3"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE passkey_challenges (
|
||||
challenge_hash TEXT PRIMARY KEY,
|
||||
session_hash TEXT,
|
||||
purpose TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO passkey_challenges VALUES ('existing', NULL, "
|
||||
"'authentication', 'sign_in', 'dashboard', 1120)"
|
||||
)
|
||||
store = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
|
||||
store.issue_challenge(
|
||||
b"new",
|
||||
session_id=None,
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
source="203.0.113.7",
|
||||
)
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(passkey_challenges)")
|
||||
}
|
||||
rows = connection.execute(
|
||||
"SELECT challenge_hash FROM passkey_challenges ORDER BY challenge_hash"
|
||||
).fetchall()
|
||||
assert "source_hash" in columns
|
||||
assert sorted(rows) == sorted([("existing",), (store._digest(b"new"),)])
|
||||
@@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -43,7 +43,10 @@ const clear = createPrivateDeviceDataPurger({{
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert state["remaining"] == ["other.preference"]
|
||||
assert state["databases"] == ["stackchain-background-outbox-v1"]
|
||||
assert state["databases"] == [
|
||||
"stackchain-background-outbox-v1",
|
||||
"stackchain-offline-work-v2",
|
||||
]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert state["complete"] is True
|
||||
@@ -125,6 +125,69 @@ async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monk
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_pull_check_status_skips_files_reviews_and_diff():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={
|
||||
"state": "open", "draft": False, "mergeable": True, "merged": False,
|
||||
"head": {"sha": "abc123"},
|
||||
})
|
||||
if request.url.path.endswith("/commits/abc123/status"):
|
||||
return httpx.Response(200, json={
|
||||
"state": "success",
|
||||
"statuses": [{"context": "tests", "status": "success", "description": "Passed"}],
|
||||
})
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
status = await gitea_proxy.pull_check_status("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("GET", "/api/v1/repos/stackchain/api/commits/abc123/status"),
|
||||
]
|
||||
assert status == {
|
||||
"repository": "stackchain/api", "number": 7, "head_sha": "abc123",
|
||||
"state": "open", "draft": False, "mergeable": True, "merged": False,
|
||||
"ci_state": "success",
|
||||
"checks": [{"name": "tests", "state": "success", "description": "Passed", "url": ""}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_checks_endpoint_authorizes_and_returns_status_only(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
calls.append(("assigned", repository, number))
|
||||
return True
|
||||
|
||||
async def checks(repository, number):
|
||||
calls.append(("checks", repository, number))
|
||||
return {"head_sha": "abc123", "ci_state": "pending", "checks": []}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "pull_check_status", checks)
|
||||
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/pulls/7/checks")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {"head_sha": "abc123", "ci_state": "pending", "checks": []}
|
||||
assert calls == [
|
||||
("assigned", "stackchain/api", 7),
|
||||
("checks", "stackchain/api", 7),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
|
||||
async def handler(request):
|
||||
|
||||
@@ -122,6 +122,15 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
|
||||
assert main.request_body_limit("POST", "/unrelated") is None
|
||||
|
||||
|
||||
def test_pull_screenshot_upload_uses_binary_attachment_boundary():
|
||||
assert (
|
||||
main.request_body_limit(
|
||||
"POST", "/api/v1/repos/stackchain/project/pulls/31/attachments"
|
||||
)
|
||||
== 2 * 1024 * 1024 + 64 * 1024
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_request_limit_uses_application_path_under_domain_subpath():
|
||||
downstream_called = False
|
||||
|
||||
@@ -36,6 +36,84 @@ async def test_review_detail_endpoint_returns_normalized_no_store_payload(monkey
|
||||
assert response.json()["files"][0]["filename"] == "src/api.py"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_requested_review_checks_endpoint_authorizes_and_returns_status_only(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def requested(repository, number):
|
||||
calls.append(("requested", repository, number))
|
||||
return True
|
||||
|
||||
async def checks(repository, number):
|
||||
calls.append(("checks", repository, number))
|
||||
return {"head_sha": "def456", "ci_state": "success", "checks": []}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "pull_check_status", checks)
|
||||
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/pulls/7/review/checks")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {"head_sha": "def456", "ci_state": "success", "checks": []}
|
||||
assert calls == [
|
||||
("requested", "stackchain/api", 7),
|
||||
("checks", "stackchain/api", 7),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_review_detail_returns_bounded_actionable_checks(monkeypatch):
|
||||
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example/git")
|
||||
|
||||
async def handler(request):
|
||||
path = request.url.path
|
||||
if path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={
|
||||
"title": "Review API", "head": {"sha": "abc123"},
|
||||
"user": {"login": "alex"},
|
||||
})
|
||||
if path.endswith("/pulls/7/files") or path.endswith("/pulls/7/reviews"):
|
||||
return httpx.Response(200, json=[])
|
||||
if path.endswith("/commits/abc123/status"):
|
||||
return httpx.Response(200, json={
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "lint/<unsafe>", "status": "failure",
|
||||
"description": "Formatting failed " + "x" * 300,
|
||||
"target_url": "https://forge.example/git/stackchain/api/actions/runs/9/jobs/4",
|
||||
},
|
||||
{
|
||||
"context": "external", "status": "pending",
|
||||
"description": "Waiting", "target_url": "https://evil.example/job/1",
|
||||
},
|
||||
{"context": "build-release", "status": "success", "description": "Passed"},
|
||||
] + [{"context": f"extra-{index}", "status": "success"} for index in range(30)],
|
||||
})
|
||||
if path.endswith("/pulls/7.diff"):
|
||||
return httpx.Response(200, text="")
|
||||
raise AssertionError(f"unexpected request: {request.method} {path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
detail = await gitea_proxy.pull_review_detail("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(detail["checks"]) == 20
|
||||
assert detail["checks"][:3] == [
|
||||
{
|
||||
"name": "lint/<unsafe>", "state": "failure",
|
||||
"description": "Formatting failed " + "x" * 222,
|
||||
"url": "https://forge.example/git/stackchain/api/actions/runs/9/jobs/4",
|
||||
},
|
||||
{"name": "external", "state": "pending", "description": "Waiting", "url": ""},
|
||||
{"name": "build-release", "state": "success", "description": "Passed", "url": ""},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_detail_rejects_pulls_not_requested_from_service_user(monkeypatch):
|
||||
async def requested(repository, number):
|
||||
|
||||
@@ -53,6 +53,7 @@ async def test_authenticated_security_activity_lists_private_sign_in_history(sec
|
||||
"status": "completed",
|
||||
}
|
||||
],
|
||||
"authentication_alerts": [],
|
||||
"next_cursor": None,
|
||||
}
|
||||
persisted = (security_access / "security.sqlite3").read_bytes()
|
||||
@@ -75,6 +76,172 @@ async def test_security_activity_limit_is_validated(security_access):
|
||||
assert invalid_cursor.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_enrollment_reservation_failure_preserves_registry(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
class VerifiedRegistration:
|
||||
credential_id = b"phone-credential"
|
||||
credential_public_key = b"credential-public-key"
|
||||
sign_count = 0
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "enroll_passkey",
|
||||
"target": "current_device",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
options = await client.post(
|
||||
"/api/v1/passkeys/registration/options",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
enrolled = await client.post(
|
||||
"/api/v1/passkeys/registration/verify",
|
||||
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert enrolled.status_code == 503
|
||||
assert enrolled.json() == {"detail": "Security activity is temporarily unavailable"}
|
||||
assert main._passkey_store().all() == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_enrollment_registry_failure_discards_reserved_event(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
class VerifiedRegistration:
|
||||
credential_id = b"phone-credential"
|
||||
credential_public_key = b"credential-public-key"
|
||||
sign_count = 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "enroll_passkey",
|
||||
"target": "current_device",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
options = await client.post(
|
||||
"/api/v1/passkeys/registration/options",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
store = main._passkey_store()
|
||||
|
||||
def fail_registration(**_kwargs):
|
||||
raise main.dashboard_auth.SessionStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(store, "register", fail_registration)
|
||||
monkeypatch.setattr(main, "_passkey_store", lambda: store)
|
||||
enrolled = await client.post(
|
||||
"/api/v1/passkeys/registration/verify",
|
||||
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
events = SecurityEventStore(
|
||||
security_access / "security.sqlite3", clock=lambda: 0
|
||||
).list(limit=10).events
|
||||
assert enrolled.status_code == 503
|
||||
assert enrolled.json() == {"detail": "Passkey registry is temporarily unavailable"}
|
||||
assert all(event.kind != "passkey_enrolled" for event in events)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_enrollment_finalization_failure_retains_pending_event(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
class VerifiedRegistration:
|
||||
credential_id = b"phone-credential"
|
||||
credential_public_key = b"credential-public-key"
|
||||
sign_count = 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "enroll_passkey",
|
||||
"target": "current_device",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
options = await client.post(
|
||||
"/api/v1/passkeys/registration/options",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
journal = main._security_event_store()
|
||||
|
||||
def fail_finalization(_operation_id):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(journal, "finalize", fail_finalization)
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: journal)
|
||||
enrolled = await client.post(
|
||||
"/api/v1/passkeys/registration/verify",
|
||||
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
|
||||
headers=headers,
|
||||
)
|
||||
activity = await client.get("/api/v1/security-events")
|
||||
|
||||
event = activity.json()["events"][0]
|
||||
assert enrolled.status_code == 201
|
||||
assert enrolled.json() == {"enrolled": True}
|
||||
assert event["kind"] == "passkey_enrolled"
|
||||
assert event["status"] == "pending"
|
||||
assert event["device_label"] == "Phone"
|
||||
assert event["method"] == "passkey"
|
||||
assert event["target"] == "passkey"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_revoked_device_activity_survives_live_session_removal(security_access):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -234,6 +401,117 @@ async def test_successful_protected_actions_record_only_bounded_targets(
|
||||
assert b"abc123" not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_approved_pull_review_records_a_privacy_safe_completed_event(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
async def requested(*_args):
|
||||
return True
|
||||
|
||||
async def submit(*_args):
|
||||
return {"id": 91, "state": "APPROVED"}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "submit_pull_review",
|
||||
"target": "stackchain/api#7@abc123:approve",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={
|
||||
"expected_head_sha": "abc123",
|
||||
"decision": "approve",
|
||||
"body": "Ready to ship with private review notes.",
|
||||
},
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
events = (await client.get("/api/v1/security-events")).json()["events"]
|
||||
|
||||
assert response.status_code == 201
|
||||
assert events[0] == {
|
||||
"id": events[0]["id"],
|
||||
"kind": "pull_review_approved",
|
||||
"method": None,
|
||||
"device_label": None,
|
||||
"target": "stackchain/api#7",
|
||||
"created_at": events[0]["created_at"],
|
||||
"status": "completed",
|
||||
}
|
||||
persisted = (security_access / "security.sqlite3").read_bytes()
|
||||
assert b"abc123" not in persisted
|
||||
assert b"private review notes" not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_reservation_failure_prevents_the_gitea_mutation(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
submit_calls = []
|
||||
|
||||
async def requested(*_args):
|
||||
return True
|
||||
|
||||
async def submit(*args):
|
||||
submit_calls.append(args)
|
||||
return {"id": 91, "state": "APPROVED"}
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
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"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "submit_pull_review",
|
||||
"target": "stackchain/api#7@abc123:approve",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={
|
||||
"expected_head_sha": "abc123",
|
||||
"decision": "approve",
|
||||
"body": "Ready.",
|
||||
},
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "Security activity is temporarily unavailable"
|
||||
assert submit_calls == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_close_reservation_failure_prevents_the_gitea_mutation(
|
||||
security_access, monkeypatch
|
||||
|
||||
@@ -17,7 +17,10 @@ const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
new Response(JSON.stringify({{expires_at: Math.floor(Date.now() / 1000) + 3600}})),
|
||||
new Response(JSON.stringify({{
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
idle_expires_at: Math.floor(Date.now() / 1000) + 900,
|
||||
}})),
|
||||
);
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
@@ -122,7 +125,7 @@ async function dispatchNotificationClick(route) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -131,14 +134,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -146,7 +149,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -154,14 +157,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -170,21 +173,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -328,6 +331,7 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
|
||||
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'},
|
||||
{id:'review-1',status:'authorization',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');
|
||||
@@ -353,6 +357,14 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
|
||||
"data": {"route": "#/my-work/drafts"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"title": "Queued review needs authorization",
|
||||
"options": {
|
||||
"body": "Tap to authorize it in the Delivery center.",
|
||||
"tag": "stackchain-delivery-review-1",
|
||||
"data": {"route": "#/my-work/drafts"},
|
||||
},
|
||||
},
|
||||
]
|
||||
assert result["focused"] == [
|
||||
"https://forge.example/dashboard/#/my-work/issue/stackchain/api/44"
|
||||
@@ -385,7 +397,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -407,6 +419,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/icons/stackchain-192.png",
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/session.js",
|
||||
"/dashboard/static/feature-loader.js",
|
||||
"/dashboard/static/markdown.js",
|
||||
"/dashboard/static/commands.js",
|
||||
"/dashboard/static/search-preview.js",
|
||||
@@ -417,6 +430,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/offline-issue-close.js",
|
||||
"/dashboard/static/offline-issue-blocker.js",
|
||||
"/dashboard/static/notification-read-outbox.js",
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/offline-today.js",
|
||||
@@ -426,6 +440,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/today-completion.js",
|
||||
"/dashboard/static/today-readiness.js",
|
||||
"/dashboard/static/comment-next.js",
|
||||
"/dashboard/static/update-reply-read-next.js",
|
||||
"/dashboard/static/plan-today.js",
|
||||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
@@ -493,7 +508,7 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
|
||||
def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchMessage({type:'stackchain-session-lease', expiresAt:1});
|
||||
await dispatchMessage({type:'stackchain-session-lease', expiresAt:1, idleExpiresAt:1});
|
||||
state.failFetch = true;
|
||||
state.cachedBody = 'private cached dashboard';
|
||||
const response = await dispatch('fetch', {
|
||||
@@ -514,6 +529,36 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
|
||||
assert "private cached dashboard" not in result["body"]
|
||||
|
||||
|
||||
def test_expired_idle_lease_locks_cached_shell_without_purging_recoverable_work():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.clientList = [{postMessage: message => { state.clientMessage = message; }}];
|
||||
await dispatchMessage({
|
||||
type:'stackchain-session-lease',
|
||||
expiresAt:Math.floor(Date.now() / 1000) + 3600,
|
||||
idleExpiresAt:1,
|
||||
});
|
||||
state.failFetch = true;
|
||||
state.cachedBody = 'private cached dashboard';
|
||||
const response = await dispatch('fetch', {
|
||||
method:'GET', mode:'navigate', url:'https://forge.example/dashboard/',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({
|
||||
state, status:response.status, body:await response.text(),
|
||||
cacheControl:response.headers.get('Cache-Control'),
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["status"] == 401
|
||||
assert result["body"] == "Your Stackchain session is locked. Reconnect and sign in."
|
||||
assert result["cacheControl"] == "no-store"
|
||||
assert result["state"]["outboxPurges"] == 0
|
||||
assert result["state"]["deleted"] == []
|
||||
assert result["state"]["clientMessage"] == {"type": "stackchain-session-idle"}
|
||||
assert "private cached dashboard" not in result["body"]
|
||||
|
||||
|
||||
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v83';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v87';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
OUTBOX = ROOT / "frontend" / "authored-outbox.js"
|
||||
SYNC = ROOT / "frontend" / "background-issue-sync.js"
|
||||
PNG = b"\x89PNG\r\n\x1a\nmobile-update"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_idempotency():
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
yield
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_attachment_endpoint_resolves_exact_pull_server_side(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def upload(thread_id, filename, content_type, content):
|
||||
calls.append((thread_id, filename, content_type, content))
|
||||
return {"name": filename, "url": "https://forge.example/a/proof.webp", "size": len(content)}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_notification_attachment", upload, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/notifications/527/attachments",
|
||||
files={"file": ("proof.png", PNG, "image/png")},
|
||||
headers={"Idempotency-Key": "reply-527:attachment"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["markdown"] == ""
|
||||
assert calls == [(527, "proof.png", "image/png", PNG)]
|
||||
assert main.request_body_limit("POST", "/api/v1/notifications/527/attachments") == 2 * 1024 * 1024 + 64 * 1024
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_proxy_notification_upload_trusts_only_matching_gitea_subject_path():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"subject": {
|
||||
"type": "Pull",
|
||||
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/web/pulls/31",
|
||||
},
|
||||
})
|
||||
return httpx.Response(201, json={
|
||||
"name": "proof.png", "size": len(PNG),
|
||||
"browser_download_url": "https://forge.example/a/proof.png",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.upload_notification_attachment(527, "proof.png", "image/png", PNG)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/notifications/threads/527"),
|
||||
("POST", "/api/v1/repos/stackchain/web/issues/31/assets"),
|
||||
]
|
||||
assert result["url"] == "https://forge.example/a/proof.png"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_reply_composer_offers_mobile_safe_removable_screenshot_preview():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="update-reply-attachment"' in html
|
||||
assert 'accept="image/png,image/jpeg,image/webp"' in html
|
||||
assert 'id="update-reply-attachment-preview"' in html
|
||||
assert 'id="remove-update-reply-attachment"' in html
|
||||
assert "const updateReplyAttachmentController = issueAttachment.mount({" in html
|
||||
assert "await updateReplyAttachmentController.serialize()" in html
|
||||
assert ".update-reply .issue-attachment-preview { width:100%; min-width:0; }" in html
|
||||
assert "@media (max-width:390px)" in html
|
||||
|
||||
|
||||
def test_authored_outbox_durably_keeps_account_bound_update_screenshot_out_of_localstorage():
|
||||
script = f"""
|
||||
const createOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const mirrored=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
|
||||
reconcile:async items=>mirrored.push(items),requestSync:async()=>{{}},
|
||||
}}}});
|
||||
(async()=>{{const result=await outbox.enqueueDurably({{
|
||||
kind:'update-reply-read',notificationId:527,body:'',operationId:'reply-image',
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',blob:new Blob(['private-bytes'],{{type:'image/png'}})}},
|
||||
}});process.stdout.write(JSON.stringify({{
|
||||
result,local:outbox.list()[0],raw:values.get('stackchain.authored-outbox.v1'),
|
||||
durable:{{ownerLogin:mirrored[0][0].ownerLogin,text:await mirrored[0][0].attachment.blob.text()}},
|
||||
}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["local"]["attachment"] == {
|
||||
"filename": "phone.png", "contentType": "image/png", "stored": True
|
||||
}
|
||||
assert output["durable"] == {"ownerLogin": "timmy", "text": "private-bytes"}
|
||||
assert "private-bytes" not in output["raw"]
|
||||
|
||||
|
||||
def test_update_screenshot_is_not_admitted_without_indexeddb_durability():
|
||||
script = f"""
|
||||
const createOutbox=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=createOutbox({{storage,getOwnerLogin:()=>'timmy'}});
|
||||
(async()=>{{let error='';try{{await outbox.enqueueDurably({{
|
||||
kind:'update-reply',notificationId:527,body:'proof',operationId:'no-db',
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',data:'private-bytes'}},
|
||||
}});}}catch(caught){{error=caught.message;}}
|
||||
process.stdout.write(JSON.stringify({{error,items:outbox.list(),raw:values.get('stackchain.authored-outbox.v1')}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["error"] == "Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry."
|
||||
assert output["items"] == []
|
||||
assert "private-bytes" not in output["raw"]
|
||||
|
||||
|
||||
def test_background_update_reply_screenshot_checkpoints_upload_then_reply_then_read():
|
||||
script = f"""
|
||||
const createSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'reply-image',operationId:'reply-image',ownerLogin:'timmy',status:'queued',
|
||||
kind:'update-reply-read',notificationId:527,body:'',replyConfirmed:false,
|
||||
attachment:{{filename:'phone.webp',contentType:'image/webp',blob:new Blob(['pixels'],{{type:'image/webp'}})}}}};
|
||||
const calls=[];let replyAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},
|
||||
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body instanceof FormData?'multipart':options.body?JSON.parse(options.body):null}});
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
if(url.endsWith('/reply') && replyAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
|
||||
return url.endsWith('/reply')?{{id:8}}:{{status:'read'}};
|
||||
}};
|
||||
(async()=>{{const sync=createSync({{store,fetchJson}});try{{await sync.flush();}}catch(_error){{}}
|
||||
const checkpoint={{attachmentMarkdown:item.attachmentMarkdown,replyConfirmed:item.replyConfirmed}};
|
||||
const result=await sync.flush();process.stdout.write(JSON.stringify({{calls,checkpoint,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["checkpoint"]["attachmentMarkdown"].startswith("![phone.webp]")
|
||||
assert output["checkpoint"]["replyConfirmed"] is False
|
||||
assert [call["url"] for call in output["calls"]] == [
|
||||
"api/v1/notifications/527/attachments",
|
||||
"api/v1/notifications/527/reply",
|
||||
"api/v1/notifications/527/reply",
|
||||
"api/v1/notifications/527/read",
|
||||
]
|
||||
assert [call["key"] for call in output["calls"][:3]] == [
|
||||
"reply-image:attachment", "reply-image:reply", "reply-image:reply"
|
||||
]
|
||||
assert output["calls"][2]["body"] == {"body": ""}
|
||||
assert output["result"]["confirmed"] == [{"status": "read"}]
|
||||
@@ -0,0 +1,176 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
CONTROLLER = ROOT / "frontend" / "update-reply-read-next.js"
|
||||
SYNC = ROOT / "frontend" / "background-issue-sync.js"
|
||||
DRAFTS = ROOT / "frontend" / "drafts.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_every_unread_update_offers_one_reply_read_next_mobile_action():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="send-update-reply-read-next"' in html
|
||||
assert '>Reply, mark read & next</button>' in html
|
||||
assert "workSession.checkpointed(selectedUpdate)" not in html
|
||||
assert '<script src="static/update-reply-read-next.js"></script>' in html
|
||||
assert ".update-reply-actions button { min-height:44px; width:100%; }" in html
|
||||
|
||||
|
||||
def test_reply_read_next_orders_online_delivery_and_is_single_flight():
|
||||
script = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
const calls=[]; let releaseReply;
|
||||
const controller=createController({{
|
||||
post:(item,body,id)=>{{calls.push(['reply',item.notification_id,body,id]);return new Promise(resolve=>releaseReply=resolve);}},
|
||||
markRead:id=>{{calls.push(['read',id]);return Promise.resolve({{ok:true}});}},
|
||||
queue:()=>{{throw new Error('must not queue');}}, canQueue:()=>false,
|
||||
accept:item=>calls.push(['accept',item.notification_id]),
|
||||
next:item=>{{calls.push(['next',item.notification_id]);return {{next:true}};}},
|
||||
}});
|
||||
const item={{notification_id:91}};
|
||||
const first=controller.submit(item,'Done','op-91');
|
||||
const second=controller.submit(item,'Done','op-91');
|
||||
releaseReply({{id:7}});
|
||||
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{same:first===second,calls,results}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"same": True,
|
||||
"calls": [
|
||||
["reply", 91, "Done", "op-91"],
|
||||
["read", 91],
|
||||
["accept", 91],
|
||||
["next", 91],
|
||||
],
|
||||
"results": [
|
||||
{"accepted": True, "delivery": "posted", "next": {"next": True}},
|
||||
{"accepted": True, "delivery": "posted", "next": {"next": True}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_reply_read_next_waits_until_the_next_update_is_open():
|
||||
script = f"""
|
||||
const createController=require({json.dumps(str(CONTROLLER))});
|
||||
let openNext;
|
||||
const controller=createController({{
|
||||
post:async()=>({{id:1}}),markRead:async()=>({{ok:true}}),queue:async()=>null,canQueue:()=>false,
|
||||
next:()=>new Promise(resolve=>openNext=()=>resolve({{notification_id:2}})),
|
||||
}});
|
||||
let settled=false;
|
||||
const result=controller.submit({{notification_id:1}},'Done','op-1').then(value=>{{settled=true;return value;}});
|
||||
setTimeout(()=>{{
|
||||
const before=settled;
|
||||
openNext();
|
||||
result.then(value=>process.stdout.write(JSON.stringify({{before,value}})));
|
||||
}},0);
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"before": False,
|
||||
"value": {
|
||||
"accepted": True,
|
||||
"delivery": "posted",
|
||||
"next": {"notification_id": 2},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_reply_read_next_durably_checkpoints_partial_delivery_without_reposting():
|
||||
script = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
const calls=[];
|
||||
const offline=Object.assign(new Error('offline'),{{status:503}});
|
||||
const controller=createController({{
|
||||
post:async()=>{{calls.push('reply');return{{id:8}};}},
|
||||
markRead:async()=>{{calls.push('read');throw offline;}},
|
||||
canQueue:error=>error.status===503,
|
||||
queue:async message=>{{calls.push(['queue',message]);return{{item:{{id:'saved'}},background:true}};}},
|
||||
accept:item=>calls.push(['accept',item.notification_id]),
|
||||
next:item=>{{calls.push(['next',item.notification_id]);return true;}},
|
||||
}});
|
||||
controller.submit({{notification_id:22}},'Ship it','stable-op').then(result=>
|
||||
process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == [
|
||||
"reply",
|
||||
"read",
|
||||
["queue", {
|
||||
"kind": "update-reply-read",
|
||||
"notificationId": 22,
|
||||
"body": "Ship it",
|
||||
"operationId": "stable-op",
|
||||
"replyConfirmed": True,
|
||||
}],
|
||||
["accept", 22],
|
||||
["next", 22],
|
||||
]
|
||||
assert output["result"] == {"accepted": True, "delivery": "queued", "next": True}
|
||||
|
||||
|
||||
def test_background_retry_resumes_at_read_after_reply_checkpoint():
|
||||
item = {
|
||||
"id": "op-31", "operationId": "op-31", "ownerLogin": "timmy",
|
||||
"status": "queued", "kind": "update-reply-read", "notificationId": 31,
|
||||
"body": "Acknowledged", "replyConfirmed": True,
|
||||
}
|
||||
script = f"""
|
||||
const createSync=require({json.dumps(str(SYNC))});
|
||||
let queued={json.dumps(item)};const calls=[];
|
||||
const store={{
|
||||
claimNext:async owner=>queued?(queued=null,{{...{json.dumps(item)}}}):null,
|
||||
complete:async()=>calls.push('complete'),release:async()=>calls.push('release'),
|
||||
fail:async()=>calls.push('fail'),countBlocked:async()=>0,
|
||||
checkpoint:async()=>{{throw new Error('must not checkpoint again');}},
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push([url,options.method]);return url==='api/v1/background-identity'?{{login:'timmy'}}:{{ok:true}};}};
|
||||
createSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == [
|
||||
["api/v1/background-identity", None],
|
||||
["api/v1/notifications/31/read", "PATCH"],
|
||||
"complete",
|
||||
]
|
||||
assert output["result"]["confirmed"] == [{"ok": True}]
|
||||
|
||||
|
||||
def test_partial_reply_read_delivery_is_recoverable_from_update_drafts():
|
||||
record = {"version": 2, "items": [{
|
||||
"id": "op-44", "operationId": "op-44", "ownerLogin": "timmy",
|
||||
"status": "attention", "kind": "update-reply-read", "notificationId": 44,
|
||||
"body": "Handled", "replyConfirmed": True, "error": "Read failed",
|
||||
}]}
|
||||
script = f"""
|
||||
const createDraftInbox=require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({json.dumps(record)})]]);
|
||||
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i],getItem:k=>values.get(k)||null,
|
||||
setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["outbox_kind"] == "update-reply-read"
|
||||
assert output["title"] == "Update #44"
|
||||
assert output["route"] == {"kind": "update", "notification_id": 44}
|
||||
assert output["status"] == "attention"
|
||||
Reference in new issue
Block a user