feat: plan search batches into releases (Closes #815)
CI / lint (pull_request) Successful in 1m37s
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 09:30:53 +00:00
1 parent 2e62eb09f5
commit 43769460fc
4 files changed
+251 -10

No files matched your search

+120 -5
View File
@@ -69,6 +69,20 @@ Promise.all([
assert result["results"][2] == "This issue is no longer available to assign."
def test_release_planning_requires_selected_issues_from_one_repository():
script = f"""
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
const flow=createSearchBatchPlan();
flow.start();
flow.toggle({{kind:'issue',state:'open',repository:'stackchain/dashboard',number:815}});
const one=flow.releaseRepository();
flow.toggle({{kind:'issue',state:'open',repository:'stackchain/api',number:17}});
process.stdout.write(JSON.stringify({{one,mixed:flow.releaseRepository()}}));
"""
assert run_node(script) == {"one": "stackchain/dashboard", "mixed": None}
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
@@ -80,12 +94,17 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
assert 'id="search-batch-actions"' in html
assert 'id="queue-selected-search-results"' in html
assert 'id="defer-selected-search-results"' in html
assert 'id="plan-selected-search-results"' in html
assert 'id="search-release-review"' in html
assert 'id="search-release-milestone"' in html
assert 'id="search-release-due-date"' in html
assert 'id="resume-search-batch"' in html
assert 'src="static/search-batch-plan.js"' in html
assert "mountSearchBatchPlanning(" in dashboard
assert "escapeHtml, escAttr, laterWork, laterPicker" in dashboard
assert "journalName:'search-today-batch'" in controller
assert "journalName:'search-later-batch'" in controller
assert "journalName:'search-release-batch'" in controller
assert "autoMount:false" in controller
assert "plan.toggle(result)" in controller
assert "processor.run(plan.snapshot().items, estimateValues())" in controller
@@ -100,6 +119,8 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
assert "position:sticky" in css
assert "env(safe-area-inset-bottom)" in css
assert ".cmd-select-result" in css and "min-height:44px" in css
assert ".search-release-review" in css and "100dvh" in css
assert ".search-release-actions" in css and "env(safe-area-inset-bottom)" in css
assert "BASE + 'static/search-batch-plan.js'" in worker
@@ -110,10 +131,12 @@ const listeners={{}};
const elements={{}};
for (const id of [
'search-batch-actions','select-search-results','queue-selected-search-results',
'defer-selected-search-results','search-selection-status','cmd-search-action-status',
'defer-selected-search-results','plan-selected-search-results','search-selection-status','cmd-search-action-status',
'resume-search-batch','open-palette','cmd-results','cancel-search-selection',
'search-batch-estimate-review','search-batch-estimate-summary','search-batch-estimate-list',
'cancel-search-batch-estimates','confirm-search-batch-estimates'
'cancel-search-batch-estimates','confirm-search-batch-estimates','search-release-review',
'search-release-summary','search-release-milestone','search-release-due-date','search-release-list',
'cancel-search-release','confirm-search-release'
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
const document={{querySelector:selector=>elements[selector] || null,querySelectorAll:()=>[]}};
@@ -156,7 +179,7 @@ process.stdout.write(JSON.stringify({{before,runs,laterResult,laterCalls}}));
"runs": [],
"count": 1,
"context": "search-batch",
"journals": ["search-today-batch", "search-later-batch"],
"journals": ["search-today-batch", "search-later-batch", "search-release-batch"],
}
assert result["runs"] == [{
"items": [{
@@ -177,11 +200,13 @@ const listeners={{}};
const elements={{}};
for (const id of [
'search-batch-actions','select-search-results','queue-selected-search-results',
'defer-selected-search-results',
'defer-selected-search-results','plan-selected-search-results',
'search-selection-status','cmd-search-action-status','resume-search-batch',
'open-palette','cmd-results','cancel-search-selection','search-batch-estimate-review',
'search-batch-estimate-summary','search-batch-estimate-list',
'cancel-search-batch-estimates','confirm-search-batch-estimates'
'cancel-search-batch-estimates','confirm-search-batch-estimates','search-release-review',
'search-release-summary','search-release-milestone','search-release-due-date','search-release-list',
'cancel-search-release','confirm-search-release'
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
const estimateInputs=[
@@ -245,3 +270,93 @@ process.stdout.write(JSON.stringify({{before,calls,afterCompleteHidden}}));
"estimates": {"stackchain/dashboard#811": 25, "stackchain/api#12": 30},
}]
assert result["afterCompleteHidden"] is True
def test_search_batch_reviews_and_applies_one_repository_release_plan_before_assignment():
script = f"""
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
const listeners={{}};
const elements={{}};
for (const id of [
'search-batch-actions','select-search-results','queue-selected-search-results',
'defer-selected-search-results','plan-selected-search-results','search-selection-status',
'cmd-search-action-status','resume-search-batch','open-palette','cmd-results',
'cancel-search-selection','search-batch-estimate-review','search-batch-estimate-summary',
'search-batch-estimate-list','cancel-search-batch-estimates','confirm-search-batch-estimates',
'search-release-review','search-release-summary','search-release-milestone',
'search-release-due-date','search-release-list','cancel-search-release','confirm-search-release'
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
const document={{querySelector:selector=>elements[selector] || null,querySelectorAll:()=>[]}};
const calls=[];
const fetchJson=(url,options={{}})=>{{
calls.push({{url,method:options.method || 'GET',body:options.body ? JSON.parse(options.body) : null}});
if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}},{{id:7,title:'Old',state:'closed'}}]);
const number=Number(url.split('/issues/')[1].split('/')[0]);
if (url.endsWith('/milestone')) return Promise.resolve({{number,milestone:{{id:9,title:'August RC'}}}});
if (url.endsWith('/due-date')) return Promise.resolve({{number,due_date:'2026-08-31T23:59:59Z'}});
return Promise.resolve();
}};
const processorOptions=[];
const processors=[];
const batchFactory=options=>{{processorOptions.push(options);const processor={{
run:(items,estimates,context)=>{{calls.push({{run:items.map(item=>item.number),context}});return Promise.resolve();}},
resume:()=>Promise.resolve(),pending:()=>0,
}};processors.push(processor);return processor;}};
const planning={{capacity_minutes:null,estimates:{{}}}};
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,replacePlanning:()=>{{}}}};
const items=[
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:815,title:'Release plan'}},
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:816,title:'Second issue'}},
];
const mounted=createSearchBatchPlan.mount(
document,batchFactory,todayWork,()=> 'timmy',fetchJson,()=>'',()=>Promise.resolve('queued'),
{{enqueueConfiguration:()=>{{}},flush:()=>{{}}}},item=>item,index=>items[index],()=>{{}},
value=>value,value=>value,null,null
);
mounted.plan.start();items.forEach(item=>mounted.plan.toggle(item));
listeners['plan-selected-search-results:click']().then(async()=>{{
const before={{calls:calls.slice(),reviewHidden:elements['#search-release-review'].hidden,
actionsHidden:elements['#search-batch-actions'].hidden,options:elements['#search-release-milestone'].innerHTML,
list:elements['#search-release-list'].innerHTML}};
elements['#search-release-milestone'].value='9';
elements['#search-release-milestone'].selectedIndex=0;
elements['#search-release-milestone'].options=[{{textContent:'August RC'}}];
elements['#search-release-due-date'].value='2026-08-31';
listeners['confirm-search-release:click']();
const queued=await processorOptions[2].queue({{...items[0],assigned_to_me:true}},
{{milestone_id:9,milestone_title:'August RC',due_date:'2026-08-31T23:59:59Z'}});
process.stdout.write(JSON.stringify({{before,calls,queued,journals:processorOptions.map(option=>option.journalName)}}));
}});
"""
result = run_node(script)
assert result["before"]["calls"] == [{
"url": "api/v1/repos/stackchain/dashboard/milestones", "method": "GET", "body": None,
}]
assert result["before"]["reviewHidden"] is False
assert result["before"]["actionsHidden"] is True
assert "August RC" in result["before"]["options"]
assert "Old" not in result["before"]["options"]
assert "Release plan" in result["before"]["list"]
assert result["calls"][1] == {
"run": [815, 816],
"context": {
"milestone_id": 9, "milestone_title": "August RC",
"due_date": "2026-08-31T23:59:59Z",
},
}
assert result["calls"][2:] == [
{
"url": "api/v1/repos/stackchain/dashboard/issues/815/milestone",
"method": "PATCH", "body": {"milestone_id": 9},
},
{
"url": "api/v1/repos/stackchain/dashboard/issues/815/due-date",
"method": "PATCH", "body": {"due_date": "2026-08-31T23:59:59Z"},
},
]
assert result["queued"] == "queued"
assert result["journals"] == [
"search-today-batch", "search-later-batch", "search-release-batch",
]