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 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 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 = ''; 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 = ''; 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; if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;