From 05b2f769e4e331085d0bd4bf20d0754dd2ca67ec Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 16 Aug 2026 00:58:39 +0000 Subject: [PATCH] feat: close completed delegated parent from Filed review (Closes #925) --- frontend/dashboard.js | 30 ++++++++++--------- frontend/issue-sheet.js | 39 +++++++++++++++++++++++-- tests/test_my_work.py | 64 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 116 insertions(+), 17 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index d69d5a7..8e9d2a4 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -3615,11 +3615,13 @@ 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.'; + button.textContent = relationship.checklistComplete ? + 'Complete & close parent · acknowledge' : relationship.alreadyCompleted ? + 'Parent complete · acknowledge' : 'Complete parent step & acknowledge'; + qs('#completed-filed-progress').textContent = relationship.checklistComplete ? + 'Delegated outcome completes the parent checklist. Confirm to close the parent.' : + relationship.alreadyCompleted ? 'Parent checklist already reflects this outcome.' : + 'Delegated outcome ready · complete its parent checklist step.'; } } catch (_error) { if (selectedIssue !== item) return; @@ -3709,21 +3711,23 @@ 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; + if (relationship.checklistComplete && !window.confirm( + 'Complete the final checklist step and close ' + parentItem.key + '?' + )) return; + button.disabled = true; 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, - }); - } + await issueController.finishDelegatedParentReview({ + relationship, parentItem, parentDetail, childUrl:selectedIssue.url, + updateContent:(item, payload) => issueController.updateContent(item, payload), + closeParent:item => issueController.close(item), + }); 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; + 'Parent review action did not finish. Reload the parent relationship and retry. ' + error.message; button.focus(); } }); diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index a74e86c..5e4b3ea 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -77,6 +77,21 @@ function manageChecklistTask(raw, targetIndex, operation = {}) { return parts.join(''); } +function checklistIsComplete(raw) { + const lines = String(raw || '').split(/(?:\r\n|\n|\r)/); + let fenced = false; + let found = false; + for (const line of lines) { + if (/^\s*```/.test(line)) { fenced = !fenced; continue; } + if (fenced) continue; + const task = line.match(/^\s*[-*+]\s+\[([ xX])\]\s+/); + if (!task) continue; + found = true; + if (task[1].toLocaleLowerCase() !== 'x') return false; + } + return found; +} + function completeDelegatedParentTask(raw, childUrl) { const url = String(childUrl || ''); if (!/^https:\/\/[^\s)]+$/.test(url)) throw new Error('The delegated issue link is unavailable.'); @@ -93,13 +108,27 @@ function completeDelegatedParentTask(raw, childUrl) { 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 }; + const body = parts.join(''); + return { body, taskIndex, alreadyCompleted, checklistComplete:checklistIsComplete(body) }; } taskIndex += 1; } throw new Error('The parent checklist no longer links to this delegated issue.'); } +async function finishDelegatedParentReview({ parentItem, parentDetail, childUrl, updateContent, closeParent }) { + const completion = completeDelegatedParentTask(parentDetail?.body, childUrl); + if (!completion.alreadyCompleted) { + await updateContent(parentItem, { + title:parentDetail.title, + body:completion.body, + expectedUpdatedAt:parentDetail.updated_at, + }); + } + if (completion.checklistComplete) await closeParent(parentItem); + return { updated:!completion.alreadyCompleted, closed:completion.checklistComplete }; +} + 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); @@ -112,7 +141,12 @@ function resolveDelegatedParent(child, childDetail, parentDetail) { if (!parent) return null; try { const completion = completeDelegatedParentTask(parentDetail?.body, child?.url); - return { parent, taskIndex:completion.taskIndex, alreadyCompleted:completion.alreadyCompleted }; + return { + parent, + taskIndex:completion.taskIndex, + alreadyCompleted:completion.alreadyCompleted, + checklistComplete:completion.checklistComplete, + }; } catch (_error) { return null; } @@ -630,6 +664,7 @@ createIssueSheet.createPlanningLoader = createPlanningLoader; createIssueSheet.manageChecklistTask = manageChecklistTask; createIssueSheet.completeDelegatedParentTask = completeDelegatedParentTask; +createIssueSheet.finishDelegatedParentReview = finishDelegatedParentReview; 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 f92b7c2..cbcee8a 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -129,6 +129,7 @@ process.stdout.write(JSON.stringify({{relationship, unrelated, mismatch, inciden }, "taskIndex": 0, "alreadyCompleted": False, + "checklistComplete": False, }, "unrelated": None, "mismatch": None, @@ -154,15 +155,72 @@ process.stdout.write(JSON.stringify({{changed, 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": False, + "checklistComplete": 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, + "checklistComplete": False, }, } +def test_delegated_parent_completion_reports_when_the_final_task_is_complete(): + script = f""" +const sheet = require({json.dumps(str(ISSUE_SHEET))}); +const childUrl = 'https://forge.example/git/stackchain/dashboard/issues/44'; +const pending = sheet.completeDelegatedParentTask('- [ ] [Ship](' + childUrl + ')', childUrl); +const already = sheet.completeDelegatedParentTask('- [x] [Ship](' + childUrl + ')', childUrl); +process.stdout.write(JSON.stringify({{pending, already}})); +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + result = json.loads(completed.stdout) + assert result["pending"]["checklistComplete"] is True + assert result["already"]["checklistComplete"] is True + + +def test_final_delegated_parent_update_closes_before_acknowledgement_can_continue(): + script = f""" +const finish = require({json.dumps(str(ISSUE_SHEET))}).finishDelegatedParentReview; +const childUrl = 'https://forge.example/git/stackchain/dashboard/issues/44'; +const calls = []; +(async () => {{ + const changed = await finish({{ + relationship:{{alreadyCompleted:false, checklistComplete:true}}, + parentItem:{{repository:'stackchain/dashboard', number:12}}, + parentDetail:{{title:'Launch', body:'- [ ] [Ship](' + childUrl + ')', updated_at:'rev-1'}}, + childUrl, + updateContent:async (_item, payload) => calls.push(['update', payload.body]), + closeParent:async () => calls.push(['close']), + }}); + const already = await finish({{ + relationship:{{alreadyCompleted:true, checklistComplete:true}}, + parentItem:{{repository:'stackchain/dashboard', number:12}}, + parentDetail:{{title:'Launch', body:'- [x] [Ship](' + childUrl + ')', updated_at:'rev-2'}}, + childUrl, + updateContent:async () => calls.push(['duplicate-update']), + closeParent:async () => calls.push(['retry-close']), + }}); + process.stdout.write(JSON.stringify({{changed, already, calls}})); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "changed": {"updated": True, "closed": True}, + "already": {"updated": False, "closed": True}, + "calls": [ + ["update", "- [x] [Ship](https://forge.example/git/stackchain/dashboard/issues/44)"], + ["close"], + ["retry-close"], + ], + } + + @pytest.mark.anyio async def test_completed_filed_review_offers_parent_completion_before_acknowledgement(): markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text() @@ -174,8 +232,10 @@ async def test_completed_filed_review_offers_parent_completion_before_acknowledg 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") + assert "window.confirm" in handler + assert "await issueController.finishDelegatedParentReview" in handler + assert handler.index("await issueController.finishDelegatedParentReview") < handler.index("acknowledgeCompletedFiled") + assert "Complete & close parent · acknowledge" in source def test_completed_filed_acknowledgement_survives_refresh_until_upstream_update(): -- 2.43.0