feat: queue offline pull request reviews (#419)
All checks were successful
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-09 19:04:28 +00:00
parent 969d5567d4
commit 0cf88d7d7a
11 changed files with 313 additions and 20 deletions

View File

@ -269,11 +269,13 @@ repository catalogs, events, raw patches, and complete API responses are exclude
A cold offline launch labels the saved time. Cached Today details open in the existing 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 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 non-review pull details, planning, assignment, review, merge, and close controls remain disabled
until reconnection. Cached until reconnection. Cached requested reviews open in the existing review sheet so file progress, notes,
requested reviews open in the existing review sheet so file progress, notes, summary, summary, decision, and inline-comment drafts remain usable under the saved head SHA.
decision, and inline-comment drafts remain usable under the saved head SHA. Review **Queue review for reconnect** durably stores that complete SHA-bound review with one
submission is never queued: reconnect reloads the current head before enabling submit; idempotency key. Reconnect or Background Sync submits it exactly once; transient failures
a changed head starts clean SHA-scoped progress while retaining the prior-head draft. retain the queue entry, while stale-head and invalid-inline-comment responses move it to
Drafts as **Needs attention** without deleting feedback. Confirmed delivery removes the
queued operation and its matching SHA-scoped draft and progress.
Cached unread updates use the same phone conversation sheet and replies enter the 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. 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 Cards without a saved detail explain that reconnection is required. **Clear offline
@ -285,7 +287,8 @@ issue comments, pull-request comments, and unread-update replies use bounded loc
outboxes when connectivity or a retryable server failure prevents delivery. Issue outboxes when connectivity or a retryable server failure prevents delivery. Issue
captures and authored messages are also mirrored into account-bound IndexedDB lanes captures and authored messages are also mirrored into account-bound IndexedDB lanes
and registered with Background Sync, so a supporting installed browser can deliver and registered with Background Sync, so a supporting installed browser can deliver
new issues, issue comments, pull-request comments, and unread-update replies after new issues, issue comments, pull-request comments, unread-update replies, and completed
pull-request reviews after
every dashboard client has closed. The worker verifies the current Gitea login, shares every dashboard client has closed. The worker verifies the current Gitea login, shares
an atomic delivery claim with the foreground path, and preserves the original an atomic delivery claim with the foreground path, and preserves the original
idempotency key. Installed browsers can explicitly enable **Notify me when queued idempotency key. Installed browsers can explicitly enable **Notify me when queued

View File

@ -4,7 +4,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
); );
const pending = new Map(); const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']); const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review']);
function reviewFingerprint(message) {
return JSON.stringify({
body: String(message.body || ''),
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
comments: Array.isArray(message.comments) ? message.comments : [],
});
}
function clearConfirmedReviewState(item) {
if (item.kind !== 'pull-review') return;
try {
if (item.draftKey && item.draftFingerprint &&
storage?.getItem(item.draftKey) === item.draftFingerprint) storage.removeItem(item.draftKey);
if (item.progressKey && item.progressFingerprint &&
storage?.getItem(item.progressKey) === item.progressFingerprint) storage.removeItem(item.progressKey);
} catch (_error) { /* Delivery is confirmed even when local cleanup is unavailable. */ }
}
function read() { function read() {
try { try {
@ -32,6 +51,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const requestedOperationId = String(message.operationId || '').slice(0, 128); const requestedOperationId = String(message.operationId || '').slice(0, 128);
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId); const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
if (existing) return { ...existing }; if (existing) return { ...existing };
if (message.kind === 'pull-review') {
const queuedReview = items.find(item => item.kind === 'pull-review' &&
item.repository === String(message.repository || '') &&
item.number === Number(message.number || 0) &&
item.expectedHeadSha === String(message.expectedHeadSha || ''));
if (queuedReview) {
if (reviewFingerprint(queuedReview) === reviewFingerprint(message)) return { ...queuedReview };
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
}
}
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.'); if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
const id = String(requestedOperationId || makeId()).slice(0, 128); const id = String(requestedOperationId || makeId()).slice(0, 128);
const item = { const item = {
@ -45,6 +74,15 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
ownerLogin, ownerLogin,
status: 'queued', status: 'queued',
queuedAt: Number(now()), queuedAt: Number(now()),
...(message.kind === 'pull-review' ? {
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
comments: Array.isArray(message.comments) ? message.comments.map(comment => ({ ...comment })) : [],
draftKey: String(message.draftKey || ''),
progressKey: String(message.progressKey || ''),
draftFingerprint: String(message.draftFingerprint || ''),
progressFingerprint: String(message.progressFingerprint || ''),
} : {}),
}; };
items.push(item); items.push(item);
write(items, mirror); write(items, mirror);
@ -95,6 +133,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply'; return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
} }
const repository = item.repository.split('/').map(encodeURIComponent).join('/'); const repository = item.repository.split('/').map(encodeURIComponent).join('/');
if (item.kind === 'pull-review') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
}
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues'; const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments'; return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
} }
@ -114,6 +155,12 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
} }
result = delivery.message; result = delivery.message;
} else { } else {
const body = item.kind === 'pull-review' ? {
body: item.body,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments,
} : { body: item.body };
result = await fetchJson(endpoint(item), { result = await fetchJson(endpoint(item), {
method: 'POST', method: 'POST',
headers: { headers: {
@ -121,10 +168,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId, 'Idempotency-Key': item.operationId,
}, },
body: JSON.stringify({ body: item.body }), body: JSON.stringify(body),
}); });
} }
if (!result) return { blocked: true }; if (!result) return { blocked: true };
clearConfirmedReviewState(item);
discard(item.id); discard(item.id);
return { result }; return { result };
} catch (error) { } catch (error) {
@ -196,7 +244,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const statuses = new Map((records || []).map(item => [item.id, item])); const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => { const items = read().flatMap(item => {
const background = statuses.get(item.id); const background = statuses.get(item.id);
if (background?.status === 'sent') return []; if (background?.status === 'sent') {
clearConfirmedReviewState(item);
return [];
}
if (background?.status === 'attention') return [{ if (background?.status === 'attention') return [{
...item, ...item,
status: 'attention', status: 'attention',

View File

@ -242,6 +242,12 @@ function createBackgroundIssueSync({
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) }; return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
} }
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'pull-review') {
return {
id: item.id, status, kind: 'message',
route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number),
};
}
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') { if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue'; const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) }; return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
@ -260,6 +266,25 @@ function createBackgroundIssueSync({
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item); return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
} }
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'pull-review') {
return {
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
options: {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
body: item.body,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments || [],
}),
},
};
}
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') { if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues'; const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return authoredRequest( return authoredRequest(

View File

@ -1227,6 +1227,7 @@
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => { list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox'; const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
const isUnfiled = item.kind === 'unfiled-issue'; const isUnfiled = item.kind === 'unfiled-issue';
const reviewOutbox = item.outbox_kind === 'pull-review';
const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now'; const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now';
const outboxActions = item.quarantined ? const outboxActions = item.quarantined ?
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' + '<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
@ -1238,10 +1239,17 @@
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' + '<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-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' : '<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
item.kind === 'authored-outbox' ? 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>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
item.kind === 'authored-outbox' && !reviewOutbox ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' + '<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</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>' : '<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
reviewOutbox ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open review</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' + '<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
(isUnfiled ? 'Choose repository' : 'Resume draft') + '</button>' + (isUnfiled ? 'Choose repository' : 'Resume draft') + '</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>'; '<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
@ -2355,7 +2363,7 @@
qs('#review-submit-status').textContent = ''; qs('#review-submit-status').textContent = '';
qs('#continue-review-to-merge').hidden = true; qs('#continue-review-to-merge').hidden = true;
qs('#submit-review').disabled = true; qs('#submit-review').disabled = true;
qs('#submit-review').textContent = offlineReview ? 'Reconnect to validate & submit' : 'Submit review'; qs('#submit-review').textContent = offlineReview ? 'Queue review for reconnect' : 'Submit review';
closeInlineComposer(); closeInlineComposer();
draft = null; draft = null;
reviewFiles = []; reviewFiles = [];
@ -2422,7 +2430,7 @@
qs('#review-sheet-status').textContent = offlineReview ? qs('#review-sheet-status').textContent = offlineReview ?
'Offline review · saved ' + fmt(detail.saved_at) + ' · draft feedback stays on this device.' : 'Offline review · saved ' + fmt(detail.saved_at) + ' · draft feedback stays on this device.' :
'Ready to review · by ' + (detail.author || 'unknown author'); 'Ready to review · by ' + (detail.author || 'unknown author');
qs('#submit-review').disabled = offlineReview; qs('#submit-review').disabled = false;
} catch (error) { } catch (error) {
if (selectedReview !== item) return; if (selectedReview !== item) return;
qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.'; qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.';
@ -3633,6 +3641,30 @@
)) return; )) return;
const button = qs('#submit-review'); const button = qs('#submit-review');
button.disabled = true; button.disabled = true;
if (offlineReview) {
qs('#review-submit-status').textContent = 'Queueing review safely…';
try {
await authoredOutbox.enqueueDurably({
kind: 'pull-review',
repository: selectedReview.repository,
number: selectedReview.number,
body: createReviewController.formatFeedback(snapshot, reviewFiles),
decision: snapshot.decision,
expectedHeadSha: selectedReviewHead,
comments: snapshot.comments,
draftKey: draft.storageKey,
progressKey: progress?.storageKey || '',
draftFingerprint: localStorage.getItem(draft.storageKey) || '',
progressFingerprint: localStorage.getItem(progress?.storageKey) || '',
});
qs('#review-submit-status').textContent = 'Review queued · it will submit after reconnect.';
} catch (error) {
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry when ready.';
button.disabled = false;
button.focus();
}
return;
}
qs('#review-submit-status').textContent = 'Submitting review…'; qs('#review-submit-status').textContent = 'Submitting review…';
try { try {
const item = selectedReview; const item = selectedReview;

View File

@ -139,22 +139,33 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string') return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
.map(item => { .map(item => {
const isUpdate = item.kind === 'update-reply'; const isUpdate = item.kind === 'update-reply';
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue'; const isReview = item.kind === 'pull-review';
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number; const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment =>
String(comment.path || 'File') + ' · ' +
(comment.new_position ? 'new line ' + comment.new_position : 'old line ' + comment.old_position) +
': ' + String(comment.body || '')
).filter(Boolean) : [];
const copyText = [item.body, inlineFeedback.length ? 'Inline feedback\n' + inlineFeedback.join('\n') : '']
.filter(Boolean).join('\n\n');
const ownerLogin = String(item.ownerLogin || '').trim(); const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin; const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
return { return {
id: 'stackchain.authored-outbox.v1:' + item.id, id: 'stackchain.authored-outbox.v1:' + item.id,
outbox_id: item.id, outbox_id: item.id,
outbox_kind: item.kind,
kind: 'authored-outbox', kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : 'queued', status: item.status === 'attention' ? 'attention' : 'queued',
label: item.deliveryState === 'uncertain' ? 'Verify delivery' : label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? 'Needs attention' : 'Queued message'), (item.status === 'attention' ? 'Needs attention' :
(isReview ? 'Queued review' : 'Queued message')),
delivery_state: item.deliveryState, delivery_state: item.deliveryState,
repository: isUpdate ? '' : item.repository, repository: isUpdate ? '' : item.repository,
title: target, title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')), preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
copy_text: item.body, copy_text: copyText,
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
quarantined, quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') + ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '', (currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',

View File

@ -169,7 +169,7 @@ function createProgress({ storage, repository, number, headSha, files }) {
return snapshot(); return snapshot();
} }
return { snapshot, markReviewed, clear }; return { snapshot, markReviewed, clear, storageKey: key };
} }
function createDraft({ storage, repository, number, headSha, files }) { function createDraft({ storage, repository, number, headSha, files }) {
@ -267,7 +267,10 @@ function createDraft({ storage, repository, number, headSha, files }) {
return snapshot(); return snapshot();
} }
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear }; return {
snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear,
storageKey: key,
};
} }
function formatFeedback(draft, files) { function formatFeedback(draft, files) {

View File

@ -77,6 +77,55 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
assert output["remaining"] == [] assert output["remaining"] == []
def test_authored_outbox_persists_and_delivers_complete_pull_review_payload():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const calls = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
fetchJson: async (url, options) => {{ calls.push({{url, options}}); return {{id:44}}; }},
}});
let queued;
try {{
queued = outbox.enqueue({{
kind:'pull-review', repository:'stackchain/web', number:8, operationId:'review-op',
body:'Looks good', decision:'approve', expectedHeadSha:'abc123',
comments:[{{path:'app.js',body:'Nice',new_position:4}}],
draftKey:'stackchain.review-draft.v1:stackchain/web#8@abc123',
progressKey:'stackchain.review-progress.v1:stackchain/web#8@abc123',
draftFingerprint:'draft', progressFingerprint:'progress',
}});
}} catch (error) {{
process.stdout.write(JSON.stringify({{error:error.message}}));
}}
if (queued) {{
storage.setItem(queued.draftKey, 'draft'); storage.setItem(queued.progressKey, 'progress');
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
queued, calls:calls.map(call=>({{url:call.url,key:call.options.headers['Idempotency-Key'],body:JSON.parse(call.options.body)}})),
result, remaining:outbox.list(), draft:storage.getItem(queued.draftKey), progress:storage.getItem(queued.progressKey),
}})));
}}
"""
output = run_node(script)
assert "error" not in output, output.get("error")
assert output["queued"]["kind"] == "pull-review"
assert output["queued"]["comments"] == [{"path": "app.js", "body": "Nice", "new_position": 4}]
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/web/pulls/8/review",
"key": "review-op",
"body": {
"body": "Looks good", "decision": "approve", "expected_head_sha": "abc123",
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
},
}]
assert output["remaining"] == []
assert output["draft"] is None
assert output["progress"] is None
def test_authored_outbox_classifies_failures_and_continues_past_attention_items(): def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
script = f""" script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@ -183,6 +232,52 @@ process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
assert len(output["items"]) == 1 assert len(output["items"]) == 1
def test_authored_outbox_deduplicates_pull_review_for_same_head_after_reload():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();let sequence=0;
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const make=()=>createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-' + (++sequence)}});
const first=make().enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'Ready'}});
const second=make().enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'Ready'}});
process.stdout.write(JSON.stringify({{first,second,items:make().list()}}));
"""
output = run_node(script)
assert output["first"] == output["second"]
assert len(output["items"]) == 1
def test_authored_outbox_rejects_changed_feedback_while_same_head_is_queued():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy'}});
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'First'}});
let error='';try{{outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'request_changes',body:'Changed'}});}}catch(e){{error=e.message;}}
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
"""
output = run_node(script)
assert output["error"] == "A review for this saved head is already queued. Open Drafts to inspect or discard it first."
assert len(output["items"]) == 1
def test_confirmed_review_keeps_draft_edits_made_after_it_was_queued():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const draftKey='draft-key';const progressKey='progress-key';storage.setItem(draftKey,'old-draft');storage.setItem(progressKey,'old-progress');
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',fetchJson:async()=>({{id:1}})}});
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',body:'Queued',draftKey,progressKey,draftFingerprint:'old-draft',progressFingerprint:'old-progress'}});
storage.setItem(draftKey,'newer-draft');storage.setItem(progressKey,'newer-progress');
outbox.flush('timmy').then(()=>process.stdout.write(JSON.stringify({{draft:storage.getItem(draftKey),progress:storage.getItem(progressKey)}})));
"""
output = run_node(script)
assert output == {"draft": "newer-draft", "progress": "newer-progress"}
def test_authored_outbox_mirrors_to_background_sync_and_reconciles_worker_results(): def test_authored_outbox_mirrors_to_background_sync_and_reconciles_worker_results():
script = f""" script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});

