897 lines
39 KiB
JavaScript
897 lines
39 KiB
JavaScript
function newIssueOperationId() {
|
|
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
|
if (globalThis.crypto?.getRandomValues) {
|
|
const bytes = new Uint8Array(16);
|
|
globalThis.crypto.getRandomValues(bytes);
|
|
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
|
|
}
|
|
|
|
function createIssueModalLifecycle({ root, background = [], document, requestClose }) {
|
|
let launcher = null;
|
|
let active = false;
|
|
let backgroundState = [];
|
|
const focusableSelector = [
|
|
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
|
|
'select:not([disabled])', 'textarea:not([disabled])',
|
|
'[tabindex]:not([tabindex="-1"])',
|
|
].join(',');
|
|
const focusable = () => Array.from(root.querySelectorAll(focusableSelector)).filter(element =>
|
|
!element.hidden && !element.disabled && element.getClientRects().length > 0
|
|
);
|
|
function keydown(event) {
|
|
if (!active) return;
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
requestClose();
|
|
return;
|
|
}
|
|
if (event.key !== 'Tab') return;
|
|
const controls = focusable();
|
|
if (!controls.length) return;
|
|
const first = controls[0];
|
|
const last = controls[controls.length - 1];
|
|
if (event.shiftKey && document.activeElement === first) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
} else if (!event.shiftKey && document.activeElement === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
root.addEventListener('keydown', keydown);
|
|
return {
|
|
open(trigger = null) {
|
|
launcher = trigger?.isConnected ? trigger : document.activeElement;
|
|
backgroundState = background.map(element => [element, element.inert]);
|
|
backgroundState.forEach(([element]) => { element.inert = true; });
|
|
active = true;
|
|
(root.querySelector('#cancel-new-issue') || focusable()[0])?.focus();
|
|
},
|
|
close({ restore = true } = {}) {
|
|
active = false;
|
|
backgroundState.forEach(([element, inert]) => { element.inert = inert; });
|
|
backgroundState = [];
|
|
if (restore && launcher?.isConnected) launcher.focus();
|
|
launcher = null;
|
|
},
|
|
};
|
|
}
|
|
|
|
function normalizeSharedContent(value = {}) {
|
|
const clean = input => String(input || '').replace(/\s+/g, ' ').trim();
|
|
const text = String(value.text || '').trim().slice(0, 9500);
|
|
const textSummary = clean(text);
|
|
const sentence = (textSummary.match(/^.{1,80}?[.!?](?:\s|$)/)?.[0] || textSummary.slice(0, 80)).trim();
|
|
const title = (clean(value.title) || sentence).slice(0, 240);
|
|
const url = clean(value.url).slice(0, 2000);
|
|
const body = text && url && text.includes(url) ? text : [text, url].filter(Boolean).join('\n\n');
|
|
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 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');
|
|
const status = documentRef.querySelector('#create-issue-assignee-status');
|
|
const getRepository = () => documentRef.querySelector('#create-issue-repository').value;
|
|
const loadOwners = repository => issueCapture.loadOwners(repository);
|
|
let ownerRequest = 0;
|
|
function reset(repository, selected = {}) {
|
|
select.replaceChildren();
|
|
const me = documentRef.createElement('option');
|
|
me.value = '';
|
|
me.textContent = 'Me';
|
|
select.appendChild(me);
|
|
const noOwner = documentRef.createElement('option');
|
|
noOwner.value = NO_OWNER;
|
|
noOwner.textContent = 'No owner';
|
|
select.appendChild(noOwner);
|
|
if (selected.unassigned === true) select.value = NO_OWNER;
|
|
if (selected.assignee) {
|
|
const option = documentRef.createElement('option');
|
|
option.value = selected.assignee;
|
|
option.textContent = (selected.assigneeName || selected.assignee) + ' (@' + selected.assignee + ')';
|
|
option.dataset.name = selected.assigneeName || selected.assignee;
|
|
select.appendChild(option);
|
|
select.value = selected.assignee;
|
|
}
|
|
select.dataset.repository = '';
|
|
status.textContent = repository ? 'Open the owner picker to load eligible teammates.' : 'Choose a repository first.';
|
|
}
|
|
|
|
async function load(repository) {
|
|
if (!repository || select.dataset.repository === repository) return;
|
|
const request = ++ownerRequest;
|
|
const selected = select.value;
|
|
const selectedName = select.selectedOptions?.[0]?.dataset.name || '';
|
|
status.textContent = 'Loading eligible teammates…';
|
|
try {
|
|
const owners = await loadOwners(repository);
|
|
if (request !== ownerRequest || getRepository() !== repository) return;
|
|
const selectedStillEligible = owners.some(owner => owner?.login === selected);
|
|
reset(repository, selected === NO_OWNER ? {unassigned:true} :
|
|
(selectedStillEligible ? {assignee:selected, assigneeName:selectedName} : {}));
|
|
owners.forEach(owner => {
|
|
if (!owner?.login || owner.login === selected) return;
|
|
const option = documentRef.createElement('option');
|
|
option.value = owner.login;
|
|
option.textContent = (owner.name || owner.login) + ' (@' + owner.login + ')';
|
|
option.dataset.name = owner.name || owner.login;
|
|
select.appendChild(option);
|
|
});
|
|
select.dataset.repository = repository;
|
|
status.textContent = owners.length ? 'Choose yourself, no owner, or an eligible teammate.' :
|
|
'Choose yourself or no owner; no eligible teammates are available.';
|
|
} catch (_error) {
|
|
if (request !== ownerRequest || getRepository() !== repository) return;
|
|
status.textContent = 'Teammates could not be loaded. The issue will stay assigned to you.';
|
|
}
|
|
}
|
|
function updateActions(hasRepository, hasBlockers, canStart) {
|
|
const submit = documentRef.querySelector('#submit-new-issue');
|
|
const start = documentRef.querySelector('#create-and-start-issue');
|
|
const hasNoOwner = select.value === NO_OWNER;
|
|
const hasTeammateOwner = Boolean(select.value) && !hasNoOwner;
|
|
submit.disabled = !hasRepository;
|
|
submit.textContent = hasNoOwner ? 'Create unassigned' :
|
|
(hasTeammateOwner ? 'Create & assign' : 'Create & assign to me');
|
|
start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || hasNoOwner || !canStart;
|
|
start.title = hasBlockers ? 'Blocked work cannot start until its blockers are complete.' :
|
|
(hasNoOwner ? 'No owner work cannot be added to your Today queue.' :
|
|
(hasTeammateOwner ? 'Work assigned to a teammate cannot be added to your Today queue.' : ''));
|
|
}
|
|
function fields() {
|
|
return {
|
|
assignee: select.value === NO_OWNER ? '' : select.value,
|
|
assigneeName: select.value === NO_OWNER ? '' : (select.selectedOptions?.[0]?.dataset.name || ''),
|
|
unassigned: select.value === NO_OWNER,
|
|
};
|
|
}
|
|
function draft(labelIds, blockers, trim = false) {
|
|
const value = id => documentRef.querySelector(id).value;
|
|
const clean = input => trim ? input.trim() : input;
|
|
return {
|
|
repository:value('#create-issue-repository'),
|
|
title:clean(value('#create-issue-title')), body:clean(value('#create-issue-body')),
|
|
labelIds, milestoneId:Number(value('#create-issue-milestone')) || null,
|
|
dueDate:value('#create-issue-due-date'), ...fields(), blockers,
|
|
};
|
|
}
|
|
select.addEventListener('focus', () => {
|
|
const repository = getRepository();
|
|
if (repository) load(repository);
|
|
});
|
|
select.addEventListener('change', onChange);
|
|
return { reset, load, updateActions, fields, draft };
|
|
}
|
|
|
|
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
|
const storageKey = 'stackchain.issue-capture.v1';
|
|
const sharedStorageKey = 'stackchain.issue-share.v1';
|
|
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
|
|
let pending = null;
|
|
let issueTemplates = [];
|
|
let activeTemplate = null;
|
|
let templateRequest = 0;
|
|
let duplicateRequest = 0;
|
|
let repositorySearchRequest = 0;
|
|
let filingMetadataRequest = 0;
|
|
let blockerSearchRequest = 0;
|
|
let duplicateState = {status: 'idle', key: '', candidates: []};
|
|
let acknowledgedDuplicateKey = '';
|
|
const repositoryPageRequests = new Map();
|
|
const safeLabelIds = value => Array.from(new Set(
|
|
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
|
)).slice(0, 20);
|
|
const safeBlockers = value => {
|
|
const seen = new Set();
|
|
return (Array.isArray(value) ? value : []).reduce((items, blocker) => {
|
|
const repository = String(blocker?.repository || '').trim();
|
|
const number = Number(blocker?.number);
|
|
const key = repository + '#' + number;
|
|
if (items.length >= 5 || seen.has(key) ||
|
|
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
|
!Number.isInteger(number) || number < 1) return items;
|
|
seen.add(key);
|
|
items.push({repository, number,
|
|
title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)});
|
|
return items;
|
|
}, []);
|
|
};
|
|
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
|
|
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
|
|
const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
|
|
const safeAssignee = value => /^[A-Za-z0-9_.-]+$/.test(String(value || '')) ? String(value) : '';
|
|
const safeEstimate = value => Number.isInteger(Number(value)) && Number(value) >= 5 && Number(value) <= 1440 ?
|
|
Number(value) : null;
|
|
|
|
function loadStored() {
|
|
try {
|
|
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
|
if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''};
|
|
const draft = {
|
|
repository: String(parsed.repository || ''),
|
|
title: String(parsed.title || ''),
|
|
body: String(parsed.body || ''),
|
|
labelIds: safeLabelIds(parsed.labelIds),
|
|
operationId: String(parsed.operationId || '').slice(0, 128),
|
|
};
|
|
if (parsed.unassigned === true) draft.unassigned = true;
|
|
if (typeof parsed.templateName === 'string' && parsed.templateName.trim()) {
|
|
draft.templateName = parsed.templateName.trim().slice(0, 80);
|
|
draft.templateId = String(parsed.templateId || '').slice(0, 80);
|
|
draft.capturedBody = String(parsed.capturedBody || '').slice(0, 10000);
|
|
}
|
|
const assignee = safeAssignee(parsed.assignee);
|
|
if (assignee) {
|
|
draft.assignee = assignee;
|
|
draft.assigneeName = String(parsed.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
|
}
|
|
const milestoneId = safeMilestoneId(parsed.milestoneId);
|
|
const dueDate = safeDueDate(parsed.dueDate);
|
|
if (milestoneId !== null) draft.milestoneId = milestoneId;
|
|
if (dueDate) draft.dueDate = dueDate;
|
|
const estimateMinutes = safeEstimate(parsed.estimateMinutes);
|
|
if (estimateMinutes !== null) draft.estimateMinutes = estimateMinutes;
|
|
if (['create', 'create-and-start'].includes(parsed.completionIntent)) {
|
|
draft.completionIntent = parsed.completionIntent;
|
|
}
|
|
const blockers = safeBlockers(parsed.blockers);
|
|
if (blockers.length) draft.blockers = blockers;
|
|
return draft;
|
|
} catch (_error) {
|
|
return {...emptyDraft(), operationId: ''};
|
|
}
|
|
}
|
|
|
|
function writeStored(record) {
|
|
try { storage.setItem(storageKey, JSON.stringify(record)); }
|
|
catch (_error) { /* Keep the form as the in-memory fallback. */ }
|
|
}
|
|
|
|
function saveDraft(draft) {
|
|
const previous = loadStored();
|
|
const safe = {
|
|
repository: String(draft?.repository || ''),
|
|
title: String(draft?.title || ''),
|
|
body: String(draft?.body || ''),
|
|
labelIds: safeLabelIds(draft?.labelIds),
|
|
};
|
|
if (draft?.unassigned === true) safe.unassigned = true;
|
|
if (typeof draft?.templateName === 'string' && draft.templateName.trim()) {
|
|
safe.templateName = draft.templateName.trim().slice(0, 80);
|
|
safe.templateId = String(draft.templateId || '').slice(0, 80);
|
|
safe.capturedBody = String(draft.capturedBody || '').slice(0, 10000);
|
|
}
|
|
const assignee = safe.unassigned ? '' : safeAssignee(draft?.assignee);
|
|
if (assignee) {
|
|
safe.assignee = assignee;
|
|
safe.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
|
}
|
|
const milestoneId = safeMilestoneId(draft?.milestoneId);
|
|
const dueDate = safeDueDate(draft?.dueDate);
|
|
if (milestoneId !== null) safe.milestoneId = milestoneId;
|
|
if (dueDate) safe.dueDate = dueDate;
|
|
const estimateMinutes = safeEstimate(draft?.estimateMinutes);
|
|
if (estimateMinutes !== null) safe.estimateMinutes = estimateMinutes;
|
|
if (['create', 'create-and-start'].includes(draft?.completionIntent)) {
|
|
safe.completionIntent = draft.completionIntent;
|
|
}
|
|
const blockers = safeBlockers(draft?.blockers);
|
|
if (blockers.length) safe.blockers = blockers;
|
|
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName', 'unassigned',
|
|
'templateName', 'templateId', 'capturedBody', 'estimateMinutes', 'completionIntent']
|
|
.every(key => (previous[key] || '') === (safe[key] || '')) &&
|
|
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) &&
|
|
JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []);
|
|
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
|
|
return safe;
|
|
}
|
|
|
|
function loadDraft() {
|
|
const {operationId: _operationId, ...draft} = loadStored();
|
|
return draft;
|
|
}
|
|
|
|
function clearDraft() {
|
|
try { storage.removeItem(storageKey); }
|
|
catch (_error) { /* Confirmed creation remains authoritative. */ }
|
|
}
|
|
|
|
function pendingSharedContent() {
|
|
try {
|
|
const parsed = JSON.parse(storage.getItem(sharedStorageKey) || 'null');
|
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
const shared = normalizeSharedContent({title: parsed.title, text: parsed.body});
|
|
return shared.title || shared.body ? shared : null;
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function acceptSharedContent() {
|
|
const shared = pendingSharedContent();
|
|
if (!shared) return loadDraft();
|
|
const accepted = saveDraft({...loadDraft(), ...shared});
|
|
try { storage.removeItem(sharedStorageKey); }
|
|
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
|
|
return accepted;
|
|
}
|
|
|
|
function discardSharedContent() {
|
|
try { storage.removeItem(sharedStorageKey); }
|
|
catch (_error) { /* The existing issue draft remains authoritative. */ }
|
|
}
|
|
|
|
function stageSharedContent(value) {
|
|
const shared = normalizeSharedContent(value);
|
|
if (!shared.title && !shared.body) return {status: 'empty'};
|
|
try { storage.setItem(sharedStorageKey, JSON.stringify(shared)); }
|
|
catch (_error) { /* The caller can still use the in-page share payload. */ }
|
|
const existing = loadDraft();
|
|
if (existing.title || existing.body) return {status: 'conflict'};
|
|
acceptSharedContent();
|
|
return {status: 'ready'};
|
|
}
|
|
|
|
function pendingFollowUp() {
|
|
try {
|
|
const parsed = JSON.parse(storage.getItem(followUpStorageKey) || 'null');
|
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(parsed.repository || '')) ? String(parsed.repository) : '';
|
|
const title = String(parsed.title || '').trim().slice(0, 240);
|
|
const body = String(parsed.body || '').trim().slice(0, 9500);
|
|
return title || body ? {repository, title, body, labelIds: []} : null;
|
|
} catch (_error) { return null; }
|
|
}
|
|
|
|
function acceptFollowUp() {
|
|
const followUp = pendingFollowUp();
|
|
if (!followUp) return loadDraft();
|
|
const accepted = saveDraft(followUp);
|
|
try { storage.removeItem(followUpStorageKey); }
|
|
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
|
|
return accepted;
|
|
}
|
|
|
|
function discardFollowUp() {
|
|
try { storage.removeItem(followUpStorageKey); }
|
|
catch (_error) { /* The existing capture remains authoritative. */ }
|
|
}
|
|
|
|
function stageFollowUp(value) {
|
|
const followUp = {repository: String(value?.repository || ''), title: String(value?.title || ''), body: String(value?.body || '')};
|
|
if (!followUp.title && !followUp.body) return {status: 'empty'};
|
|
try { storage.setItem(followUpStorageKey, JSON.stringify(followUp)); }
|
|
catch (_error) { /* The in-page flow can still continue. */ }
|
|
const existing = loadDraft();
|
|
if (existing.title || existing.body) return {status: 'conflict'};
|
|
acceptFollowUp();
|
|
return {status: 'ready'};
|
|
}
|
|
|
|
function loadLabels(repository) {
|
|
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
|
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
|
return fetchJson('api/v1/repos/' + encoded + '/labels').then(labels =>
|
|
(Array.isArray(labels) ? labels : []).slice().sort((left, right) => {
|
|
const leftPriority = priorities.has(String(left?.name || '').toLowerCase());
|
|
const rightPriority = priorities.has(String(right?.name || '').toLowerCase());
|
|
return Number(rightPriority) - Number(leftPriority);
|
|
})
|
|
);
|
|
}
|
|
|
|
function loadMilestones(repository) {
|
|
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
|
return fetchJson('api/v1/repos/' + encoded + '/milestones').then(milestones =>
|
|
Array.isArray(milestones) ? milestones : []
|
|
);
|
|
}
|
|
|
|
function loadOwners(repository) {
|
|
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
|
return fetchJson('api/v1/repos/' + encoded + '/issue-assignees').then(owners =>
|
|
Array.isArray(owners) ? owners : []
|
|
);
|
|
}
|
|
|
|
function loadTemplates(repository) {
|
|
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
|
return fetchJson('api/v1/repos/' + encoded + '/issue-templates').then(templates =>
|
|
Array.isArray(templates) ? templates.slice(0, 20) : []
|
|
);
|
|
}
|
|
|
|
async function loadFilingMetadata(repository) {
|
|
const selectedRepository = String(repository || '');
|
|
const encoded = selectedRepository.split('/').map(encodeURIComponent).join('/');
|
|
const request = ++filingMetadataRequest;
|
|
const payload = await fetchJson(
|
|
'api/v1/repos/' + encoded + '/issue-filing-metadata'
|
|
);
|
|
if (request !== filingMetadataRequest) {
|
|
return {status:'stale', repository:selectedRepository};
|
|
}
|
|
const section = name => {
|
|
const value = payload?.[name];
|
|
const available = value?.available === true;
|
|
return {
|
|
available,
|
|
items:available && Array.isArray(value?.items) ? value.items : [],
|
|
...(!available && typeof value?.error === 'string' ? {error:value.error} : {}),
|
|
};
|
|
};
|
|
return {
|
|
status:'ready', repository:selectedRepository,
|
|
labels:section('labels'), milestones:section('milestones'),
|
|
templates:section('templates'),
|
|
};
|
|
}
|
|
|
|
function applyTemplate(draft, template, availableLabels = []) {
|
|
const source = {...(draft || {})};
|
|
const capturedBody = source.templateName ? String(source.capturedBody || '') : String(source.body || '');
|
|
if (!template) {
|
|
delete source.templateName;
|
|
delete source.templateId;
|
|
delete source.capturedBody;
|
|
return {...source, body: capturedBody};
|
|
}
|
|
const scaffold = String(template.body || '').trim().slice(0, 9000);
|
|
const body = [capturedBody.trim(), scaffold].filter(Boolean).join('\n\n---\n\n').slice(0, 10000);
|
|
const validByName = new Map((Array.isArray(availableLabels) ? availableLabels : [])
|
|
.filter(label => Number.isInteger(label?.id) && typeof label?.name === 'string')
|
|
.map(label => [label.name.toLowerCase(), label.id]));
|
|
const templateLabelIds = (Array.isArray(template.labels) ? template.labels : [])
|
|
.map(name => validByName.get(String(name).toLowerCase())).filter(Boolean);
|
|
return {
|
|
...source,
|
|
title: source.title || String(template.title || '').trim().slice(0, 255),
|
|
body,
|
|
labelIds: safeLabelIds([...(source.labelIds || []), ...templateLabelIds]),
|
|
templateId: String(template.id || '').slice(0, 80),
|
|
templateName: String(template.name || 'Issue template').trim().slice(0, 80),
|
|
capturedBody,
|
|
};
|
|
}
|
|
|
|
function setTemplateState(draft) {
|
|
activeTemplate = draft?.templateName ? {
|
|
templateName:draft.templateName, templateId:draft.templateId,
|
|
capturedBody:draft.capturedBody || '',
|
|
} : null;
|
|
}
|
|
|
|
function templateFields(draft) {
|
|
return activeTemplate ? {...draft, ...activeTemplate} : draft;
|
|
}
|
|
|
|
function restoreTemplate(draft, availableLabels) {
|
|
const restored = applyTemplate(templateFields(draft), null, availableLabels);
|
|
activeTemplate = null;
|
|
return restored;
|
|
}
|
|
|
|
async function loadTemplateOptions(repository, elements, selectedId = '') {
|
|
const request = ++templateRequest;
|
|
issueTemplates = [];
|
|
elements.select.innerHTML = '<option value="">Blank issue</option>';
|
|
elements.field.hidden = true;
|
|
if (!repository) return;
|
|
elements.status.textContent = 'Loading issue types…';
|
|
try {
|
|
const templates = await loadTemplates(repository);
|
|
if (request !== templateRequest || elements.getRepository() !== repository) return;
|
|
issueTemplates = templates;
|
|
templates.forEach(template => {
|
|
const option = elements.document.createElement('option');
|
|
option.value = template.id;
|
|
option.textContent = template.name;
|
|
elements.select.appendChild(option);
|
|
});
|
|
elements.field.hidden = templates.length === 0;
|
|
elements.select.value = templates.some(template => template.id === selectedId) ? selectedId : '';
|
|
elements.status.textContent = templates.length ?
|
|
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
|
|
} catch (_error) {
|
|
if (request !== templateRequest || elements.getRepository() !== repository) return;
|
|
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
|
|
}
|
|
}
|
|
|
|
function selectTemplate(identifier, draft, availableLabels) {
|
|
const template = issueTemplates.find(item => item.id === identifier) || null;
|
|
const applied = applyTemplate(templateFields(draft), template, availableLabels);
|
|
activeTemplate = template ? {
|
|
templateId:applied.templateId, templateName:applied.templateName,
|
|
capturedBody:applied.capturedBody,
|
|
} : null;
|
|
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 => {
|
|
const {draft, template} = selectTemplate(event.target.value, callbacks.getDraft(), labels);
|
|
callbacks.setDraft(draft);
|
|
const selected = new Set(draft.labelIds || []);
|
|
elements.labelInputs().forEach(input => { input.checked = selected.has(Number(input.value)); });
|
|
elements.status.textContent = template ?
|
|
template.name + (template.about ? ' — ' + template.about : '') : 'Blank issue selected.';
|
|
callbacks.changed();
|
|
});
|
|
return {
|
|
fields:templateFields,
|
|
reset:setTemplateState,
|
|
setLabels(value) { labels = Array.isArray(value) ? value : []; },
|
|
load(repository, selectedId) {
|
|
return loadTemplateOptions(repository, elements, selectedId);
|
|
},
|
|
render(repository, section, selectedId = '') {
|
|
++templateRequest;
|
|
issueTemplates = [];
|
|
elements.select.innerHTML = '<option value="">Blank issue</option>';
|
|
elements.field.hidden = true;
|
|
if (!repository || elements.getRepository() !== repository) return;
|
|
if (section?.available !== true) {
|
|
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
|
|
return;
|
|
}
|
|
issueTemplates = Array.isArray(section.items) ? section.items.slice(0, 20) : [];
|
|
issueTemplates.forEach(template => {
|
|
const option = elements.document.createElement('option');
|
|
option.value = template.id;
|
|
option.textContent = template.name;
|
|
elements.select.appendChild(option);
|
|
});
|
|
elements.field.hidden = issueTemplates.length === 0;
|
|
elements.select.value = issueTemplates.some(template => template.id === selectedId) ? selectedId : '';
|
|
elements.status.textContent = issueTemplates.length ?
|
|
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
|
|
},
|
|
changeRepository(draft) {
|
|
const restored = restoreTemplate(draft, labels);
|
|
callbacks.setDraft(restored);
|
|
return restored;
|
|
},
|
|
};
|
|
}
|
|
|
|
function bindFilingMetadata(elements, templatePicker) {
|
|
function renderLabels(repository, section, selectedIds = []) {
|
|
elements.labelList.replaceChildren();
|
|
if (!repository) {
|
|
elements.labelStatus.textContent = 'Choose a repository to load labels.';
|
|
return;
|
|
}
|
|
if (section?.available !== true) {
|
|
templatePicker.setLabels([]);
|
|
elements.labelStatus.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
|
|
return;
|
|
}
|
|
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
|
const labels = (Array.isArray(section.items) ? section.items : []).slice().sort((left, right) =>
|
|
Number(priorities.has(String(right?.name || '').toLowerCase())) -
|
|
Number(priorities.has(String(left?.name || '').toLowerCase()))
|
|
);
|
|
templatePicker.setLabels(labels);
|
|
const selected = new Set(selectedIds.map(Number));
|
|
labels.forEach(label => {
|
|
const option = elements.document.createElement('label');
|
|
option.className = 'create-issue-label-option';
|
|
const input = elements.document.createElement('input');
|
|
input.type = 'checkbox';
|
|
input.name = 'create-issue-label';
|
|
input.value = String(Number(label.id));
|
|
input.checked = selected.has(Number(label.id));
|
|
const name = elements.document.createElement('span');
|
|
name.textContent = label.name;
|
|
option.append(input, name);
|
|
elements.labelList.appendChild(option);
|
|
});
|
|
elements.labelStatus.textContent = labels.length ?
|
|
'Select labels to triage this issue.' : 'This repository has no labels.';
|
|
}
|
|
|
|
function renderMilestones(repository, section, selectedId = null) {
|
|
elements.milestoneSelect.replaceChildren();
|
|
const blank = elements.document.createElement('option');
|
|
blank.value = '';
|
|
blank.textContent = 'No milestone';
|
|
elements.milestoneSelect.appendChild(blank);
|
|
if (!repository) {
|
|
elements.milestoneStatus.textContent = 'Choose a repository to load milestones.';
|
|
return;
|
|
}
|
|
if (section?.available !== true) {
|
|
elements.milestoneStatus.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
|
|
return;
|
|
}
|
|
const milestones = Array.isArray(section.items) ? section.items : [];
|
|
milestones.forEach(milestone => {
|
|
const option = elements.document.createElement('option');
|
|
option.value = String(Number(milestone.id));
|
|
option.textContent = milestone.title;
|
|
elements.milestoneSelect.appendChild(option);
|
|
});
|
|
if (selectedId) elements.milestoneSelect.value = String(selectedId);
|
|
elements.milestoneStatus.textContent = milestones.length ?
|
|
'Choose the release lane for this issue.' : 'This repository has no open milestones.';
|
|
}
|
|
|
|
async function load(repository, selected = {}) {
|
|
renderLabels(repository, null, selected.labelIds || []);
|
|
renderMilestones(repository, null, selected.milestoneId);
|
|
templatePicker.render(repository, null, selected.templateId);
|
|
if (!repository) return;
|
|
elements.labelStatus.textContent = 'Loading labels…';
|
|
elements.milestoneStatus.textContent = 'Loading milestones…';
|
|
elements.templateStatus.textContent = 'Loading issue types…';
|
|
try {
|
|
const metadata = await loadFilingMetadata(repository);
|
|
if (metadata.status !== 'ready' || elements.getRepository() !== repository) return;
|
|
renderLabels(repository, metadata.labels, selected.labelIds || []);
|
|
renderMilestones(repository, metadata.milestones, selected.milestoneId);
|
|
templatePicker.render(repository, metadata.templates, selected.templateId);
|
|
} catch (_error) {
|
|
if (elements.getRepository() !== repository) return;
|
|
renderLabels(repository, {available:false}, selected.labelIds || []);
|
|
renderMilestones(repository, {available:false}, selected.milestoneId);
|
|
templatePicker.render(repository, {available:false}, selected.templateId);
|
|
}
|
|
}
|
|
return {load};
|
|
}
|
|
|
|
function loadRepositoryPage(page) {
|
|
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
|
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
|
const request = fetchJson('api/v1/repositories?page=' + safePage + '&limit=50')
|
|
.then(payload => ({
|
|
items: Array.isArray(payload?.items) ? payload.items : [],
|
|
page: Number(payload?.page) || safePage,
|
|
total: Math.max(0, Number(payload?.total) || 0),
|
|
has_more: payload?.has_more === true,
|
|
}))
|
|
.finally(() => repositoryPageRequests.delete(safePage));
|
|
repositoryPageRequests.set(safePage, request);
|
|
return request;
|
|
}
|
|
|
|
async function searchRepositories(value) {
|
|
const query = String(value || '').trim().slice(0, 80);
|
|
const request = ++repositorySearchRequest;
|
|
if (query.length < 2) return {status: 'idle', items: []};
|
|
try {
|
|
const payload = await fetchJson(
|
|
'api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20'
|
|
);
|
|
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
|
|
return {
|
|
status: 'ready',
|
|
items: Array.isArray(payload?.items) ? payload.items : [],
|
|
};
|
|
} catch (error) {
|
|
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
|
|
return {status: 'failed', items: [], error};
|
|
}
|
|
}
|
|
|
|
async function searchBlockers(value) {
|
|
const query = String(value || '').trim().slice(0, 80);
|
|
const request = ++blockerSearchRequest;
|
|
if (query.length < 2) return {status:'idle', items:[]};
|
|
try {
|
|
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(query) + '&limit=20');
|
|
if (request !== blockerSearchRequest) return {status:'stale', items:[]};
|
|
return {status:'ready', items:(Array.isArray(payload?.items) ? payload.items : [])
|
|
.filter(item => item?.kind === 'issue' && item?.state === 'open').slice(0, 20)};
|
|
} catch (error) {
|
|
if (request !== blockerSearchRequest) return {status:'stale', items:[]};
|
|
return {status:'failed', items:[], error};
|
|
}
|
|
}
|
|
|
|
function duplicateKey(draft) {
|
|
const repository = String(draft?.repository || '').trim();
|
|
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
|
return { repository, title, key: repository + '\n' + title.toLowerCase() };
|
|
}
|
|
|
|
async function findDuplicates(draft) {
|
|
const input = duplicateKey(draft);
|
|
const request = ++duplicateRequest;
|
|
if (!input.repository || input.title.length < 4) {
|
|
duplicateState = {status: 'idle', key: input.key, candidates: []};
|
|
return duplicateState;
|
|
}
|
|
try {
|
|
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(input.title) + '&limit=10');
|
|
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
|
|
duplicateState = {
|
|
status: 'ready',
|
|
key: input.key,
|
|
partial: payload?.partial === true,
|
|
candidates: (Array.isArray(payload?.items) ? payload.items : []).filter(item =>
|
|
item?.kind === 'issue' && item?.state === 'open' && item?.repository === input.repository
|
|
).slice(0, 3),
|
|
};
|
|
return duplicateState;
|
|
} catch (error) {
|
|
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
|
|
duplicateState = {status: 'failed', key: input.key, candidates: [], error};
|
|
return duplicateState;
|
|
}
|
|
}
|
|
|
|
function needsDuplicateAcknowledgement(draft) {
|
|
const {key} = duplicateKey(draft);
|
|
return duplicateState.status === 'ready' && duplicateState.partial !== true && duplicateState.key === key &&
|
|
duplicateState.candidates.length > 0 && acknowledgedDuplicateKey !== key;
|
|
}
|
|
|
|
function acknowledgeDuplicates(draft) {
|
|
const {key} = duplicateKey(draft);
|
|
if (duplicateState.status === 'ready' && duplicateState.key === key && duplicateState.candidates.length) {
|
|
acknowledgedDuplicateKey = key;
|
|
}
|
|
}
|
|
|
|
function submit(draft) {
|
|
if (pending) return pending;
|
|
const saved = saveDraft(draft);
|
|
const stored = loadStored();
|
|
const operationId = stored.operationId || String(createOperationId()).slice(0, 128);
|
|
writeStored({...saved, operationId});
|
|
const repository = saved.repository.split('/').map(encodeURIComponent).join('/');
|
|
pending = fetchJson('api/v1/repos/' + repository + '/issues', {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json', 'Content-Type': 'application/json',
|
|
'Idempotency-Key': operationId,
|
|
},
|
|
body: JSON.stringify({
|
|
title: saved.title, body: saved.body, label_ids: saved.labelIds,
|
|
...(saved.unassigned ? {unassigned:true} : {}),
|
|
...(saved.assignee ? {assignee: saved.assignee} : {}),
|
|
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
|
|
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
|
|
}),
|
|
}).then(issue => {
|
|
clearDraft();
|
|
return issue;
|
|
}).finally(() => { pending = null; });
|
|
return pending;
|
|
}
|
|
|
|
return {
|
|
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadTemplates,
|
|
loadFilingMetadata, applyTemplate, setTemplateState, templateFields, restoreTemplate, loadTemplateOptions,
|
|
selectTemplate, buildRelatedDraft:relatedDraft, bindTemplatePicker, bindFilingMetadata, loadRepositoryPage,
|
|
searchRepositories, searchBlockers, findDuplicates,
|
|
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
|
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
|
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
|
|
};
|
|
}
|
|
|
|
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
|
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
|
|
createIssueCapture.buildRelatedDraft = buildRelatedDraft;
|
|
createIssueCapture.relatedChecklistDraft = relatedChecklistDraft;
|
|
createIssueCapture.linkChecklistTask = linkChecklistTask;
|
|
createIssueCapture.createChecklistPromotion = createChecklistPromotion;
|
|
createIssueCapture.createModalLifecycle = createIssueModalLifecycle;
|
|
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|