Plan selected mobile Search issues into a release milestone #816

Merged
timmy merged 1 commits from timmy/815-plan-search-release into main 2026-08-14 09:34:29 +00:00
4 changed files with 251 additions and 10 deletions

View File

@ -129,7 +129,7 @@ textarea { resize: vertical; min-height: 120px; }
.cmd-select-result { min-height:44px; width:100%; display:grid; grid-template-columns:28px 1fr auto; gap:10px; align-items:center; text-align:left; }
.cmd-select-result input { width:22px; height:22px; }
.cmd-select-result[disabled] { cursor:not-allowed; opacity:.62; }
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
.search-batch-actions[hidden] { display:none; }
.search-batch-actions span { grid-column:1 / -1; }
.search-batch-actions button, .search-batch-recovery { min-height:44px; }
@ -142,6 +142,14 @@ textarea { resize: vertical; min-height: 120px; }
.search-batch-estimate-review input { min-height:44px; width:76px; }
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.search-batch-estimate-actions button { min-height:44px; }
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.search-release-review[hidden] { display:none; }
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
.search-release-review label { display:grid; gap:6px; }
.search-release-review select, .search-release-review input { min-height:44px; width:100%; }
.search-release-row { padding:8px 0; border-bottom:1px solid #2a496e; }
.search-release-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.search-release-actions button { min-height:44px; }
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }

View File

@ -459,6 +459,7 @@
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
<button id="cancel-search-selection" type="button">Cancel</button>
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
<button id="queue-selected-search-results" type="button" disabled>Assign &amp; add to Today</button>
</div>
<section class="search-batch-estimate-review" id="search-batch-estimate-review"
@ -471,6 +472,19 @@
<button id="confirm-search-batch-estimates" type="button">Confirm &amp; assign</button>
</div>
</section>
<section class="search-release-review" id="search-release-review"
aria-labelledby="search-release-heading" hidden>
<div><strong id="search-release-heading">Plan selected issues into a release</strong>
<span class="small" id="search-release-summary" aria-live="polite"></span></div>
<label>Open milestone<select id="search-release-milestone" required></select></label>
<label>Shared due date <span class="small">(optional)</span>
<input id="search-release-due-date" type="date" /></label>
<div id="search-release-list"></div>
<div class="search-release-actions">
<button id="cancel-search-release" type="button">Back</button>
<button id="confirm-search-release" type="button">Assign &amp; plan</button>
</div>
</section>
</div>
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">

View File

