diff --git a/CHANGELOG.md b/CHANGELOG.md index be867de..b6e838c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ยท versioned wi - Conversation Playground: the metrics charts render at the column width (re-rendered on resize) so axis, tick and legend text is 11px instead of ~8px; a single point is centred with a larger dot. ### Added +- QA: `scripts/sonar_triage.mjs` (`npm run sonar:triage`), a local SonarQube triage loop (dry-run default): classifies findings small/medium/large/skipped via allow/skip lists, files one GitHub issue per (rule, file) with a dedupe marker, hands them to the multica `sonar-fixer` agent, and can auto-fix small ones as a local commit. See `architecture/sonar-triage.md`. - Conversation Playground: Metrics view (Conversation | Metrics tabs) with Pipecat-style TTFB line charts for STT, LLM first token and TTS, plus session Token Usage (prompt/completion/total, "n/a" when the backend reports none). The stream now requests `stream_options.include_usage` (dropped and retried on 400/422) and forwards `usage` in the final stats event; the TTS value is now the time to the first audio chunk instead of including the remaining LLM generation time. - Conversation Playground: a Thinking on/off switch next to the Language Model (default off, setting `conversation_thinking`). Off sends `chat_template_kwargs.enable_thinking=false` (plus `think:false` for Ollama) with each reply request and never touches the system prompt; the empty-reply error now says to turn Thinking off. diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 777c819..68a44de 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -94,6 +94,7 @@ which is the writable volume. | `llm_url` / `llm_model` / `llm_api_key` | settings.json, OpenAI-compatible LLM | QA: `npm run qa -- --fast` runs lint, unit, security, build checks (see `architecture/qa.md`); optional `--sonar` needs `SONAR_HOST_URL` + `SONAR_TOKEN` in the shell/CI secrets. +Sonar triage: `npm run sonar:triage` (dry-run; `architecture/sonar-triage.md`). ## Pending decisions diff --git a/architecture/sonar-triage.md b/architecture/sonar-triage.md new file mode 100644 index 0000000..60df4bc --- /dev/null +++ b/architecture/sonar-triage.md @@ -0,0 +1,54 @@ +# SonarQube triage loop + +Local script `scripts/sonar_triage.mjs` (`npm run sonar:triage`). Dry-run by default; nothing is pushed, ever. + +``` +SonarQube /api/issues/search (GET, paged, OPEN/CONFIRMED/REOPENED) + | + classify --skip-list--> SKIPPED (reported only) + | + +----+-----------------------------+ + SMALL MEDIUM / LARGE + | (--apply --fix-small) | (--apply) + task file -> `claude -p` group by (rule, file) -> `gh issue create` + -> ONE local commit (labels sonar, agent-fix; dedupe marker) + `fix(sonar): ...` | + `multica issue create --assignee sonar-fixer` + -> agent fixes, local commit only +``` + +## Classification +- SMALL: severity INFO/MINOR (or MAJOR with effort <= 5 min) AND rule in `scripts/sonar_triage.rules.json` AND file under `core/ routes/ static/js/ scripts/`. +- Never small: any VULNERABILITY; path or message mentioning auth/crypto/async/thread/lock/SQL/secret/token/password; files elsewhere (incl. tests). +- LARGE: severity BLOCKER/CRITICAL or effort > 60 min; otherwise MEDIUM. +- SKIPPED: matches an entry of `scripts/sonar_triage.skip.json` (rule, file regex, message regex; every given field must match). Entries: server.py 0.0.0.0 bind, tmp-dir dev script, Math.random ids, http:// LAN URLs, md5 cache key. +- Allowlist (each reason is in the JSON): python/javascript S1481 unused local, S1116 empty statement, javascript S1128 unused import, python/javascript S125 commented-out code. + +## Flags +`--dry-run` (default) | `--apply` | `--since-new-code` (default) | `--all` | `--json ` | `--max-issues N` | `--cap N` (GitHub issues per run, default 10) | `--fix-small` (needs `--apply`) | `--list-rules`. + +## GitHub issues +One per (rule, file); title `[sonar] : in `; body holds Sonar keys, lines, rule link, effort, approach, acceptance. Hidden marker ``; an open `sonar` issue containing any of the keys suppresses a new one. Bodies contain paths/lines only, no code. If `gh auth status` fails the script reports and continues. + +## multica hookup +After a GitHub issue is created the script runs `multica issue create --project --assignee sonar-fixer --description-stdin` (the multica issue links the GitHub URL; assigning starts the agent run). Override with `MULTICA_AGENT`, `MULTICA_PROJECT`. Failure is reported, not fatal. Agent `sonar-fixer` instructions: see the end of this file. + +## --fix-small +Refuses on a dirty tracked tree. Writes a task file to a temp dir (not committed), runs `claude -p` with only Read/Edit/Grep/Glob and Bash for check-frontend, `node --test`, git add/commit/status/diff, then verifies the new commit count and clean tree. Local commit only. + +## Secrets +The token is read inside the script from `~/.config/sonar-token` (or `SONAR_TOKEN`) and sent as HTTP Basic auth (token as user, empty password). It is never printed, put in argv/URLs or written to files; a unit test asserts it is absent from output. + +## Run +``` +npm run sonar:triage # dry-run, new code +npm run sonar:triage -- --all --json /tmp/t.json +npm run sonar:triage -- --apply --cap 5 # create GitHub + multica issues +npm run sonar:triage -- --apply --fix-small +``` + +## Rollback +Local fix commits: `git reset --hard ` / `git revert`. Issues: close the GitHub issue and the multica issue. Nothing is pushed by the script. + +## sonar-fixer agent instructions +Fix only the given Sonar issue. Read CLAUDE.md and PROJECT_MAP.md first. Run `node scripts/check-frontend.mjs` and Python `unittest` with an isolated HOME; tests mock the API and import from tests/_guard.js. One local Conventional Commit `fix(sonar): ...`; never push; never touch secrets; if a design decision is needed, stop and comment. diff --git a/package.json b/package.json index de2fc67..aba5d12 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "minify": "node scripts/minify.mjs", "check:frontend": "node scripts/check-frontend.mjs", "qa": "node scripts/qa.mjs", + "sonar:triage": "node scripts/sonar_triage.mjs", "test:unit": "node --test tests/*.test.mjs" }, "devDependencies": { diff --git a/scripts/sonar_triage.mjs b/scripts/sonar_triage.mjs new file mode 100644 index 0000000..a61da81 --- /dev/null +++ b/scripts/sonar_triage.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +// Local SonarQube triage loop. Dry-run by default. See architecture/sonar-triage.md. +import { readFileSync, writeFileSync, mkdtempSync, existsSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const HOST = process.env.SONAR_HOST_URL || 'https://sq.martin-bierschenk.de'; +const PROJECT = process.env.SONAR_PROJECT_KEY || 'mARTin-B78_tts-voice-creator-clone-and-design-2_2b78a834-b2ae-4292-9363-d41c1254cb7a'; +const MULTICA_AGENT = process.env.MULTICA_AGENT || 'sonar-fixer'; +const MULTICA_PROJECT = process.env.MULTICA_PROJECT || 'fe4b0ad0-111c-4801-a8af-2301b37f3ee6'; +const SMALL_DIRS = ['core/', 'routes/', 'static/js/', 'scripts/']; +const RISKY_PATH = /auth|crypt|async|thread|sql|secret|token|password/i; +const RISKY_MSG = /\b(await|async|thread|lock|sql|crypt\w*|hash\w*|password|token|secret)\b/i; + +export function parseEffort(s) { + if (!s) return 0; + const m = /^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)min)?$/.exec(s); + return m ? (+m[1] || 0) * 480 + (+m[2] || 0) * 60 + (+m[3] || 0) : 0; +} + +export const pathOf = (component) => component.slice(component.indexOf(':') + 1); + +export function isSkipped(issue, path, skip) { + return skip.find((s) => (!s.rule || s.rule === issue.rule) + && (!s.file || new RegExp(s.file).test(path)) + && (!s.message || new RegExp(s.message, 'i').test(issue.message || ''))); +} + +// -> {cls: small|medium|large|skipped, why} +export function classify(issue, { allow, skip }) { + const path = pathOf(issue.component); + const sk = isSkipped(issue, path, skip); + if (sk) return { cls: 'skipped', why: sk.reason || 'skip-list' }; + const effort = parseEffort(issue.effort); + const big = effort > 60 || ['BLOCKER', 'CRITICAL'].includes(issue.severity); + const never = issue.type === 'VULNERABILITY' ? 'vulnerability' + : !SMALL_DIRS.some((d) => path.startsWith(d)) ? 'outside core/routes/static/js/scripts' + : RISKY_PATH.test(path) || RISKY_MSG.test(issue.message || '') ? 'touches auth/crypto/async/threading/SQL' : null; + const sevOk = ['INFO', 'MINOR'].includes(issue.severity) || (issue.severity === 'MAJOR' && effort <= 5); + if (!never && sevOk && allow.has(issue.rule)) return { cls: 'small', why: 'allowlisted mechanical rule' }; + return { cls: big ? 'large' : 'medium', why: never || (allow.has(issue.rule) ? 'severity/effort above small' : 'rule not allowlisted') }; +} + +export function loadConfig(dir = __dirname_()) { + const rd = (f) => JSON.parse(readFileSync(join(dir, f), 'utf8')); + return { allow: new Set(Object.keys(rd('sonar_triage.rules.json').rules)), skip: rd('sonar_triage.skip.json').skip }; +} +function __dirname_() { return dirname(fileURLToPath(import.meta.url)); } + +export async function fetchIssues({ fetchFn = fetch, token, newCode = true, max = Infinity }) { + const auth = 'Basic ' + Buffer.from(`${token}:`).toString('base64'); // token as username, empty password + const out = []; + for (let p = 1; out.length < max; p++) { + const q = new URLSearchParams({ componentKeys: PROJECT, statuses: 'OPEN,CONFIRMED,REOPENED', types: 'BUG,VULNERABILITY,CODE_SMELL', ps: '500', p: String(p) }); + if (newCode) q.set('inNewCodePeriod', 'true'); + const r = await fetchFn(`${HOST}/api/issues/search?${q}`, { headers: { Authorization: auth }, signal: AbortSignal.timeout(30000) }); + if (!r.ok) throw new Error(`SonarQube HTTP ${r.status}`); + const j = await r.json(); + out.push(...j.issues); + if (p * 500 >= j.paging.total) break; + } + return out.slice(0, max); +} + +export function triage(issues, cfg) { + const res = { small: [], medium: [], large: [], skipped: [] }; + for (const i of issues) { const c = classify(i, cfg); res[c.cls].push({ ...i, path: pathOf(i.component), why: c.why }); } + return res; +} + +const countBy = (xs, f) => xs.reduce((m, x) => (m[f(x)] = (m[f(x)] || 0) + 1, m), {}); + +export function groupIssues(items) { + const g = new Map(); + for (const i of items) { const k = `${i.rule}\u0000${i.path}`; (g.get(k) || g.set(k, []).get(k)).push(i); } + return [...g.values()].map((is) => { + const keys = is.map((i) => i.key).sort(); + return { rule: is[0].rule, path: is[0].path, issues: is, keys, effort: is.reduce((s, i) => s + parseEffort(i.effort), 0), + title: `[sonar] ${is[0].rule}: ${(is[0].message || '').slice(0, 60).replace(/\s+/g, ' ')} in ${is[0].path}`.slice(0, 200) }; + }); +} + +export function issueBody(g) { + return [ + ``, + `Rule: [${g.rule}](${HOST}/coding_rules?open=${encodeURIComponent(g.rule)}&rule_key=${encodeURIComponent(g.rule)})`, + `File: \`${g.path}\` | Estimated effort: ${g.effort} min`, + '', '| Sonar key | Line | Message |', '|---|---|---|', + ...g.issues.map((i) => `| ${i.key} | ${i.line ?? '-'} | ${(i.message || '').replace(/\|/g, '\\|')} |`), + '', '## Suggested approach', + 'Read the rule description linked above, fix each listed line at the root cause, keep behaviour unchanged unless the rule demands otherwise.', + '', '## Acceptance', + '- A re-scan clears the listed Sonar keys.', + '- Follow CLAUDE.md and PROJECT_MAP.md; `node scripts/check-frontend.mjs` and the relevant unit tests pass (tests mock the API, import from tests/_guard.js).', + '- LOCAL commit only (`fix(sonar): ...`), never push; stop and comment if a design decision is needed.', + ].join('\n'); +} + +export function hasMarker(existingBodies, keys) { + return existingBodies.some((b) => { const m = //.exec(b || ''); return m && keys.some((k) => m[1].split(',').includes(k)); }); +} + +const run = (cmd, args, input) => spawnSync(cmd, args, { input, encoding: 'utf8', cwd: ROOT, timeout: 600000 }); + +function ghIssues(groups, apply, cap, log) { + if (run('gh', ['auth', 'status']).status !== 0) { log('gh unauthenticated: skipping GitHub issues (run `gh auth login`).'); return []; } + const list = run('gh', ['issue', 'list', '--state', 'open', '--label', 'sonar', '--limit', '300', '--json', 'number,body']); + const bodies = list.status === 0 ? JSON.parse(list.stdout).map((x) => x.body) : []; + const todo = groups.filter((g) => !hasMarker(bodies, g.keys)); + log(`GitHub: ${groups.length - todo.length} duplicate(s) skipped, ${Math.min(todo.length, cap)} of ${todo.length} to create (cap ${cap}).`); + const done = []; + if (apply) for (const l of ['sonar', 'agent-fix']) run('gh', ['label', 'create', l, '--color', '5319e7', '--description', 'SonarQube triage']); + for (const g of todo.slice(0, cap)) { + if (!apply) { log(` [dry-run] would create: ${g.title}`); continue; } + const r = run('gh', ['issue', 'create', '--title', g.title, '--label', 'sonar', '--label', 'agent-fix', '--body-file', '-'], issueBody(g)); + if (r.status !== 0) { log(` gh create failed for ${g.title}`); continue; } + const url = r.stdout.trim().split('\n').pop(); + log(` created ${url}`); + // Mechanism: a multica issue (assigned to the agent) that points at the GitHub issue. + const m = run('multica', ['issue', 'create', '--title', g.title, '--project', MULTICA_PROJECT, '--assignee', MULTICA_AGENT, '--description-stdin'], `GitHub issue: ${url}\n\n${issueBody(g)}`); + log(m.status === 0 ? ` multica issue assigned to ${MULTICA_AGENT}` : ` multica hookup failed (create it manually): ${(m.stderr || '').split('\n')[0]}`); + done.push(url); + } + return done; +} + +function fixSmall(small, log) { + const dirty = () => run('git', ['status', '--porcelain', '--untracked-files=no']).stdout.trim(); + if (dirty()) { log('fix-small: refusing, tracked working tree is dirty.'); return; } + const head = run('git', ['rev-parse', 'HEAD']).stdout.trim(); + const dir = mkdtempSync(join(tmpdir(), 'sonar-small-')); + const task = join(dir, 'task.md'); + const lines = groupIssues(small).flatMap((g) => g.issues.map((i) => `- ${i.rule} ${g.path}:${i.line ?? '?'} (${i.key}): ${i.message}`)); + writeFileSync(task, `Fix ONLY these SonarQube issues, changing behaviour nowhere. Follow CLAUDE.md.\n${lines.join('\n')}\n\nThen run \`node scripts/check-frontend.mjs\` and \`node --test tests/*.test.mjs\`. If they pass, make exactly ONE local commit \`fix(sonar): \` ending with the line "Co-Authored-By: Claude Sonnet 5.5 ". Never push. Touch no other file; if an issue needs a design decision, skip it and say so.\n`); + const tools = ['Read', 'Edit', 'Grep', 'Glob', 'Bash(node scripts/check-frontend.mjs)', 'Bash(node --test:*)', 'Bash(git add:*)', 'Bash(git commit:*)', 'Bash(git status:*)', 'Bash(git diff:*)']; + const r = run('claude', ['-p', '--allowedTools', ...tools], `Read the task file ${task} and do exactly what it says.`); + if (r.status !== 0) { log(`fix-small: headless run failed; task file kept at ${task}. Manual: claude -p "do ${task}"`); return; } + const n = run('git', ['rev-list', '--count', `${head}..HEAD`]).stdout.trim(); + log(`fix-small: ${n} new local commit(s); tree ${dirty() ? 'DIRTY (review!)' : 'clean'}. Not pushed.`); +} + +export async function main(argv, deps = {}) { + const log = deps.log || console.log; + const a = { apply: false, newCode: true, max: Infinity, json: null, fixSmall: false, list: false, cap: 10 }; + for (let i = 0; i < argv.length; i++) { + const f = argv[i]; + if (f === '--apply') a.apply = true; else if (f === '--dry-run') a.apply = false; + else if (f === '--all') a.newCode = false; else if (f === '--since-new-code') a.newCode = true; + else if (f === '--fix-small') a.fixSmall = true; else if (f === '--list-rules') a.list = true; + else if (f === '--json') a.json = argv[++i]; else if (f === '--max-issues') a.max = +argv[++i]; + else if (f === '--cap') a.cap = +argv[++i]; + else throw new Error(`unknown flag ${f}`); + } + const cfg = deps.cfg || loadConfig(); + if (a.list) { log('Allowlisted (small) rules:'); for (const r of cfg.allow) log(` ${r}`); log('Skip-list:'); for (const s of cfg.skip) log(` ${s.rule || '*'} ${s.file || ''} ${s.message || ''} -- ${s.reason}`); return; } + const tokFile = join(homedir(), '.config', 'sonar-token'); + const token = deps.token || process.env.SONAR_TOKEN || (existsSync(tokFile) ? readFileSync(tokFile, 'utf8').trim() : ''); + if (!token) throw new Error('no token: put it in ~/.config/sonar-token (or SONAR_TOKEN)'); + const issues = await fetchIssues({ fetchFn: deps.fetchFn, token, newCode: a.newCode, max: a.max }); + const res = triage(issues, cfg); + log(`Mode: ${a.apply ? 'APPLY' : 'dry-run'} | scope: ${a.newCode ? 'new code' : 'all'} | issues: ${issues.length}`); + for (const c of ['small', 'medium', 'large', 'skipped']) { + log(`\n${c.toUpperCase()} (${res[c].length})`); + for (const [r, n] of Object.entries(countBy(res[c], (i) => i.rule)).sort((x, y) => y[1] - x[1])) log(` ${String(n).padStart(4)} ${r}`); + } + if (a.json) writeFileSync(a.json, JSON.stringify(res, null, 2)); + if (deps.noActions) return res; + const groups = groupIssues([...res.medium, ...res.large]); + log(`\nMedium+ groups (rule, file): ${groups.length}`); + ghIssues(groups, a.apply, a.cap, log); + if (a.fixSmall) { if (a.apply) fixSmall(res.small, log); else log('fix-small needs --apply (dry-run).'); } + return res; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(process.argv.slice(2)).catch((e) => { console.error(`sonar_triage: ${e.message}`); process.exit(1); }); +} diff --git a/scripts/sonar_triage.rules.json b/scripts/sonar_triage.rules.json new file mode 100644 index 0000000..5f2ef98 --- /dev/null +++ b/scripts/sonar_triage.rules.json @@ -0,0 +1,12 @@ +{ + "_doc": "Allowlist of Sonar rules whose fix is purely mechanical and cannot change behaviour. Only these may be auto-fixed (--fix-small). Everything else becomes a GitHub issue.", + "rules": { + "python:S1481": "Unused local variable: remove the binding but keep the right-hand expression if it has side effects.", + "javascript:S1481": "Unused local variable: remove the binding but keep any side-effecting right-hand expression.", + "python:S1116": "Empty statement (stray semicolon): deleting it is a no-op.", + "javascript:S1116": "Empty statement / extra semicolon: deleting it is a no-op.", + "javascript:S1128": "Unused import in an ES module: remove it (verify it is not imported for side effects).", + "python:S125": "Commented-out code: delete the comment lines; no runtime effect.", + "javascript:S125": "Commented-out code: delete the comment lines; no runtime effect." + } +} diff --git a/scripts/sonar_triage.skip.json b/scripts/sonar_triage.skip.json new file mode 100644 index 0000000..5d606b9 --- /dev/null +++ b/scripts/sonar_triage.skip.json @@ -0,0 +1,12 @@ +{ + "_doc": "Findings the owner reviewed and accepted (false positives / deliberate). Each entry matches when ALL given fields match: rule (exact), file (regex on repo path), message (regex, case-insensitive).", + "skip": [ + {"rule": "python:S104", "file": "^server\\.py$", "reason": "server.py binds 0.0.0.0 on purpose: Docker container reached over the LAN."}, + {"file": "^server\\.py$", "message": "0\\.0\\.0\\.0|all network interfaces", "reason": "Same: deliberate container bind (rule key differs between analyzer versions)."}, + {"rule": "python:S5443", "file": "^scripts/", "reason": "Dev-only script writes to the system tmp dir."}, + {"rule": "javascript:S2245", "file": "^static/js/", "reason": "Math.random is used for non-security UI ids."}, + {"rule": "python:S5332", "reason": "http:// URLs are LAN-internal services (no TLS inside the LAN)."}, + {"rule": "javascript:S5332", "reason": "http:// URLs are LAN-internal services (no TLS inside the LAN)."}, + {"rule": "python:S4790", "reason": "md5 is used as a cache key, not for security."} + ] +} diff --git a/tests/sonar_triage.test.mjs b/tests/sonar_triage.test.mjs new file mode 100644 index 0000000..81cf3c2 --- /dev/null +++ b/tests/sonar_triage.test.mjs @@ -0,0 +1,57 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { classify, parseEffort, groupIssues, hasMarker, issueBody, fetchIssues, main, loadConfig } from '../scripts/sonar_triage.mjs'; + +const cfg = { allow: new Set(['python:S1481']), skip: [{ rule: 'javascript:S2245', file: '^static/js/', reason: 'ids' }] }; +const mk = (o) => ({ key: 'K' + Math.random(), rule: 'python:S1481', severity: 'MINOR', type: 'CODE_SMELL', effort: '2min', component: 'p:core/x.py', line: 3, message: 'm', ...o }); + +test('effort parsing', () => { assert.equal(parseEffort('1h30min'), 90); assert.equal(parseEffort('5min'), 5); assert.equal(parseEffort(undefined), 0); }); +test('small: allowlisted + minor in core', () => assert.equal(classify(mk(), cfg).cls, 'small')); +test('small: MAJOR only with effort <= 5min', () => { + assert.equal(classify(mk({ severity: 'MAJOR' }), cfg).cls, 'small'); + assert.equal(classify(mk({ severity: 'MAJOR', effort: '10min' }), cfg).cls, 'medium'); +}); +test('not allowlisted -> medium; critical or >60min -> large', () => { + assert.equal(classify(mk({ rule: 'python:S9' }), cfg).cls, 'medium'); + assert.equal(classify(mk({ rule: 'python:S9', severity: 'CRITICAL' }), cfg).cls, 'large'); + assert.equal(classify(mk({ rule: 'python:S9', effort: '2h' }), cfg).cls, 'large'); +}); +test('never small: vulnerability, outside dirs, risky path/message', () => { + assert.notEqual(classify(mk({ type: 'VULNERABILITY' }), cfg).cls, 'small'); + assert.notEqual(classify(mk({ component: 'p:tests/a.py' }), cfg).cls, 'small'); + assert.notEqual(classify(mk({ component: 'p:core/auth.py' }), cfg).cls, 'small'); + assert.notEqual(classify(mk({ message: 'unused var in await call' }), cfg).cls, 'small'); +}); +test('skip list wins', () => assert.equal(classify(mk({ rule: 'javascript:S2245', component: 'p:static/js/a.js' }), cfg).cls, 'skipped')); +test('shipped config loads and skips server.py bind', () => { + const c = loadConfig(); + assert.equal(classify(mk({ rule: 'python:S104', component: 'p:server.py' }), c).cls, 'skipped'); +}); +test('grouping by rule+file and body marker', () => { + const g = groupIssues([mk({ key: 'b' }), mk({ key: 'a' }), mk({ key: 'c', component: 'p:core/y.py' })].map((i) => ({ ...i, path: i.component.slice(2) }))); + assert.equal(g.length, 2); + const body = issueBody(g.find((x) => x.keys.length === 2)); + assert.match(body, //); + assert.ok(hasMarker([body], ['b'])); + assert.ok(!hasMarker([body], ['zzz'])); +}); + +const page = (issues) => ({ ok: true, json: async () => ({ issues, paging: { total: issues.length } }) }); + +test('fetch sends token as basic username, new-code filter', async () => { + let seen; + await fetchIssues({ token: 'SECRET', fetchFn: async (u, o) => { seen = { u, o }; return page([]); } }); + assert.equal(seen.o.headers.Authorization, 'Basic ' + Buffer.from('SECRET:').toString('base64')); + assert.match(seen.u, /inNewCodePeriod=true/); + assert.doesNotMatch(seen.u, /SECRET/); +}); +test('dry-run makes only GET requests and never prints the token', async () => { + const methods = []; const out = []; + await main(['--dry-run', '--all'], { + token: 'SECRET-TOKEN-123', cfg, noActions: true, log: (s) => out.push(s), + fetchFn: async (u, o) => { methods.push(o.method || 'GET'); return page([mk(), mk({ rule: 'python:S9' })]); }, + }); + assert.deepEqual([...new Set(methods)], ['GET']); + assert.ok(out.join('\n').includes('SMALL (1)')); + assert.ok(!out.join('\n').includes('SECRET-TOKEN-123')); +});