Compare commits

...
Author SHA1 Message Date
timmy 29ceedfc01 Merge pull request 'Keep dashboard live when IndexedDB stalls' (#530) from timmy/529-indexeddb-startup-deadline into main
CI / lint (push) Successful in 1m13s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-11 01:31:05 +00:00
timmy 11a56b21a1 fix: bound IndexedDB dashboard startup (Closes #529)
CI / lint (pull_request) Successful in 1m16s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 01:28:56 +00:00
timmy 3e16dd00d1 Merge pull request 'Attach screenshots to unread update replies' (#528) from timmy/527-update-reply-screenshots into main
CI / lint (push) Successful in 1m11s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-11 01:10:54 +00:00
timmy 31f6097afc feat: attach screenshots to update replies (Closes #527)
CI / lint (pull_request) Successful in 1m13s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 01:04:18 +00:00
timmy beaf5e0587 Merge pull request 'Move offline work details from localStorage to IndexedDB' (#526) from timmy/525-indexeddb-offline-work into main
CI / lint (push) Successful in 1m3s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-11 00:36:26 +00:00
timmy 0a96dfb173 feat: move offline work into IndexedDB (Closes #525)
CI / lint (pull_request) Successful in 1m8s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 00:34:25 +00:00
timmy 7cadb36b41 Merge pull request 'Lock cached dashboard after the confirmed idle deadline' (#524) from timmy/523-confirmed-offline-idle-deadline into main
CI / lint (push) Successful in 1m12s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-11 00:02:39 +00:00
timmy b1890a29ff feat: enforce confirmed offline idle deadlines (Closes #523)
CI / lint (pull_request) Successful in 1m15s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 00:00:19 +00:00
timmy 100d38cc5c Merge pull request 'Refresh pull request checks without reloading review data' (#522) from timmy/521-status-only-check-refresh into main
CI / lint (push) Successful in 1m5s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 23:28:26 +00:00
timmy e5a352b418 perf: isolate pull check refreshes (Closes #521)
CI / lint (pull_request) Successful in 1m5s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 23:26:35 +00:00
timmy 8907263760 Merge pull request 'Avoid decoding unchanged live snapshots on every poll' (#520) from timmy/519-metadata-only-live-polls into main
CI / lint (push) Successful in 1m2s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 22:56:35 +00:00
timmy fdcd0d31ab perf: reuse unchanged live snapshot values (Closes #519)
CI / lint (pull_request) Successful in 1m6s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 22:54:08 +00:00
timmy 5bef6291e8 Merge pull request 'Show actionable CI checks in mobile pull reviews' (#518) from timmy/517-actionable-ci-checks into main
CI / lint (push) Successful in 1m4s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
2026-08-10 22:33:52 +00:00
timmy 9724358c89 feat: expose actionable pull request checks (Closes #517)
CI / lint (pull_request) Successful in 1m5s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 22:31:50 +00:00
timmy 7555cefccb Merge pull request 'Attach screenshots to assigned pull request comments online and offline' (#516) from timmy/515-pr-comment-screenshots into main
CI / lint (push) Successful in 1m10s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 21:44:58 +00:00
timmy 04621223fd feat: attach screenshots to pull comments (Closes #515)
CI / lint (pull_request) Successful in 1m4s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 21:39:22 +00:00
timmy e5016d8b86 Merge pull request 'Audit passkey enrollment with durable Security activity' (#514) from timmy/513-audit-passkey-enrollment into main
CI / lint (push) Successful in 59s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-10 20:58:07 +00:00
timmy b455dc6286 feat: audit passkey enrollment (Closes #513)
CI / lint (pull_request) Successful in 59s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 20:56:20 +00:00
timmy 338b6ebbd0 Make every PWA shell release content-addressed and atomic
CI / lint (push) Successful in 1m5s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #511
2026-08-10 20:27:06 +00:00
48 changed files with 2630 additions and 174 deletions

No files matched your search

+21 -16
View File
@@ -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
@@ -212,16 +212,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
@@ -385,8 +386,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
@@ -400,8 +405,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
+6 -1
View File
@@ -75,7 +75,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
status: 'queued',
queuedAt: Number(now()),
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
...(message.kind === 'issue-comment' && message.attachment ? {
...(['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 || ''),
@@ -99,6 +99,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' };
}
+57 -4
View File
@@ -437,10 +437,32 @@ function createBackgroundIssueSync({
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',
authoredRequest('', current).options,
options,
);
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
@@ -510,11 +532,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: {
@@ -535,7 +558,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: {
@@ -547,11 +570,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.kind === 'update-reply-read' ? await deliverReplyRead(item) :
item.attachment && item.kind === 'issue-comment' ?
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') {
+18
View File
@@ -232,6 +232,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,6 +255,12 @@ 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%; }
+263 -64
View File
@@ -1,4 +1,4 @@
(function(){
(async function(){
const qs = (s, el=document) => el.querySelector(s);
const fmt = (d) => new Date(d).toLocaleString();
const cardPlanning = createCardPlanning(document);
@@ -303,6 +303,44 @@
);
},
});
const pullAttachmentController = issueAttachment.mount({
input: qs('#pull-attachment'),
preview: qs('#pull-attachment-preview'),
image: qs('#pull-attachment-image'),
meta: qs('#pull-attachment-meta'),
remove: qs('#remove-pull-attachment'),
status: qs('#pull-comment-status'),
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
upload: payload => {
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
return fetchReviewJson(
'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(payload.number) + '/attachments',
{
method: 'POST',
headers: { Accept:'application/json', 'Idempotency-Key':payload.operation_id },
body: issueAttachment.multipart(payload),
},
);
},
});
const updateReplyAttachmentController = issueAttachment.mount({
input: qs('#update-reply-attachment'),
preview: qs('#update-reply-attachment-preview'),
image: qs('#update-reply-attachment-image'),
meta: qs('#update-reply-attachment-meta'),
remove: qs('#remove-update-reply-attachment'),
status: qs('#update-reply-status'),
readyMessage: 'Screenshot ready to send with this reply.',
removedMessage: 'Screenshot removed. Your reply is unchanged.',
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
upload: payload => fetchReviewJson(
'api/v1/notifications/' + encodeURIComponent(payload.notificationId) + '/attachments',
{ method:'POST', headers:{ Accept:'application/json', 'Idempotency-Key':payload.operation_id },
body:issueAttachment.multipart(payload) },
),
});
const createIssueAttachmentController = issueAttachment.mount({
input: qs('#create-issue-attachment'),
preview: qs('#create-issue-attachment-preview'),
@@ -378,7 +416,8 @@
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
outboxCoordinator.subscribe(() => refreshMyWorkView());
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
let offlineStorageReady = await offlineWorkStore.ready();
function renderOfflineTodayStatus(status) {
const container = qs('#offline-today-readiness');
const label = qs('#offline-today-status');
@@ -613,6 +652,9 @@
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
onOpen: item => {
if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) {
updateReplyAttachmentController.clear();
}
selectedUpdate = item;
updateMentions.dismiss();
qs('#update-sheet').classList.add('open');
@@ -635,7 +677,7 @@
setOfflineUpdateControls(false);
qs('#keep-update-unread').focus();
},
onDetail: detail => {
onDetail: async detail => {
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
qs('#update-subject-state').textContent = detail.state || '';
@@ -646,7 +688,7 @@
} else {
updateOwnership.open(detail, selectedUpdate);
if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) {
offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
await offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
}
}
qs('#retry-update-load').hidden = true;
@@ -1071,6 +1113,7 @@
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
markRead: markNotificationRead,
queue: message => authoredOutbox.enqueueDurably(message),
deliver: item => authoredOutbox.retry(item.id, activeFlushLogin),
canQueue: canQueueMessage,
accept: item => {
notificationReplier.saveDraft(item, '');
@@ -1528,11 +1571,11 @@
setClock();
}
function handleContextError(e) {
async function handleContextError(e) {
console.error('context failed', e);
liveMode = false;
activeFlushLogin = '';
if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;
if (!hasContextSnapshot && await hydrateOfflineWork('outage')) return;
const timeoutStatus = e.name === 'TimeoutError' ?
'Update delayed · showing last snapshot' : 'Update failed · showing last snapshot';
setStatus(hasContextSnapshot ? timeoutStatus : 'Unavailable');
@@ -2247,7 +2290,7 @@
qs('#issue-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
' · comments queue for sync';
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
}
} catch (error) {
if (selectedIssue !== item) return;
@@ -2272,7 +2315,15 @@
if (issueTrigger?.isConnected) issueTrigger.focus();
}
function renderCheckSection(prefix, detail, offline = false) {
const rendered = createReviewController.renderChecks(detail?.checks, escapeHtml, { offline });
qs('#' + prefix + '-checks-summary').textContent = rendered.summary;
qs('#' + prefix + '-check-list').innerHTML = rendered.html || '<div class="muted">No individual checks were reported.</div>';
qs('#' + prefix + '-checks').open = rendered.expanded;
}
function renderPullReview(detail, focusFilename = null) {
renderCheckSection('pull', detail);
pullReviewState = pullController.reviewState(selectedPull, detail);
qs('#pull-review-progress').textContent = pullReviewState.total ?
pullReviewState.reviewed.length + ' of ' + pullReviewState.total + ' files reviewed' : 'No changed files to review';
@@ -2304,6 +2355,22 @@
}
}
function renderPullCheckStatus(status) {
qs('#pull-ci-state').textContent = 'CI ' + (status.ci_state || 'unknown');
renderCheckSection('pull', status);
if (status.head_sha !== selectedPullDetail?.head_sha) {
qs('#pull-review-status').textContent = 'New commits detected. Reload review data before merging.';
qs('#pull-merge-state').textContent = 'Review data is stale';
qs('#merge-pull').disabled = true;
return;
}
selectedPullDetail = { ...selectedPullDetail, ...status };
const eligibility = createPullSheet.mergeEligibility(selectedPullDetail, pullReviewState);
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-review-status').textContent = 'Checks refreshed for the current head.';
}
function focusNextUnreviewedPullFile() {
if (!selectedPullDetail || !pullReviewState) return;
const filename = pullController.nextUnreviewed(selectedPullDetail, pullReviewState);
@@ -2319,7 +2386,7 @@
}
}
async function loadPullReview() {
async function loadPullReview({ refresh = false } = {}) {
if (!selectedPull || !selectedPullDetail?.head_sha) return;
const item = selectedPull;
const readDetail = selectedPullDetail;
@@ -2327,7 +2394,7 @@
qs('#pull-review-retry').hidden = true;
qs('#merge-pull').disabled = true;
try {
const review = await pullController.loadReview(item, readDetail.head_sha);
const review = await pullController.loadReview(item, readDetail.head_sha, { refresh });
if (selectedPull !== item) return;
selectedPullDetail = { ...readDetail, ...review };
qs('#pull-ci-state').textContent = 'CI ' + (review.ci_state || 'unknown');
@@ -2343,8 +2410,14 @@
}
}
function sameWorkTarget(left, right) {
return Boolean(left && right && left.repository === right.repository &&
Number(left.number) === Number(right.number));
}
async function openPullSheet(item, trigger, offlineDetail = null) {
if (!item) return;
if (!sameWorkTarget(selectedPull, item)) pullAttachmentController.clear();
qs('#pull-review').inert = false;
selectedPull = item;
pullMentions.dismiss();
@@ -2369,6 +2442,9 @@
qs('#pull-comment').value = pullController.loadDraft(item);
qs('#pull-comment-status').textContent = '';
qs('#pull-ci-state').textContent = 'CI unknown';
qs('#pull-checks-summary').textContent = 'Not loaded';
qs('#pull-check-list').textContent = '';
qs('#pull-checks').open = false;
qs('#pull-merge-state').textContent = 'Review data not loaded';
qs('#merge-pull').disabled = true;
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
@@ -2391,7 +2467,7 @@
qs('#pull-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
' · comments queue for sync';
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
}
} catch (error) {
if (selectedPull !== item) return;
@@ -2407,6 +2483,7 @@
return;
}
mobileComposerViewport.close(qs('#pull-sheet .pull-sheet-panel'));
pullAttachmentController.clear();
qs('#pull-sheet').classList.remove('open');
selectedPull = null;
selectedPullDetail = null;
@@ -2918,6 +2995,11 @@
qs('#review-handoff-status').textContent = '';
qs('#review-submit-status').textContent = '';
qs('#continue-review-to-merge').hidden = true;
qs('#review-ci-state').textContent = 'CI unknown';
qs('#review-checks-summary').textContent = cachedDetail ? 'Last known · loading' : 'Loading';
qs('#review-check-list').textContent = '';
qs('#review-checks').open = false;
qs('#refresh-review-checks').disabled = offlineReview;
qs('#submit-review').disabled = true;
qs('#submit-review').textContent = offlineReview && reviewingActiveTodayItem() ? 'Queue review & next' :
(offlineReview ? 'Queue review for reconnect' : 'Submit review');
@@ -2931,6 +3013,7 @@
if (selectedReview !== item) return;
qs('#review-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
renderCheckSection('review', detail, offlineReview);
qs('#review-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
createReviewController.renderDiffFile(file, index, escapeHtml)
).join('') : '<div>No changed files reported.</div>';
@@ -3018,6 +3101,7 @@
return;
}
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
updateReplyAttachmentController.clear();
qs('#update-sheet').classList.remove('open');
selectedUpdate = null;
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
@@ -3056,7 +3140,7 @@
qs('#gitea-events-status').textContent = message;
}
function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) {
async function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) {
setOfflineWorkMode(false);
offlineStatus.hidden = true;
const contextFreshness = snapshot.freshness?.sections?.context;
@@ -3110,12 +3194,13 @@
if (contextFreshness?.stale) markMyWorkStale();
else if (!notificationsFresh) markNotificationsStale();
if (!contextFreshness?.stale && notificationsFresh) {
offlineWorkStore.save({
const admitted = await offlineWorkStore.save({
...snapshot.context,
notifications: snapshot.notifications,
notification_pagination: snapshot.notification_pagination,
});
updateOfflineWorkControls();
await updateOfflineWorkControls(admitted === false ?
'Offline saving unavailable · retry after reconnect.' : undefined);
warmTodayOffline();
}
flushIssueOutbox();
@@ -3929,13 +4014,32 @@
return admission;
}
async function queuePullScreenshotComment(item, body, operationId, advance = false, deliver = false) {
const message = {
kind: 'pull-comment', repository: item.repository, number: item.number, body,
operationId: operationId || globalThis.crypto?.randomUUID?.() || String(Date.now()),
attachment: await pullAttachmentController.serialize(),
};
if (advance) return await pullCommentNext.admit(item, message);
const admission = await authoredOutbox.enqueueDurably(message);
if (deliver) {
const delivery = await authoredOutbox.retry(admission.item.id, activeFlushLogin);
return { ...admission, delivered: delivery.confirmed?.[0] || null };
}
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
return admission;
}
async function submitCommentAndNext(kind) {
const item = kind === 'issue' ? selectedIssue : selectedPull;
if (!item || !workSession.checkpointed(item)) return;
const textarea = qs('#' + kind + '-comment');
const status = qs('#' + kind + '-comment-status');
const body = textarea.value.trim();
if (!body && (kind !== 'issue' || !issueAttachmentController.state())) {
const attachmentController = kind === 'issue' ? issueAttachmentController : pullAttachmentController;
const queueScreenshot = kind === 'issue' ? queueIssueScreenshotComment : queuePullScreenshotComment;
if (!body && !attachmentController.state()) {
status.textContent = 'Write a comment before posting.';
textarea.focus();
return;
@@ -3947,26 +4051,28 @@
item.repository + '#' + item.number + ':operation');
postButton.disabled = true;
nextButton.disabled = true;
status.textContent = kind === 'issue' && issueAttachmentController.state() ?
attachmentController.setBusy(true);
status.textContent = attachmentController.state() ?
'Uploading screenshot before opening next…' : 'Posting comment and opening next…';
try {
let result;
if (kind === 'issue' && issueAttachmentController.state() && navigator.onLine === false) {
result = await queueIssueScreenshotComment(item, body, operationId(), true);
if (attachmentController.state() && (kind === 'pull' || navigator.onLine === false)) {
result = await queueScreenshot(item, body, operationId(), true);
} else {
let preparedBody;
try {
preparedBody = kind === 'issue' ?
await issueAttachmentController.prepareComment(item, body) : body;
await issueAttachmentController.prepareComment(item, body) :
await pullAttachmentController.prepareComment(item, body);
} catch (error) {
if (kind !== 'issue' || !issueAttachmentController.state() || !canQueueMessage(error)) throw error;
result = await queueIssueScreenshotComment(item, body, operationId(), true);
if (!attachmentController.state() || !canQueueMessage(error)) throw error;
result = await queueScreenshot(item, body, operationId(), true);
}
if (!result) result = await controller.submit(item, preparedBody, operationId);
}
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
if (!stillOpen) return;
if (kind === 'issue') issueAttachmentController.clear();
attachmentController.clear();
if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.';
else if (result.delivery === 'posted') status.textContent = 'Comment posted.';
else if (result.background) status.textContent = 'Queued for sync when the connection returns.';
@@ -3975,6 +4081,7 @@
status.textContent = error.message + ' Your draft, screenshot, and Today position are safe; retry.';
textarea.focus();
} finally {
attachmentController.setBusy(false);
postButton.disabled = false;
nextButton.disabled = false;
}
@@ -4212,6 +4319,20 @@
if (event.currentTarget.open) loadPullReview();
});
qs('#pull-review-retry').addEventListener('click', loadPullReview);
qs('#refresh-pull-checks').addEventListener('click', async () => {
if (!selectedPull || !selectedPullDetail?.head_sha) return;
const item = selectedPull;
const button = qs('#refresh-pull-checks');
button.disabled = true;
qs('#pull-checks-summary').textContent = 'Refreshing…';
try {
const status = await pullController.loadChecks(item);
if (selectedPull !== item) return;
renderPullCheckStatus(status);
} catch (_error) {
if (selectedPull === item) qs('#pull-checks-summary').textContent = 'Refresh failed · retry';
} finally { button.disabled = false; }
});
qs('#next-unreviewed-pull-file').addEventListener('click', focusNextUnreviewedPullFile);
qs('#load-older-pull-comments').addEventListener('click', async () => {
if (!pullConversation) return;
@@ -4234,39 +4355,68 @@
});
qs('#send-pull-comment').addEventListener('click', async () => {
if (!selectedPull) return;
const item = selectedPull;
const body = qs('#pull-comment').value.trim();
if (!body) {
if (!body && !pullAttachmentController.state()) {
qs('#pull-comment-status').textContent = 'Write a comment before posting.';
qs('#pull-comment').focus();
return;
}
const button = qs('#send-pull-comment');
button.disabled = true;
qs('#pull-comment-status').textContent = 'Posting comment…';
pullAttachmentController.setBusy(true);
qs('#pull-comment-status').textContent = pullAttachmentController.state() ?
'Uploading screenshot…' : 'Posting comment…';
const operationId = localStorage.getItem('stackchain.pull-comment.v1:' +
item.repository + '#' + item.number + ':operation');
let preparedBody;
try {
const comment = await pullController.comment(selectedPull, body);
if (pullConversation) renderPullConversation(pullConversation.append(comment));
qs('#pull-comment').value = '';
if (pullAttachmentController.state()) {
const admission = await queuePullScreenshotComment(item, body, operationId, false, true);
refreshMyWorkView();
pullController.saveDraft(item, '');
if (selectedPull === item) {
if (admission.delivered && pullConversation) {
renderPullConversation(pullConversation.append(admission.delivered));
}
qs('#pull-comment').value = '';
pullAttachmentController.clear();
qs('#pull-comment-status').textContent = admission.delivered ? 'Comment posted.' :
(admission.background ? 'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.');
}
return;
}
preparedBody = body;
const comment = await pullController.comment(item, preparedBody);
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
pullAttachmentController.clear();
qs('#pull-comment-status').textContent = 'Comment posted.';
} catch (error) {
if (canQueueMessage(error)) {
const operationId = localStorage.getItem('stackchain.pull-comment.v1:' + selectedPull.repository + '#' + selectedPull.number + ':operation');
qs('#pull-comment-status').textContent = 'Saving for background delivery…';
const admission = await authoredOutbox.enqueueDurably({ kind:'pull-comment', repository:selectedPull.repository,
number:selectedPull.number, body, operationId });
refreshMyWorkView();
if (admission.background) {
qs('#pull-comment').value = '';
qs('#pull-comment-status').textContent = 'Queued for sync when the connection returns.';
} else {
qs('#pull-comment-status').textContent = 'Saved for next launch; background delivery unavailable.';
qs('#pull-comment').focus();
if (canQueueMessage(error) && !pullAttachmentController.state()) {
try {
qs('#pull-comment-status').textContent = 'Saving for background delivery…';
const admission = await authoredOutbox.enqueueDurably({
kind:'pull-comment', repository:item.repository, number:item.number,
body:preparedBody ?? body, operationId,
});
pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = '';
refreshMyWorkView();
qs('#pull-comment-status').textContent = admission.background ?
'Queued for sync when the connection returns.' :
'Saved for next launch; background delivery unavailable.';
return;
} catch (admissionError) {
error = admissionError;
}
} else {
qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#pull-comment').focus();
}
qs('#pull-comment-status').textContent = error.message + ' Your comment and screenshot are safe; retry.';
qs('#pull-comment').focus();
} finally {
pullAttachmentController.setBusy(false);
button.disabled = false;
}
});
@@ -4320,21 +4470,26 @@
qs('#send-update-reply').addEventListener('click', async () => {
if (!selectedUpdate) return;
const body = qs('#update-reply').value.trim();
if (!body) {
qs('#update-reply-status').textContent = 'Write a reply before sending.';
const attachment = await updateReplyAttachmentController.serialize();
if (!body && !attachment) {
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
qs('#update-reply').focus();
return;
}
qs('#send-update-reply').disabled = true;
const result = await notificationReplier.submit(selectedUpdate, body);
updateReplyAttachmentController.setBusy(true);
const result = await notificationReplier.submit(selectedUpdate, body, attachment);
qs('#send-update-reply').disabled = false;
updateReplyAttachmentController.setBusy(false);
if (result?.queued) {
qs('#update-reply').value = '';
updateReplyAttachmentController.clear();
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
} else if (result) {
notificationReader.appendReply(result);
qs('#update-reply').value = '';
updateReplyAttachmentController.clear();
qs('#mark-update-read-next').focus();
} else {
qs('#update-reply').focus();
@@ -4344,8 +4499,9 @@
if (!selectedUpdate) return;
const item = selectedUpdate;
const body = qs('#update-reply').value.trim();
if (!body) {
qs('#update-reply-status').textContent = 'Write a reply before sending.';
const attachment = await updateReplyAttachmentController.serialize();
if (!body && !attachment) {
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
qs('#update-reply').focus();
return;
}
@@ -4353,10 +4509,12 @@
const sendButton = qs('#send-update-reply');
button.disabled = true;
sendButton.disabled = true;
updateReplyAttachmentController.setBusy(true);
qs('#update-reply-status').textContent = 'Replying, then marking read…';
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
try {
const result = await updateReplyReadNext.submit(item, body, operationId);
const result = await updateReplyReadNext.submit(item, body, operationId, attachment);
if (result?.accepted) updateReplyAttachmentController.clear();
if (result?.accepted) qs('#my-work-action-status').textContent =
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
'Reply and read acknowledgement queued for sync.';
@@ -4366,6 +4524,7 @@
} finally {
button.disabled = false;
sendButton.disabled = false;
updateReplyAttachmentController.setBusy(false);
}
});
qs('#mark-update-read-next').addEventListener('click', async () => {
@@ -4380,6 +4539,25 @@
qs('#retry-review-load').addEventListener('click', () => {
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
});
qs('#refresh-review-checks').addEventListener('click', async () => {
if (!selectedReview || offlineReview) return;
const item = selectedReview;
const button = qs('#refresh-review-checks');
button.disabled = true;
qs('#review-checks-summary').textContent = 'Refreshing…';
try {
const detail = await reviewController.loadChecks(item);
if (selectedReview !== item) return;
if (selectedReviewHead && detail.head_sha !== selectedReviewHead) {
qs('#review-sheet-status').textContent = 'New commits detected. Reload the review before submitting feedback.';
qs('#submit-review').disabled = true;
}
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
renderCheckSection('review', detail);
} catch (error) {
if (selectedReview === item) qs('#review-checks-summary').textContent = 'Refresh failed · retry';
} finally { button.disabled = false; }
});
qs('#next-unreviewed-review').addEventListener('click', () => {
if (progress) openNextUnreviewed(progress.snapshot());
});
@@ -4545,8 +4723,8 @@
const contextPoller = createContextPoller({
fetchContext: fetchLiveSnapshot,
onSnapshot: renderLiveSnapshot,
onError: error => {
handleContextError(error);
onError: async error => {
await handleContextError(error);
setEventStreamStatus('Update failed · showing last activity');
},
isHidden: () => document.hidden,
@@ -4557,12 +4735,16 @@
const offlineStatus = qs('#offline-status');
const keepWorkOffline = qs('#keep-work-offline');
const offlineWorkStatus = qs('#offline-work-status');
const retryOfflineStorage = qs('#retry-offline-storage');
const deliveryReceipts = qs('#delivery-receipts');
const deliveryReceiptStatus = qs('#delivery-receipt-status');
function updateOfflineWorkControls(message) {
async function updateOfflineWorkControls(message) {
keepWorkOffline.checked = offlineWorkStore.enabled();
const saved = offlineWorkStore.load();
offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
keepWorkOffline.disabled = !offlineStorageReady;
retryOfflineStorage.hidden = offlineStorageReady;
const saved = await offlineWorkStore.load();
offlineWorkStatus.textContent = message || (!offlineStorageReady ?
'Offline saving unavailable · online work remains live.' : saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
}
async function updateDeliveryReceiptControls(message) {
@@ -4593,8 +4775,8 @@
.forEach(button => { button.disabled = true; });
}
}
function hydrateOfflineWork(mode = 'offline') {
const saved = offlineWorkStore.load();
async function hydrateOfflineWork(mode = 'offline') {
const saved = await offlineWorkStore.load();
if (!saved) return false;
const outage = mode === 'outage';
confirmedOwnerLogin = String(saved.user?.login || '').trim();
@@ -4623,11 +4805,11 @@
offlineStatus.hidden = false;
return true;
}
function showOfflineStatus() {
async function showOfflineStatus() {
activeFlushLogin = '';
offlineStatus.hidden = false;
setStatus('Offline');
if (!hasContextSnapshot) hydrateOfflineWork();
if (!hasContextSnapshot) await hydrateOfflineWork();
}
function reconnectLiveData() {
offlineStatus.hidden = true;
@@ -4637,13 +4819,18 @@
if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);
});
}
keepWorkOffline.addEventListener('change', () => {
keepWorkOffline.addEventListener('change', async () => {
offlineWorkStore.setEnabled(keepWorkOffline.checked);
if (keepWorkOffline.checked && liveMode && lastContextSnapshot) {
offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
const admitted = await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
notification_pagination:notificationPagination });
if (admitted === false) {
await updateOfflineWorkControls('Offline saving unavailable · retry after reconnect.');
return;
}
}
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
if (!keepWorkOffline.checked) await offlineWorkStore.clear();
await updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
if (keepWorkOffline.checked) warmTodayOffline();
else offlineToday.cancel();
});
@@ -4659,18 +4846,30 @@
await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' :
(Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.'));
});
qs('#clear-offline-work').addEventListener('click', () => {
offlineWorkStore.clear();
qs('#clear-offline-work').addEventListener('click', async () => {
await offlineWorkStore.clear();
offlineToday.cancel();
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
updateOfflineWorkControls('Offline work data cleared.');
await updateOfflineWorkControls('Offline work data cleared.');
});
retryOfflineStorage.addEventListener('click', async () => {
retryOfflineStorage.disabled = true;
offlineWorkStatus.textContent = 'Retrying offline saving…';
offlineStorageReady = await offlineWorkStore.retry();
retryOfflineStorage.disabled = false;
if (offlineStorageReady && offlineWorkStore.enabled() && liveMode && lastContextSnapshot) {
await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
notification_pagination:notificationPagination });
}
await updateOfflineWorkControls(offlineStorageReady ? 'Offline saving restored.' :
'Offline saving unavailable · online work remains live.');
});
qs('#retry-offline-today').addEventListener('click', () =>
offlineToday.retry(confirmedOwnerLogin, todayMyWork)
);
updateOfflineWorkControls();
await updateOfflineWorkControls();
updateDeliveryReceiptControls();
if (!navigator.onLine) showOfflineStatus();
if (!navigator.onLine) await showOfflineStatus();
window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectLiveData);
+29
View File
@@ -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>
@@ -516,6 +517,15 @@
<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-read-next" type="button">Reply, mark read &amp; next</button>
@@ -558,6 +568,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 &amp; next</button>
@@ -569,6 +588,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>
@@ -600,6 +624,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>
+6 -1
View File
@@ -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 };
+18 -1
View File
@@ -404,7 +404,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);
@@ -415,6 +415,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. */ }
+9 -4
View File
@@ -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
View File
@@ -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;
+4 -3
View File
@@ -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
View File
@@ -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', {
+39 -1
View File
@@ -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;
+24 -9
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v85';
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;
@@ -122,20 +122,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() {
@@ -152,8 +157,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);
}
@@ -239,7 +254,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 {
+57 -16
View File
@@ -145,6 +145,7 @@
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',
@@ -227,6 +228,7 @@
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
let idleLockStarted = false;
let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY;
let activityHeartbeat = null;
@@ -246,8 +248,8 @@
}
function handleSessionIdle() {
if (expirationStarted) return;
expirationStarted = true;
if (expirationStarted || idleLockStarted) return;
idleLockStarted = true;
location.replace(base + 'login?reason=session-idle');
}
@@ -268,16 +270,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() {
@@ -448,7 +466,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') {
@@ -469,12 +490,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;
@@ -491,17 +521,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;
}
}
@@ -532,11 +572,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.'));
@@ -549,7 +589,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);
+16 -2
View File
@@ -1,5 +1,5 @@
function createUpdateReplyReadNext({
post, markRead, queue, canQueue, accept = () => undefined, next = () => undefined,
post, markRead, queue, canQueue, deliver, accept = () => undefined, next = () => undefined,
}) {
let inFlight = null;
@@ -22,11 +22,25 @@ function createUpdateReplyReadNext({
};
}
function submit(item, body, operationId) {
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;
+9 -1
View File
@@ -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:
+136 -1
View File
@@ -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
@@ -600,6 +600,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")
@@ -847,6 +892,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 +1002,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",
@@ -1714,6 +1823,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 +1846,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 +1980,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,
}
+37
View File
@@ -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,
+247 -6
View File
@@ -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
@@ -1158,6 +1163,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,
@@ -1167,9 +1190,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"}
)
@@ -1481,6 +1512,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
@@ -1503,7 +1535,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(),
),
}
@@ -2345,6 +2380,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
@@ -2577,7 +2641,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:
@@ -2927,6 +2991,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"![{safe_name}](<{safe_url}>)"
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)
@@ -3442,6 +3560,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"![{safe_name}](<{safe_url}>)"
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,
@@ -3597,6 +3776,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}"
@@ -3657,6 +3867,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,
+4 -2
View File
@@ -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
View File
@@ -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
+26
View File
@@ -578,3 +578,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"}
+65
View File
@@ -237,6 +237,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:'![proof.png](https://forge.example/proof.png)'}};
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![proof.png](https://forge.example/proof.png)"
}
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:'![proof.png](https://forge.example/proof.png)'}};
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))});
+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
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-v85" in worker
assert "stackchain-dashboard-shell-v87" in worker
+42 -1
View File
@@ -154,6 +154,15 @@ 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
@@ -782,9 +791,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)
@@ -825,6 +864,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(
@@ -837,10 +877,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
+104 -8
View File
@@ -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
@@ -655,6 +743,14 @@ 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()
+1 -1
View File
@@ -89,7 +89,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
worker = changed_frontend / "service-worker.js"
worker.write_text(
worker.read_text().replace(
"const CACHE = 'stackchain-dashboard-shell-v85';",
"const CACHE = 'stackchain-dashboard-shell-v87';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)
+69
View File
@@ -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
+70
View File
@@ -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"] == (
"![pull.png](<https://forge.example/attachments/pull.png>)"
)
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"
+1 -1
View File
@@ -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-v85" in source
assert "stackchain-dashboard-shell-v87" in source
assert "BASE + 'static/later-sync.js'" in source
+42
View File
@@ -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():
+21
View File
@@ -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)
+1 -1
View File
@@ -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-v85" in worker
assert "stackchain-dashboard-shell-v87" in worker
+1 -1
View File
@@ -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-v85" in worker
assert "stackchain-dashboard-shell-v87" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+105 -2
View File
@@ -3965,7 +3965,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)));
@@ -3974,7 +3976,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()
@@ -4053,6 +4055,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()
@@ -4453,6 +4487,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))});
@@ -4493,6 +4549,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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
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 &lt;mobile&gt;") < 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))});
+27
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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-v85" 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
+4 -1
View File
@@ -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
+63
View File
@@ -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):
+9
View File
@@ -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
+78
View File
@@ -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):
+166
View File
@@ -75,6 +75,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)
+45 -12
View File
@@ -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-v85" 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-v85" 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-v85" 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-v85" 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-v85" 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-v85" 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-v85" 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-v85" 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-v85" in source
assert "stackchain-dashboard-shell-v87" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -394,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-v85" in source
assert "stackchain-dashboard-shell-v87" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -503,7 +506,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', {
@@ -524,6 +527,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(
"""
+1 -1
View File
@@ -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-v85';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v87';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -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-v85" in source
assert "stackchain-dashboard-shell-v87" in source
assert "BASE + 'static/today-sync.js'" in source
+178
View File
@@ -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"] == "![proof.png](<https://forge.example/a/proof.webp>)"
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:'![phone.webp](https://forge.example/phone.webp)'}};
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": "![phone.webp](https://forge.example/phone.webp)"}
assert output["result"]["confirmed"] == [{"status": "read"}]