@ -57,7 +57,11 @@
if (detail?.claimable) return claim(detail);
throw new Error('This issue is no longer available to assign.');
}
return { identity, eligible, start, cancel, toggle, snapshot, prepare, limit:maximum };
function releaseRepository() {
const repositories = new Set(Array.from(selected.values()).map(item => item.repository));
return repositories.size === 1 ? repositories.values().next().value : null;
}
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
}
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
@ -68,6 +72,8 @@
const get = selector => document.querySelector(selector);
let processor;
let laterProcessor;
let releaseProcessor;
let releaseContext = null;
const plan = createSearchBatchPlan({
resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}),
claim:detail => fetchJson(
@ -80,6 +86,7 @@
get('#select-search-results').hidden = state.active;
get('#queue-selected-search-results').disabled = state.count === 0;
get('#defer-selected-search-results').disabled = state.count === 0;
get('#plan-selected-search-results').disabled = state.count === 0;
get('#search-selection-status').textContent = state.count ?
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
render();
@ -152,6 +159,48 @@
}
},
});
releaseProcessor = batchFactory({
capacity:() => plan.limit,
owner:getOwner,
journalName:'search-release-batch',
queueFailureReason:'assigned but release planning could not be confirmed',
autoMount:false,
claim:item => plan.prepare(item),
queue:async (confirmed, context) => {
const issuePath = 'api/v1/repos/' + confirmed.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(confirmed.number);
const milestone = await fetchJson(issuePath + '/milestone', {
method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'},
body:JSON.stringify({milestone_id:context.milestone_id}),
});
if (milestone?.number !== confirmed.number || milestone?.milestone?.id !== context.milestone_id) {
throw new Error('Issue milestone was not confirmed.');
}
if (context.due_date) {
const deadline = await fetchJson(issuePath + '/due-date', {
method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'},
body:JSON.stringify({due_date:context.due_date}),
});
if (deadline?.number !== confirmed.number || deadline?.due_date !== context.due_date) {
throw new Error('Issue due date was not confirmed.');
}
}
return 'queued';
},
onProgress:progress => {
get('#confirm-search-release').disabled = progress.status === 'running';
if (progress.status === 'running') {
get('#search-release-summary').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
} else if (progress.status === 'complete') {
get('#cmd-search-action-status').textContent = progress.failed.length ?
progress.queued.length + ' planned · ' + progress.failed.length + ' need retry.' :
progress.queued.length + ' planned for ' + (releaseContext?.milestone_title || 'release') + '.';
get('#resume-search-batch').hidden = releaseProcessor.pending() === 0;
closeReleaseReview();
if (!progress.failed.length) plan.cancel();
}
},
});
function estimateValues() {
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
[input.dataset.searchBatchEstimate, Number(input.value)]
@ -180,6 +229,42 @@
get('#search-batch-estimate-review').hidden = false;
document.querySelector('[data-search-batch-estimate]')?.focus();
}
function closeReleaseReview() {
get('#search-release-review').hidden = true;
get('#search-batch-actions').hidden = !plan.snapshot().active;
}
async function openReleaseReview() {
const items = plan.snapshot().items;
const repository = plan.releaseRepository();
if (!items.length) return;
if (!repository) {
get('#search-selection-status').textContent = 'Select issues from one repository to plan a release.';
return;
}
get('#plan-selected-search-results').disabled = true;
get('#search-selection-status').textContent = 'Loading release milestones…';
try {
const milestones = await fetchJson(
'api/v1/repos/' + repository.split('/').map(encodeURIComponent).join('/') + '/milestones',
{headers:{Accept:'application/json'}}
);
const open = (Array.isArray(milestones) ? milestones : []).filter(item => item?.state !== 'closed');
get('#search-release-milestone').innerHTML = '<option value="">Choose milestone</option>' + open.map(item =>
'<option value="' + Number(item.id) + '">' + escapeHtml(item.title) + '</option>'
).join('');
get('#search-release-list').innerHTML = items.map(item => '<div class="search-release-row"><span>' +
escapeHtml(item.repository + ' #' + item.number) + '</span><strong>' +
escapeHtml(item.title || 'Untitled work') + '</strong></div>').join('');
get('#search-release-summary').textContent = items.length + ' issues in ' + repository + '. Assignment starts only after confirmation.';
get('#search-batch-actions').hidden = true;
get('#search-release-review').hidden = false;
get('#search-release-milestone').focus();
} catch (error) {
get('#search-selection-status').textContent = error?.message || 'Milestones could not be loaded. Try again.';
} finally {
get('#plan-selected-search-results').disabled = false;
}
}
get('#select-search-results').addEventListener('click', () => plan.start());
get('#cancel-search-selection').addEventListener('click', () => plan.cancel());
get('#queue-selected-search-results').addEventListener('click', () => {
@ -195,13 +280,32 @@
items, {}, { until:new Date(until).toISOString() }
) }, event.currentTarget, 'search-batch');
});
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
get('#confirm-search-release').addEventListener('click', () => {
const milestoneSelect = get('#search-release-milestone');
const milestoneId = Number(milestoneSelect.value);
if (!Number.isInteger(milestoneId) || milestoneId < 1) {
get('#search-release-summary').textContent = 'Choose an open milestone.';
return;
}
const selectedOption = milestoneSelect.options?.[milestoneSelect.selectedIndex];
const milestoneTitle = selectedOption?.textContent || selectedOption?.text || 'release';
const dueDay = get('#search-release-due-date').value;
releaseContext = {
milestone_id:milestoneId, milestone_title:milestoneTitle,
due_date:dueDay ? dueDay + 'T23:59:59Z' : null,
};
releaseProcessor.run(plan.snapshot().items, {}, releaseContext);
});
get('#cancel-search-batch-estimates').addEventListener('click', closeEstimateReview);
get('#confirm-search-batch-estimates').addEventListener('click', () =>
processor.run(plan.snapshot().items, estimateValues())
);
get('#resume-search-batch').addEventListener('click', () => {
if (processor.pending()) return processor.resume();
return laterProcessor.resume();
if (laterProcessor.pending()) return laterProcessor.resume();
return releaseProcessor.resume();
});
get('#open-palette').addEventListener('click', () => restore());
get('#cmd-results').addEventListener('change', event => {
@ -211,7 +315,7 @@
if (result) plan.toggle(result);
});
function restore() {
const pending = processor.pending() + laterProcessor.pending();
const pending = processor.pending() + laterProcessor.pending() + releaseProcessor.pending();
get('#resume-search-batch').hidden = pending === 0;
get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
}
@ -225,7 +329,7 @@
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
}
return { plan, processor, laterProcessor, restore, resultHtml };
return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
};
return createSearchBatchPlan;

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",
]