feat: file related issues from confirmed receipts (Closes #868)
CI / lint (pull_request) Successful in 1m47s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-15 04:12:27 +00:00
1 parent 7a31ea5cba
commit 4b88e1a533
8 files changed
+213 -15

No files matched your search

+31 -1
View File
@@ -19,6 +19,30 @@ function normalizeSharedContent(value = {}) {
return { title, body };
}
function buildRelatedDraft(value = {}, template = null) {
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(value.repository || ''))
? String(value.repository) : '';
const labelIds = Array.from(new Set((Array.isArray(value.labelIds) ? value.labelIds : [])
.filter(id => Number.isInteger(id) && id > 0))).slice(0, 20);
const draft = {repository, title:'', body:'', labelIds};
const milestoneId = Number(value.milestoneId);
if (Number.isInteger(milestoneId) && milestoneId > 0) draft.milestoneId = milestoneId;
if (/^\d{4}-\d{2}-\d{2}$/.test(String(value.dueDate || ''))) draft.dueDate = String(value.dueDate);
if (value.unassigned === true) draft.unassigned = true;
else if (/^[A-Za-z0-9_.-]+$/.test(String(value.assignee || ''))) {
draft.assignee = String(value.assignee);
draft.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
const templateId = String(value.templateId || '').slice(0, 80);
if (templateId && template && String(template.id || '') === templateId) {
draft.templateId = templateId;
draft.templateName = String(value.templateName || template.name || 'Issue template').trim().slice(0, 80);
draft.capturedBody = '';
draft.body = String(template.body || '').trim().slice(0, 9000);
}
return draft;
}
function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
const NO_OWNER = '__unassigned__';
const select = documentRef.querySelector('#create-issue-assignee');
@@ -460,6 +484,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
return {draft: applied, template};
}
function relatedDraft(draft) {
const template = issueTemplates.find(item => String(item?.id || '') === String(draft?.templateId || '')) || null;
return buildRelatedDraft(draft, template);
}
function bindTemplatePicker(elements, callbacks) {
let labels = [];
elements.select.addEventListener('change', event => {
@@ -718,7 +747,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadTemplates,
loadFilingMetadata, applyTemplate, setTemplateState, templateFields, restoreTemplate, loadTemplateOptions,
selectTemplate, bindTemplatePicker, bindFilingMetadata, loadRepositoryPage,
selectTemplate, buildRelatedDraft:relatedDraft, bindTemplatePicker, bindFilingMetadata, loadRepositoryPage,
searchRepositories, searchBlockers, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
@@ -728,5 +757,6 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
createIssueCapture.buildRelatedDraft = buildRelatedDraft;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
+17 -7
View File
@@ -3966,9 +3966,16 @@
root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'),
key:qs('#issue-filing-receipt-key'), title:qs('#issue-filing-receipt-title'),
ownership:qs('#issue-filing-receipt-ownership'), openLink:qs('#issue-filing-receipt-open'),
shareButton:qs('#issue-filing-receipt-share'), fileAnotherButton:qs('#issue-filing-receipt-another'),
shareButton:qs('#issue-filing-receipt-share'), relatedButton:qs('#issue-filing-receipt-related'),
fileAnotherButton:qs('#issue-filing-receipt-another'),
doneButton:qs('#issue-filing-receipt-done'), status:qs('#issue-filing-receipt-status'),
navigator, onFileAnother:() => qs('#new-issue').click(),
navigator,
onFileRelated:plan => {
issueCapture.saveDraft(plan);
createIssueAttachmentController.clear();
qs('#new-issue').click();
},
onFileAnother:() => qs('#new-issue').click(),
});
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
if (navigate && taskOverlayHistory.current() === 'new') {
@@ -4018,7 +4025,9 @@
);
const completionHandled = startedCompletions.has(confirmed.repository + '#' + confirmed.number);
if (!(confirmed.assignees || []).includes(activeFlushLogin)) {
issueFilingReceipt.show(confirmed, qs('#new-issue'));
const filing = (result.filings || []).find(candidate => candidate.issue?.repository === confirmed.repository &&
candidate.issue?.number === confirmed.number);
issueFilingReceipt.show(confirmed, qs('#new-issue'), filing?.relatedDraft);
}
if (startCreated && !(result.completions || []).length && created) {
const outcome = createAndStart.complete(created);
@@ -5221,6 +5230,7 @@
});
async function admitReviewedIssue(review) {
const durableDraft = review.draft;
const deliveryDraft = {...durableDraft, relatedDraft:issueCapture.buildRelatedDraft(durableDraft)};
const followUpNextRequested = review.intent === 'follow-up-and-next';
createAndStartRequested = review.intent === 'create-and-start';
if (createAndStartRequested && !createAndStart.available()) {
@@ -5236,12 +5246,12 @@
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
try {
const admission = followUpNextRequested ? (await updateFollowUp.complete({
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, durableDraft) :
issueOutbox.enqueueDurably(durableDraft),
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, deliveryDraft) :
issueOutbox.enqueueDurably(deliveryDraft),
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
advance: source => notificationReader.acceptReadAndNext(lastMyWork, source),
})).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
await issueOutbox.enqueueDurably(durableDraft));
})).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, deliveryDraft) :
await issueOutbox.enqueueDurably(deliveryDraft));
const queued = admission.item;
pendingIssueFilingIntent = 'create-and-assign';
const fS = dFS.current();
+1
View File
@@ -435,6 +435,7 @@
<div class="issue-filing-receipt-actions">
<a id="issue-filing-receipt-open" class="button-link" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<button id="issue-filing-receipt-share" type="button">Share link</button>
<button id="issue-filing-receipt-related" type="button">File related issue</button>
<button id="issue-filing-receipt-another" type="button">File another</button>
<button id="issue-filing-receipt-done" type="button">Done</button>
</div>
+13 -2
View File
@@ -7,15 +7,18 @@
function createIssueFilingReceipt({
root, heading, key, title, ownership, openLink, shareButton,
fileAnotherButton, doneButton, status, navigator = {}, clipboard = navigator.clipboard,
relatedButton, fileAnotherButton, doneButton, status, navigator = {}, clipboard = navigator.clipboard,
onFileRelated = function () {},
onFileAnother = function () {},
}) {
let active = null;
let relatedPlan = null;
let restoreFocus = null;
function close() {
root.hidden = true;
active = null;
relatedPlan = null;
const target = restoreFocus;
restoreFocus = null;
target?.focus?.();
@@ -44,8 +47,9 @@
status.textContent = 'Issue link copied.';
}
function show(issue, returnTarget) {
function show(issue, returnTarget, reusablePlan = null) {
active = issue;
relatedPlan = reusablePlan;
restoreFocus = returnTarget || null;
key.textContent = issue.repository + '#' + issue.number;
title.textContent = issue.title;
@@ -54,6 +58,7 @@
: 'Created with no owner.';
openLink.href = issue.url;
status.textContent = '';
if (relatedButton) relatedButton.hidden = !relatedPlan;
root.hidden = false;
heading.focus();
}
@@ -65,6 +70,12 @@
close();
});
doneButton.addEventListener('click', close);
relatedButton?.addEventListener('click', () => {
if (!relatedPlan) return;
const plan = relatedPlan;
close();
onFileRelated(plan);
});
fileAnotherButton.addEventListener('click', () => {
close();
onFileAnother();
+36 -3
View File
@@ -54,6 +54,31 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return Number.isInteger(estimate) && estimate >= 5 && estimate <= 1440 ? estimate : undefined;
}
function captureRelatedDraft(value) {
const repository = String(value?.repository || '');
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return undefined;
const plan = {
repository, title:'', body:String(value?.body || '').slice(0, 9000),
labelIds:Array.from(new Set((Array.isArray(value?.labelIds) ? value.labelIds : [])
.filter(id => Number.isInteger(id) && id > 0))).slice(0, 20),
};
const milestoneId = Number(value?.milestoneId);
if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId;
if (/^\d{4}-\d{2}-\d{2}$/.test(String(value?.dueDate || ''))) plan.dueDate = String(value.dueDate);
if (value?.unassigned === true) plan.unassigned = true;
else if (/^[A-Za-z0-9_.-]+$/.test(String(value?.assignee || ''))) {
plan.assignee = String(value.assignee);
plan.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
const templateId = String(value?.templateId || '').slice(0, 80);
if (templateId) {
plan.templateId = templateId;
plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80);
plan.capturedBody = '';
}
return plan;
}
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
@@ -107,6 +132,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
if (attachments) item.attachments = attachments;
const blockers = captureBlockers(draft?.blockers);
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);
@@ -220,6 +247,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const attachment = captureAttachment(draft?.attachment);
const attachments = captureAttachments(draft?.attachments);
const blockers = captureBlockers(draft?.blockers);
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(attachment || null) ||
JSON.stringify(item.attachments || null) !== JSON.stringify(attachments || null);
const changed = item.repository !== repository || item.title !== title || item.body !== body
@@ -228,13 +256,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|| item.assignee !== assignee || item.assigneeName !== assigneeName
|| item.milestoneId !== milestoneId || item.dueDate !== dueDate
|| item.estimateMinutes !== captureEstimate(draft?.estimateMinutes)
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(blockers || null);
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(blockers || null)
|| JSON.stringify(item.relatedDraft || null) !== JSON.stringify(relatedDraft || null);
updated = {
...item,
repository, title, body, labelIds,
unassigned: unassigned || undefined,
assignee, assigneeName, milestoneId, dueDate,
attachment, attachments, blockers,
attachment, attachments, blockers, relatedDraft,
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued',
};
@@ -255,6 +284,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
if (attachment === undefined) delete updated.attachment;
if (attachments === undefined) delete updated.attachments;
if (blockers === undefined) delete updated.blockers;
if (relatedDraft === undefined) delete updated.relatedDraft;
if (attachmentChanged) {
delete updated.attachmentMarkdown;
delete updated.attachmentMarkdowns;
@@ -465,6 +495,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
async function flushQueue(currentLogin) {
const confirmed = [];
const completions = [];
const filings = [];
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
@@ -473,6 +504,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const result = await sendItem(item, currentLogin);
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,
@@ -484,7 +516,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
if (result.transient) break;
}
return { confirmed, completions, remaining: read(), blocked };
return { confirmed, completions, filings, remaining: read(), blocked };
}
async function flush(currentLogin) {
@@ -513,6 +545,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const result = await sendItem(queued, currentLogin);
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,