import { createHash } from 'node:crypto'; export const LABELS = Object.freeze(['bug','feature','documentation','question']); export const THRESHOLDS = Object.freeze({minimumSimilarity:0.40, minimumMargin:0.04, duplicateSimilarity:0.83}); export function normalizeIssues(value) { const raw = Array.isArray(value) ? value : value?.issues; if (!Array.isArray(raw) || raw.length === 0 || raw.length > 100) throw new Error('Provide between 1 and 100 issues.'); const seen = new Set(); const issues = raw.map((item) => { if (!item || typeof item !== 'object' || Array.isArray(item)) throw new Error('Each issue must be an object.'); const id = item.id ?? item.number; if (!Number.isSafeInteger(id) || id < 1 || seen.has(id)) throw new Error('Issue IDs must be unique positive safe integers.'); seen.add(id); if (typeof item.title !== 'string' || !item.title.trim() || item.title.length > 300) throw new Error('Every issue needs a title of 1–300 characters.'); if (item.body != null && typeof item.body !== 'string') throw new Error('Issue bodies must be text or null.'); const body = item.body ?? ''; if (body.length > 12000) throw new Error('Issue bodies must be at most 12,000 characters.'); if (item.labels != null && !Array.isArray(item.labels)) throw new Error('Labels must be an array.'); const labels = (item.labels ?? []).map(label => typeof label === 'string' ? label : label?.name); if (labels.length > 30 || labels.some(label => typeof label !== 'string' || label.length > 100)) throw new Error('Invalid existing labels.'); return {id,title:item.title.trim(),body,labels:[...new Set(labels)]}; }); if (JSON.stringify(issues).length > 256000) throw new Error('The complete export must be at most 256,000 characters.'); return issues; } export function cosine(a,b) { if (!a.length || a.length !== b.length) throw new Error('Mismatched embedding dimensions.'); let dot=0,na=0,nb=0; for(let i=0;i({...reference,similarity:cosine(vector,referenceVectors[index])})); const ranked=LABELS.map(label=>{ const matches=scores.filter(x=>x.label===label).sort((a,b)=>b.similarity-a.similarity); return {label,score:(matches[0].similarity+matches[1].similarity)/2,evidence:matches[0]}; }).sort((a,b)=>b.score-a.score || a.label.localeCompare(b.label)); const top=ranked[0],margin=top.score-ranked[1].score; const needsReview=top.score({label,score}))}; } export function suggest(issues,vectors,references,referenceVectors) { return issues.map((issue,index)=>{ let duplicateOf=null,duplicateSimilarity=0,nearestCandidateId=null,nearestCandidateSimilarity=-1; for(let other=0;othernearestCandidateSimilarity){nearestCandidateId=issues[other].id;nearestCandidateSimilarity=similarity;} } // Suggest only an earlier canonical issue to avoid cycles and duplicate pair counts. for(let other=0;other=THRESHOLDS.duplicateSimilarity&&similarity>duplicateSimilarity){duplicateOf=issues[other].id;duplicateSimilarity=similarity;} } return {...issue,...rankIssue(vectors[index],references,referenceVectors),duplicateOf,duplicateSimilarity,nearestCandidateId,nearestCandidateSimilarity:nearestCandidateId===null?null:nearestCandidateSimilarity}; }); } export function digest(value) {return createHash('sha256').update(JSON.stringify(value)).digest('hex');} export function applyDecisions(issues,decisions) { if(!Array.isArray(decisions)||!decisions.length||decisions.length>issues.length)throw new Error('Select at least one issue.'); const ids=new Set(issues.map(x=>x.id)),seen=new Set(); for(const decision of decisions){ if(!decision||!ids.has(decision.id)||seen.has(decision.id)||!LABELS.includes(decision.label))throw new Error('Every decision needs a unique current issue and a supported label.'); seen.add(decision.id); } const selected=new Map(decisions.map(x=>[x.id,x.label])); return issues.map(issue=>({...issue,labels:selected.has(issue.id)?[...new Set([...issue.labels,selected.get(issue.id)])]:[...issue.labels]})); } export function keywordBaseline(issue) { const text=(issue.title+' '+issue.body).toLowerCase(); const words={bug:['crash','error','exception','broken','fail','wrong','incorrect','regression'],feature:['add','feature','enhancement','implement','support','new'],documentation:['readme','document','guide','tutorial','manual','typo'],question:['how','where','which','what','help','?']}; const scores=LABELS.map(label=>({label,score:words[label].reduce((n,word)=>n+Number(text.includes(word)),0)})).sort((a,b)=>b.score-a.score); return scores[0].score ? scores[0].label : 'unsure'; }