feat: resume fully planned drafts across devices (Closes #859)
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-15 00:07:32 +00:00
1 parent 7cab4e8749
commit f23839f2b9
10 files changed
+380 -30

No files matched your search

+20
View File
@@ -4091,6 +4091,26 @@ capture.saveDraft({{repository:'o/r',title:'Backlog capture',body:'Context',labe
}]
def test_issue_capture_persists_estimate_and_completion_intent_for_resumed_drafts():
script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
const capture=createIssueCapture({{storage,fetchJson:async()=>{{}}}});
capture.saveDraft({{
repository:'o/r',title:'Planned work',body:'Context',labelIds:[3],
estimateMinutes:45,completionIntent:'create-and-start',
}});
process.stdout.write(JSON.stringify(capture.loadDraft()));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["estimateMinutes"] == 45
assert output["completionIntent"] == "create-and-start"
def test_issue_capture_applies_and_switches_repository_templates_without_losing_authored_work():
script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
+101 -2
View File
@@ -38,6 +38,28 @@ process.stdout.write(JSON.stringify({{opened,skipped,afterFiled,previous}}));
}
def test_draft_filing_session_restores_all_saved_evidence():
script = f"""
const createDraftFilingSession=require({json.dumps(str(DRAFT_SESSION))});
const evidence=[{{filename:'one.png'}},{{filename:'two.png'}}];
const restored=[];
const elements=new Map();
const qs=selector=>{{
if(!elements.has(selector)) elements.set(selector,{{hidden:false,disabled:false,textContent:'',addEventListener:()=>{{}}}});
return elements.get(selector);
}};
const session=createDraftFilingSession({{list:()=>[{{id:'planned'}}]}});
session.attach(qs,{{
captures:{{resume:async()=>({{repository:'o/r',title:'Planned',body:'',labelIds:[],attachments:evidence}})}},
issueCapture:{{saveDraft:()=>{{}}}},attachment:{{restore:value=>restored.push(value),clear:()=>restored.push(null)}},
getLogin:()=>'timmy',setResumedId:()=>{{}},openSheet:async()=>{{}},setFilingMode:()=>{{}},
}});
session.start('planned');
(async()=>{{await session.nextCapture();process.stdout.write(JSON.stringify(restored));}})();
"""
assert run_node(script) == [[{"filename": "one.png"}, {"filename": "two.png"}]]
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
@@ -145,6 +167,65 @@ function device() {{
assert output["blockers"] == [{"repository": "o/api", "number": 7, "title": "API"}]
def test_unfiled_captures_resume_complete_filing_plan_on_another_device():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
function device(id) {{
const values=new Map();
return createUnfiledCaptures({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>id,now:()=>42,
}});
}}
(async()=>{{
const phone=device('planned-draft'), desktop=device('unused');
await phone.save({{
repository:'stackchain/dashboard',title:'Restore my plan',body:'Do not make me plan twice',
labelIds:[7,3,7],milestoneId:12,dueDate:'2026-08-21',
templateName:'Bug report',templateId:'bug.yml',capturedBody:'Original field notes',
assignee:'alexander',assigneeName:'Alexander',unassigned:false,
estimateMinutes:45,completionIntent:'create-and-start',
blockers:[{{repository:'stackchain/api',number:9,title:'API rollout'}}],
}});
const exported=await phone.exportOwned('timmy');
await desktop.mergeRemote(exported,'timmy');
const resumed=await desktop.resume('planned-draft','timmy');
process.stdout.write(JSON.stringify({{exported:exported[0],resumed}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output["exported"]["filing_plan"] == {
"repository": "stackchain/dashboard",
"label_ids": [7, 3],
"milestone_id": 12,
"due_date": "2026-08-21",
"template_name": "Bug report",
"template_id": "bug.yml",
"captured_body": "Original field notes",
"assignee": "alexander",
"assignee_name": "Alexander",
"estimate_minutes": 45,
"completion_intent": "create-and-start",
}
assert output["resumed"] == {
"repository": "stackchain/dashboard",
"title": "Restore my plan",
"body": "Do not make me plan twice",
"labelIds": [7, 3],
"milestoneId": 12,
"dueDate": "2026-08-21",
"templateName": "Bug report",
"templateId": "bug.yml",
"capturedBody": "Original field notes",
"assignee": "alexander",
"assigneeName": "Alexander",
"estimateMinutes": 45,
"completionIntent": "create-and-start",
"blockers": [{"repository": "stackchain/api", "number": 9, "title": "API rollout"}],
}
def test_unfiled_captures_reconcile_remote_replaces_stale_content_and_evidence():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
@@ -510,11 +591,11 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert "data-capture-id=\"' + escapeHtml(item.capture_id) + '\"" in html
assert "requestAnimationFrame(() =>" in html
assert "savedCard?.scrollIntoView({block:'nearest'})" in html
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in html
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in UNFILED.read_text()
assert "await dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text()
assert "await createIssueAttachmentController.serialize()" in html
assert "createUnfiledAttachmentStore()" in html
assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text()
assert "dependencies.attachment[evidence ? 'restore' : 'clear'](evidence)" in DRAFT_SESSION.read_text()
assert "await unfiledCaptures.completeResume(rUC)" in html
assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
@@ -526,6 +607,22 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert '@media(max-width:320px)' in html
@pytest.mark.anyio
async def test_mobile_planned_draft_can_be_saved_and_resumed_without_replanning():
html = await dashboard()
feature = UNFILED.read_text()
assert "const captureDraft = currentIssueCaptureDraft();" in html
assert "return withFilingEstimate(issueTemplatePicker.fields(" in html
assert "draft.estimateMinutes = Number(value) || null" in feature
assert "qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';" in html
assert "qs('#file-new-issue').hidden = enabled" in feature
assert "qs('.create-issue-capture-actions').hidden = false" in feature
assert "label:item.filingPlan?.repository ? 'Ready to file' : 'Needs filing'" in feature
assert "repository:item.filingPlan?.repository || ''" in feature
assert "Saved planned Draft" in feature
@pytest.mark.anyio
async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
html = await dashboard()
@@ -556,6 +653,8 @@ async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision(
assert 'id="keep-editing-draft"' in html
assert "showDraftCapacityDialog(unfiledCaptures)" in html
feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text()
assert "const draft = currentIssueCaptureDraft();" in feature
assert "Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}" in feature
assert "unfiledCaptures.replaceOldest(draft, oldest.id)" in feature
assert '.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh;' in html
assert '.draft-capacity-actions button { min-height:44px;' in html
+42 -2
View File
@@ -51,6 +51,32 @@ def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path):
}])])
def test_unfiled_drafts_validate_and_round_trip_complete_filing_plan(tmp_path):
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
planned = draft(evidence=[])
planned.pop("evidence")
planned["filing_plan"] = {
"repository": "stackchain/dashboard",
"label_ids": [7, 3],
"milestone_id": 12,
"due_date": "2026-08-21",
"template_name": "Bug report",
"template_id": "bug.yml",
"captured_body": "Original field notes",
"assignee": "alexander",
"assignee_name": "Alexander",
"estimate_minutes": 45,
"completion_intent": "create-and-start",
}
assert store.replace("timmy", 0, [planned])["drafts"] == [planned]
invalid = draft(evidence=[])
invalid["filing_plan"] = {**planned["filing_plan"], "repository": "not-a-repository"}
with pytest.raises(ValueError, match="filing repository is invalid"):
store.replace("timmy", 1, [invalid])
@pytest.mark.anyio
async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_safe(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
@@ -69,7 +95,21 @@ async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_s
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
payload = {"revision": 0, "drafts": [draft(evidence=[])]}
planned = draft(evidence=[])
planned["filing_plan"] = {
"repository": "stackchain/dashboard",
"label_ids": [7, 3],
"milestone_id": 12,
"due_date": "2026-08-21",
"template_name": "Bug report",
"template_id": "bug.yml",
"captured_body": "Original field notes",
"assignee": "alexander",
"assignee_name": "Alexander",
"estimate_minutes": 45,
"completion_intent": "create-and-start",
}
payload = {"revision": 0, "drafts": [planned]}
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"})
forbidden = await client.put("/api/v1/unfiled-drafts", json=payload)
@@ -82,7 +122,7 @@ async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_s
assert forbidden.status_code == 403
assert saved.status_code == 200
expected = draft(evidence=[])
expected = planned.copy()
expected.pop("evidence")
assert saved.json() == {"revision": 1, "drafts": [expected]}
assert stale.status_code == 409