Complete delegated parent task from mobile Filed review #924

Merged
rockachopa merged 1 commits from timmy/923-complete-delegated-parent-checklist into main 2026-08-16 00:35:40 +00:00
5 changed files with 189 additions and 5 deletions

View File

@ -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; }

View File

@ -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) {

View File

@ -600,7 +600,10 @@
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
<section class="completed-filed-actions" id="completed-filed-actions" aria-label="Completed Filed review" hidden>
<span id="completed-filed-progress" role="status" aria-live="polite">Completed Filed issue ready for review.</span>
<button id="acknowledge-completed-filed" type="button">Acknowledge &amp; next</button>
<div class="completed-filed-buttons">
<button id="complete-parent-and-acknowledge" type="button" hidden>Complete parent step &amp; acknowledge</button>
<button id="acknowledge-completed-filed" type="button">Acknowledge &amp; next</button>
</div>
</section>
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>

View File

@ -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;

View File

@ -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 &amp; 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