Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f18c59c7b3 | ||
|
|
e9cab92aff | ||
|
|
877dc475dd | ||
|
|
28e70f6415 | ||
|
|
2a6f2ab12d | ||
|
|
c8809a0728 | ||
|
|
2717f41957 | ||
|
|
b26b57d616 | ||
|
|
1052ce2c84 | ||
|
|
4f0e6544b3 |
No files matched your search
@@ -73,6 +73,10 @@ restart, **Resume Today** reopens the saved item (or the next surviving item if
|
||||
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||
one without closing or merging it. **Reply & next** provides the same one-action continuation
|
||||
for the current unread-update conversation. It deliberately leaves the notification unread;
|
||||
an open, unassigned issue update also offers **Take ownership & start**, which checks Today capacity
|
||||
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
|
||||
the session, and opens the issue. The adjacent **Take ownership** action remains available for
|
||||
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
|
||||
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
|
||||
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
|
||||
@@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review', 'issue-close']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close']);
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
@@ -74,6 +74,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
|
||||
...(message.kind === 'issue-comment' && message.attachment ? {
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
@@ -183,7 +184,24 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
}
|
||||
result = delivery.message;
|
||||
} else {
|
||||
if (item.kind === 'issue-close') {
|
||||
if (item.kind === 'update-reply-read') {
|
||||
if (!item.replyConfirmed) {
|
||||
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
});
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, replyConfirmed: true, status: 'sending', lastAttemptAt: attemptAt,
|
||||
} : candidate), false);
|
||||
}
|
||||
result = await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
} else if (item.kind === 'issue-close') {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
@@ -297,6 +315,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
error: String(background.error || 'Message needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
if (background?.status === 'authorization') return [{
|
||||
...item,
|
||||
status: 'authorization',
|
||||
error: String(background.error || 'Fresh authorization required').slice(0, 240),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
write(items);
|
||||
|
||||
@@ -85,7 +85,8 @@ function createIssueSyncStore({
|
||||
incoming.set(current.id, replacement);
|
||||
}
|
||||
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && replacement.status === 'attention') ||
|
||||
(['attention', 'authorization'].includes(current.status) &&
|
||||
replacement.status === current.status) ||
|
||||
current.status === 'sent') {
|
||||
incoming.set(current.id, current);
|
||||
}
|
||||
@@ -153,7 +154,8 @@ function createIssueSyncStore({
|
||||
return transact(async records => {
|
||||
const current = (await records.getAll()).find(candidate => candidate.id === item.id);
|
||||
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && item.status === 'attention') ||
|
||||
(['attention', 'authorization'].includes(current.status) &&
|
||||
item.status === current.status) ||
|
||||
current.status === 'sent')) return current;
|
||||
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
|
||||
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
|
||||
@@ -243,6 +245,9 @@ function createIssueSyncStore({
|
||||
status: 'attention', error,
|
||||
...(deliveryState ? { deliveryState } : {}),
|
||||
})),
|
||||
authorization: (id, claimToken, error) => updateClaim(
|
||||
id, claimToken, item => clearClaim(item, { status: 'authorization', error })
|
||||
),
|
||||
snapshot: () => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
|
||||
countBlocked: ownerLogin => transact(async records =>
|
||||
@@ -271,6 +276,9 @@ function createBackgroundIssueSync({
|
||||
const failClaim = (item, error, deliveryState) => store.supportsClaimTokens
|
||||
? store.fail(item.id, item.claimToken, error, deliveryState)
|
||||
: store.fail(item.id, error, deliveryState);
|
||||
const requireAuthorization = (item, error) => store.supportsClaimTokens
|
||||
? store.authorization(item.id, item.claimToken, error)
|
||||
: store.authorization(item.id, error);
|
||||
const checkpointClaim = (item, transform) => store.supportsClaimTokens
|
||||
? store.checkpoint(item.id, item.claimToken, transform) : store.update?.(item.id, transform);
|
||||
|
||||
@@ -311,7 +319,7 @@ function createBackgroundIssueSync({
|
||||
if (item.kind === 'notification-read') {
|
||||
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
|
||||
}
|
||||
if (item.kind === 'update-reply') {
|
||||
if (item.kind === 'update-reply' || item.kind === 'update-reply-read') {
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
@@ -344,6 +352,12 @@ function createBackgroundIssueSync({
|
||||
if (item.kind === 'update-reply') {
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
}
|
||||
if (item.kind === 'update-reply-read') {
|
||||
return {
|
||||
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
|
||||
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
|
||||
};
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-close') {
|
||||
return {
|
||||
@@ -420,6 +434,22 @@ function createBackgroundIssueSync({
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
async function deliverReplyRead(item) {
|
||||
let current = item;
|
||||
if (!current.replyConfirmed) {
|
||||
await requestStage(
|
||||
current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
||||
authoredRequest('', current).options,
|
||||
);
|
||||
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
|
||||
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
|
||||
current = { ...current, replyConfirmed: true };
|
||||
}
|
||||
const request = deliveryRequest(current);
|
||||
return requestStage(current, request.url, request.options);
|
||||
}
|
||||
|
||||
function attachmentMultipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
@@ -520,7 +550,8 @@ function createBackgroundIssueSync({
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = item.attachment && item.kind === 'issue-comment' ?
|
||||
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
|
||||
item.attachment && item.kind === 'issue-comment' ?
|
||||
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') {
|
||||
@@ -537,6 +568,15 @@ function createBackgroundIssueSync({
|
||||
await releaseClaim(item);
|
||||
throw error;
|
||||
}
|
||||
if (status === 428 && item.kind === 'pull-review') {
|
||||
const message = String(error?.message || 'Fresh authorization required').slice(0, 240);
|
||||
await requireAuthorization(item, message);
|
||||
return {
|
||||
authorization: true,
|
||||
error,
|
||||
receipt: receiptFor(item, 'authorization'),
|
||||
};
|
||||
}
|
||||
if (status >= 400 && status < 500) {
|
||||
await failClaim(
|
||||
item,
|
||||
@@ -567,11 +607,13 @@ function createBackgroundIssueSync({
|
||||
const confirmed = [];
|
||||
const receipts = [];
|
||||
let attention = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
|
||||
let authorization = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention, authorization, login, receipts };
|
||||
const collect = result => {
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.message) confirmed.push(result.message);
|
||||
if (result.attention) attention += 1;
|
||||
if (result.authorization) authorization += 1;
|
||||
if (result.receipt) receipts.push(result.receipt);
|
||||
};
|
||||
if (store.planBatch) {
|
||||
@@ -632,7 +674,7 @@ function createBackgroundIssueSync({
|
||||
}
|
||||
}
|
||||
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
||||
return { confirmed, blocked, attention, login, receipts };
|
||||
return { confirmed, blocked, attention, authorization, login, receipts };
|
||||
}
|
||||
|
||||
function flush() {
|
||||
|
||||
@@ -244,6 +244,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.update-ownership-actions button { min-width:0; width:100%; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
@@ -446,7 +448,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
|
||||
|
||||
+42
-25
@@ -562,6 +562,7 @@
|
||||
onStatus: message => { qs('#update-reply-status').textContent = message; },
|
||||
});
|
||||
const updateOwnership = createUpdateOwnership({
|
||||
available: () => createAndStart.available(),
|
||||
claim: item => fetchReviewJson(
|
||||
'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number) + '/claim',
|
||||
@@ -577,6 +578,12 @@
|
||||
}
|
||||
return result;
|
||||
},
|
||||
start: item => {
|
||||
const outcome = createAndStart.complete(item);
|
||||
if (outcome === 'started') openRoutedWork(item, qs('#update-ownership-start'));
|
||||
return outcome;
|
||||
},
|
||||
recover: item => openRoutedWork(item, qs('#update-ownership-start')),
|
||||
onClaimed: item => {
|
||||
if (!lastContextSnapshot) return;
|
||||
const issues = (lastContextSnapshot.issues || []).filter(candidate =>
|
||||
@@ -592,6 +599,12 @@
|
||||
button.textContent = state.action === 'today' ? 'Add to Today' : 'Take ownership';
|
||||
if (state.message) qs('#update-sheet-status').textContent = state.message;
|
||||
},
|
||||
onStartState: state => {
|
||||
const button = qs('#update-ownership-start');
|
||||
button.hidden = state.action === 'hidden';
|
||||
button.disabled = state.busy;
|
||||
if (state.message) qs('#update-sheet-status').textContent = state.message;
|
||||
},
|
||||
});
|
||||
const notificationReader = createNotificationReader({
|
||||
load: fetchNotificationDetail,
|
||||
@@ -615,9 +628,9 @@
|
||||
qs('#update-reply').value = notificationReplier.loadDraft(item);
|
||||
qs('#update-reply-status').textContent = '';
|
||||
qs('#send-update-reply').disabled = false;
|
||||
qs('#send-update-reply-next').disabled = false;
|
||||
setUpdateReplyNextVisibility();
|
||||
qs('#send-update-reply-read-next').disabled = false;
|
||||
qs('#update-ownership-action').hidden = true;
|
||||
qs('#update-ownership-start').hidden = true;
|
||||
qs('#retry-update-load').hidden = true;
|
||||
setOfflineUpdateControls(false);
|
||||
qs('#keep-update-unread').focus();
|
||||
@@ -1019,9 +1032,7 @@
|
||||
const item = kind === 'issue' ? selectedIssue : selectedPull;
|
||||
qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item);
|
||||
}
|
||||
function setUpdateReplyNextVisibility() {
|
||||
qs('#send-update-reply-next').hidden = !selectedUpdate || !workSession.checkpointed(selectedUpdate);
|
||||
}
|
||||
|
||||
const issueCommentNext = createCommentNext({
|
||||
post: async (item, body) => {
|
||||
const comment = await issueController.comment(item, body);
|
||||
@@ -1056,25 +1067,16 @@
|
||||
failureMessage: 'Comment saved, but Today still needs completion.',
|
||||
}),
|
||||
});
|
||||
const updateReplyNext = createCommentNext({
|
||||
queueKind: 'update-reply',
|
||||
const updateReplyReadNext = createUpdateReplyReadNext({
|
||||
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
|
||||
markRead: markNotificationRead,
|
||||
queue: message => authoredOutbox.enqueueDurably(message),
|
||||
canQueue: canQueueMessage,
|
||||
accept: (item, result) => {
|
||||
accept: item => {
|
||||
notificationReplier.saveDraft(item, '');
|
||||
if (selectedUpdate === item) {
|
||||
qs('#update-reply').value = '';
|
||||
if (result.comment) notificationReader.appendReply(result.comment);
|
||||
qs('#update-reply-status').textContent = result.delivery === 'queued' ?
|
||||
'Reply queued for background delivery.' : result.delivery === 'saved' ?
|
||||
'Reply saved for next-launch delivery.' : 'Reply posted.';
|
||||
}
|
||||
if (selectedUpdate === item) qs('#update-reply').value = '';
|
||||
},
|
||||
complete: item => completeTodayItem(item, {
|
||||
successMessage: 'Reply saved. Next Today item opened.',
|
||||
failureMessage: 'Reply saved, but Today still needs completion.',
|
||||
}),
|
||||
next: item => notificationReader.acceptReadAndNext(lastMyWork, item),
|
||||
});
|
||||
const closeOfflineIssue = createOfflineIssueClose({
|
||||
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
||||
@@ -1473,6 +1475,7 @@
|
||||
qs('#mark-update-read-next').disabled = false;
|
||||
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
|
||||
qs('#update-ownership-action').disabled = offline;
|
||||
qs('#update-ownership-start').disabled = offline;
|
||||
qs('#load-older-update-comments').disabled = offline;
|
||||
qs('#update-sheet .detail-defer').inert = offline;
|
||||
}
|
||||
@@ -1654,6 +1657,10 @@
|
||||
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
|
||||
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
reviewOutbox && item.status === 'authorization' ?
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open review</button>' +
|
||||
'<button class="draft-authorize" data-draft-index="' + index + '" type="button">Authorize & send review</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
|
||||
reviewOutbox && item.status === 'attention' ?
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open current review</button>' +
|
||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy feedback</button>' +
|
||||
@@ -1775,11 +1782,17 @@
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item?.outbox_id || !activeFlushLogin) return;
|
||||
button.disabled = true;
|
||||
qs('#my-work-action-status').textContent = 'Fresh authorization required for this exact issue.';
|
||||
const reviewAuthorization = item.outbox_kind === 'pull-review';
|
||||
qs('#my-work-action-status').textContent = reviewAuthorization ?
|
||||
'Fresh authorization required for this exact review decision and head.' :
|
||||
'Fresh authorization required for this exact issue.';
|
||||
const result = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
|
||||
applyAuthoredOutboxResult(result);
|
||||
qs('#my-work-action-status').textContent = result.confirmed?.length ?
|
||||
'Issue closed and queued intent cleared.' : 'Issue closure was not confirmed. The queued intent is still safe.';
|
||||
(reviewAuthorization ? 'Review submitted and queued intent cleared.' :
|
||||
'Issue closed and queued intent cleared.') :
|
||||
(reviewAuthorization ? 'Review was not confirmed. The queued review and feedback are still safe.' :
|
||||
'Issue closure was not confirmed. The queued intent is still safe.');
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-copy').forEach(button => {
|
||||
@@ -4287,6 +4300,7 @@
|
||||
});
|
||||
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
|
||||
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
|
||||
qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start());
|
||||
qs('#retry-update-load').addEventListener('click', () => {
|
||||
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
|
||||
});
|
||||
@@ -4326,7 +4340,7 @@
|
||||
qs('#update-reply').focus();
|
||||
}
|
||||
});
|
||||
qs('#send-update-reply-next').addEventListener('click', async () => {
|
||||
qs('#send-update-reply-read-next').addEventListener('click', async () => {
|
||||
if (!selectedUpdate) return;
|
||||
const item = selectedUpdate;
|
||||
const body = qs('#update-reply').value.trim();
|
||||
@@ -4335,14 +4349,17 @@
|
||||
qs('#update-reply').focus();
|
||||
return;
|
||||
}
|
||||
const button = qs('#send-update-reply-next');
|
||||
const button = qs('#send-update-reply-read-next');
|
||||
const sendButton = qs('#send-update-reply');
|
||||
button.disabled = true;
|
||||
sendButton.disabled = true;
|
||||
qs('#update-reply-status').textContent = 'Sending reply…';
|
||||
qs('#update-reply-status').textContent = 'Replying, then marking read…';
|
||||
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
|
||||
try {
|
||||
await updateReplyNext.submit(item, body, operationId);
|
||||
const result = await updateReplyReadNext.submit(item, body, operationId);
|
||||
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.';
|
||||
} catch (error) {
|
||||
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
qs('#update-reply').focus();
|
||||
|
||||
+5
-4
@@ -141,7 +141,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
|
||||
.map(item => {
|
||||
const isUpdate = item.kind === 'update-reply';
|
||||
const isUpdate = item.kind === 'update-reply' || item.kind === 'update-reply-read';
|
||||
const isReview = item.kind === 'pull-review';
|
||||
const isClosure = item.kind === 'issue-close';
|
||||
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
|
||||
@@ -161,11 +161,12 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
outbox_kind: item.kind,
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
|
||||
(isClosure ? 'authorization' : 'queued')),
|
||||
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||
(isReview ? 'Queued review' : (isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
||||
authorization_required: isClosure,
|
||||
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
|
||||
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
||||
authorization_required: isClosure || (isReview && item.status === 'authorization'),
|
||||
delivery_state: item.deliveryState,
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
|
||||
+6
-2
@@ -518,12 +518,15 @@
|
||||
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
|
||||
<div class="update-reply-actions">
|
||||
<button id="send-update-reply" type="button">Send reply</button>
|
||||
<button id="send-update-reply-next" type="button" hidden>Reply & next</button>
|
||||
<button id="send-update-reply-read-next" type="button">Reply, mark read & next</button>
|
||||
</div>
|
||||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="update-sheet-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<div class="update-ownership-actions">
|
||||
<button id="update-ownership-action" type="button" hidden aria-describedby="update-sheet-status">Take ownership</button>
|
||||
<button id="update-ownership-start" type="button" hidden aria-describedby="update-sheet-status">Take ownership & start</button>
|
||||
</div>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
@@ -696,6 +699,7 @@
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
<script src="static/comment-next.js"></script>
|
||||
<script src="static/update-reply-read-next.js"></script>
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
|
||||
+21
-13
@@ -292,6 +292,22 @@ function createNotificationReader({
|
||||
let conversationPager = null;
|
||||
let offlineHydrated = false;
|
||||
|
||||
async function advanceAfterRead(items, current, queueing = false) {
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item => item && item.has_update &&
|
||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
}
|
||||
|
||||
async function open(item, savedDetail = null) {
|
||||
selected = item;
|
||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||
@@ -327,6 +343,10 @@ function createNotificationReader({
|
||||
onConversation(conversationPager.append(comment));
|
||||
return true;
|
||||
},
|
||||
acceptReadAndNext(items, item = selected) {
|
||||
if (!item || selected !== item) return false;
|
||||
return advanceAfterRead(items, item);
|
||||
},
|
||||
async loadOlder() {
|
||||
if (!conversationPager || !selected || offlineHydrated) return false;
|
||||
const pager = conversationPager;
|
||||
@@ -354,19 +374,7 @@ function createNotificationReader({
|
||||
try {
|
||||
if (queueing) await queueRead(current.notification_id);
|
||||
else await markRead(current.notification_id);
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item => item && item.has_update &&
|
||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
return await advanceAfterRead(items, current, queueing);
|
||||
} catch (_error) {
|
||||
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v83';
|
||||
const CACHE = 'stackchain-dashboard-shell-v85';
|
||||
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;
|
||||
@@ -31,6 +31,7 @@ const SHELL = [
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
BASE + 'static/update-reply-read-next.js',
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
@@ -204,11 +205,13 @@ async function flushAndNotify() {
|
||||
!await issueSync.getReceiptPreference?.(result.login)) return;
|
||||
for (const receipt of result.receipts) {
|
||||
const needsAttention = receipt.status === 'attention';
|
||||
const title = needsAttention
|
||||
const needsAuthorization = receipt.status === 'authorization';
|
||||
const title = needsAuthorization ? 'Queued review needs authorization' : needsAttention
|
||||
? 'Queued work needs attention'
|
||||
: receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent';
|
||||
await self.registration.showNotification(title, {
|
||||
body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
||||
body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' :
|
||||
needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
||||
tag: 'stackchain-delivery-' + receipt.id,
|
||||
data: { route: receipt.route },
|
||||
});
|
||||
|
||||
@@ -149,6 +149,8 @@
|
||||
all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed',
|
||||
pull_merged: 'Pull request merged',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested',
|
||||
};
|
||||
page.events.forEach(event => {
|
||||
const row = root.document.createElement('article');
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
function createUpdateOwnership({
|
||||
claim, addToday, available, start, recover, onClaimed, onState, onStartState,
|
||||
}) {
|
||||
let detail = null;
|
||||
let update = null;
|
||||
let claimedItem = null;
|
||||
@@ -8,6 +10,36 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
onState?.(value);
|
||||
}
|
||||
|
||||
function startState(value) {
|
||||
onStartState?.(value);
|
||||
}
|
||||
|
||||
function claimed(result) {
|
||||
claimedItem = {
|
||||
...result,
|
||||
repository: detail.repository,
|
||||
kind: 'issue',
|
||||
notification_id: update?.notification_id,
|
||||
updated_at: update?.updated_at || result.updated_at,
|
||||
has_update: true,
|
||||
};
|
||||
detail.issue.claimable = false;
|
||||
onClaimed?.(claimedItem);
|
||||
startState({ action: 'hidden', busy: false, message: '' });
|
||||
return claimedItem;
|
||||
}
|
||||
|
||||
function recoverStartedItem(item) {
|
||||
recover?.(item);
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.',
|
||||
});
|
||||
state({ action: 'today', busy: false, message: '' });
|
||||
return 'recovery';
|
||||
}
|
||||
|
||||
function open(nextDetail, nextUpdate) {
|
||||
detail = nextDetail;
|
||||
update = nextUpdate;
|
||||
@@ -18,6 +50,11 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
busy: false,
|
||||
message: '',
|
||||
});
|
||||
startState({
|
||||
action: nextDetail?.issue?.claimable && available && start ? 'start' : 'hidden',
|
||||
busy: false,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
|
||||
function act() {
|
||||
@@ -40,21 +77,13 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
repository: detail.repository,
|
||||
number: detail.issue.number,
|
||||
})).then(result => {
|
||||
claimedItem = {
|
||||
...result,
|
||||
repository: detail.repository,
|
||||
kind: 'issue',
|
||||
notification_id: update?.notification_id,
|
||||
updated_at: update?.updated_at || result.updated_at,
|
||||
has_update: true,
|
||||
};
|
||||
detail.issue.claimable = false;
|
||||
onClaimed?.(claimedItem);
|
||||
claimed(result);
|
||||
state({ action: 'today', busy: false, message: 'Assigned to you. The update is still unread.' });
|
||||
return claimedItem;
|
||||
}).catch(error => {
|
||||
if (error?.status === 409) {
|
||||
detail.issue.claimable = false;
|
||||
startState({ action: 'hidden', busy: false, message: '' });
|
||||
state({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
@@ -72,7 +101,58 @@ function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
return request;
|
||||
}
|
||||
|
||||
return { open, act };
|
||||
function startNow() {
|
||||
if (request) return request;
|
||||
if (!detail?.issue?.claimable || !available || !start) return Promise.resolve('hidden');
|
||||
if (!available()) {
|
||||
const message = 'Today is limited to 5 items. Remove one before taking ownership.';
|
||||
startState({ action: 'start', busy: false, message });
|
||||
return Promise.resolve('full');
|
||||
}
|
||||
startState({ action: 'start', busy: true, message: 'Assigning and starting…' });
|
||||
state({ action: 'claim', busy: true, message: '' });
|
||||
request = Promise.resolve(claim({
|
||||
repository: detail.repository,
|
||||
number: detail.issue.number,
|
||||
})).then(result => {
|
||||
const item = claimed(result);
|
||||
const outcome = start(item);
|
||||
if (outcome === 'started') {
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Assigned, added to Today, and ready to work. The update is still unread.',
|
||||
});
|
||||
state({ action: 'today', busy: false, message: '' });
|
||||
return outcome;
|
||||
}
|
||||
return recoverStartedItem(item);
|
||||
}).catch(error => {
|
||||
if (claimedItem) {
|
||||
return recoverStartedItem(claimedItem);
|
||||
}
|
||||
if (error?.status === 409) {
|
||||
detail.issue.claimable = false;
|
||||
state({ action: 'hidden', busy: false, message: '' });
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Someone else claimed or closed this issue. The update is still unread.',
|
||||
});
|
||||
return 'conflict';
|
||||
}
|
||||
state({ action: 'claim', busy: false, message: '' });
|
||||
startState({
|
||||
action: 'start',
|
||||
busy: false,
|
||||
message: (error?.message || 'The issue could not be assigned.') + ' The update and your reply draft are safe; retry.',
|
||||
});
|
||||
return 'retry';
|
||||
}).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { open, act, start:startNow };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateOwnership;
|
||||
@@ -0,0 +1,50 @@
|
||||
function createUpdateReplyReadNext({
|
||||
post, markRead, queue, canQueue, accept = () => undefined, next = () => undefined,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
|
||||
async function admit(item, body, operationId, replyConfirmed) {
|
||||
const admission = await queue({
|
||||
kind: 'update-reply-read',
|
||||
notificationId: item.notification_id,
|
||||
body,
|
||||
operationId,
|
||||
replyConfirmed,
|
||||
});
|
||||
if (!admission || (!admission.item && admission.durable !== true)) {
|
||||
throw new Error('Reply and acknowledgement were not saved for delivery.');
|
||||
}
|
||||
accept(item);
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: admission.background ? 'queued' : 'saved',
|
||||
next: await next(item),
|
||||
};
|
||||
}
|
||||
|
||||
function submit(item, body, operationId) {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
let replyConfirmed = false;
|
||||
try {
|
||||
try {
|
||||
await post(item, body, operationId);
|
||||
replyConfirmed = true;
|
||||
await markRead(item.notification_id);
|
||||
} catch (error) {
|
||||
if (!canQueue(error)) throw error;
|
||||
return await admit(item, body, operationId, replyConfirmed);
|
||||
}
|
||||
accept(item);
|
||||
return { accepted: true, delivery: 'posted', next: await next(item) };
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
return { submit, busy: () => Boolean(inFlight) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateReplyReadNext;
|
||||
+42
-6
@@ -13,6 +13,9 @@ import rjsmin
|
||||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
CACHE_DECLARATION = re.compile(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -22,6 +25,7 @@ class FrontendBuild:
|
||||
runtime_gzip_bytes: bytes
|
||||
runtime_digest: str
|
||||
runtime_name: str
|
||||
shell_digest: str
|
||||
page_sources: tuple[str, ...]
|
||||
service_worker_source: str
|
||||
|
||||
@@ -61,11 +65,42 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n",
|
||||
)
|
||||
worker = re.sub(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-v\d+';",
|
||||
f"const CACHE = 'stackchain-dashboard-shell-{digest}';",
|
||||
worker,
|
||||
count=1,
|
||||
worker = CACHE_DECLARATION.sub(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||
)
|
||||
|
||||
shell_hasher = hashlib.sha256()
|
||||
shell_inputs = (
|
||||
("dashboard.html", dashboard_html.encode()),
|
||||
(runtime_name, runtime),
|
||||
("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()),
|
||||
("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()),
|
||||
(
|
||||
"static/icons/stackchain-192.png",
|
||||
(frontend_dir / "icons/stackchain-192.png").read_bytes(),
|
||||
),
|
||||
(
|
||||
"static/icons/stackchain-512.png",
|
||||
(frontend_dir / "icons/stackchain-512.png").read_bytes(),
|
||||
),
|
||||
(
|
||||
WORKER_RUNTIME_SOURCE,
|
||||
(
|
||||
frontend_dir / WORKER_RUNTIME_SOURCE.removeprefix("static/")
|
||||
).read_bytes(),
|
||||
),
|
||||
("service-worker.js", worker.encode()),
|
||||
)
|
||||
for name, content in shell_inputs:
|
||||
shell_hasher.update(len(name).to_bytes(4, "big"))
|
||||
shell_hasher.update(name.encode())
|
||||
shell_hasher.update(len(content).to_bytes(8, "big"))
|
||||
shell_hasher.update(content)
|
||||
shell_digest = shell_hasher.hexdigest()[:16]
|
||||
worker = worker.replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';",
|
||||
f"const CACHE = 'stackchain-dashboard-shell-{shell_digest}';",
|
||||
1,
|
||||
)
|
||||
|
||||
return FrontendBuild(
|
||||
@@ -74,6 +109,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
runtime_gzip_bytes=runtime_gzip,
|
||||
runtime_digest=digest,
|
||||
runtime_name=runtime_name,
|
||||
shell_digest=shell_digest,
|
||||
page_sources=sources,
|
||||
service_worker_source=worker,
|
||||
)
|
||||
)
|
||||
+71
-17
@@ -200,16 +200,20 @@ class DashboardSignIn(BaseModel):
|
||||
return normalized
|
||||
|
||||
|
||||
StepUpAction = Literal[
|
||||
"merge_pull",
|
||||
"submit_pull_review",
|
||||
"close_issue",
|
||||
"revoke_device",
|
||||
"revoke_all_sessions",
|
||||
"enroll_passkey",
|
||||
"revoke_passkey",
|
||||
]
|
||||
|
||||
|
||||
class FreshAuthorization(BaseModel):
|
||||
access_token: str = Field(min_length=1, max_length=1_024)
|
||||
action: Literal[
|
||||
"merge_pull",
|
||||
"close_issue",
|
||||
"revoke_device",
|
||||
"revoke_all_sessions",
|
||||
"enroll_passkey",
|
||||
"revoke_passkey",
|
||||
]
|
||||
action: StepUpAction
|
||||
target: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
@@ -227,9 +231,7 @@ class PasskeyAuthentication(PasskeyCeremony):
|
||||
|
||||
|
||||
class PasskeyAuthorizationTarget(BaseModel):
|
||||
action: Literal[
|
||||
"merge_pull", "close_issue", "revoke_device", "revoke_all_sessions", "revoke_passkey"
|
||||
]
|
||||
action: StepUpAction
|
||||
target: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
@@ -3793,12 +3795,26 @@ async def merge_assigned_pull(
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
|
||||
async def submit_review(
|
||||
submission: PullReviewSubmission,
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
if submission.decision != "comment":
|
||||
await _require_step_up(
|
||||
request,
|
||||
step_up_grant,
|
||||
action="submit_pull_review",
|
||||
target=(
|
||||
f"{repository}#{number}@{submission.expected_head_sha}:"
|
||||
f"{submission.decision}"
|
||||
),
|
||||
)
|
||||
|
||||
async def submit_requested_review():
|
||||
if not await is_requested_review(repository, number):
|
||||
@@ -3810,12 +3826,50 @@ async def submit_review(
|
||||
submission.decision,
|
||||
submission.body,
|
||||
)
|
||||
if submission.comments:
|
||||
return await gitea_proxy.submit_pull_review(
|
||||
*args,
|
||||
[comment.model_dump() for comment in submission.comments],
|
||||
)
|
||||
return await gitea_proxy.submit_pull_review(*args)
|
||||
journal = None
|
||||
operation_id = None
|
||||
if submission.decision != "comment":
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
(
|
||||
"pull_review_approved"
|
||||
if submission.decision == "approve"
|
||||
else "pull_review_changes_requested"
|
||||
),
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Security activity is temporarily unavailable",
|
||||
)
|
||||
try:
|
||||
if submission.comments:
|
||||
result = await gitea_proxy.submit_pull_review(
|
||||
*args,
|
||||
[comment.model_dump() for comment in submission.comments],
|
||||
)
|
||||
else:
|
||||
result = await gitea_proxy.submit_pull_review(*args)
|
||||
except (
|
||||
HTTPException,
|
||||
gitea_proxy.StaleReviewError,
|
||||
gitea_proxy.InvalidReviewCommentError,
|
||||
):
|
||||
if journal is not None and operation_id is not None:
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
raise
|
||||
if journal is not None and operation_id is not None:
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
return result
|
||||
|
||||
try:
|
||||
comment_fingerprint = tuple(
|
||||
|
||||
@@ -480,6 +480,35 @@ outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Review',ope
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authorization_hold_can_resume_in_foreground_with_original_review_identity():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const sent=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createAuthoredOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',
|
||||
backgroundSync:{{reconcile:async()=>{{}},requestSync:async()=>{{}},
|
||||
send:async item=>{{sent.push({{operationId:item.operationId,status:item.status,
|
||||
head:item.expectedHeadSha,decision:item.decision}});return{{message:{{id:91}}}};}}}},
|
||||
}});
|
||||
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,operationId:'review-stable',
|
||||
body:'Ship it',decision:'approve',expectedHeadSha:'abc123'}});
|
||||
outbox.reconcileBackground([{{id:'review-stable',kind:'pull-review',status:'authorization',
|
||||
error:'Fresh authorization required'}}]);
|
||||
(async()=>{{const held=outbox.list()[0];const result=await outbox.retry(held.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{held,sent,result,remaining:outbox.list()}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["held"]["status"] == "authorization"
|
||||
assert output["sent"] == [{
|
||||
"operationId": "review-stable", "status": "authorization",
|
||||
"head": "abc123", "decision": "approve",
|
||||
}]
|
||||
assert output["result"]["confirmed"] == [{"id": 91}]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -409,6 +409,41 @@ createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdo
|
||||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_holds_consequential_review_for_foreground_authorization():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const item={{id:'review-op',operationId:'review-op',ownerLogin:'timmy',status:'queued',
|
||||
kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
|
||||
decision:'approve',expectedHeadSha:'abc123',comments:[]}};
|
||||
const state={{authorized:[],failed:[],released:[],completed:[]}};let claimed=false;
|
||||
const store={{
|
||||
claimNext:async()=>claimed?null:(claimed=true,item),
|
||||
authorization:async(id,error)=>state.authorized.push({{id,error}}),
|
||||
fail:async(...args)=>state.failed.push(args),release:async id=>state.released.push(id),
|
||||
complete:async id=>state.completed.push(id),countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async url=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
const error=new Error('Fresh authorization required');error.status=428;
|
||||
error.code='step_up_required';throw error;
|
||||
}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"] == {
|
||||
"authorized": [{"id": "review-op", "error": "Fresh authorization required"}],
|
||||
"failed": [], "released": [], "completed": [],
|
||||
}
|
||||
assert output["result"]["attention"] == 0
|
||||
assert output["result"]["authorization"] == 1
|
||||
assert output["result"]["receipts"] == [{
|
||||
"id": "review-op", "status": "authorization", "kind": "message",
|
||||
"route": "#/my-work/review/stackchain/web/8",
|
||||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_leaves_issue_closure_awaiting_foreground_authorization():
|
||||
records = [
|
||||
{
|
||||
|
||||
@@ -292,17 +292,15 @@ async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_current_today_update_offers_reply_and_next_without_marking_read():
|
||||
async def test_unread_update_offers_reply_mark_read_and_next_independent_of_today():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="send-update-reply-next"' in html
|
||||
assert '>Reply & next</button>' in html
|
||||
assert "setUpdateReplyNextVisibility();" in html
|
||||
assert "const updateReplyNext = createCommentNext({" in html
|
||||
assert "notificationReplier.saveDraft(item, '');" in html
|
||||
assert "successMessage: 'Reply saved. Next Today item opened.'" in html
|
||||
assert "qs('#mark-update-read-next').click()" not in html
|
||||
assert 'id="send-update-reply-read-next"' in html
|
||||
assert '>Reply, mark read & next</button>' in html
|
||||
assert "const updateReplyReadNext = createUpdateReplyReadNext({" in html
|
||||
assert "markRead: markNotificationRead" in html
|
||||
assert "next: item => notificationReader.acceptReadAndNext(lastMyWork, item)" in html
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v83" in worker
|
||||
assert "stackchain-dashboard-shell-v85" in worker
|
||||
@@ -201,6 +201,14 @@ async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use(
|
||||
json={"action": "close_issue", "target": "stackchain/api#7"},
|
||||
headers=headers,
|
||||
)
|
||||
review_options = await client.post(
|
||||
"/api/v1/passkeys/authorization/options",
|
||||
json={
|
||||
"action": "submit_pull_review",
|
||||
"target": "stackchain/api#7@abc123:approve",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
ceremony = {
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
@@ -220,6 +228,8 @@ async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use(
|
||||
)
|
||||
|
||||
assert options.status_code == 200
|
||||
assert review_options.status_code == 200
|
||||
assert review_options.json()["challenge"]
|
||||
assert authorized.status_code == 201
|
||||
assert authorized.json()["grant"]
|
||||
assert authorized.json()["expires_in"] == 90
|
||||
@@ -953,6 +963,91 @@ async def test_other_high_impact_routes_require_fresh_authorization_before_mutat
|
||||
assert laptop_still_active.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_consequential_review_requires_decision_and_head_bound_fresh_authorization(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
calls = []
|
||||
|
||||
async def requested(repository, number):
|
||||
calls.append(("requested", repository, number))
|
||||
return True
|
||||
|
||||
async def submit(repository, number, head, decision, body):
|
||||
calls.append(("submit", repository, number, head, decision, body))
|
||||
return {"id": 91, "state": "APPROVED"}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
payload = {
|
||||
"expected_head_sha": "abc123",
|
||||
"decision": "approve",
|
||||
"body": "Ready to ship.",
|
||||
}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
missing = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
wrong_grant = await fresh_grant(
|
||||
client,
|
||||
"submit_pull_review",
|
||||
"stackchain/api#7@different:approve",
|
||||
)
|
||||
mismatched = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": wrong_grant},
|
||||
)
|
||||
grant = await fresh_grant(
|
||||
client,
|
||||
"submit_pull_review",
|
||||
"stackchain/api#7@abc123:approve",
|
||||
)
|
||||
approved = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
replayed = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
comment = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={**payload, "decision": "comment"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert [missing.status_code, mismatched.status_code] == [428, 428]
|
||||
assert missing.json()["detail"] == {
|
||||
"detail": "Fresh authorization required",
|
||||
"code": "step_up_required",
|
||||
"action": "submit_pull_review",
|
||||
"target": "stackchain/api#7@abc123:approve",
|
||||
}
|
||||
assert approved.status_code == 201
|
||||
assert replayed.status_code == 428
|
||||
assert comment.status_code == 201
|
||||
assert calls == [
|
||||
("requested", "stackchain/api", 7),
|
||||
("submit", "stackchain/api", 7, "abc123", "approve", "Ready to ship."),
|
||||
("requested", "stackchain/api", 7),
|
||||
("submit", "stackchain/api", 7, "abc123", "comment", "Ready to ship."),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_operator_can_review_and_revoke_one_remote_device(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
@@ -655,6 +655,13 @@ def test_security_activity_explains_pending_outcome_confirmation():
|
||||
assert "event.status === 'pending'" in source
|
||||
|
||||
|
||||
def test_security_activity_labels_consequential_pull_review_decisions():
|
||||
source = SESSION_JS.read_text()
|
||||
|
||||
assert "pull_review_approved: 'Pull request approved'" in source
|
||||
assert "pull_review_changes_requested: 'Changes requested'" in source
|
||||
|
||||
|
||||
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
|
||||
@@ -142,6 +142,27 @@ process.stdout.write(JSON.stringify(item));
|
||||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_held_review_as_awaiting_foreground_authorization():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
|
||||
id:'review-1',kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
|
||||
decision:'approve',expectedHeadSha:'abc',comments:[],ownerLogin:'timmy',
|
||||
status:'authorization',error:'Fresh authorization required',queuedAt:200
|
||||
}}]}})]]);
|
||||
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const inbox=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
|
||||
process.stdout.write(JSON.stringify({{item:inbox.list()[0],partition:inbox.partition()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["item"]["status"] == "authorization"
|
||||
assert output["item"]["label"] == "Review awaiting authorization"
|
||||
assert output["item"]["authorization_required"] is True
|
||||
assert output["partition"]["counts"]["authorization"] == 1
|
||||
assert output["partition"]["retryable"] == []
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_queued_issue_closure_as_awaiting_authorization():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
|
||||
@@ -42,6 +42,64 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
assert changed.runtime_bytes != first.runtime_bytes
|
||||
|
||||
|
||||
def test_css_only_release_gets_a_new_shell_generation(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
(changed_frontend / "dashboard.css").write_text(
|
||||
(changed_frontend / "dashboard.css").read_text() + "\n/* release change */\n"
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
|
||||
assert changed.runtime_name == first.runtime_name
|
||||
assert changed.runtime_bytes == first.runtime_bytes
|
||||
assert changed.shell_digest != first.shell_digest
|
||||
assert f"const CACHE = 'stackchain-dashboard-shell-{changed.shell_digest}';" in (
|
||||
changed.service_worker_source
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"asset",
|
||||
[
|
||||
"manifest.webmanifest",
|
||||
"icons/stackchain-192.png",
|
||||
"icons/stackchain-512.png",
|
||||
"background-issue-sync.js",
|
||||
"service-worker.js",
|
||||
],
|
||||
)
|
||||
def test_every_offline_shell_asset_versions_the_shell_generation(tmp_path, asset):
|
||||
first = build_frontend(FRONTEND)
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
path = changed_frontend / asset
|
||||
path.write_bytes(path.read_bytes() + b"\nrelease-change")
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
|
||||
assert changed.shell_digest != first.shell_digest
|
||||
|
||||
|
||||
def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v85';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
|
||||
assert changed.shell_digest == first.shell_digest
|
||||
assert changed.service_worker_source == first.service_worker_source
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v83" in worker
|
||||
assert "stackchain-dashboard-shell-v85" in worker
|
||||
@@ -41,7 +41,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v83" in worker
|
||||
assert "stackchain-dashboard-shell-v85" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -176,19 +176,137 @@ controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}
|
||||
}
|
||||
|
||||
|
||||
def test_unread_issue_update_claims_and_starts_once_without_marking_it_read():
|
||||
script = f"""
|
||||
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
||||
let claims = 0;
|
||||
let starts = 0;
|
||||
let finishClaim;
|
||||
const reconciled = [];
|
||||
const states = [];
|
||||
const controller = createUpdateOwnership({{
|
||||
available: () => true,
|
||||
claim: () => {{ claims += 1; return new Promise(resolve => {{ finishClaim = resolve; }}); }},
|
||||
addToday: () => 'added',
|
||||
start: item => {{ starts += 1; return item.notification_id === 42 ? 'started' : 'broken'; }},
|
||||
recover: () => {{ throw new Error('recovery should not run'); }},
|
||||
onClaimed: item => reconciled.push(item),
|
||||
onStartState: state => states.push(state),
|
||||
onState: () => {{}},
|
||||
}});
|
||||
controller.open({{
|
||||
repository:'stackchain/api', title:'Retry deploy',
|
||||
issue:{{number:7, assignees:[], claimable:true}},
|
||||
}}, {{notification_id:42, updated_at:'2026-08-08T12:00:00Z'}});
|
||||
const first = controller.start();
|
||||
const second = controller.start();
|
||||
if (first !== second || claims !== 1) throw new Error('start was not single-flight');
|
||||
finishClaim({{number:7, title:'Retry deploy', assignees:['timmy'], state:'open'}});
|
||||
(async () => {{
|
||||
const result = await first;
|
||||
process.stdout.write(JSON.stringify({{result, claims, starts, reconciled, states}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["result"] == "started"
|
||||
assert output["claims"] == 1
|
||||
assert output["starts"] == 1
|
||||
assert len(output["reconciled"]) == 1
|
||||
assert output["reconciled"][0]["notification_id"] == 42
|
||||
assert output["reconciled"][0]["has_update"] is True
|
||||
assert output["states"][-1] == {
|
||||
"action": "hidden",
|
||||
"busy": False,
|
||||
"message": "Assigned, added to Today, and ready to work. The update is still unread.",
|
||||
}
|
||||
|
||||
|
||||
def test_unread_update_start_checks_today_capacity_before_claiming():
|
||||
script = f"""
|
||||
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
||||
let claims = 0;
|
||||
const states = [];
|
||||
const controller = createUpdateOwnership({{
|
||||
available: () => false,
|
||||
claim: () => {{ claims += 1; return Promise.resolve({{number:7}}); }},
|
||||
addToday: () => 'added', start: () => 'started',
|
||||
onStartState: state => states.push(state), onState: () => {{}},
|
||||
}});
|
||||
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
|
||||
(async () => {{
|
||||
const result = await controller.start();
|
||||
process.stdout.write(JSON.stringify({{result, claims, states}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["result"] == "full"
|
||||
assert output["claims"] == 0
|
||||
assert output["states"][-1] == {
|
||||
"action": "start",
|
||||
"busy": False,
|
||||
"message": "Today is limited to 5 items. Remove one before taking ownership.",
|
||||
}
|
||||
|
||||
|
||||
def test_unread_update_opens_owned_issue_when_today_start_throws():
|
||||
script = f"""
|
||||
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
||||
let recovered = null;
|
||||
const states = [];
|
||||
const controller = createUpdateOwnership({{
|
||||
available: () => true,
|
||||
claim: () => Promise.resolve({{number:7, title:'Retry deploy'}}),
|
||||
addToday: () => 'added',
|
||||
start: () => {{ throw new Error('storage failed'); }},
|
||||
recover: item => {{ recovered = item; }},
|
||||
onStartState: state => states.push(state), onState: () => {{}},
|
||||
}});
|
||||
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
|
||||
(async () => {{
|
||||
const result = await controller.start();
|
||||
process.stdout.write(JSON.stringify({{result, recovered, states}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["result"] == "recovery"
|
||||
assert output["recovered"]["number"] == 7
|
||||
assert output["states"][-1] == {
|
||||
"action": "hidden",
|
||||
"busy": False,
|
||||
"message": "Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_sheet_wires_phone_safe_ownership_to_my_work_and_today():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/update-ownership.js"></script>' in html
|
||||
assert 'id="update-ownership-action"' in html
|
||||
assert 'id="update-ownership-start"' in html
|
||||
assert '>Take ownership & start</button>' in html
|
||||
assert 'hidden aria-describedby="update-sheet-status"' in html
|
||||
assert 'const updateOwnership = createUpdateOwnership({' in html
|
||||
assert 'available: () => createAndStart.available()' in html
|
||||
assert 'const outcome = createAndStart.complete(item)' in html
|
||||
assert "openRoutedWork(item, qs('#update-ownership-start'))" in html
|
||||
assert 'updateOwnership.open(detail, selectedUpdate)' in html
|
||||
assert "qs('#update-ownership-action').addEventListener('click'" in html
|
||||
assert "qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start())" in html
|
||||
assert "lastContextSnapshot.issues" in html
|
||||
assert "todayWork.add(item)" in html
|
||||
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
||||
assert '.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden;' in html
|
||||
|
||||
|
||||
def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
|
||||
@@ -4682,6 +4800,9 @@ async def test_review_attention_drafts_require_revision_instead_of_resending_sta
|
||||
assert "reviewOutbox && item.status === 'attention'" in html
|
||||
assert ">Open current review</button>" in html
|
||||
assert ">Copy feedback</button>" in html
|
||||
assert "reviewOutbox && item.status === 'authorization'" in html
|
||||
assert ">Authorize & send review</button>" in html
|
||||
assert "Review submitted and queued intent cleared." in html
|
||||
assert "item.kind === 'authored-outbox' && !reviewOutbox" in html
|
||||
|
||||
|
||||
|
||||
@@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -234,6 +234,117 @@ async def test_successful_protected_actions_record_only_bounded_targets(
|
||||
assert b"abc123" not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_approved_pull_review_records_a_privacy_safe_completed_event(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
async def requested(*_args):
|
||||
return True
|
||||
|
||||
async def submit(*_args):
|
||||
return {"id": 91, "state": "APPROVED"}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "submit_pull_review",
|
||||
"target": "stackchain/api#7@abc123:approve",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={
|
||||
"expected_head_sha": "abc123",
|
||||
"decision": "approve",
|
||||
"body": "Ready to ship with private review notes.",
|
||||
},
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
events = (await client.get("/api/v1/security-events")).json()["events"]
|
||||
|
||||
assert response.status_code == 201
|
||||
assert events[0] == {
|
||||
"id": events[0]["id"],
|
||||
"kind": "pull_review_approved",
|
||||
"method": None,
|
||||
"device_label": None,
|
||||
"target": "stackchain/api#7",
|
||||
"created_at": events[0]["created_at"],
|
||||
"status": "completed",
|
||||
}
|
||||
persisted = (security_access / "security.sqlite3").read_bytes()
|
||||
assert b"abc123" not in persisted
|
||||
assert b"private review notes" not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_reservation_failure_prevents_the_gitea_mutation(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
submit_calls = []
|
||||
|
||||
async def requested(*_args):
|
||||
return True
|
||||
|
||||
async def submit(*args):
|
||||
submit_calls.append(args)
|
||||
return {"id": 91, "state": "APPROVED"}
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "submit_pull_review",
|
||||
"target": "stackchain/api#7@abc123:approve",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={
|
||||
"expected_head_sha": "abc123",
|
||||
"decision": "approve",
|
||||
"body": "Ready.",
|
||||
},
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "Security activity is temporarily unavailable"
|
||||
assert submit_calls == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_close_reservation_failure_prevents_the_gitea_mutation(
|
||||
security_access, monkeypatch
|
||||
|
||||
@@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v85" 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 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -146,7 +146,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v85" 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 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -328,6 +328,7 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
|
||||
state.flushResult = {login:'timmy', receipts:[
|
||||
{id:'capture-1',status:'confirmed',kind:'issue',route:'#/my-work/issue/stackchain/api/44'},
|
||||
{id:'bad-1',status:'attention',kind:'message',route:'#/my-work/drafts'},
|
||||
{id:'review-1',status:'authorization',kind:'message',route:'#/my-work/drafts'},
|
||||
]};
|
||||
state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}];
|
||||
await dispatchSync('stackchain-issue-outbox-v1');
|
||||
@@ -353,6 +354,14 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
|
||||
"data": {"route": "#/my-work/drafts"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"title": "Queued review needs authorization",
|
||||
"options": {
|
||||
"body": "Tap to authorize it in the Delivery center.",
|
||||
"tag": "stackchain-delivery-review-1",
|
||||
"data": {"route": "#/my-work/drafts"},
|
||||
},
|
||||
},
|
||||
]
|
||||
assert result["focused"] == [
|
||||
"https://forge.example/dashboard/#/my-work/issue/stackchain/api/44"
|
||||
@@ -385,7 +394,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -426,6 +435,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/today-completion.js",
|
||||
"/dashboard/static/today-readiness.js",
|
||||
"/dashboard/static/comment-next.js",
|
||||
"/dashboard/static/update-reply-read-next.js",
|
||||
"/dashboard/static/plan-today.js",
|
||||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v83';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v85';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v83" in source
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
CONTROLLER = ROOT / "frontend" / "update-reply-read-next.js"
|
||||
SYNC = ROOT / "frontend" / "background-issue-sync.js"
|
||||
DRAFTS = ROOT / "frontend" / "drafts.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_every_unread_update_offers_one_reply_read_next_mobile_action():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="send-update-reply-read-next"' in html
|
||||
assert '>Reply, mark read & next</button>' in html
|
||||
assert "workSession.checkpointed(selectedUpdate)" not in html
|
||||
assert '<script src="static/update-reply-read-next.js"></script>' in html
|
||||
assert ".update-reply-actions button { min-height:44px; width:100%; }" in html
|
||||
|
||||
|
||||
def test_reply_read_next_orders_online_delivery_and_is_single_flight():
|
||||
script = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
const calls=[]; let releaseReply;
|
||||
const controller=createController({{
|
||||
post:(item,body,id)=>{{calls.push(['reply',item.notification_id,body,id]);return new Promise(resolve=>releaseReply=resolve);}},
|
||||
markRead:id=>{{calls.push(['read',id]);return Promise.resolve({{ok:true}});}},
|
||||
queue:()=>{{throw new Error('must not queue');}}, canQueue:()=>false,
|
||||
accept:item=>calls.push(['accept',item.notification_id]),
|
||||
next:item=>{{calls.push(['next',item.notification_id]);return {{next:true}};}},
|
||||
}});
|
||||
const item={{notification_id:91}};
|
||||
const first=controller.submit(item,'Done','op-91');
|
||||
const second=controller.submit(item,'Done','op-91');
|
||||
releaseReply({{id:7}});
|
||||
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{same:first===second,calls,results}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"same": True,
|
||||
"calls": [
|
||||
["reply", 91, "Done", "op-91"],
|
||||
["read", 91],
|
||||
["accept", 91],
|
||||
["next", 91],
|
||||
],
|
||||
"results": [
|
||||
{"accepted": True, "delivery": "posted", "next": {"next": True}},
|
||||
{"accepted": True, "delivery": "posted", "next": {"next": True}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_reply_read_next_waits_until_the_next_update_is_open():
|
||||
script = f"""
|
||||
const createController=require({json.dumps(str(CONTROLLER))});
|
||||
let openNext;
|
||||
const controller=createController({{
|
||||
post:async()=>({{id:1}}),markRead:async()=>({{ok:true}}),queue:async()=>null,canQueue:()=>false,
|
||||
next:()=>new Promise(resolve=>openNext=()=>resolve({{notification_id:2}})),
|
||||
}});
|
||||
let settled=false;
|
||||
const result=controller.submit({{notification_id:1}},'Done','op-1').then(value=>{{settled=true;return value;}});
|
||||
setTimeout(()=>{{
|
||||
const before=settled;
|
||||
openNext();
|
||||
result.then(value=>process.stdout.write(JSON.stringify({{before,value}})));
|
||||
}},0);
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"before": False,
|
||||
"value": {
|
||||
"accepted": True,
|
||||
"delivery": "posted",
|
||||
"next": {"notification_id": 2},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_reply_read_next_durably_checkpoints_partial_delivery_without_reposting():
|
||||
script = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
const calls=[];
|
||||
const offline=Object.assign(new Error('offline'),{{status:503}});
|
||||
const controller=createController({{
|
||||
post:async()=>{{calls.push('reply');return{{id:8}};}},
|
||||
markRead:async()=>{{calls.push('read');throw offline;}},
|
||||
canQueue:error=>error.status===503,
|
||||
queue:async message=>{{calls.push(['queue',message]);return{{item:{{id:'saved'}},background:true}};}},
|
||||
accept:item=>calls.push(['accept',item.notification_id]),
|
||||
next:item=>{{calls.push(['next',item.notification_id]);return true;}},
|
||||
}});
|
||||
controller.submit({{notification_id:22}},'Ship it','stable-op').then(result=>
|
||||
process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == [
|
||||
"reply",
|
||||
"read",
|
||||
["queue", {
|
||||
"kind": "update-reply-read",
|
||||
"notificationId": 22,
|
||||
"body": "Ship it",
|
||||
"operationId": "stable-op",
|
||||
"replyConfirmed": True,
|
||||
}],
|
||||
["accept", 22],
|
||||
["next", 22],
|
||||
]
|
||||
assert output["result"] == {"accepted": True, "delivery": "queued", "next": True}
|
||||
|
||||
|
||||
def test_background_retry_resumes_at_read_after_reply_checkpoint():
|
||||
item = {
|
||||
"id": "op-31", "operationId": "op-31", "ownerLogin": "timmy",
|
||||
"status": "queued", "kind": "update-reply-read", "notificationId": 31,
|
||||
"body": "Acknowledged", "replyConfirmed": True,
|
||||
}
|
||||
script = f"""
|
||||
const createSync=require({json.dumps(str(SYNC))});
|
||||
let queued={json.dumps(item)};const calls=[];
|
||||
const store={{
|
||||
claimNext:async owner=>queued?(queued=null,{{...{json.dumps(item)}}}):null,
|
||||
complete:async()=>calls.push('complete'),release:async()=>calls.push('release'),
|
||||
fail:async()=>calls.push('fail'),countBlocked:async()=>0,
|
||||
checkpoint:async()=>{{throw new Error('must not checkpoint again');}},
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push([url,options.method]);return url==='api/v1/background-identity'?{{login:'timmy'}}:{{ok:true}};}};
|
||||
createSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == [
|
||||
["api/v1/background-identity", None],
|
||||
["api/v1/notifications/31/read", "PATCH"],
|
||||
"complete",
|
||||
]
|
||||
assert output["result"]["confirmed"] == [{"ok": True}]
|
||||
|
||||
|
||||
def test_partial_reply_read_delivery_is_recoverable_from_update_drafts():
|
||||
record = {"version": 2, "items": [{
|
||||
"id": "op-44", "operationId": "op-44", "ownerLogin": "timmy",
|
||||
"status": "attention", "kind": "update-reply-read", "notificationId": 44,
|
||||
"body": "Handled", "replyConfirmed": True, "error": "Read failed",
|
||||
}]}
|
||||
script = f"""
|
||||
const createDraftInbox=require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({json.dumps(record)})]]);
|
||||
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i],getItem:k=>values.get(k)||null,
|
||||
setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["outbox_kind"] == "update-reply-read"
|
||||
assert output["title"] == "Update #44"
|
||||
assert output["route"] == {"kind": "update", "notification_id": 44}
|
||||
assert output["status"] == "attention"
|
||||
Reference in new issue
Block a user