feat: close completed delegated parent from Filed review (Closes #925)
This commit is contained in:
parent
40b124bc2c
commit
05b2f769e4
|
|
@ -3615,11 +3615,13 @@
|
||||||
const button = qs('#complete-parent-and-acknowledge');
|
const button = qs('#complete-parent-and-acknowledge');
|
||||||
button.hidden = false;
|
button.hidden = false;
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.textContent = relationship.alreadyCompleted ?
|
button.textContent = relationship.checklistComplete ?
|
||||||
'Parent complete · acknowledge' : 'Complete parent step & acknowledge';
|
'Complete & close parent · acknowledge' : relationship.alreadyCompleted ?
|
||||||
qs('#completed-filed-progress').textContent = relationship.alreadyCompleted ?
|
'Parent complete · acknowledge' : 'Complete parent step & acknowledge';
|
||||||
'Parent checklist already reflects this outcome.' :
|
qs('#completed-filed-progress').textContent = relationship.checklistComplete ?
|
||||||
'Delegated outcome ready · complete its parent checklist step.';
|
'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) {
|
} catch (_error) {
|
||||||
if (selectedIssue !== item) return;
|
if (selectedIssue !== item) return;
|
||||||
|
|
@ -3709,21 +3711,23 @@
|
||||||
qs('#complete-parent-and-acknowledge').addEventListener('click', async () => {
|
qs('#complete-parent-and-acknowledge').addEventListener('click', async () => {
|
||||||
if (!selectedIssue?.is_completed || !delegatedParentReview) return;
|
if (!selectedIssue?.is_completed || !delegatedParentReview) return;
|
||||||
const button = qs('#complete-parent-and-acknowledge');
|
const button = qs('#complete-parent-and-acknowledge');
|
||||||
button.disabled = true;
|
|
||||||
const { parentItem, parentDetail, relationship } = delegatedParentReview;
|
const { parentItem, parentDetail, relationship } = delegatedParentReview;
|
||||||
|
if (relationship.checklistComplete && !window.confirm(
|
||||||
|
'Complete the final checklist step and close ' + parentItem.key + '?'
|
||||||
|
)) return;
|
||||||
|
button.disabled = true;
|
||||||
try {
|
try {
|
||||||
if (!relationship.alreadyCompleted) {
|
await issueController.finishDelegatedParentReview({
|
||||||
const completion = issueController.completeDelegatedParentTask(parentDetail.body, selectedIssue.url);
|
relationship, parentItem, parentDetail, childUrl:selectedIssue.url,
|
||||||
await issueController.updateContent(parentItem, {
|
updateContent:(item, payload) => issueController.updateContent(item, payload),
|
||||||
title:parentDetail.title, body:completion.body, expectedUpdatedAt:parentDetail.updated_at,
|
closeParent:item => issueController.close(item),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
acknowledgeCompletedFiled();
|
acknowledgeCompletedFiled();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!selectedIssue) return;
|
if (!selectedIssue) return;
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
qs('#issue-sheet-status').textContent =
|
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();
|
button.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,21 @@ function manageChecklistTask(raw, targetIndex, operation = {}) {
|
||||||
return parts.join('');
|
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) {
|
function completeDelegatedParentTask(raw, childUrl) {
|
||||||
const url = String(childUrl || '');
|
const url = String(childUrl || '');
|
||||||
if (!/^https:\/\/[^\s)]+$/.test(url)) throw new Error('The delegated issue link is unavailable.');
|
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) {
|
if (linkedTask?.[1] === url) {
|
||||||
const alreadyCompleted = task[2].toLocaleLowerCase() === 'x';
|
const alreadyCompleted = task[2].toLocaleLowerCase() === 'x';
|
||||||
if (!alreadyCompleted) parts[index] = task[1] + 'x' + task[3];
|
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;
|
taskIndex += 1;
|
||||||
}
|
}
|
||||||
throw new Error('The parent checklist no longer links to this delegated issue.');
|
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) {
|
function delegatedParentReference(childDetail) {
|
||||||
const body = String(childDetail?.body || '');
|
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);
|
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;
|
if (!parent) return null;
|
||||||
try {
|
try {
|
||||||
const completion = completeDelegatedParentTask(parentDetail?.body, child?.url);
|
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) {
|
} catch (_error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -630,6 +664,7 @@ createIssueSheet.createPlanningLoader = createPlanningLoader;
|
||||||
|
|
||||||
createIssueSheet.manageChecklistTask = manageChecklistTask;
|
createIssueSheet.manageChecklistTask = manageChecklistTask;
|
||||||
createIssueSheet.completeDelegatedParentTask = completeDelegatedParentTask;
|
createIssueSheet.completeDelegatedParentTask = completeDelegatedParentTask;
|
||||||
|
createIssueSheet.finishDelegatedParentReview = finishDelegatedParentReview;
|
||||||
createIssueSheet.delegatedParentReference = delegatedParentReference;
|
createIssueSheet.delegatedParentReference = delegatedParentReference;
|
||||||
createIssueSheet.resolveDelegatedParent = resolveDelegatedParent;
|
createIssueSheet.resolveDelegatedParent = resolveDelegatedParent;
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ process.stdout.write(JSON.stringify({{relationship, unrelated, mismatch, inciden
|
||||||
},
|
},
|
||||||
"taskIndex": 0,
|
"taskIndex": 0,
|
||||||
"alreadyCompleted": False,
|
"alreadyCompleted": False,
|
||||||
|
"checklistComplete": False,
|
||||||
},
|
},
|
||||||
"unrelated": None,
|
"unrelated": None,
|
||||||
"mismatch": 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)",
|
"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,
|
"taskIndex": 0,
|
||||||
"alreadyCompleted": False,
|
"alreadyCompleted": False,
|
||||||
|
"checklistComplete": False,
|
||||||
},
|
},
|
||||||
"repeated": {
|
"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)",
|
"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,
|
"taskIndex": 0,
|
||||||
"alreadyCompleted": True,
|
"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
|
@pytest.mark.anyio
|
||||||
async def test_completed_filed_review_offers_parent_completion_before_acknowledgement():
|
async def test_completed_filed_review_offers_parent_completion_before_acknowledgement():
|
||||||
markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
|
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(
|
handler = source.split("qs('#complete-parent-and-acknowledge').addEventListener('click'", 1)[1].split(
|
||||||
"function renderCheckSection", 1
|
"function renderCheckSection", 1
|
||||||
)[0]
|
)[0]
|
||||||
assert "await issueController.updateContent" in handler
|
assert "window.confirm" in handler
|
||||||
assert handler.index("await issueController.updateContent") < handler.index("acknowledgeCompletedFiled")
|
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():
|
def test_completed_filed_acknowledgement_survives_refresh_until_upstream_update():
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user