View File

@ -203,6 +203,39 @@ const fetchJson = async (url, options = {{}}) => {{
assert output["result"]["confirmed"] == [{"id": 42}] assert output["result"]["confirmed"] == [{"id": 42}]
def test_closed_app_sync_delivers_pull_review_with_stable_identity_and_full_payload():
authored = {
"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": [{"path": "app.js", "body": "Nice", "new_position": 4}],
}
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let queued = {json.dumps(authored)}; const calls=[];
const store = {{
claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
}};
const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{id:45}};}};
createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
"""
output = run_node(script)
mutation = output["calls"][1]
assert mutation["url"] == "api/v1/repos/stackchain/web/pulls/8/review"
assert mutation["options"]["headers"]["Idempotency-Key"] == "review-op"
assert json.loads(mutation["options"]["body"]) == {
"body": "Looks good", "decision": "approve", "expected_head_sha": "abc123",
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
}
assert output["result"]["confirmed"] == [{"id": 45}]
assert output["result"]["receipts"] == [{
"id": "review-op", "status": "confirmed", "kind": "message",
"route": "#/my-work/review/stackchain/web/8",
}]
def test_reconciling_one_outbox_lane_preserves_the_other_lane(): def test_reconciling_one_outbox_lane_preserves_the_other_lane():
script = f""" script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); const createBackgroundIssueSync = require({json.dumps(str(SYNC))});

