feat: promote checklist steps to related issues (Closes #921)
This commit is contained in:
1 parent
08139bc3ee
commit
170828b308
8 files changed
+254
-5
No files matched your search
@@ -43,6 +43,85 @@ function buildRelatedDraft(value = {}, template = null) {
|
||||
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) {
|
||||
const NO_OWNER = '__unassigned__';
|
||||
const select = documentRef.querySelector('#create-issue-assignee');
|
||||
@@ -758,5 +837,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
||||
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
|
||||
createIssueCapture.buildRelatedDraft = buildRelatedDraft;
|
||||
createIssueCapture.relatedChecklistDraft = relatedChecklistDraft;
|
||||
createIssueCapture.linkChecklistTask = linkChecklistTask;
|
||||
createIssueCapture.createChecklistPromotion = createChecklistPromotion;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
+31
-1
@@ -606,6 +606,7 @@
|
||||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
});
|
||||
let issueCapture = null;
|
||||
let checklistPromotion = null;
|
||||
let issueOwnerPicker = null;
|
||||
let issueTemplatePicker = null;
|
||||
let issueFilingMetadata = null;
|
||||
@@ -761,6 +762,18 @@
|
||||
getRepository:()=>qs('#create-issue-repository').value,
|
||||
}, issueTemplatePicker);
|
||||
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)) {
|
||||
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
|
||||
@@ -4240,6 +4253,11 @@
|
||||
}
|
||||
|
||||
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({
|
||||
root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'),
|
||||
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(', ');
|
||||
qs('#my-work-action-status').textContent = keys + ' created and assigned to you.';
|
||||
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 =>
|
||||
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
|
||||
);
|
||||
@@ -5414,6 +5435,10 @@
|
||||
qs('#create-issue-title').focus();
|
||||
});
|
||||
qs('#cancel-new-issue').addEventListener('click', () => {
|
||||
if (checklistPromotion?.cancel()) {
|
||||
closeCreateIssueSheet(true, false);
|
||||
return;
|
||||
}
|
||||
const discardEditedDraft = Boolean(editingOutboxId);
|
||||
if (editingOutboxId) {
|
||||
issueCapture.clearDraft();
|
||||
@@ -5520,7 +5545,11 @@
|
||||
});
|
||||
async function admitReviewedIssue(review) {
|
||||
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';
|
||||
createAndStartRequested = review.intent === 'create-and-start';
|
||||
if (createAndStartRequested && !createAndStart.available()) {
|
||||
@@ -5678,6 +5707,7 @@
|
||||
const checklistStepManagement = issueController.bindTaskManagement({
|
||||
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'),
|
||||
fileRelated:qs('#file-checklist-step-related'), onFileRelated:startChecklistPromotion,
|
||||
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'),
|
||||
current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}),
|
||||
|
||||
@@ -612,6 +612,7 @@
|
||||
<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-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="cancel-checklist-step-edit" type="button">Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -76,6 +76,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -134,6 +142,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
if (blockers) item.blockers = blockers;
|
||||
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
|
||||
if (relatedDraft) item.relatedDraft = relatedDraft;
|
||||
|
||||
item.operationId = item.id;
|
||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||
item.milestoneId = Number(draft.milestoneId);
|
||||
@@ -496,6 +505,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const confirmed = [];
|
||||
const completions = [];
|
||||
const filings = [];
|
||||
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
@@ -505,6 +515,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
if (result.issue) {
|
||||
confirmed.push(result.issue);
|
||||
if (result.item?.relatedDraft) filings.push({issue:result.issue, relatedDraft:result.item.relatedDraft});
|
||||
|
||||
if (result.item?.completionIntent) completions.push({
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
@@ -546,6 +557,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return {
|
||||
confirmed: result.issue ? [result.issue] : [],
|
||||
filings: result.issue && result.item?.relatedDraft ? [{issue:result.issue, relatedDraft:result.item.relatedDraft}] : [],
|
||||
|
||||
completions: result.issue && result.item?.completionIntent ? [{
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
|
||||
+30
-3
@@ -77,7 +77,7 @@ function manageChecklistTask(raw, targetIndex, operation = {}) {
|
||||
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 closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
@@ -213,6 +213,18 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
||||
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) {
|
||||
const body = appendChecklistTask(detail.body, label);
|
||||
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 trigger = null;
|
||||
const reset = (returnFocus = false) => {
|
||||
@@ -305,8 +317,10 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
||||
label.value = control.dataset.taskLabel || '';
|
||||
earlier.disabled = control.dataset.taskFirst === 'true';
|
||||
later.disabled = control.dataset.taskLast === 'true';
|
||||
fileRelated.disabled = /^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(label.value);
|
||||
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();
|
||||
});
|
||||
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 }); });
|
||||
earlier.addEventListener('click', () => run({ action:'move-earlier' }));
|
||||
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' }));
|
||||
cancel.addEventListener('click', () => reset(true));
|
||||
return { reset };
|
||||
|
||||
Reference in new issue
Block a user