From 170828b308f11d9c793b3fbaadd5a8a73487d50d Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 23:40:17 +0000 Subject: [PATCH] feat: promote checklist steps to related issues (Closes #921) --- frontend/create-issue-sheet.js | 82 ++++++++++++++++++++++++++++++++++ frontend/dashboard.js | 32 ++++++++++++- frontend/index.html | 1 + frontend/issue-outbox.js | 12 +++++ frontend/issue-sheet.js | 33 ++++++++++++-- src/frontend_bundle.py | 2 +- tests/test_issue_outbox.py | 34 ++++++++++++++ tests/test_my_work.py | 63 ++++++++++++++++++++++++++ 8 files changed, 254 insertions(+), 5 deletions(-) diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index 467bfe5..a88f391 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -43,6 +43,85 @@ function buildRelatedDraft(value = {}, template = null) { return draft; } +function relatedChecklistDraft(item, label) { + const title = String(label || '').trim().replace(/\s+/g, ' ').slice(0, 240); + if (!title) throw new Error('Choose a checklist step to file.'); + const repository = String(item?.repository || ''); + const number = Number(item?.number); + const url = String(item?.url || ''); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || + !Number.isInteger(number) || number < 1 || !/^https:\/\//.test(url)) { + throw new Error('The parent issue link is unavailable.'); + } + return {repository, title, + body:'Related to [' + repository + '#' + number + '](' + url + ').', labelIds:[]}; +} + +function linkChecklistTask(raw, targetIndex, child) { + const url = String(child?.url || ''); + if (!/^https:\/\//.test(url)) throw new Error('The related issue link is unavailable.'); + const parts = String(raw || '').split(/(\r\n|\n|\r)/); + let fenced = false; + let taskIndex = 0; + for (let index = 0; index < parts.length; index += 2) { + const line = parts[index]; + if (/^\s*```/.test(line)) { fenced = !fenced; continue; } + if (fenced) continue; + const task = line.match(/^(\s*[-*+]\s+\[[ xX]\]\s+)(.*)$/); + if (!task) continue; + if (taskIndex === Number(targetIndex)) { + if (/^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(task[2])) throw new Error('That checklist step is already linked.'); + const label = task[2].trim().replace(/\s+/g, ' '); + parts[index] = task[1] + '[' + label + '](' + url + ')'; + return parts.join(''); + } + taskIndex += 1; + } + throw new Error('The checklist step is no longer available.'); +} + +function createChecklistPromotion({ issueController, issueCapture, clearAttachments, onLinked, onStatus }) { + let pending = null; + const persistedPromotion = value => ({ + item:{repository:value.repository, number:value.number, url:value.url}, + detail:{title:value.title, body:value.body, updated_at:value.updatedAt}, taskIndex:value.taskIndex, + }); + return { + pending:() => Boolean(pending), + start(context) { + pending = {item:{...context.item}, detail:{...context.detail}, taskIndex:Number(context.taskIndex)}; + const draft = issueController.relatedTaskDraft(context.item, context.detail, context.taskIndex, context.label); + issueCapture.saveDraft(draft); + clearAttachments(); + return draft; + }, + cancel() { if (!pending) return false; pending = null; issueCapture.clearDraft(); return true; }, + deliveryContext() { + if (!pending) return null; + return {repository:pending.item.repository, number:pending.item.number, url:pending.item.url, + title:pending.detail.title, body:pending.detail.body, updatedAt:pending.detail.updated_at, + taskIndex:pending.taskIndex}; + }, + async finish(child, persisted = null) { + const promotion = pending || (persisted ? persistedPromotion(persisted) : null); + if (!promotion || !child) return false; + pending = null; + try { + const confirmed = await issueController.linkRelatedTask( + promotion.item, promotion.detail, promotion.taskIndex, child + ); + onLinked(promotion, confirmed); + onStatus('Related issue created and linked from its parent checklist.'); + return true; + } catch (error) { + onStatus('Related issue created, but the parent link needs attention.' + + (child.url ? ' ' + child.url : '') + ' ' + error.message); + return false; + } + }, + }; +} + function createIssueOwnerPicker(issueCapture, documentRef, onChange) { const NO_OWNER = '__unassigned__'; const select = documentRef.querySelector('#create-issue-assignee'); @@ -758,5 +837,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp createIssueCapture.normalizeSharedContent = normalizeSharedContent; createIssueCapture.createOwnerPicker = createIssueOwnerPicker; createIssueCapture.buildRelatedDraft = buildRelatedDraft; +createIssueCapture.relatedChecklistDraft = relatedChecklistDraft; +createIssueCapture.linkChecklistTask = linkChecklistTask; +createIssueCapture.createChecklistPromotion = createChecklistPromotion; if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture; diff --git a/frontend/dashboard.js b/frontend/dashboard.js index ed8d186..9197b7a 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -606,6 +606,7 @@ loadMilestones: item => issueController.loadMilestones(item), }); let issueCapture = null; + let checklistPromotion = null; let issueOwnerPicker = null; let issueTemplatePicker = null; let issueFilingMetadata = null; @@ -761,6 +762,18 @@ getRepository:()=>qs('#create-issue-repository').value, }, issueTemplatePicker); updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin }); + checklistPromotion = createIssueCapture.createChecklistPromotion({ + issueController, issueCapture, + clearAttachments:()=>createIssueAttachmentController.clear(), + onLinked:(promotion, confirmed) => { + if (selectedIssue?.repository === promotion.item.repository && + selectedIssue?.number === promotion.item.number) { + applyIssueContent(promotion.item, promotion.detail, confirmed); + qs('#issue-sheet').classList.add('open'); + } + }, + onStatus:message=>{ qs('#my-work-action-status').textContent = message; }, + }); } if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) { sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch); @@ -4240,6 +4253,11 @@ } let suppressCreateDraftOnHistoryClose = false; + async function startChecklistPromotion(context) { + if (!issueCapture && !await ensureIssueCapture()) throw new Error('Issue capture is unavailable.'); + checklistPromotion.start(context); + await openCreateIssueSheet(); + } const issueFilingReceipt = createIssueFilingReceipt({ root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'), key:qs('#issue-filing-receipt-key'), title:qs('#issue-filing-receipt-title'), @@ -4306,6 +4324,9 @@ const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', '); qs('#my-work-action-status').textContent = keys + ' created and assigned to you.'; const confirmed = result.confirmed[result.confirmed.length - 1]; + (result.filings || []).filter(filing => filing.relatedDraft?.checklistPromotion).forEach(filing => { + void checklistPromotion?.finish(filing.issue, filing.relatedDraft.checklistPromotion); + }); const created = lastMyWork.find(item => item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number ); @@ -5414,6 +5435,10 @@ qs('#create-issue-title').focus(); }); qs('#cancel-new-issue').addEventListener('click', () => { + if (checklistPromotion?.cancel()) { + closeCreateIssueSheet(true, false); + return; + } const discardEditedDraft = Boolean(editingOutboxId); if (editingOutboxId) { issueCapture.clearDraft(); @@ -5520,7 +5545,11 @@ }); async function admitReviewedIssue(review) { const durableDraft = review.draft; - const deliveryDraft = {...durableDraft, relatedDraft:issueCapture.buildRelatedDraft(durableDraft)}; + const promotion = checklistPromotion?.deliveryContext(); + const deliveryDraft = { + ...durableDraft, relatedDraft:issueCapture.buildRelatedDraft(durableDraft), + }; + if (promotion) deliveryDraft.relatedDraft.checklistPromotion = promotion; const followUpNextRequested = review.intent === 'follow-up-and-next'; createAndStartRequested = review.intent === 'create-and-start'; if (createAndStartRequested && !createAndStart.available()) { @@ -5678,6 +5707,7 @@ const checklistStepManagement = issueController.bindTaskManagement({ container:qs('#issue-sheet-body'), editor:qs('#checklist-step-editor'), label:qs('#checklist-step-label'), earlier:qs('#move-checklist-step-earlier'), later:qs('#move-checklist-step-later'), + fileRelated:qs('#file-checklist-step-related'), onFileRelated:startChecklistPromotion, remove:qs('#remove-checklist-step'), cancel:qs('#cancel-checklist-step-edit'), status:qs('#checklist-step-edit-status'), sheetStatus:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'), current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}), diff --git a/frontend/index.html b/frontend/index.html index 954c0c9..839991d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -612,6 +612,7 @@ + diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index 7f21229..bc087fe 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -76,6 +76,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80); plan.capturedBody = ''; } + const promotion = value?.checklistPromotion; + if (promotion?.repository && promotion?.number && promotion?.url && promotion?.updatedAt) { + plan.checklistPromotion = { + repository:String(promotion.repository), number:Number(promotion.number), url:String(promotion.url), + title:String(promotion.title || ''), body:String(promotion.body || '').slice(0, 10000), + updatedAt:String(promotion.updatedAt), taskIndex:Number(promotion.taskIndex), + }; + } return plan; } @@ -134,6 +142,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge if (blockers) item.blockers = blockers; const relatedDraft = captureRelatedDraft(draft?.relatedDraft); if (relatedDraft) item.relatedDraft = relatedDraft; + item.operationId = item.id; if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) { item.milestoneId = Number(draft.milestoneId); @@ -496,6 +505,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge const confirmed = []; const completions = []; const filings = []; + let blocked = 0; currentLogin = String(currentLogin || '').trim(); for (const item of read()) { @@ -505,6 +515,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge if (result.issue) { confirmed.push(result.issue); if (result.item?.relatedDraft) filings.push({issue:result.issue, relatedDraft:result.item.relatedDraft}); + if (result.item?.completionIntent) completions.push({ id: result.item.id, intent: result.item.completionIntent, @@ -546,6 +557,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge return { confirmed: result.issue ? [result.issue] : [], filings: result.issue && result.item?.relatedDraft ? [{issue:result.issue, relatedDraft:result.item.relatedDraft}] : [], + completions: result.issue && result.item?.completionIntent ? [{ id: result.item.id, intent: result.item.completionIntent, diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index e78be47..a352bb3 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -77,7 +77,7 @@ function manageChecklistTask(raw, targetIndex, operation = {}) { return parts.join(''); } -function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, manageTask: manageTaskTransform = manageChecklistTask, enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) { +function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, manageTask: manageTaskTransform = manageChecklistTask, relatedTaskDraft: relatedTaskDraftTransform = (...args) => globalThis.createIssueCapture?.relatedChecklistDraft(...args), linkRelatedTask: linkRelatedTaskTransform = (...args) => globalThis.createIssueCapture?.linkChecklistTask(...args), enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) { let commentRequest = null; let closeRequest = null; let releaseRequest = null; @@ -213,6 +213,18 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend expectedUpdatedAt: detail.updated_at, }); }, + relatedTaskDraft(item, _detail, _taskIndex, label) { + if (typeof relatedTaskDraftTransform !== 'function') throw new Error('Related issue capture is unavailable.'); + return relatedTaskDraftTransform(item, label); + }, + linkRelatedTask(item, detail, taskIndex, child) { + if (typeof linkRelatedTaskTransform !== 'function') return Promise.reject(new Error('Related issue linking is unavailable.')); + return this.updateContent(item, { + title:detail.title, + body:linkRelatedTaskTransform(detail.body, taskIndex, child), + expectedUpdatedAt:detail.updated_at, + }); + }, async addTask(item, detail, label) { const body = appendChecklistTask(detail.body, label); return this.updateContent(item, { @@ -287,7 +299,7 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend } }); }, - bindTaskManagement({ container, editor, label, earlier, later, remove, cancel, status, sheetStatus, retry, current, confirmed }) { + bindTaskManagement({ container, editor, label, earlier, later, fileRelated, onFileRelated, remove, cancel, status, sheetStatus, retry, current, confirmed }) { let taskIndex = null; let trigger = null; const reset = (returnFocus = false) => { @@ -305,8 +317,10 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend label.value = control.dataset.taskLabel || ''; earlier.disabled = control.dataset.taskFirst === 'true'; later.disabled = control.dataset.taskLast === 'true'; + fileRelated.disabled = /^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(label.value); editor.hidden = false; - status.textContent = 'Rename, reorder, or remove this step.'; + status.textContent = fileRelated.disabled ? 'This step already links to related work.' : + 'Rename, reorder, remove, or file this step as related work.'; label.focus(); }); const run = async operation => { @@ -340,6 +354,19 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend editor.addEventListener('submit', event => { event.preventDefault(); run({ action:'rename', label:label.value }); }); earlier.addEventListener('click', () => run({ action:'move-earlier' })); later.addEventListener('click', () => run({ action:'move-later' })); + fileRelated.addEventListener('click', async () => { + const state = current(); + const selectedIndex = taskIndex; + if (!state?.item || !state.detail?.updated_at || selectedIndex === null || fileRelated.disabled) return; + try { + await onFileRelated({ item:state.item, detail:state.detail, taskIndex:selectedIndex, + label:label.value, trigger }); + reset(); + } catch (error) { + status.textContent = error.message + ' The checklist step was not changed.'; + label.focus(); + } + }); remove.addEventListener('click', () => run({ action:'remove' })); cancel.addEventListener('click', () => reset(true)); return { reset }; diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index bf1ff0f..ac5fb0e 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/checklist-conflict.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", + "static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.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/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index dca0820..9f62725 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -72,6 +72,40 @@ outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result, assert "relatedDraft" not in output["sentBody"] +def test_confirmed_delivery_returns_durable_checklist_promotion_without_sending_parent_context(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values=new Map(); let sentBody; +const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}}; +const outbox=createIssueOutbox({{ + storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'promote-operation', + fetchJson:async (_url,options)=>{{sentBody=JSON.parse(options.body);return {{repository:'stackchain/dashboard',number:44,title:'Build',url:'https://forge.example/issues/44'}};}}, +}}); +outbox.enqueue({{ + repository:'stackchain/dashboard',title:'Build',body:'Related parent', + relatedDraft:{{repository:'stackchain/dashboard',title:'',body:'',labelIds:[], + checklistPromotion:{{repository:'stackchain/dashboard',number:17,url:'https://forge.example/issues/17', + title:'Parent',body:'- [ ] Build',updatedAt:'old',taskIndex:0}}}}, +}}); +outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result,sentBody}}))); +""" + output = run_node(script) + + assert output["result"]["filings"] == [{ + "issue": {"repository": "stackchain/dashboard", "number": 44, + "title": "Build", "url": "https://forge.example/issues/44"}, + "relatedDraft": { + "repository": "stackchain/dashboard", "title": "", "body": "", "labelIds": [], + "checklistPromotion": { + "repository": "stackchain/dashboard", "number": 17, + "url": "https://forge.example/issues/17", "title": "Parent", + "body": "- [ ] Build", "updatedAt": "old", "taskIndex": 0, + }, + }, + }] + assert "checklistPromotion" not in output["sentBody"] + + def test_editing_a_queued_issue_refreshes_its_related_plan(): script = f""" const createIssueOutbox = require({json.dumps(str(OUTBOX))}); diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 876f335..48a2686 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -2266,6 +2266,69 @@ async def test_mobile_issue_detail_manages_checklist_steps_with_touch_safe_inlin assert "label.focus()" in controller +@pytest.mark.anyio +async def test_mobile_checklist_manager_files_related_issue_and_links_confirmed_child(): + html = await dashboard() + controller = ISSUE_SHEET.read_text() + capture_controller = CREATE_ISSUE_SHEET.read_text() + + assert 'id="file-checklist-step-related" type="button">File as related issue' in html + assert "fileRelated, onFileRelated, remove, cancel" in controller + assert "onFileRelated({ item:state.item, detail:state.detail, taskIndex:selectedIndex" in controller + assert "relatedTaskDraft" in controller + assert "linkRelatedTask" in controller + assert "startChecklistPromotion" in html + assert "createIssueCapture.createChecklistPromotion({" in html + assert "checklistPromotion.start(context)" in html + assert "checklistPromotion?.cancel()" in html + assert "checklistPromotion?.deliveryContext()" in html + assert "await issueController.linkRelatedTask(" in capture_controller + assert "Related issue created, but the parent link needs attention" in capture_controller + assert ".checklist-step-editor button, .checklist-step-editor input { min-height:44px;" in html + + +def test_checklist_step_promotion_builds_related_draft_and_revision_checked_parent_link(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); +const calls = []; +const controller = createIssueSheet({{ + storage:null, + relatedTaskDraft:createIssueCapture.relatedChecklistDraft, + linkRelatedTask:createIssueCapture.linkChecklistTask, + fetchJson:async (url, options) => {{ + calls.push({{url, body:JSON.parse(options.body)}}); + return {{number:17,title:'Release',body:options && JSON.parse(options.body).body,updated_at:'new'}}; + }}, +}}); +const item = {{repository:'stackchain/api',number:17,url:'https://forge.example/stackchain/api/issues/17'}}; +const detail = {{title:'Release',body:'Intro\\r\\n - [x] Build package\\r\\n - [ ] Ship',updated_at:'old'}}; +const draft = controller.relatedTaskDraft(item, detail, 0, ' Build package '); +controller.linkRelatedTask(item, detail, 0, {{ + repository:'stackchain/api',number:44,url:'https://forge.example/stackchain/api/issues/44',title:'Build package', +}}).then(result => process.stdout.write(JSON.stringify({{draft,calls,result}}))); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output["draft"] == { + "repository": "stackchain/api", + "title": "Build package", + "body": "Related to [stackchain/api#17](https://forge.example/stackchain/api/issues/17).", + "labelIds": [], + } + assert output["calls"] == [{ + "url": "api/v1/repos/stackchain/api/issues/17/content", + "body": { + "title": "Release", + "body": "Intro\r\n - [x] [Build package](https://forge.example/stackchain/api/issues/44)\r\n - [ ] Ship", + "expected_updated_at": "old", + }, + }] + assert output["result"]["updated_at"] == "new" + + @pytest.mark.anyio async def test_mobile_drafts_reviews_and_retries_an_unambiguous_checklist_conflict(): html = await dashboard()