View File

@ -121,6 +121,27 @@ process.stdout.write(JSON.stringify(drafts));
assert output[1]["label"] == "Queued issue" assert output[1]["label"] == "Queued issue"
def test_draft_inbox_exposes_queued_review_with_review_route_and_actionable_state():
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:[{{path:'app.js',body:'Nice',new_position:4}}],ownerLogin:'timmy',status:'queued',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 item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
process.stdout.write(JSON.stringify(item));
"""
output = run_node(script)
assert output["label"] == "Queued review"
assert output["outbox_kind"] == "pull-review"
assert output["title"] == "stackchain/web#8"
assert output["route"] == {"kind": "review", "repository": "stackchain/web", "number": 8}
assert output["copy_text"] == "Looks good\n\nInline feedback\napp.js · new line 4: Nice"
assert output["quarantined"] is False
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation(): def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
script = f""" script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))}); const createDraftInbox = require({json.dumps(str(DRAFTS))});

View File

@ -4205,6 +4205,17 @@ async def test_review_requests_open_an_accessible_mobile_detail_sheet():
assert '.review-diff' in html and 'overflow-x:auto' in html assert '.review-diff' in html and 'overflow-x:auto' in html
@pytest.mark.anyio
async def test_review_attention_drafts_require_revision_instead_of_resending_stale_payload():
html = await dashboard()
assert "const reviewOutbox = item.outbox_kind === 'pull-review';" in html
assert "reviewOutbox && item.status === 'attention'" in html
assert ">Open current review</button>" in html
assert ">Copy feedback</button>" in html
assert "item.kind === 'authored-outbox' && !reviewOutbox" in html
@pytest.mark.anyio @pytest.mark.anyio
async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff(): async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
html = await dashboard() html = await dashboard()

