From 4ab0ffde589502d3ca9116495ffbc54a7b6029a3 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 16 Aug 2026 00:31:37 +0000 Subject: [PATCH] feat: complete delegated parent task from Filed review (Closes #923) --- frontend/dashboard.css | 6 ++- frontend/dashboard.js | 54 ++++++++++++++++++++++++++- frontend/index.html | 5 ++- frontend/issue-sheet.js | 47 +++++++++++++++++++++++ tests/test_my_work.py | 82 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 189 insertions(+), 5 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 51bc6b1..99158d8 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -469,10 +469,12 @@ textarea { resize: vertical; min-height: 120px; } .issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; } .issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } .issue-sheet-header button { min-height:44px; } -.completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; } +.completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr); align-items:center; gap:8px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; } .completed-filed-actions[hidden] { display:none; } .completed-filed-actions button { min-height:44px; min-width:0; } -#issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(110px + env(safe-area-inset-bottom)); } +.completed-filed-buttons { display:flex; flex-wrap:wrap; gap:8px; } +.completed-filed-buttons button { flex:1 1 180px; } +#issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(170px + env(safe-area-inset-bottom)); } .issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; } .checklist-step-editor { display:grid; gap:8px; margin:10px 0 16px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1526; } .checklist-step-editor[hidden] { display:none; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 9197b7a..d69d5a7 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -217,6 +217,7 @@ let selectedIssue = null; let selectedIssueOffline = false; let selectedIssueDetail = null; + let delegatedParentReview = null; let dismissedChecklistBody = null; let issueBlockerCandidates = []; @@ -3502,6 +3503,7 @@ issueMentions.dismiss(); selectedIssueOffline = Boolean(offlineDetail); selectedIssueDetail = null; + delegatedParentReview = null; issueConversation = null; issueTrigger = trigger; qs('#issue-sheet').classList.add('open'); @@ -3513,6 +3515,8 @@ candidate.repository === item.repository && candidate.number === item.number ); qs('#completed-filed-actions').hidden = !item.is_completed; + qs('#complete-parent-and-acknowledge').hidden = true; + qs('#complete-parent-and-acknowledge').disabled = true; qs('#filed-claim-actions').hidden = true; qs('#queue-filed-issue').disabled = true; qs('#start-filed-issue').disabled = true; @@ -3598,6 +3602,30 @@ qs('#issue-sheet-status').textContent = item.is_completed ? 'Completed Filed outcome ready · review the conversation' : readOnly ? 'Filed issue ready · follow-up enabled' : 'Issue ready · ' + (detail.state || 'open'); + const parentReference = item.is_completed && !offlineDetail ? + issueController.delegatedParentReference(detail) : null; + if (parentReference) { + try { + const parentItem = { ...parentReference, key:parentReference.repository + '#' + parentReference.number }; + const parentDetail = await issueController.load(parentItem); + if (selectedIssue !== item) return; + const relationship = issueController.resolveDelegatedParent(item, detail, parentDetail); + if (relationship && parentDetail.state === 'open' && parentDetail.updated_at) { + delegatedParentReview = { parentItem, parentDetail, relationship }; + const button = qs('#complete-parent-and-acknowledge'); + button.hidden = false; + button.disabled = false; + button.textContent = relationship.alreadyCompleted ? + 'Parent complete · acknowledge' : 'Complete parent step & acknowledge'; + qs('#completed-filed-progress').textContent = relationship.alreadyCompleted ? + 'Parent checklist already reflects this outcome.' : + 'Delegated outcome ready · complete its parent checklist step.'; + } + } catch (_error) { + if (selectedIssue !== item) return; + delegatedParentReview = null; + } + } const revisableFiling = item.is_filed && !item.is_completed && detail.state === 'open'; qs('#edit-issue-content').textContent = revisableFiling ? 'Revise filing' : 'Edit issue'; qs('#edit-issue-content').disabled = readOnly && !revisableFiling; @@ -3651,7 +3679,7 @@ } window.addEventListener('popstate', closeIssueEditorFromHistory); - qs('#acknowledge-completed-filed').addEventListener('click', () => { + function acknowledgeCompletedFiled() { if (!selectedIssue?.is_completed) return; if (!completedFiledReview.acknowledge(selectedIssue)) { qs('#issue-sheet-status').textContent = 'Could not save this acknowledgement on this device. Retry.'; @@ -3674,6 +3702,30 @@ window.location.hash = '#/my-work/filed'; qs('#my-work').focus(); } + } + + qs('#acknowledge-completed-filed').addEventListener('click', acknowledgeCompletedFiled); + + qs('#complete-parent-and-acknowledge').addEventListener('click', async () => { + if (!selectedIssue?.is_completed || !delegatedParentReview) return; + const button = qs('#complete-parent-and-acknowledge'); + button.disabled = true; + const { parentItem, parentDetail, relationship } = delegatedParentReview; + try { + if (!relationship.alreadyCompleted) { + const completion = issueController.completeDelegatedParentTask(parentDetail.body, selectedIssue.url); + await issueController.updateContent(parentItem, { + title:parentDetail.title, body:completion.body, expectedUpdatedAt:parentDetail.updated_at, + }); + } + acknowledgeCompletedFiled(); + } catch (error) { + if (!selectedIssue) return; + button.disabled = false; + qs('#issue-sheet-status').textContent = + 'Parent step was not changed. Reload the parent relationship and retry. ' + error.message; + button.focus(); + } }); function renderCheckSection(prefix, detail, offline = false) { diff --git a/frontend/index.html b/frontend/index.html index 839991d..53d7377 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -600,7 +600,10 @@
Choose an issue.
diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index a352bb3..a74e86c 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -77,6 +77,47 @@ function manageChecklistTask(raw, targetIndex, operation = {}) { return parts.join(''); } +function completeDelegatedParentTask(raw, childUrl) { + const url = String(childUrl || ''); + if (!/^https:\/\/[^\s)]+$/.test(url)) throw new Error('The delegated 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; + const linkedTask = task[3].match(/^\]\s+\[[^\]]+\]\((https:\/\/[^\s)]+)\)\s*$/); + if (linkedTask?.[1] === url) { + const alreadyCompleted = task[2].toLocaleLowerCase() === 'x'; + if (!alreadyCompleted) parts[index] = task[1] + 'x' + task[3]; + return { body:parts.join(''), taskIndex, alreadyCompleted }; + } + taskIndex += 1; + } + throw new Error('The parent checklist no longer links to this delegated issue.'); +} + +function delegatedParentReference(childDetail) { + const body = String(childDetail?.body || ''); + const match = body.match(/(?:^|\n)Related to \[([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9]\d*)\]\((https:\/\/[^\s)]+)\)\.(?:\r?$|\s)/m); + if (!match) return null; + return { repository:match[1], number:Number(match[2]), url:match[3] }; +} + +function resolveDelegatedParent(child, childDetail, parentDetail) { + const parent = delegatedParentReference(childDetail); + if (!parent) return null; + try { + const completion = completeDelegatedParentTask(parentDetail?.body, child?.url); + return { parent, taskIndex:completion.taskIndex, alreadyCompleted:completion.alreadyCompleted }; + } catch (_error) { + return null; + } +} + 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; @@ -98,6 +139,9 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number; return { + delegatedParentReference, + resolveDelegatedParent, + completeDelegatedParentTask, readOnly(item) { return Boolean(item?.is_completed || (item?.is_filed && !item?.is_assigned)); }, @@ -585,4 +629,7 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend createIssueSheet.createPlanningLoader = createPlanningLoader; createIssueSheet.manageChecklistTask = manageChecklistTask; +createIssueSheet.completeDelegatedParentTask = completeDelegatedParentTask; +createIssueSheet.delegatedParentReference = delegatedParentReference; +createIssueSheet.resolveDelegatedParent = resolveDelegatedParent; if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet; diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 48a2686..f92b7c2 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -100,6 +100,84 @@ process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)}))); assert result[1]["reason"] == "Filed by you" +def test_completed_child_resolves_only_a_canonical_reciprocal_parent_task(): + script = f""" +const sheet = require({json.dumps(str(ISSUE_SHEET))}); +const child = {{repository:'stackchain/dashboard', number:44, + url:'https://forge.example/git/stackchain/dashboard/issues/44'}}; +const childDetail = {{body:'Delivered.\\n\\nRelated to [stackchain/dashboard#12](https://forge.example/git/stackchain/dashboard/issues/12).'}}; +const parentDetail = {{title:'Launch', updated_at:'2026-08-16T10:00:00Z', + body:'Plan\\r\\n - [ ] [Ship mobile flow](https://forge.example/git/stackchain/dashboard/issues/44)\\r\\n- [ ] Other'}}; +const relationship = sheet.resolveDelegatedParent(child, childDetail, parentDetail); +const unrelated = sheet.resolveDelegatedParent(child, + {{body:'Related to stackchain/dashboard#12 without a canonical link.'}}, parentDetail); +const mismatch = sheet.resolveDelegatedParent(child, childDetail, + {{...parentDetail, body:'- [ ] [Different](https://forge.example/git/stackchain/dashboard/issues/45)'}}); +const incidental = sheet.resolveDelegatedParent(child, childDetail, + {{...parentDetail, body:'- [ ] Notes [child](https://forge.example/git/stackchain/dashboard/issues/44)'}}); +process.stdout.write(JSON.stringify({{relationship, unrelated, mismatch, incidental}})); +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "relationship": { + "parent": { + "repository": "stackchain/dashboard", + "number": 12, + "url": "https://forge.example/git/stackchain/dashboard/issues/12", + }, + "taskIndex": 0, + "alreadyCompleted": False, + }, + "unrelated": None, + "mismatch": None, + "incidental": None, + } + + +def test_delegated_parent_completion_changes_only_the_matching_task_marker(): + script = f""" +const complete = require({json.dumps(str(ISSUE_SHEET))}).completeDelegatedParentTask; +const childUrl = 'https://forge.example/git/stackchain/dashboard/issues/44'; +const body = 'Plan\\r\\n - [ ] [Ship mobile flow](' + childUrl + ')\\r\\n' + + '- [ ] Duplicate words\\r\\n- [x] [Already](https://forge.example/issues/9)'; +const changed = complete(body, childUrl); +const repeated = complete(changed.body, childUrl); +process.stdout.write(JSON.stringify({{changed, repeated}})); +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "changed": { + "body": "Plan\r\n - [x] [Ship mobile flow](https://forge.example/git/stackchain/dashboard/issues/44)\r\n- [ ] Duplicate words\r\n- [x] [Already](https://forge.example/issues/9)", + "taskIndex": 0, + "alreadyCompleted": False, + }, + "repeated": { + "body": "Plan\r\n - [x] [Ship mobile flow](https://forge.example/git/stackchain/dashboard/issues/44)\r\n- [ ] Duplicate words\r\n- [x] [Already](https://forge.example/issues/9)", + "taskIndex": 0, + "alreadyCompleted": True, + }, + } + + +@pytest.mark.anyio +async def test_completed_filed_review_offers_parent_completion_before_acknowledgement(): + markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text() + source = await dashboard() + + assert 'id="complete-parent-and-acknowledge"' in markup + assert 'Complete parent step & acknowledge' in markup + assert "resolveDelegatedParent(item, detail, parentDetail)" in source + handler = source.split("qs('#complete-parent-and-acknowledge').addEventListener('click'", 1)[1].split( + "function renderCheckSection", 1 + )[0] + assert "await issueController.updateContent" in handler + assert handler.index("await issueController.updateContent") < handler.index("acknowledgeCompletedFiled") + + def test_completed_filed_acknowledgement_survives_refresh_until_upstream_update(): script = f""" const buildMyWork = require({json.dumps(str(MY_WORK))}); @@ -249,7 +327,9 @@ async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow(): assert "createCompletedFiledReview({" in source assert "lastMyWork = buildMyWork(data)" in source assert "filed: 'filed issues'" in source - handler = source.split("qs('#acknowledge-completed-filed').addEventListener('click'", 1)[1] + handler = source.split("function acknowledgeCompletedFiled()", 1)[1].split( + "qs('#acknowledge-completed-filed').addEventListener", 1 + )[0] assert "completedFiledReview.acknowledge(selectedIssue)" in handler assert "syncCompletedFiledReviews()" in source assert "api/v1/completed-filed-reviews" in source