From 4b88e1a533ffe086b67db5dd8503e80b22d36a31 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 04:12:27 +0000 Subject: [PATCH] feat: file related issues from confirmed receipts (Closes #868) --- frontend/create-issue-sheet.js | 32 ++++++++++++++++- frontend/dashboard.js | 24 +++++++++---- frontend/index.html | 1 + frontend/issue-filing-receipt.js | 15 ++++++-- frontend/issue-outbox.js | 39 ++++++++++++++++++-- tests/test_issue_filing_receipt.py | 26 +++++++++++++- tests/test_issue_outbox.py | 57 +++++++++++++++++++++++++++++- tests/test_my_work.py | 34 ++++++++++++++++++ 8 files changed, 213 insertions(+), 15 deletions(-) diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index 07c6efa..467bfe5 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -19,6 +19,30 @@ function normalizeSharedContent(value = {}) { return { title, body }; } +function buildRelatedDraft(value = {}, template = null) { + const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(value.repository || '')) + ? String(value.repository) : ''; + const labelIds = Array.from(new Set((Array.isArray(value.labelIds) ? value.labelIds : []) + .filter(id => Number.isInteger(id) && id > 0))).slice(0, 20); + const draft = {repository, title:'', body:'', labelIds}; + const milestoneId = Number(value.milestoneId); + if (Number.isInteger(milestoneId) && milestoneId > 0) draft.milestoneId = milestoneId; + if (/^\d{4}-\d{2}-\d{2}$/.test(String(value.dueDate || ''))) draft.dueDate = String(value.dueDate); + if (value.unassigned === true) draft.unassigned = true; + else if (/^[A-Za-z0-9_.-]+$/.test(String(value.assignee || ''))) { + draft.assignee = String(value.assignee); + draft.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255); + } + const templateId = String(value.templateId || '').slice(0, 80); + if (templateId && template && String(template.id || '') === templateId) { + draft.templateId = templateId; + draft.templateName = String(value.templateName || template.name || 'Issue template').trim().slice(0, 80); + draft.capturedBody = ''; + draft.body = String(template.body || '').trim().slice(0, 9000); + } + return draft; +} + function createIssueOwnerPicker(issueCapture, documentRef, onChange) { const NO_OWNER = '__unassigned__'; const select = documentRef.querySelector('#create-issue-assignee'); @@ -460,6 +484,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp return {draft: applied, template}; } + function relatedDraft(draft) { + const template = issueTemplates.find(item => String(item?.id || '') === String(draft?.templateId || '')) || null; + return buildRelatedDraft(draft, template); + } + function bindTemplatePicker(elements, callbacks) { let labels = []; elements.select.addEventListener('change', event => { @@ -718,7 +747,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp return { saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadTemplates, loadFilingMetadata, applyTemplate, setTemplateState, templateFields, restoreTemplate, loadTemplateOptions, - selectTemplate, bindTemplatePicker, bindFilingMetadata, loadRepositoryPage, + selectTemplate, buildRelatedDraft:relatedDraft, bindTemplatePicker, bindFilingMetadata, loadRepositoryPage, searchRepositories, searchBlockers, findDuplicates, needsDuplicateAcknowledgement, acknowledgeDuplicates, submit, stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent, @@ -728,5 +757,6 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp createIssueCapture.normalizeSharedContent = normalizeSharedContent; createIssueCapture.createOwnerPicker = createIssueOwnerPicker; +createIssueCapture.buildRelatedDraft = buildRelatedDraft; if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture; diff --git a/frontend/dashboard.js b/frontend/dashboard.js index d96e392..38e626d 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -3966,9 +3966,16 @@ root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'), key:qs('#issue-filing-receipt-key'), title:qs('#issue-filing-receipt-title'), ownership:qs('#issue-filing-receipt-ownership'), openLink:qs('#issue-filing-receipt-open'), - shareButton:qs('#issue-filing-receipt-share'), fileAnotherButton:qs('#issue-filing-receipt-another'), + shareButton:qs('#issue-filing-receipt-share'), relatedButton:qs('#issue-filing-receipt-related'), + fileAnotherButton:qs('#issue-filing-receipt-another'), doneButton:qs('#issue-filing-receipt-done'), status:qs('#issue-filing-receipt-status'), - navigator, onFileAnother:() => qs('#new-issue').click(), + navigator, + onFileRelated:plan => { + issueCapture.saveDraft(plan); + createIssueAttachmentController.clear(); + qs('#new-issue').click(); + }, + onFileAnother:() => qs('#new-issue').click(), }); function closeCreateIssueSheet(navigate = true, preserveDraft = true) { if (navigate && taskOverlayHistory.current() === 'new') { @@ -4018,7 +4025,9 @@ ); const completionHandled = startedCompletions.has(confirmed.repository + '#' + confirmed.number); if (!(confirmed.assignees || []).includes(activeFlushLogin)) { - issueFilingReceipt.show(confirmed, qs('#new-issue')); + const filing = (result.filings || []).find(candidate => candidate.issue?.repository === confirmed.repository && + candidate.issue?.number === confirmed.number); + issueFilingReceipt.show(confirmed, qs('#new-issue'), filing?.relatedDraft); } if (startCreated && !(result.completions || []).length && created) { const outcome = createAndStart.complete(created); @@ -5221,6 +5230,7 @@ }); async function admitReviewedIssue(review) { const durableDraft = review.draft; + const deliveryDraft = {...durableDraft, relatedDraft:issueCapture.buildRelatedDraft(durableDraft)}; const followUpNextRequested = review.intent === 'follow-up-and-next'; createAndStartRequested = review.intent === 'create-and-start'; if (createAndStartRequested && !createAndStart.available()) { @@ -5236,12 +5246,12 @@ 'Creating issue and adding it to Today…' : 'Saving for background delivery…'; try { const admission = followUpNextRequested ? (await updateFollowUp.complete({ - admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, durableDraft) : - issueOutbox.enqueueDurably(durableDraft), + admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, deliveryDraft) : + issueOutbox.enqueueDurably(deliveryDraft), queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId), advance: source => notificationReader.acceptReadAndNext(lastMyWork, source), - })).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) : - await issueOutbox.enqueueDurably(durableDraft)); + })).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, deliveryDraft) : + await issueOutbox.enqueueDurably(deliveryDraft)); const queued = admission.item; pendingIssueFilingIntent = 'create-and-assign'; const fS = dFS.current(); diff --git a/frontend/index.html b/frontend/index.html index e83666b..72afa89 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -435,6 +435,7 @@
Open in Gitea +
diff --git a/frontend/issue-filing-receipt.js b/frontend/issue-filing-receipt.js index 14c0387..80d4451 100644 --- a/frontend/issue-filing-receipt.js +++ b/frontend/issue-filing-receipt.js @@ -7,15 +7,18 @@ function createIssueFilingReceipt({ root, heading, key, title, ownership, openLink, shareButton, - fileAnotherButton, doneButton, status, navigator = {}, clipboard = navigator.clipboard, + relatedButton, fileAnotherButton, doneButton, status, navigator = {}, clipboard = navigator.clipboard, + onFileRelated = function () {}, onFileAnother = function () {}, }) { let active = null; + let relatedPlan = null; let restoreFocus = null; function close() { root.hidden = true; active = null; + relatedPlan = null; const target = restoreFocus; restoreFocus = null; target?.focus?.(); @@ -44,8 +47,9 @@ status.textContent = 'Issue link copied.'; } - function show(issue, returnTarget) { + function show(issue, returnTarget, reusablePlan = null) { active = issue; + relatedPlan = reusablePlan; restoreFocus = returnTarget || null; key.textContent = issue.repository + '#' + issue.number; title.textContent = issue.title; @@ -54,6 +58,7 @@ : 'Created with no owner.'; openLink.href = issue.url; status.textContent = ''; + if (relatedButton) relatedButton.hidden = !relatedPlan; root.hidden = false; heading.focus(); } @@ -65,6 +70,12 @@ close(); }); doneButton.addEventListener('click', close); + relatedButton?.addEventListener('click', () => { + if (!relatedPlan) return; + const plan = relatedPlan; + close(); + onFileRelated(plan); + }); fileAnotherButton.addEventListener('click', () => { close(); onFileAnother(); diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index 4cc3472..7f21229 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -54,6 +54,31 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge return Number.isInteger(estimate) && estimate >= 5 && estimate <= 1440 ? estimate : undefined; } + function captureRelatedDraft(value) { + const repository = String(value?.repository || ''); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return undefined; + const plan = { + repository, title:'', body:String(value?.body || '').slice(0, 9000), + labelIds:Array.from(new Set((Array.isArray(value?.labelIds) ? value.labelIds : []) + .filter(id => Number.isInteger(id) && id > 0))).slice(0, 20), + }; + const milestoneId = Number(value?.milestoneId); + if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId; + if (/^\d{4}-\d{2}-\d{2}$/.test(String(value?.dueDate || ''))) plan.dueDate = String(value.dueDate); + if (value?.unassigned === true) plan.unassigned = true; + else if (/^[A-Za-z0-9_.-]+$/.test(String(value?.assignee || ''))) { + plan.assignee = String(value.assignee); + plan.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255); + } + const templateId = String(value?.templateId || '').slice(0, 80); + if (templateId) { + plan.templateId = templateId; + plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80); + plan.capturedBody = ''; + } + return plan; + } + function read() { try { const record = JSON.parse(storage?.getItem(storageKey) || 'null'); @@ -107,6 +132,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge if (attachments) item.attachments = attachments; const blockers = captureBlockers(draft?.blockers); 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); @@ -220,6 +247,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge const attachment = captureAttachment(draft?.attachment); const attachments = captureAttachments(draft?.attachments); const blockers = captureBlockers(draft?.blockers); + const relatedDraft = captureRelatedDraft(draft?.relatedDraft); const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(attachment || null) || JSON.stringify(item.attachments || null) !== JSON.stringify(attachments || null); const changed = item.repository !== repository || item.title !== title || item.body !== body @@ -228,13 +256,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge || item.assignee !== assignee || item.assigneeName !== assigneeName || item.milestoneId !== milestoneId || item.dueDate !== dueDate || item.estimateMinutes !== captureEstimate(draft?.estimateMinutes) - || attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(blockers || null); + || attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(blockers || null) + || JSON.stringify(item.relatedDraft || null) !== JSON.stringify(relatedDraft || null); updated = { ...item, repository, title, body, labelIds, unassigned: unassigned || undefined, assignee, assigneeName, milestoneId, dueDate, - attachment, attachments, blockers, + attachment, attachments, blockers, relatedDraft, operationId: changed ? String(operationId()).slice(0, 128) : item.operationId, status: 'queued', }; @@ -255,6 +284,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge if (attachment === undefined) delete updated.attachment; if (attachments === undefined) delete updated.attachments; if (blockers === undefined) delete updated.blockers; + if (relatedDraft === undefined) delete updated.relatedDraft; if (attachmentChanged) { delete updated.attachmentMarkdown; delete updated.attachmentMarkdowns; @@ -465,6 +495,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge async function flushQueue(currentLogin) { const confirmed = []; const completions = []; + const filings = []; let blocked = 0; currentLogin = String(currentLogin || '').trim(); for (const item of read()) { @@ -473,6 +504,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge const result = await sendItem(item, currentLogin); 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, @@ -484,7 +516,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge } if (result.transient) break; } - return { confirmed, completions, remaining: read(), blocked }; + return { confirmed, completions, filings, remaining: read(), blocked }; } async function flush(currentLogin) { @@ -513,6 +545,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge const result = await sendItem(queued, currentLogin); 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/tests/test_issue_filing_receipt.py b/tests/test_issue_filing_receipt.py index 4dc4df4..2e4d12e 100644 --- a/tests/test_issue_filing_receipt.py +++ b/tests/test_issue_filing_receipt.py @@ -69,6 +69,26 @@ controller.show({{repository:'stackchain/dashboard',number:867,title:'Unowned fo } +def test_related_action_returns_only_the_confirmed_reusable_plan(): + script = f""" +const createReceipt = require({json.dumps(str(RECEIPT))}); +const element = () => ({{hidden:true,focusCount:0,focus(){{this.focusCount++;}},addEventListener(name, fn){{this[name]=fn;}}}}); +const elements = {{root:element(),heading:element(),key:element(),title:element(),ownership:element(),openLink:element(),shareButton:element(),relatedButton:element(),fileAnotherButton:element(),doneButton:element(),status:element()}}; +const plans=[]; +const controller=createReceipt({{...elements,onFileRelated:plan=>plans.push(plan)}}); +const plan={{repository:'stackchain/dashboard',title:'',body:'## Bug',labelIds:[7],templateId:'bug.yml'}}; +controller.show({{repository:'stackchain/dashboard',number:868,title:'Filed',assignees:[],url:'https://forge.example/issues/868'}},elements.doneButton,plan); +elements.relatedButton.click(); +process.stdout.write(JSON.stringify({{plans,hidden:elements.root.hidden,restored:elements.doneButton.focusCount}})); +""" + output = run_node(script) + + assert output == {"plans": [{ + "repository": "stackchain/dashboard", "title": "", "body": "## Bug", + "labelIds": [7], "templateId": "bug.yml", + }], "hidden": True, "restored": 1} + + def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt(): index = INDEX.read_text() css = CSS.read_text() @@ -78,6 +98,7 @@ def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt(): assert 'id="issue-filing-receipt-heading"' in index assert 'id="issue-filing-receipt-open"' in index assert 'id="issue-filing-receipt-share"' in index + assert 'id="issue-filing-receipt-related"' in index assert 'id="issue-filing-receipt-another"' in index assert 'id="issue-filing-receipt-done"' in index assert '' in index @@ -86,4 +107,7 @@ def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt(): assert '.issue-filing-receipt-actions' in css assert 'min-height:44px' in css assert 'createIssueFilingReceipt({' in dashboard - assert "issueFilingReceipt.show(confirmed, qs('#new-issue'))" in dashboard + assert "relatedButton:qs('#issue-filing-receipt-related')" in dashboard + assert 'relatedDraft:issueCapture.buildRelatedDraft(durableDraft)' in dashboard + assert "issueCapture.saveDraft(plan)" in dashboard + assert "issueFilingReceipt.show(confirmed, qs('#new-issue'), filing?.relatedDraft)" in dashboard diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index 3db2ad5..dca0820 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -44,6 +44,61 @@ confirmedLogin = 'alexander'; assert len(output["calls"]) == 1 +def test_confirmed_delivery_returns_the_safe_related_plan_without_sending_it_to_gitea(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values=new Map(); +const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}}; +let sentBody; +const outbox=createIssueOutbox({{ + storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'first-operation', + fetchJson:async (_url,options)=>{{sentBody=JSON.parse(options.body);return {{repository:'stackchain/dashboard',number:869,title:'Related source',assignees:[]}};}}, +}}); +outbox.enqueue({{ + repository:'stackchain/dashboard',title:'Related source',body:'Filled report',labelIds:[7], + relatedDraft:{{repository:'stackchain/dashboard',title:'',body:'## Bug',labelIds:[7],templateId:'bug.yml',templateName:'Bug report',capturedBody:''}}, +}}); +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": 869, + "title": "Related source", "assignees": []}, + "relatedDraft": {"repository": "stackchain/dashboard", "title": "", + "body": "## Bug", "labelIds": [7], "templateId": "bug.yml", + "templateName": "Bug report", "capturedBody": ""}, + }] + assert "relatedDraft" not in output["sentBody"] + + +def test_editing_a_queued_issue_refreshes_its_related_plan(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values=new Map(); +const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}}; +let sequence=0; +const outbox=createIssueOutbox({{ + storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'operation-' + (++sequence), + fetchJson:async ()=>({{repository:'stackchain/dashboard',number:870,title:'Edited',assignees:[]}}), +}}); +const queued=outbox.enqueue({{ + repository:'stackchain/dashboard',title:'Original',body:'One', + relatedDraft:{{repository:'stackchain/dashboard',title:'',body:'Old scaffold',labelIds:[1]}}, +}}); +outbox.update(queued.id,{{ + repository:'stackchain/dashboard',title:'Edited',body:'Two', + relatedDraft:{{repository:'stackchain/dashboard',title:'',body:'New scaffold',labelIds:[2]}}, +}}); +outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify(result.filings[0].relatedDraft))); +""" + + assert run_node(script) == { + "repository": "stackchain/dashboard", "title": "", "body": "New scaffold", + "labelIds": [2], + } + + def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids(): script = f""" const createIssueOutbox = require({json.dumps(str(OUTBOX))}); @@ -968,7 +1023,7 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio assert '' in html assert "const issueOutbox = createIssueOutbox({" in html - assert "await issueOutbox.enqueueDurably(durableDraft)" in html + assert "await issueOutbox.enqueueDurably(deliveryDraft)" in html assert "Saving for background delivery…" in html assert "Saved for next launch; background delivery unavailable." in html assert "throw new Error('Background Sync unavailable')" in html diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 8920599..7f92b2d 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1578,6 +1578,40 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string assert output["results"][0]["available"] is True +def test_related_issue_draft_reuses_plan_but_resets_issue_specific_work(): + script = f""" +const createIssueSheet = require({json.dumps(str(CREATE_ISSUE_SHEET))}); +const related = createIssueSheet.buildRelatedDraft({{ + repository:'stackchain/dashboard', title:'Completed discovery', body:'Filled private report', + labelIds:[7, 7, 9], milestoneId:4, dueDate:'2026-08-20', + assignee:'alex', assigneeName:'Alexander', unassigned:false, + templateId:'bug.yml', templateName:'Bug report', capturedBody:'Original notes', + blockers:[{{repository:'stackchain/api',number:2}}], estimateMinutes:45, + completionIntent:'create-and-start', operationId:'already-delivered', + attachment:{{name:'secret.png'}}, duplicateAcknowledged:true, +}}, {{id:'bug.yml', name:'Bug report', body:'## What happened?\\n\\n## Expected'}}); +process.stdout.write(JSON.stringify(related)); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + output = json.loads(result.stdout) + + assert output == { + "repository": "stackchain/dashboard", + "title": "", + "body": "## What happened?\n\n## Expected", + "labelIds": [7, 9], + "milestoneId": 4, + "dueDate": "2026-08-20", + "assignee": "alex", + "assigneeName": "Alexander", + "templateId": "bug.yml", + "templateName": "Bug report", + "capturedBody": "", + } + + def test_issue_release_rejects_response_that_still_assigns_current_user(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});