View File

@ -264,7 +264,7 @@ async def test_saved_unread_update_opens_offline_with_queued_reply_and_read_cont
@pytest.mark.anyio @pytest.mark.anyio
async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only(): async def test_saved_requested_review_queues_complete_sha_scoped_feedback_offline():
html = await dashboard() html = await dashboard()
assert "item.is_review ? reviewController.load(item)" in html assert "item.is_review ? reviewController.load(item)" in html
@ -272,6 +272,14 @@ async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only
assert "async function openReviewSheet(item, trigger, cachedDetail = null)" in html assert "async function openReviewSheet(item, trigger, cachedDetail = null)" in html
assert "cachedDetail || await reviewController.load(selectedReview)" in html assert "cachedDetail || await reviewController.load(selectedReview)" in html
assert "Offline review · saved " in html assert "Offline review · saved " in html
assert "Reconnect to validate & submit" in html assert "Queue review for reconnect" in html
assert "qs('#submit-review').disabled = offlineReview;" in html assert "kind: 'pull-review'" in html
assert "await authoredOutbox.enqueueDurably" in html
assert "expectedHeadSha: selectedReviewHead" in html
assert "comments: snapshot.comments" in html
assert "draftKey: draft.storageKey" in html
assert "progressKey: progress?.storageKey" in html
assert "draftFingerprint: localStorage.getItem(draft.storageKey) || ''" in html
assert "progressFingerprint: localStorage.getItem(progress?.storageKey) || ''" in html
assert "Review queued · it will submit after reconnect." in html
assert "if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);" in html assert "if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);" in html