| 1 | #!/usr/bin/env node |
| 2 | // Backfill area/platform/severity labels on existing open issues via the DeepSeek |
| 3 | // API — the one-off companion to .github/workflows/issue-auto-label.yml (which |
| 4 | // only fires on new issues). Keep the label sets and prompt in sync with that |
| 5 | // workflow. GitHub access uses the local `gh` CLI (must be authenticated). |
| 6 | // |
| 7 | // Usage: |
| 8 | // DEEPSEEK_API_KEY=... node scripts/backfill-issue-labels.mjs [options] |
| 9 | // Options: |
| 10 | // --dry-run print what would change, apply nothing |
| 11 | // --only-unlabeled skip issues that already have an area label |
| 12 | // --limit N process at most N issues (default 200) |
| 13 | |
| 14 | import { execFileSync } from 'node:child_process'; |
| 15 | |
| 16 | const args = process.argv.slice(2); |
| 17 | const dryRun = args.includes('--dry-run'); |
| 18 | const onlyUnlabeled = args.includes('--only-unlabeled'); |
| 19 | const li = args.indexOf('--limit'); |
| 20 | const limit = li >= 0 ? parseInt(args[li + 1], 10) : 200; |
| 21 | |
| 22 | const KEY = process.env.DEEPSEEK_API_KEY; |
| 23 | if (!KEY) { |
| 24 | console.error('DEEPSEEK_API_KEY is not set'); |
| 25 | process.exit(1); |
| 26 | } |
| 27 | |
| 28 | const AREA = ['agent', 'mcp', 'config', 'updater', 'provider', 'desktop', 'tui', 'skills', 'rendering']; |
| 29 | const PLATFORM = ['windows', 'macos', 'linux']; |
| 30 | const SEVERITY = ['crash', 'data-loss', 'security']; |
| 31 | const ALLOWED = new Set([...AREA, ...PLATFORM, ...SEVERITY]); |
| 32 | |
| 33 | const SYSTEM = [ |
| 34 | 'You categorize GitHub issues for Reasonix, a Go-based AI coding agent with a Wails desktop app and a terminal UI.', |
| 35 | 'Pick labels ONLY from these fixed sets. Never invent labels.', |
| 36 | 'area (0-2, the affected subsystem):', |
| 37 | ' agent: core agent loop / tool-calling / reasoning', |
| 38 | ' mcp: MCP servers and plugins', |
| 39 | ' config: configuration, setup wizard, .toml/.env', |
| 40 | ' updater: auto-update, installer, release packaging', |
| 41 | ' provider: model providers, model selection/switching', |
| 42 | ' desktop: Wails desktop GUI', |
| 43 | ' tui: terminal UI / CLI', |
| 44 | ' skills: skills system', |
| 45 | ' rendering: terminal rendering / flicker / repaint', |
| 46 | 'platform (only if clearly specific to one OS): windows, macos, linux', |
| 47 | 'severity (only if clearly applicable):', |
| 48 | ' crash: app crashes, hangs, or freezes', |
| 49 | ' data-loss: loss of sessions, config, or history', |
| 50 | ' security: credential/secret exposure or a security flaw', |
| 51 | 'Be conservative: omit a label when unsure. The issue may be in Chinese.', |
| 52 | 'Reply with JSON only: {"area":[],"platform":[],"severity":[]}', |
| 53 | ].join('\n'); |
| 54 | |
| 55 | function gh(args) { |
| 56 | return execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); |
| 57 | } |
| 58 | |
| 59 | async function classify(title, body) { |
| 60 | const res = await fetch('https://api.deepseek.com/chat/completions', { |
| 61 | method: 'POST', |
| 62 | headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }, |
| 63 | body: JSON.stringify({ |
| 64 | model: 'deepseek-chat', |
| 65 | temperature: 0, |
| 66 | response_format: { type: 'json_object' }, |
| 67 | messages: [ |
| 68 | { role: 'system', content: SYSTEM }, |
| 69 | { role: 'user', content: `Title: ${title}\n\nBody:\n${body}` }, |
| 70 | ], |
| 71 | }), |
| 72 | }); |
| 73 | if (!res.ok) throw new Error(`DeepSeek API ${res.status}: ${await res.text()}`); |
| 74 | const data = await res.json(); |
| 75 | const parsed = JSON.parse(data.choices[0].message.content); |
| 76 | return [...(parsed.area || []), ...(parsed.platform || []), ...(parsed.severity || [])].filter((l) => ALLOWED.has(l)); |
| 77 | } |
| 78 | |
| 79 | const issues = JSON.parse( |
| 80 | gh(['issue', 'list', '--state', 'open', '--limit', String(limit), '--json', 'number,title,body,labels']), |
| 81 | ); |
| 82 | console.log(`${issues.length} open issues; dryRun=${dryRun} onlyUnlabeled=${onlyUnlabeled}`); |
| 83 | |
| 84 | let changed = 0; |
| 85 | for (const it of issues) { |
| 86 | const existing = it.labels.map((l) => l.name); |
| 87 | if (onlyUnlabeled && existing.some((l) => AREA.includes(l))) { |
| 88 | continue; |
| 89 | } |
| 90 | let labels; |
| 91 | try { |
| 92 | labels = await classify(it.title, (it.body || '').slice(0, 4000)); |
| 93 | } catch (e) { |
| 94 | console.warn(`#${it.number}: classify failed: ${e.message}`); |
| 95 | continue; |
| 96 | } |
| 97 | if (!labels.some((l) => AREA.includes(l))) labels.push('needs-triage'); |
| 98 | const toAdd = labels.filter((l) => !existing.includes(l)); |
| 99 | if (!toAdd.length) { |
| 100 | console.log(`#${it.number}: nothing new`); |
| 101 | continue; |
| 102 | } |
| 103 | if (dryRun) { |
| 104 | console.log(`#${it.number}: would add ${toAdd.join(', ')} — ${it.title.slice(0, 50)}`); |
| 105 | } else { |
| 106 | gh(['issue', 'edit', String(it.number), ...toAdd.flatMap((l) => ['--add-label', l])]); |
| 107 | console.log(`#${it.number}: +${toAdd.join(', ')}`); |
| 108 | } |
| 109 | changed++; |
| 110 | } |
| 111 | console.log(`Done. ${changed} issue(s) ${dryRun ? 'would be' : ''} updated.`); |
| 112 |