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 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 duplicateRequest = 0; let repositorySearchRequest = 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) : ''; 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), }; const milestoneId = safeMilestoneId(parsed.milestoneId); const dueDate = safeDueDate(parsed.dueDate); if (milestoneId !== null) draft.milestoneId = milestoneId; if (dueDate) draft.dueDate = dueDate; 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), }; const milestoneId = safeMilestoneId(draft?.milestoneId); const dueDate = safeDueDate(draft?.dueDate); if (milestoneId !== null) safe.milestoneId = milestoneId; if (dueDate) safe.dueDate = dueDate; const blockers = safeBlockers(draft?.blockers); if (blockers.length) safe.blockers = blockers; const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate'] .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 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.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, loadRepositoryPage, searchRepositories, searchBlockers, findDuplicates, needsDuplicateAcknowledgement, acknowledgeDuplicates, submit, stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent, stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp, }; } createIssueCapture.normalizeSharedContent = normalizeSharedContent; if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;