feat: capture blockers while filing mobile issues (Closes #841)
CI / lint (pull_request) Successful in 1m45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 17:33:45 +00:00
1 parent 35747cf9fb
commit 9ab22b97f8
13 files changed
+390 -10

No files matched your search

+27
View File
@@ -164,6 +164,33 @@ const fetchJson=async(url,options={{}})=>{{
assert output["second"]["confirmed"][0]["number"] == 469
def test_closed_app_capture_retries_only_unfinished_blockers_after_creation():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'blocked',operationId:'blocked',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Blocked',body:'',labelIds:[],blockers:[
{{repository:'o/api',number:7,title:'API'}},{{repository:'o/web',number:8,title:'Web'}}
]}};
const calls=[];let secondAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
const body=options.body?JSON.parse(options.body):null;calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
if(url.endsWith('/issues'))return{{number:42,repository:'o/r'}};
if(body.number===8 && secondAttempts++===0){{const e=new Error('offline');e.status=503;throw e;}}
return{{number:42,dependencies_available:true,dependencies:[body]}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';try{{await sync.flush();}}catch(e){{first=e.message;}}const checkpoint={{...item}};const result=await sync.flush();process.stdout.write(JSON.stringify({{first,checkpoint,calls,result}}));}})();
"""
output = run_node(script)
assert output["first"] == "offline"
assert output["checkpoint"]["deliveredIssue"]["number"] == 42
assert output["checkpoint"]["deliveredBlockers"] == 1
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
blockers = [call for call in output["calls"] if call["url"].endswith("/blockers")]
assert [call["body"]["number"] for call in blockers] == [7, 8, 8]
assert output["result"]["confirmed"][0]["number"] == 42
def test_evidence_bundle_retry_resumes_at_failed_image_and_posts_one_ordered_comment():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
+16
View File
@@ -69,6 +69,22 @@ setImmediate(()=>process.stdout.write(JSON.stringify({{
}
def test_review_lists_selected_blockers_in_filing_order():
script = f"""
const createReview=require({json.dumps(str(MODULE))});
function target(){{const listeners={{}};return{{hidden:true,disabled:false,textContent:'',children:[],addEventListener:(n,f)=>listeners[n]=f,replaceChildren(...items){{this.children=items;}},focus(){{}}}};}}
const blockerList=target();
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),blockerList,
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
review.open({{draft:{{repository:'o/r',title:'Blocked',blockers:[
{{repository:'o/api',number:7,title:'API ready'}},{{repository:'o/web',number:8,title:'Web ready'}}
]}},intent:'create-and-assign'}},target());
process.stdout.write(JSON.stringify(blockerList.children.map(item=>item.textContent)));
"""
assert run_node(script) == ["o/api #7 — API ready", "o/web #8 — Web ready"]
def test_escape_returns_to_the_unchanged_issue_form():
script = f"""
const createReview = require({json.dumps(str(MODULE))});
+43
View File
@@ -349,6 +349,49 @@ const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{f
assert output["remaining"] == []
def test_foreground_blocker_retry_checkpoints_each_relationship_without_recreating_issue():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const calls=[];let secondAttempts=0;
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createIssueOutbox({{
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'blocked-op',
fetchJson:async(url,options={{}})=>{{
const body=options.body ? JSON.parse(options.body) : null;
calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
if(url.endsWith('/issues'))return{{repository:'o/r',number:42,title:'Blocked work'}};
if(body?.number===8 && secondAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
return{{number:42,dependencies_available:true,dependencies:[body]}};
}},
}});
const blockers=[
{{repository:'o/api',number:7,title:'API ready'}},
{{repository:'o/web',number:8,title:'Web ready'}},
];
const queued=outbox.enqueue({{repository:'o/r',title:'Blocked work',body:'',blockers}});
(async()=>{{
await outbox.flush('timmy');const partial=outbox.list()[0];
const result=await outbox.retry(queued.id,'timmy');
process.stdout.write(JSON.stringify({{queued,partial,result,calls,remaining:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output["queued"]["blockers"] == [
{"repository": "o/api", "number": 7, "title": "API ready"},
{"repository": "o/web", "number": 8, "title": "Web ready"},
]
assert output["partial"]["deliveredIssue"]["number"] == 42
assert output["partial"]["deliveredBlockers"] == 1
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
blocker_calls = [call for call in output["calls"] if call["url"].endswith("/blockers")]
assert [call["body"]["number"] for call in blocker_calls] == [7, 8, 8]
assert blocker_calls[0]["key"] == "blocked-op:blocker-0"
assert blocker_calls[-1]["key"] == "blocked-op:blocker-1"
assert output["result"]["confirmed"][0]["number"] == 42
assert output["remaining"] == []
def test_replacing_a_partial_capture_screenshot_keeps_issue_and_restarts_upload_stage():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
+46
View File
@@ -4023,6 +4023,52 @@ async def test_mobile_issue_capture_requires_explicit_searchable_repository_sele
assert '.create-issue-repository-picker { min-width:0;' in html
def test_issue_capture_persists_bounded_blockers_and_searches_open_issues():
script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
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 capture=createIssueCapture({{storage,fetchJson:async url=>{{calls.push(url);return{{items:[
{{kind:'issue',state:'open',repository:'o/api',number:7,title:'API ready'}},
{{kind:'pull',state:'open',repository:'o/web',number:8,title:'Not an issue'}},
{{kind:'issue',state:'closed',repository:'o/old',number:9,title:'Closed'}}
]}};}}}});
const blockers=[
{{repository:'o/api',number:7,title:'API ready'}},
{{repository:'o/api',number:7,title:'duplicate'}},
{{repository:'bad',number:2,title:'invalid'}},
];
capture.saveDraft({{repository:'o/r',title:'Blocked work',body:'',blockers}});
(async()=>{{const found=await capture.searchBlockers('api');process.stdout.write(JSON.stringify({{draft:capture.loadDraft(),found,calls}}));}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["draft"]["blockers"] == [
{"repository": "o/api", "number": 7, "title": "API ready"}
]
assert output["found"]["items"] == [
{"kind": "issue", "state": "open", "repository": "o/api", "number": 7, "title": "API ready"}
]
assert output["calls"] == ["api/v1/search?q=api&limit=20"]
@pytest.mark.anyio
async def test_mobile_issue_capture_selects_blockers_without_starting_blocked_work():
html = await dashboard()
assert 'id="create-issue-blocker-search" type="search"' in html
assert 'id="create-issue-blocker-results" role="listbox"' in html
assert 'id="create-issue-blocker-selected"' in html
assert 'id="create-issue-blocker-status" class="small" aria-live="polite"' in html
assert "issueCapture.searchBlockers(query)" in html
assert "blockers: issueCaptureBlockers" in html
assert "renderIssueCaptureBlockers(captureDraft.blockers || [])" in html
assert "const hasBlockers = issueCaptureBlockers.length > 0;" in html
assert ".create-issue-blocker-result" in html
assert "min-height:44px" in html.split(".create-issue-blocker-result", 1)[1]
assert "@media(max-width:320px)" in html
@pytest.mark.anyio
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
html = await dashboard()
+17
View File
@@ -104,6 +104,23 @@ process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.at
assert output["stored"] == output["notes"]
def test_unfiled_capture_restores_selected_blockers():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const captures=createUnfiledCaptures({{storage,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'blocked'}});
const blockers=[{{repository:'o/api',number:7,title:'API ready'}},{{repository:'o/web',number:8,title:'Web ready'}}];
const saved=captures.save({{title:'Blocked work',body:'Context',blockers}});
const resumed=captures.resume(saved.id,'timmy');
process.stdout.write(JSON.stringify({{listed:captures.list()[0],resumed}}));
"""
output = run_node(script)
assert output["listed"]["blockerCount"] == 2
assert output["resumed"]["blockers"] == [
{"repository": "o/api", "number": 7, "title": "API ready"},
{"repository": "o/web", "number": 8, "title": "Web ready"},
]
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
script = f"""