diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index 19c7d19..38eb77d 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -108,6 +108,9 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp 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 blockerSearchRequest = 0; @@ -148,6 +151,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp labelIds: safeLabelIds(parsed.labelIds), operationId: String(parsed.operationId || '').slice(0, 128), }; + 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; @@ -178,6 +186,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp body: String(draft?.body || ''), labelIds: safeLabelIds(draft?.labelIds), }; + 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 = safeAssignee(draft?.assignee); if (assignee) { safe.assignee = assignee; @@ -189,7 +202,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp if (dueDate) safe.dueDate = dueDate; const blockers = safeBlockers(draft?.blockers); if (blockers.length) safe.blockers = blockers; - const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName'] + const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName', + 'templateName', 'templateId', 'capturedBody'] .every(key => (previous[key] || '') === (safe[key] || '')) && JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) && JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []); @@ -305,6 +319,120 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp ); } + 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) : [] + ); + } + + 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 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); + }, + changeRepository(draft) { + const restored = restoreTemplate(draft, labels); + callbacks.setDraft(restored); + return restored; + }, + }; + } + function loadRepositoryPage(page) { const safePage = Math.max(1, Math.floor(Number(page) || 1)); if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage); @@ -426,7 +554,9 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp } return { - saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadRepositoryPage, + saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadTemplates, + applyTemplate, setTemplateState, templateFields, restoreTemplate, loadTemplateOptions, + selectTemplate, bindTemplatePicker, loadRepositoryPage, searchRepositories, searchBlockers, findDuplicates, needsDuplicateAcknowledgement, acknowledgeDuplicates, submit, stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent, diff --git a/frontend/dashboard.js b/frontend/dashboard.js index bf190f2..efc9aef 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -520,6 +520,7 @@ }); let issueCapture = null; let issueOwnerPicker = null; + let issueTemplatePicker = null; let updateFollowUp = null; const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null; const unfiledCaptures = createUnfiledCaptures({ @@ -566,6 +567,7 @@ evidenceNote: qs('#issue-filing-review-evidence-note'), repository: qs('#issue-filing-review-repository'), intent: qs('#issue-filing-review-intent'), + issueType: qs('#issue-filing-review-template'), title: qs('#issue-filing-review-title'), body: qs('#issue-filing-review-body'), metadata: qs('#issue-filing-review-metadata'), @@ -645,6 +647,16 @@ issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document, () => { saveIssueCaptureDraft(); updateIssueCreateActions(); }); + issueTemplatePicker = issueCapture.bindTemplatePicker({ + field:qs('#create-issue-template-field'), select:qs('#create-issue-template'), + status:qs('#create-issue-template-status'), document, + getRepository:()=>qs('#create-issue-repository').value, + labelInputs:()=>document.querySelectorAll('input[name="create-issue-label"]'), + }, { + getDraft:currentIssueCaptureDraft, + setDraft:draft=>{ qs('#create-issue-title').value=draft.title; qs('#create-issue-body').value=draft.body; }, + changed:()=>{ saveIssueCaptureDraft(); scheduleIssueDuplicateCheck(); }, + }); updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin }); } if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) { @@ -3678,13 +3690,15 @@ let issueCaptureBlockers = []; function saveIssueCaptureDraft() { - if (issueCapture) issueCapture.saveDraft( + if (issueCapture) issueCapture.saveDraft(issueTemplatePicker.fields( issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers) - ); + )); } function currentIssueCaptureDraft() { - return issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, true); + return issueTemplatePicker.fields( + issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, true) + ); } let issueCaptureRepositories = []; @@ -3856,6 +3870,8 @@ status.textContent = 'Loading labels…'; try { const labels = await issueCapture.loadLabels(repository); + if (qs('#create-issue-repository').value !== repository) return; + issueTemplatePicker.setLabels(labels); const selected = new Set(selectedIds.map(Number)); list.innerHTML = labels.map(label => '