feat: promote checklist steps to related issues (Closes #921)
All checks were successful
CI / lint (pull_request) Successful in 1m58s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 58s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-15 23:40:17 +00:00
parent 08139bc3ee
commit 170828b308
8 changed files with 254 additions and 5 deletions

View File

@ -43,6 +43,85 @@ function buildRelatedDraft(value = {}, template = null) {
return draft; return draft;
} }
function relatedChecklistDraft(item, label) {
const title = String(label || '').trim().replace(/\s+/g, ' ').slice(0, 240);
if (!title) throw new Error('Choose a checklist step to file.');
const repository = String(item?.repository || '');
const number = Number(item?.number);
const url = String(item?.url || '');
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
!Number.isInteger(number) || number < 1 || !/^https:\/\//.test(url)) {
throw new Error('The parent issue link is unavailable.');
}
return {repository, title,
body:'Related to [' + repository + '#' + number + '](' + url + ').', labelIds:[]};
}
function linkChecklistTask(raw, targetIndex, child) {
const url = String(child?.url || '');
if (!/^https:\/\//.test(url)) throw new Error('The related 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;
if (taskIndex === Number(targetIndex)) {
if (/^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(task[2])) throw new Error('That checklist step is already linked.');
const label = task[2].trim().replace(/\s+/g, ' ');
parts[index] = task[1] + '[' + label + '](' + url + ')';
return parts.join('');
}
taskIndex += 1;
}
throw new Error('The checklist step is no longer available.');
}
function createChecklistPromotion({ issueController, issueCapture, clearAttachments, onLinked, onStatus }) {
let pending = null;
const persistedPromotion = value => ({
item:{repository:value.repository, number:value.number, url:value.url},
detail:{title:value.title, body:value.body, updated_at:value.updatedAt}, taskIndex:value.taskIndex,
});
return {
pending:() => Boolean(pending),
start(context) {
pending = {item:{...context.item}, detail:{...context.detail}, taskIndex:Number(context.taskIndex)};
const draft = issueController.relatedTaskDraft(context.item, context.detail, context.taskIndex, context.label);
issueCapture.saveDraft(draft);
clearAttachments();
return draft;
},
cancel() { if (!pending) return false; pending = null; issueCapture.clearDraft(); return true; },
deliveryContext() {
if (!pending) return null;
return {repository:pending.item.repository, number:pending.item.number, url:pending.item.url,
title:pending.detail.title, body:pending.detail.body, updatedAt:pending.detail.updated_at,
taskIndex:pending.taskIndex};
},
async finish(child, persisted = null) {
const promotion = pending || (persisted ? persistedPromotion(persisted) : null);
if (!promotion || !child) return false;
pending = null;
try {
const confirmed = await issueController.linkRelatedTask(
promotion.item, promotion.detail, promotion.taskIndex, child
);
onLinked(promotion, confirmed);
onStatus('Related issue created and linked from its parent checklist.');
return true;
} catch (error) {
onStatus('Related issue created, but the parent link needs attention.' +
(child.url ? ' ' + child.url : '') + ' ' + error.message);
return false;
}
},
};
}
function createIssueOwnerPicker(issueCapture, documentRef, onChange) { function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
const NO_OWNER = '__unassigned__'; const NO_OWNER = '__unassigned__';
const select = documentRef.querySelector('#create-issue-assignee'); const select = documentRef.querySelector('#create-issue-assignee');
@ -758,5 +837,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
createIssueCapture.normalizeSharedContent = normalizeSharedContent; createIssueCapture.normalizeSharedContent = normalizeSharedContent;
createIssueCapture.createOwnerPicker = createIssueOwnerPicker; createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
createIssueCapture.buildRelatedDraft = buildRelatedDraft; createIssueCapture.buildRelatedDraft = buildRelatedDraft;
createIssueCapture.relatedChecklistDraft = relatedChecklistDraft;
createIssueCapture.linkChecklistTask = linkChecklistTask;
createIssueCapture.createChecklistPromotion = createChecklistPromotion;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture; if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;

View File

@ -606,6 +606,7 @@
loadMilestones: item => issueController.loadMilestones(item), loadMilestones: item => issueController.loadMilestones(item),
}); });
let issueCapture = null; let issueCapture = null;
let checklistPromotion = null;
let issueOwnerPicker = null; let issueOwnerPicker = null;
let issueTemplatePicker = null; let issueTemplatePicker = null;
let issueFilingMetadata = null; let issueFilingMetadata = null;
@ -761,6 +762,18 @@
getRepository:()=>qs('#create-issue-repository').value, getRepository:()=>qs('#create-issue-repository').value,
}, issueTemplatePicker); }, issueTemplatePicker);
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin }); updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
checklistPromotion = createIssueCapture.createChecklistPromotion({
issueController, issueCapture,
clearAttachments:()=>createIssueAttachmentController.clear(),
onLinked:(promotion, confirmed) => {
if (selectedIssue?.repository === promotion.item.repository &&
selectedIssue?.number === promotion.item.number) {
applyIssueContent(promotion.item, promotion.detail, confirmed);
qs('#issue-sheet').classList.add('open');
}
},
onStatus:message=>{ qs('#my-work-action-status').textContent = message; },
});
} }
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) { if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch); sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
@ -4240,6 +4253,11 @@
} }
let suppressCreateDraftOnHistoryClose = false; let suppressCreateDraftOnHistoryClose = false;
async function startChecklistPromotion(context) {
if (!issueCapture && !await ensureIssueCapture()) throw new Error('Issue capture is unavailable.');
checklistPromotion.start(context);
await openCreateIssueSheet();
}
const issueFilingReceipt = createIssueFilingReceipt({ const issueFilingReceipt = createIssueFilingReceipt({
root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'), root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'),
key:qs('#issue-filing-receipt-key'), title:qs('#issue-filing-receipt-title'), key:qs('#issue-filing-receipt-key'), title:qs('#issue-filing-receipt-title'),
@ -4306,6 +4324,9 @@
const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', '); const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', ');
qs('#my-work-action-status').textContent = keys + ' created and assigned to you.'; qs('#my-work-action-status').textContent = keys + ' created and assigned to you.';
const confirmed = result.confirmed[result.confirmed.length - 1]; const confirmed = result.confirmed[result.confirmed.length - 1];
(result.filings || []).filter(filing => filing.relatedDraft?.checklistPromotion).forEach(filing => {
void checklistPromotion?.finish(filing.issue, filing.relatedDraft.checklistPromotion);
});
const created = lastMyWork.find(item => const created = lastMyWork.find(item =>
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
); );
@ -5414,6 +5435,10 @@
qs('#create-issue-title').focus(); qs('#create-issue-title').focus();
}); });
qs('#cancel-new-issue').addEventListener('click', () => { qs('#cancel-new-issue').addEventListener('click', () => {
if (checklistPromotion?.cancel()) {
closeCreateIssueSheet(true, false);
return;
}
const discardEditedDraft = Boolean(editingOutboxId); const discardEditedDraft = Boolean(editingOutboxId);
if (editingOutboxId) { if (editingOutboxId) {
issueCapture.clearDraft(); issueCapture.clearDraft();
@ -5520,7 +5545,11 @@
}); });
async function admitReviewedIssue(review) { async function admitReviewedIssue(review) {
const durableDraft = review.draft; const durableDraft = review.draft;
const deliveryDraft = {...durableDraft, relatedDraft:issueCapture.buildRelatedDraft(durableDraft)}; const promotion = checklistPromotion?.deliveryContext();
const deliveryDraft = {
...durableDraft, relatedDraft:issueCapture.buildRelatedDraft(durableDraft),
};
if (promotion) deliveryDraft.relatedDraft.checklistPromotion = promotion;
const followUpNextRequested = review.intent === 'follow-up-and-next'; const followUpNextRequested = review.intent === 'follow-up-and-next';
createAndStartRequested = review.intent === 'create-and-start'; createAndStartRequested = review.intent === 'create-and-start';
if (createAndStartRequested && !createAndStart.available()) { if (createAndStartRequested && !createAndStart.available()) {
@ -5678,6 +5707,7 @@
const checklistStepManagement = issueController.bindTaskManagement({ const checklistStepManagement = issueController.bindTaskManagement({
container:qs('#issue-sheet-body'), editor:qs('#checklist-step-editor'), label:qs('#checklist-step-label'), container:qs('#issue-sheet-body'), editor:qs('#checklist-step-editor'), label:qs('#checklist-step-label'),
earlier:qs('#move-checklist-step-earlier'), later:qs('#move-checklist-step-later'), earlier:qs('#move-checklist-step-earlier'), later:qs('#move-checklist-step-later'),
fileRelated:qs('#file-checklist-step-related'), onFileRelated:startChecklistPromotion,
remove:qs('#remove-checklist-step'), cancel:qs('#cancel-checklist-step-edit'), remove:qs('#remove-checklist-step'), cancel:qs('#cancel-checklist-step-edit'),
status:qs('#checklist-step-edit-status'), sheetStatus:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'), status:qs('#checklist-step-edit-status'), sheetStatus:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'),
current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}), current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}),

View File

@ -612,6 +612,7 @@
<button id="save-checklist-step-edit" type="submit">Rename</button> <button id="save-checklist-step-edit" type="submit">Rename</button>
<button id="move-checklist-step-earlier" type="button">Move earlier</button> <button id="move-checklist-step-earlier" type="button">Move earlier</button>
<button id="move-checklist-step-later" type="button">Move later</button> <button id="move-checklist-step-later" type="button">Move later</button>
<button id="file-checklist-step-related" type="button">File as related issue</button>
<button id="remove-checklist-step" type="button">Remove</button> <button id="remove-checklist-step" type="button">Remove</button>
<button id="cancel-checklist-step-edit" type="button">Cancel</button> <button id="cancel-checklist-step-edit" type="button">Cancel</button>
</div> </div>

View File

@ -76,6 +76,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80); plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80);
plan.capturedBody = ''; plan.capturedBody = '';
} }
const promotion = value?.checklistPromotion;
if (promotion?.repository && promotion?.number && promotion?.url && promotion?.updatedAt) {
plan.checklistPromotion = {
repository:String(promotion.repository), number:Number(promotion.number), url:String(promotion.url),
title:String(promotion.title || ''), body:String(promotion.body || '').slice(0, 10000),
updatedAt:String(promotion.updatedAt), taskIndex:Number(promotion.taskIndex),
};
}
return plan; return plan;
} }
@ -134,6 +142,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
if (blockers) item.blockers = blockers; if (blockers) item.blockers = blockers;
const relatedDraft = captureRelatedDraft(draft?.relatedDraft); const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
if (relatedDraft) item.relatedDraft = relatedDraft; if (relatedDraft) item.relatedDraft = relatedDraft;
item.operationId = item.id; item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) { if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId); item.milestoneId = Number(draft.milestoneId);
@ -496,6 +505,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const confirmed = []; const confirmed = [];
const completions = []; const completions = [];
const filings = []; const filings = [];
let blocked = 0; let blocked = 0;
currentLogin = String(currentLogin || '').trim(); currentLogin = String(currentLogin || '').trim();
for (const item of read()) { for (const item of read()) {
@ -505,6 +515,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
if (result.issue) { if (result.issue) {
confirmed.push(result.issue); confirmed.push(result.issue);
if (result.item?.relatedDraft) filings.push({issue:result.issue, relatedDraft:result.item.relatedDraft}); if (result.item?.relatedDraft) filings.push({issue:result.issue, relatedDraft:result.item.relatedDraft});
if (result.item?.completionIntent) completions.push({ if (result.item?.completionIntent) completions.push({
id: result.item.id, id: result.item.id,
intent: result.item.completionIntent, intent: result.item.completionIntent,
@ -546,6 +557,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return { return {
confirmed: result.issue ? [result.issue] : [], confirmed: result.issue ? [result.issue] : [],
filings: result.issue && result.item?.relatedDraft ? [{issue:result.issue, relatedDraft:result.item.relatedDraft}] : [], filings: result.issue && result.item?.relatedDraft ? [{issue:result.issue, relatedDraft:result.item.relatedDraft}] : [],
completions: result.issue && result.item?.completionIntent ? [{ completions: result.issue && result.item?.completionIntent ? [{
id: result.item.id, id: result.item.id,
intent: result.item.completionIntent, intent: result.item.completionIntent,

View File

@ -77,7 +77,7 @@ function manageChecklistTask(raw, targetIndex, operation = {}) {
return parts.join(''); return parts.join('');
} }
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, manageTask: manageTaskTransform = manageChecklistTask, enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) { 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 commentRequest = null;
let closeRequest = null; let closeRequest = null;
let releaseRequest = null; let releaseRequest = null;
@ -213,6 +213,18 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
expectedUpdatedAt: detail.updated_at, expectedUpdatedAt: detail.updated_at,
}); });
}, },
relatedTaskDraft(item, _detail, _taskIndex, label) {
if (typeof relatedTaskDraftTransform !== 'function') throw new Error('Related issue capture is unavailable.');
return relatedTaskDraftTransform(item, label);
},
linkRelatedTask(item, detail, taskIndex, child) {
if (typeof linkRelatedTaskTransform !== 'function') return Promise.reject(new Error('Related issue linking is unavailable.'));
return this.updateContent(item, {
title:detail.title,
body:linkRelatedTaskTransform(detail.body, taskIndex, child),
expectedUpdatedAt:detail.updated_at,
});
},
async addTask(item, detail, label) { async addTask(item, detail, label) {
const body = appendChecklistTask(detail.body, label); const body = appendChecklistTask(detail.body, label);
return this.updateContent(item, { return this.updateContent(item, {
@ -287,7 +299,7 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
} }
}); });
}, },
bindTaskManagement({ container, editor, label, earlier, later, remove, cancel, status, sheetStatus, retry, current, confirmed }) { bindTaskManagement({ container, editor, label, earlier, later, fileRelated, onFileRelated, remove, cancel, status, sheetStatus, retry, current, confirmed }) {
let taskIndex = null; let taskIndex = null;
let trigger = null; let trigger = null;
const reset = (returnFocus = false) => { const reset = (returnFocus = false) => {
@ -305,8 +317,10 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
label.value = control.dataset.taskLabel || ''; label.value = control.dataset.taskLabel || '';
earlier.disabled = control.dataset.taskFirst === 'true'; earlier.disabled = control.dataset.taskFirst === 'true';
later.disabled = control.dataset.taskLast === 'true'; later.disabled = control.dataset.taskLast === 'true';
fileRelated.disabled = /^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(label.value);
editor.hidden = false; editor.hidden = false;
status.textContent = 'Rename, reorder, or remove this step.'; status.textContent = fileRelated.disabled ? 'This step already links to related work.' :
'Rename, reorder, remove, or file this step as related work.';
label.focus(); label.focus();
}); });
const run = async operation => { const run = async operation => {
@ -340,6 +354,19 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
editor.addEventListener('submit', event => { event.preventDefault(); run({ action:'rename', label:label.value }); }); editor.addEventListener('submit', event => { event.preventDefault(); run({ action:'rename', label:label.value }); });
earlier.addEventListener('click', () => run({ action:'move-earlier' })); earlier.addEventListener('click', () => run({ action:'move-earlier' }));
later.addEventListener('click', () => run({ action:'move-later' })); later.addEventListener('click', () => run({ action:'move-later' }));
fileRelated.addEventListener('click', async () => {
const state = current();
const selectedIndex = taskIndex;
if (!state?.item || !state.detail?.updated_at || selectedIndex === null || fileRelated.disabled) return;
try {
await onFileRelated({ item:state.item, detail:state.detail, taskIndex:selectedIndex,
label:label.value, trigger });
reset();
} catch (error) {
status.textContent = error.message + ' The checklist step was not changed.';
label.focus();
}
});
remove.addEventListener('click', () => run({ action:'remove' })); remove.addEventListener('click', () => run({ action:'remove' }));
cancel.addEventListener('click', () => reset(true)); cancel.addEventListener('click', () => reset(true));
return { reset }; return { reset };

View File

@ -36,7 +36,7 @@ FEATURE_SOURCES = {
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js", "static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
"static/issue-attachment.js", "static/checklist-conflict.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", "static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
), ),
} }
CACHE_DECLARATION = re.compile( CACHE_DECLARATION = re.compile(

View File

@ -72,6 +72,40 @@ outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result,
assert "relatedDraft" not in output["sentBody"] assert "relatedDraft" not in output["sentBody"]
def test_confirmed_delivery_returns_durable_checklist_promotion_without_sending_parent_context():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map(); let sentBody;
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const outbox=createIssueOutbox({{
storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'promote-operation',
fetchJson:async (_url,options)=>{{sentBody=JSON.parse(options.body);return {{repository:'stackchain/dashboard',number:44,title:'Build',url:'https://forge.example/issues/44'}};}},
}});
outbox.enqueue({{
repository:'stackchain/dashboard',title:'Build',body:'Related parent',
relatedDraft:{{repository:'stackchain/dashboard',title:'',body:'',labelIds:[],
checklistPromotion:{{repository:'stackchain/dashboard',number:17,url:'https://forge.example/issues/17',
title:'Parent',body:'- [ ] Build',updatedAt:'old',taskIndex:0}}}},
}});
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": 44,
"title": "Build", "url": "https://forge.example/issues/44"},
"relatedDraft": {
"repository": "stackchain/dashboard", "title": "", "body": "", "labelIds": [],
"checklistPromotion": {
"repository": "stackchain/dashboard", "number": 17,
"url": "https://forge.example/issues/17", "title": "Parent",
"body": "- [ ] Build", "updatedAt": "old", "taskIndex": 0,
},
},
}]
assert "checklistPromotion" not in output["sentBody"]
def test_editing_a_queued_issue_refreshes_its_related_plan(): def test_editing_a_queued_issue_refreshes_its_related_plan():
script = f""" script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))}); const createIssueOutbox = require({json.dumps(str(OUTBOX))});

View File

@ -2266,6 +2266,69 @@ async def test_mobile_issue_detail_manages_checklist_steps_with_touch_safe_inlin
assert "label.focus()" in controller assert "label.focus()" in controller
@pytest.mark.anyio
async def test_mobile_checklist_manager_files_related_issue_and_links_confirmed_child():
html = await dashboard()
controller = ISSUE_SHEET.read_text()
capture_controller = CREATE_ISSUE_SHEET.read_text()
assert 'id="file-checklist-step-related" type="button">File as related issue</button>' in html
assert "fileRelated, onFileRelated, remove, cancel" in controller
assert "onFileRelated({ item:state.item, detail:state.detail, taskIndex:selectedIndex" in controller
assert "relatedTaskDraft" in controller
assert "linkRelatedTask" in controller
assert "startChecklistPromotion" in html
assert "createIssueCapture.createChecklistPromotion({" in html
assert "checklistPromotion.start(context)" in html
assert "checklistPromotion?.cancel()" in html
assert "checklistPromotion?.deliveryContext()" in html
assert "await issueController.linkRelatedTask(" in capture_controller
assert "Related issue created, but the parent link needs attention" in capture_controller
assert ".checklist-step-editor button, .checklist-step-editor input { min-height:44px;" in html
def test_checklist_step_promotion_builds_related_draft_and_revision_checked_parent_link():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const calls = [];
const controller = createIssueSheet({{
storage:null,
relatedTaskDraft:createIssueCapture.relatedChecklistDraft,
linkRelatedTask:createIssueCapture.linkChecklistTask,
fetchJson:async (url, options) => {{
calls.push({{url, body:JSON.parse(options.body)}});
return {{number:17,title:'Release',body:options && JSON.parse(options.body).body,updated_at:'new'}};
}},
}});
const item = {{repository:'stackchain/api',number:17,url:'https://forge.example/stackchain/api/issues/17'}};
const detail = {{title:'Release',body:'Intro\\r\\n - [x] Build package\\r\\n - [ ] Ship',updated_at:'old'}};
const draft = controller.relatedTaskDraft(item, detail, 0, ' Build package ');
controller.linkRelatedTask(item, detail, 0, {{
repository:'stackchain/api',number:44,url:'https://forge.example/stackchain/api/issues/44',title:'Build package',
}}).then(result => process.stdout.write(JSON.stringify({{draft,calls,result}})));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["draft"] == {
"repository": "stackchain/api",
"title": "Build package",
"body": "Related to [stackchain/api#17](https://forge.example/stackchain/api/issues/17).",
"labelIds": [],
}
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/api/issues/17/content",
"body": {
"title": "Release",
"body": "Intro\r\n - [x] [Build package](https://forge.example/stackchain/api/issues/44)\r\n - [ ] Ship",
"expected_updated_at": "old",
},
}]
assert output["result"]["updated_at"] == "new"
@pytest.mark.anyio @pytest.mark.anyio
async def test_mobile_drafts_reviews_and_retries_an_unambiguous_checklist_conflict(): async def test_mobile_drafts_reviews_and_retries_an_unambiguous_checklist_conflict():
html = await dashboard() html = await dashboard()