Queue completed pull request reviews for reconnect delivery #420

Merged
timmy merged 1 commits from timmy/419-offline-review-outbox into main 2026-08-09 19:06:15 +00:00
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
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
until reconnection. Cached
requested reviews open in the existing review sheet so file progress, notes, summary,
decision, and inline-comment drafts remain usable under the saved head SHA. Review
submission is never queued: reconnect reloads the current head before enabling submit;
a changed head starts clean SHA-scoped progress while retaining the prior-head draft.
until reconnection. Cached requested reviews open in the existing review sheet so file progress, notes,
summary, decision, and inline-comment drafts remain usable under the saved head SHA.
**Queue review for reconnect** durably stores that complete SHA-bound review with one
idempotency key. Reconnect or Background Sync submits it exactly once; transient failures
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
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
@ -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
captures and authored messages are also mirrored into account-bound IndexedDB lanes
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
an atomic delivery claim with the foreground path, and preserves the original
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)
);
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() {
try {
@ -32,6 +51,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const requestedOperationId = String(message.operationId || '').slice(0, 128);
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
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.');
const id = String(requestedOperationId || makeId()).slice(0, 128);
const item = {
@ -45,6 +74,15 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
ownerLogin,
status: 'queued',
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);
write(items, mirror);
@ -95,6 +133,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
}
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';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
}
@ -114,6 +155,12 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
}
result = delivery.message;
} 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), {
method: 'POST',
headers: {
@ -121,10 +168,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
body: JSON.stringify(body),
});
}
if (!result) return { blocked: true };
clearConfirmedReviewState(item);
discard(item.id);
return { result };
} catch (error) {
@ -196,7 +244,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
if (background?.status === 'sent') return [];
if (background?.status === 'sent') {
clearConfirmedReviewState(item);
return [];
}
if (background?.status === 'attention') return [{
...item,
status: 'attention',

View File

@ -242,6 +242,12 @@ function createBackgroundIssueSync({
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === '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') {
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
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);
}
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') {
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return authoredRequest(

View File

@ -1227,6 +1227,7 @@
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
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 outboxActions = item.quarantined ?
'<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-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</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-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</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">' +
(isUnfiled ? 'Choose repository' : 'Resume 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('#continue-review-to-merge').hidden = 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();
draft = null;
reviewFiles = [];
@ -2422,7 +2430,7 @@
qs('#review-sheet-status').textContent = offlineReview ?
'Offline review · saved ' + fmt(detail.saved_at) + ' · draft feedback stays on this device.' :
'Ready to review · by ' + (detail.author || 'unknown author');
qs('#submit-review').disabled = offlineReview;
qs('#submit-review').disabled = false;
} catch (error) {
if (selectedReview !== item) return;
qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.';
@ -3633,6 +3641,30 @@
)) return;
const button = qs('#submit-review');
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…';
try {
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')
.map(item => {
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 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 quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
return {
id: 'stackchain.authored-outbox.v1:' + item.id,
outbox_id: item.id,
outbox_kind: item.kind,
kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
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,
repository: isUpdate ? '' : item.repository,
title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
copy_text: item.body,
copy_text: copyText,
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(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, markReviewed, clear };
return { snapshot, markReviewed, clear, storageKey: key };
}
function createDraft({ storage, repository, number, headSha, files }) {
@ -267,7 +267,10 @@ function createDraft({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear };
return {
snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear,
storageKey: key,
};
}
function formatFeedback(draft, files) {

View File

@ -77,6 +77,55 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
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():
script = f"""
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
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():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});

View File

@ -203,6 +203,39 @@ const fetchJson = async (url, options = {{}}) => {{
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():
script = f"""
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"
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():
script = f"""
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
@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
async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
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
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()
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 "cachedDetail || await reviewController.load(selectedReview)" in html
assert "Offline review · saved " in html
assert "Reconnect to validate & submit" in html
assert "qs('#submit-review').disabled = offlineReview;" in html
assert "Queue review for reconnect" 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