From 411e40d117dcf8c19f8da064604d48ffbfcbd8ed Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 17:35:21 +0000 Subject: [PATCH] feat: queue offline checklist progress (Closes #907) --- frontend/authored-outbox.js | 17 ++++++- frontend/background-issue-sync.js | 24 ++++++++++ frontend/dashboard.js | 15 ++++-- frontend/issue-sheet.js | 33 +++++++++++--- src/frontend_bundle.py | 2 +- src/gitea_proxy.py | 10 ++++ tests/test_authored_outbox.py | 36 +++++++++++++++ tests/test_background_issue_sync.py | 35 ++++++++++++++ tests/test_issue_api.py | 39 ++++++++++++++++ tests/test_markdown_renderer.py | 2 +- tests/test_my_work.py | 71 ++++++++++++++++++++++++++++- 11 files changed, 267 insertions(+), 17 deletions(-) diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js index 69cdd7a..a49d3d8 100644 --- a/frontend/authored-outbox.js +++ b/frontend/authored-outbox.js @@ -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', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']); + const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']); function reviewFingerprint(message) { return JSON.stringify({ @@ -96,6 +96,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, blockerNumber: Number(message.blockerNumber || 0), present: message.present === true, } : {}), + ...(message.kind === 'issue-content' ? { + title: String(message.title || ''), + expectedUpdatedAt: String(message.expectedUpdatedAt || ''), + } : {}), }; items.push(item); write(items, mirror); @@ -171,6 +175,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, if (item.kind === 'issue-blocker') { return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers'; } + if (item.kind === 'issue-content') { + return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content'; + } if (item.kind === 'pull-review') { return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review'; } @@ -246,9 +253,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, decision: item.decision, expected_head_sha: item.expectedHeadSha, comments: item.comments, + } : item.kind === 'issue-content' ? { + title:item.title, body:item.body, expected_updated_at:item.expectedUpdatedAt, } : { body: item.body }; result = await fetchJson(endpoint(item), { - method: 'POST', + method: item.kind === 'issue-content' ? 'PATCH' : 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', @@ -256,6 +265,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, }, body: JSON.stringify(body), }); + if (item.kind === 'issue-content' && + (result?.number !== item.number || result?.title !== item.title || result?.body !== item.body)) { + throw new Error('Checklist update was not confirmed.'); + } } } if (!result) return { blocked: true }; diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index bb071c8..32b8bf9 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -410,6 +410,24 @@ function createBackgroundIssueSync({ }, }; } + if (item.kind === 'issue-content') { + return { + url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content', + options: { + method: 'PATCH', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Idempotency-Key': item.operationId, + }, + body: JSON.stringify({ + title: item.title, + body: item.body, + expected_updated_at: item.expectedUpdatedAt, + }), + }, + }; + } if (item.kind === 'pull-review') { return { url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review', @@ -706,6 +724,12 @@ function createBackgroundIssueSync({ throw error; } } + if (item.kind === 'issue-content' && + (delivered?.number !== item.number || delivered?.title !== item.title || delivered?.body !== item.body)) { + const error = new Error('Checklist update was not confirmed.'); + error.status = 422; + throw error; + } await completeClaim(item, delivered); const receipt = receiptFor(item, 'confirmed', delivered); return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt }; diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 503ce43..4f7a035 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -429,7 +429,11 @@ } let reviewController = null; let wrapPreference = null; - const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage }); + const issueController = createIssueSheet({ + fetchJson: fetchReviewJson, + storage: localStorage, + enqueueDurably:message => authoredOutbox.enqueueDurably(message), + }); function overdueAgendaItems() { return agendaMyWork(activeMyWork).filter(item => item.agenda_group === 'Overdue'); } @@ -3395,7 +3399,7 @@ } function renderIssueBody(detail) { - issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline && + issueController.renderTasks(qs('#issue-sheet-body'), detail, !issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at); } @@ -3490,8 +3494,11 @@ qs('#release-issue').textContent = workSession.checkpointed(item) ? 'Release & next' : 'Release assignment'; qs('#close-issue-sheet').focus(); try { - const detail = offlineDetail || await issueController.load(item); + const loadedDetail = offlineDetail || await issueController.load(item); if (selectedIssue !== item) return; + const detail = issueController.pendingTask( + item, loadedDetail, authoredOutbox.list(), confirmedOwnerLogin + ); selectedIssueDetail = detail; renderPlanIssueDependencies(detail); issueConversation = issueController.conversation(item, detail.conversation); @@ -5598,7 +5605,7 @@ }); issueController.bindTaskToggles({ container:qs('#issue-sheet-body'), status:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'), - current:()=>({item:selectedIssue,detail:selectedIssueDetail}), + current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}), confirmed:applyIssueContent, restore:renderIssueBody, }); diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index 208297c..74ad780 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -24,7 +24,7 @@ function escapeOptionHtml(value) { })[character]); } -function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) { +function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) { let commentRequest = null; let closeRequest = null; let releaseRequest = null; @@ -152,6 +152,22 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend expectedUpdatedAt: detail.updated_at, }); }, + async queueTask(item, detail, taskIndex, checked) { + if (typeof toggleTask !== 'function' || typeof enqueueDurably !== 'function') { + throw new Error('Offline checklist updates are unavailable.'); + } + const body = toggleTask(detail.body, taskIndex, checked); + await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number, + title:detail.title, body, expectedUpdatedAt:detail.updated_at }); + return { queued:true, detail:{ ...detail, body, checklist_pending:true } }; + }, + pendingTask(item, detail, items, ownerLogin) { + const pending = [...(items || [])].reverse().find(candidate => candidate?.kind === 'issue-content' && + candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) && + candidate.ownerLogin === ownerLogin && ['queued', 'sending', 'attention'].includes(candidate.status)); + if (!pending) return { ...detail }; + return { ...detail, title:pending.title, body:pending.body, checklist_pending:true }; + }, bindTaskToggles({ container, status, retry, current, confirmed, restore }) { container.addEventListener('change', async event => { const control = event.target.closest('input.task-list-toggle'); @@ -161,13 +177,14 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend container.querySelectorAll('input.task-list-toggle').forEach(input => { input.disabled = true; }); status.textContent = 'Updating checklist…'; try { - const result = await this.toggleTask( - state.item, state.detail, Number(control.dataset.taskIndex), control.checked + const result = await (state.offline ? this.queueTask : this.toggleTask).call( + this, state.item, state.detail, Number(control.dataset.taskIndex), control.checked ); const latest = current(); if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) { - confirmed(state.item, state.detail, result); - status.textContent = 'Checklist updated.'; + confirmed(state.item, state.detail, state.offline ? result.detail : result); + if (state.offline) status.textContent = 'Checklist queued. Pending sync.'; + else status.textContent = 'Checklist updated.'; } } catch (error) { const latest = current(); @@ -180,9 +197,11 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend }); }, renderTasks(container, detail, interactive) { - container.classList.remove('checklist-pending'); + container.classList.toggle('checklist-pending', detail.checklist_pending === true); container.innerHTML = renderMarkdown( - detail.body || 'No description provided.', { interactiveTasks: Boolean(interactive) } + detail.body || 'No description provided.', { + interactiveTasks: Boolean(interactive) && detail.checklist_pending !== true, + } ); }, mergeContent(snapshot, item, detail, confirmed, replace) { diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 29925ee..ad79b1a 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -36,7 +36,7 @@ FEATURE_SOURCES = { "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js", "static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js", - "static/issue-attachment.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", + "static/issue-attachment.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", ), } CACHE_DECLARATION = re.compile( diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index a5e3fdf..85de895 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -2391,6 +2391,16 @@ async def _update_issue_content( ): raise IssueNotAvailableError("issue not found") if issue.get("updated_at") != expected_updated_at: + if issue.get("title") == title and issue.get("body", "") == body: + return { + "repository": repository, + "number": number, + "title": title, + "body": body, + "state": issue.get("state", "open"), + "updated_at": issue.get("updated_at", ""), + "url": _safe_web_url(issue.get("html_url")), + } raise IssueEditConflictError("issue changed upstream") response = await _get_client().patch( diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py index 5c95054..f8a2e24 100644 --- a/tests/test_authored_outbox.py +++ b/tests/test_authored_outbox.py @@ -77,6 +77,42 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi assert output["remaining"] == [] +def test_authored_outbox_persists_and_delivers_revision_checked_issue_content(): + script = f""" +const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); const calls = []; +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const outbox = createAuthoredOutbox({{ + storage, getOwnerLogin:()=> 'timmy', + fetchJson: async (url, options) => {{ + calls.push({{url,method:options.method,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}}); + return {{repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',updated_at:'2026-08-15T11:00:00Z'}}; + }}, +}}); +const queued = outbox.enqueue({{ + kind:'issue-content',repository:'stackchain/dashboard',number:17,operationId:'check-op', + title:'Ship',body:'- [x] Test',expectedUpdatedAt:'2026-08-15T10:00:00Z', +}}); +const restored = createAuthoredOutbox({{storage}}).list()[0]; +outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{queued,restored,calls,result,remaining:outbox.list()}}))); +""" + output = run_node(script) + + assert output["queued"]["expectedUpdatedAt"] == "2026-08-15T10:00:00Z" + assert output["restored"]["title"] == "Ship" + assert output["calls"] == [{ + "url": "api/v1/repos/stackchain/dashboard/issues/17/content", + "method": "PATCH", + "key": "check-op", + "body": { + "title": "Ship", "body": "- [x] Test", + "expected_updated_at": "2026-08-15T10:00:00Z", + }, + }] + assert output["result"]["confirmed"][0]["body"] == "- [x] Test" + assert output["remaining"] == [] + + def test_authored_outbox_persists_and_delivers_desired_blocker_state(): script = f""" const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index 86735a7..7a44788 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -96,6 +96,41 @@ const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}: } +def test_closed_app_sync_delivers_revision_checked_issue_content(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +let item = {{ + id:'check-1',operationId:'check-1',kind:'issue-content',ownerLogin:'timmy',status:'queued', + repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',expectedUpdatedAt:'2026-08-15T10:00:00Z', +}}; +const state={{calls:[],completed:[]}}; +const store={{ + claimNext:async()=>item?{{...item}}:null, + complete:async id=>{{state.completed.push(id);item=null;}}, + release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0, +}}; +const fetchJson=async(url,options={{}})=>{{ + state.calls.push({{url,method:options.method,key:options.headers?.['Idempotency-Key']||'',body:options.body?JSON.parse(options.body):null}}); + if(url==='api/v1/background-identity') return {{login:'timmy'}}; + return {{repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',updated_at:'2026-08-15T11:00:00Z'}}; +}}; +(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})(); +""" + output = run_node(script) + + assert output["state"]["calls"][1] == { + "url": "api/v1/repos/stackchain/dashboard/issues/17/content", + "method": "PATCH", + "key": "check-1", + "body": { + "title": "Ship", "body": "- [x] Test", + "expected_updated_at": "2026-08-15T10:00:00Z", + }, + } + assert output["state"]["completed"] == ["check-1"] + assert output["result"]["confirmed"][0]["body"] == "- [x] Test" + + def test_closed_app_sync_delivers_desired_blocker_state(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 1dfb824..2358ac4 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -664,6 +664,45 @@ async def test_gitea_edit_issue_rejects_stale_revision_without_patch(): ] +@pytest.mark.anyio +async def test_gitea_edit_issue_confirms_already_applied_content_after_lost_response(): + requests = [] + + async def handler(request): + requests.append(request) + if request.url.path == "/api/v1/user": + return httpx.Response(200, json={"login": "timmy"}) + return httpx.Response(200, json={ + "number": 17, + "title": "My draft", + "body": "Draft body", + "state": "open", + "updated_at": "2026-08-07T10:02:00Z", + "assignees": [{"login": "timmy"}], + "pull_request": None, + "html_url": "https://forge.example/stackchain/api/issues/17", + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.update_assigned_issue( + "stackchain/api", 17, "My draft", "Draft body", + "2026-08-07T10:00:00Z", + ) + finally: + await gitea_proxy.stop_client() + + assert [(request.method, request.url.path) for request in requests] == [ + ("GET", "/api/v1/user"), + ("GET", "/api/v1/repos/stackchain/api/issues/17"), + ] + assert result == { + "repository": "stackchain/api", "number": 17, "title": "My draft", + "body": "Draft body", "state": "open", "updated_at": "2026-08-07T10:02:00Z", + "url": "https://forge.example/stackchain/api/issues/17", + } + + @pytest.mark.anyio async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content(): requests = [] diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index f7981ec..d8a552d 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -155,7 +155,7 @@ def test_all_read_only_work_bodies_use_the_shared_markdown_renderer(): "renderMarkdown(detail.body || 'No description provided.')", "renderMarkdown(item.body || 'No description provided.')", "renderMarkdown(review.body)", - "issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline", + "issueController.renderTasks(qs('#issue-sheet-body'), detail,", ) for path in expected_paths: assert path in dashboard diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 7afae15..4e7586b 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -2206,12 +2206,16 @@ async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery(): html = await dashboard() controller = ISSUE_SHEET.read_text() - assert "issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline" in html + assert "issueController.renderTasks(qs('#issue-sheet-body'), detail," in html + assert "!issueController.readOnly(selectedIssue) && detail.state === 'open'" in html + assert "enqueueDurably:message => authoredOutbox.enqueueDurably(message)" in html assert "issueController.bindTaskToggles({" in html assert "container.addEventListener('change', async event =>" in controller assert "event.target.closest('input.task-list-toggle')" in controller assert "state.item, state.detail, Number(control.dataset.taskIndex), control.checked" in controller - assert "current:()=>({item:selectedIssue,detail:selectedIssueDetail})" in html + assert "current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline})" in html + assert "state.offline ? this.queueTask" in controller + assert "status.textContent = 'Checklist queued. Pending sync.'" in controller assert "buildMyWork.replaceIssueContent" in html assert "status.textContent = 'Checklist updated.'" in controller assert "restore(state.detail)" in controller @@ -2220,6 +2224,69 @@ async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery(): assert '.checklist-pending .task-list-toggle { opacity:.65;' in html +def test_offline_issue_checklist_toggle_waits_for_durable_admission_and_returns_pending_detail(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +let admit; const queued = []; +const controller = createIssueSheet({{ + storage:null, + toggleTask:(body, index, checked) => body.replace(index === 0 ? '[ ]' : '[X]', checked ? '[x]' : '[ ]'), + enqueueDurably:message => {{ queued.push(message); return new Promise(resolve => admit = resolve); }}, +}}); +const item = {{repository:'stackchain/api', number:17}}; +const detail = {{title:'Release', body:'- [ ] Build\\n- [X] Ship', updated_at:'2026-08-15T10:00:00Z'}}; +let settled = false; +const pending = controller.queueTask(item, detail, 0, true).then(result => {{ settled = true; return result; }}); +const before = settled; +admit({{item:{{id:'queued-check'}}}}); +pending.then(result => process.stdout.write(JSON.stringify({{before,queued,result}}))); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output["before"] is False + assert output["queued"] == [{ + "kind": "issue-content", "repository": "stackchain/api", "number": 17, + "title": "Release", "body": "- [x] Build\n- [X] Ship", + "expectedUpdatedAt": "2026-08-15T10:00:00Z", + }] + assert output["result"] == { + "queued": True, + "detail": { + "title": "Release", "body": "- [x] Build\n- [X] Ship", + "updated_at": "2026-08-15T10:00:00Z", "checklist_pending": True, + }, + } + + +def test_offline_issue_checklist_restores_account_bound_pending_body_after_reload(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +const controller = createIssueSheet({{storage:null}}); +const detail = {{title:'Release',body:'- [ ] Build',updated_at:'old'}}; +const items = [ + {{kind:'issue-content',repository:'o/r',number:7,ownerLogin:'alexander',title:'Wrong',body:'- [x] Wrong',expectedUpdatedAt:'wrong',status:'queued'}}, + {{kind:'issue-content',repository:'o/r',number:7,ownerLogin:'timmy',title:'Release',body:'- [x] Build',expectedUpdatedAt:'old',status:'queued'}}, +]; +process.stdout.write(JSON.stringify({{ + restored:controller.pendingTask({{repository:'o/r',number:7}}, detail, items, 'timmy'), + isolated:controller.pendingTask({{repository:'o/r',number:7}}, detail, items, 'hou3'), +}})); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output["restored"] == { + "title": "Release", "body": "- [x] Build", "updated_at": "old", + "checklist_pending": True, + } + assert output["isolated"] == { + "title": "Release", "body": "- [ ] Build", "updated_at": "old", + } + + def test_issue_checklist_toggle_submits_exact_revision_checked_body_once(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});