feat(qa): group sonar issues per rule
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
98a87077d9
commit
220f5fc1ec
@ -6,6 +6,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
---
|
---
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Changed
|
||||||
|
- QA: the Sonar triage script groups medium/large findings per rule instead of per (rule, file): one GitHub issue per rule with a file/line table (max 40 rows, 'and N more'; split into 'part n/m' beyond 40 locations); dedupe marker still carries all Sonar keys.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Conversation Playground: the round microphone button is red (pressed, "Stop microphone") while the mic is hot (live or recording) and blue ("Start microphone") when off; it returns to blue on stop, auto-stop and mic errors. The pulse ring only animates when reduced motion is not requested.
|
- Conversation Playground: the round microphone button is red (pressed, "Stop microphone") while the mic is hot (live or recording) and blue ("Start microphone") when off; it returns to blue on stop, auto-stop and mic errors. The pulse ring only animates when reduced motion is not requested.
|
||||||
- Conversation Playground: the metrics (Token Usage and STT / LLM first token / TTS first audio TTFB charts) now sit in the right-hand Latency column below Turn history instead of a second "Metrics" tab; the Conversation | Metrics tablist is removed. Charts gain axis titles ("Time (ms)", "Time"), a legend and three y-ticks.
|
- Conversation Playground: the metrics (Token Usage and STT / LLM first token / TTS first audio TTFB charts) now sit in the right-hand Latency column below Turn history instead of a second "Metrics" tab; the Conversation | Metrics tablist is removed. Charts gain axis titles ("Time (ms)", "Time"), a legend and three y-ticks.
|
||||||
@ -15,7 +18,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.
|
- 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
|
### 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`.
|
- 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/line table, max 40 locations, larger rules split into 'part n/m') 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: 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ SonarQube /api/issues/search (GET, paged, OPEN/CONFIRMED/REOPENED)
|
|||||||
+----+-----------------------------+
|
+----+-----------------------------+
|
||||||
SMALL MEDIUM / LARGE
|
SMALL MEDIUM / LARGE
|
||||||
| (--apply --fix-small) | (--apply)
|
| (--apply --fix-small) | (--apply)
|
||||||
task file -> `claude -p` group by (rule, file) -> `gh issue create`
|
task file -> `claude -p` group by rule (<=40 locations) -> `gh issue create`
|
||||||
-> ONE local commit (labels sonar, agent-fix; dedupe marker)
|
-> ONE local commit (labels sonar, agent-fix; dedupe marker)
|
||||||
`fix(sonar): ...` |
|
`fix(sonar): ...` |
|
||||||
`multica issue create --assignee sonar-fixer`
|
`multica issue create --assignee sonar-fixer`
|
||||||
@ -28,7 +28,7 @@ SonarQube /api/issues/search (GET, paged, OPEN/CONFIRMED/REOPENED)
|
|||||||
`--dry-run` (default) | `--apply` | `--since-new-code` (default) | `--all` | `--json <file>` | `--max-issues N` | `--cap N` (GitHub issues per run, default 10) | `--fix-small` (needs `--apply`) | `--list-rules`.
|
`--dry-run` (default) | `--apply` | `--since-new-code` (default) | `--all` | `--json <file>` | `--max-issues N` | `--cap N` (GitHub issues per run, default 10) | `--fix-small` (needs `--apply`) | `--list-rules`.
|
||||||
|
|
||||||
## GitHub issues
|
## GitHub issues
|
||||||
One per (rule, file); title `[sonar] <rule>: <short> in <file>`; body holds Sonar keys, lines, rule link, effort, approach, acceptance. Hidden marker `<!-- sonar-keys: k1,k2 -->`; 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.
|
One per rule (all files); title `[sonar] <rule>: <short> in N file(s)`; body has a table of file, line, Sonar key, message (capped at 40 rows, then 'and N more'), rule link, effort, approach, acceptance. A rule with more than 40 locations is split into numbered parts (`part 1/3`), 40 locations each. Hidden marker `<!-- sonar-keys: k1,k2 -->` lists every key of the part; 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
|
## multica hookup
|
||||||
After a GitHub issue is created the script runs `multica issue create --project <id> --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.
|
After a GitHub issue is created the script runs `multica issue create --project <id> --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.
|
||||||
|
|||||||
@ -73,25 +73,39 @@ export function triage(issues, cfg) {
|
|||||||
|
|
||||||
const countBy = (xs, f) => xs.reduce((m, x) => (m[f(x)] = (m[f(x)] || 0) + 1, m), {});
|
const countBy = (xs, f) => xs.reduce((m, x) => (m[f(x)] = (m[f(x)] || 0) + 1, m), {});
|
||||||
|
|
||||||
export function groupIssues(items) {
|
export const MAX_LOCATIONS = 40;
|
||||||
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); }
|
// One group per rule; a rule with more than `size` locations is split into numbered parts.
|
||||||
return [...g.values()].map((is) => {
|
export function groupIssues(items, size = MAX_LOCATIONS) {
|
||||||
const keys = is.map((i) => i.key).sort();
|
const byRule = new Map();
|
||||||
return { rule: is[0].rule, path: is[0].path, issues: is, keys, effort: is.reduce((s, i) => s + parseEffort(i.effort), 0),
|
for (const i of items) (byRule.get(i.rule) || byRule.set(i.rule, []).get(i.rule)).push(i);
|
||||||
title: `[sonar] ${is[0].rule}: ${(is[0].message || '').slice(0, 60).replace(/\s+/g, ' ')} in ${is[0].path}`.slice(0, 200) };
|
const out = [];
|
||||||
});
|
for (const [rule, all] of byRule) {
|
||||||
|
all.sort((a, b) => a.path.localeCompare(b.path) || (a.line ?? 0) - (b.line ?? 0) || a.key.localeCompare(b.key));
|
||||||
|
const parts = Math.ceil(all.length / size);
|
||||||
|
for (let p = 0; p < parts; p++) {
|
||||||
|
const is = all.slice(p * size, (p + 1) * size);
|
||||||
|
const files = new Set(is.map((i) => i.path)).size;
|
||||||
|
const tag = parts > 1 ? ` (part ${p + 1}/${parts})` : '';
|
||||||
|
out.push({ rule, part: p + 1, parts, issues: is, files, keys: is.map((i) => i.key).sort(), effort: is.reduce((s, i) => s + parseEffort(i.effort), 0),
|
||||||
|
title: `[sonar] ${rule}: ${(is[0].message || '').slice(0, 60).replace(/\s+/g, ' ')} in ${files} file(s)${tag}`.slice(0, 200) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function issueBody(g) {
|
export function issueBody(g) {
|
||||||
|
const shown = g.issues.slice(0, MAX_LOCATIONS);
|
||||||
|
const more = g.issues.length - shown.length;
|
||||||
return [
|
return [
|
||||||
`<!-- sonar-keys: ${g.keys.join(',')} -->`,
|
`<!-- sonar-keys: ${g.keys.join(',')} -->`,
|
||||||
`Rule: [${g.rule}](${HOST}/coding_rules?open=${encodeURIComponent(g.rule)}&rule_key=${encodeURIComponent(g.rule)})`,
|
`Rule: [${g.rule}](${HOST}/coding_rules?open=${encodeURIComponent(g.rule)}&rule_key=${encodeURIComponent(g.rule)})`,
|
||||||
`File: \`${g.path}\` | Estimated effort: ${g.effort} min`,
|
`${g.parts > 1 ? `Part ${g.part}/${g.parts} | ` : ''}${g.issues.length} location(s) in ${g.files} file(s) | Estimated effort: ${g.effort} min`,
|
||||||
'', '| Sonar key | Line | Message |', '|---|---|---|',
|
'', '| File | Line | Sonar key | Message |', '|---|---|---|---|',
|
||||||
...g.issues.map((i) => `| ${i.key} | ${i.line ?? '-'} | ${(i.message || '').replace(/\|/g, '\\|')} |`),
|
...shown.map((i) => `| \`${i.path}\` | ${i.line ?? '-'} | ${i.key} | ${(i.message || '').replace(/\|/g, '\\|')} |`),
|
||||||
|
...(more > 0 ? ['', `and ${more} more (keys are in the marker above).`] : []),
|
||||||
'', '## Suggested approach',
|
'', '## Suggested approach',
|
||||||
'Read the rule description linked above, fix each listed line at the root cause, keep behaviour unchanged unless the rule demands otherwise.',
|
'Read the rule description linked above, fix each listed location at the root cause, keep behaviour unchanged unless the rule demands otherwise.',
|
||||||
'', '## Acceptance',
|
'', '## Acceptance',
|
||||||
'- A re-scan clears the listed Sonar keys.',
|
'- 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).',
|
'- 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).',
|
||||||
@ -133,7 +147,7 @@ function fixSmall(small, log) {
|
|||||||
const head = run('git', ['rev-parse', 'HEAD']).stdout.trim();
|
const head = run('git', ['rev-parse', 'HEAD']).stdout.trim();
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'sonar-small-'));
|
const dir = mkdtempSync(join(tmpdir(), 'sonar-small-'));
|
||||||
const task = join(dir, 'task.md');
|
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}`));
|
const lines = groupIssues(small).flatMap((g) => g.issues.map((i) => `- ${i.rule} ${i.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): <summary>\` ending with the line "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>". Never push. Touch no other file; if an issue needs a design decision, skip it and say so.\n`);
|
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): <summary>\` ending with the line "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>". 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 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.`);
|
const r = run('claude', ['-p', '--allowedTools', ...tools], `Read the task file ${task} and do exactly what it says.`);
|
||||||
@ -169,7 +183,7 @@ export async function main(argv, deps = {}) {
|
|||||||
if (a.json) writeFileSync(a.json, JSON.stringify(res, null, 2));
|
if (a.json) writeFileSync(a.json, JSON.stringify(res, null, 2));
|
||||||
if (deps.noActions) return res;
|
if (deps.noActions) return res;
|
||||||
const groups = groupIssues([...res.medium, ...res.large]);
|
const groups = groupIssues([...res.medium, ...res.large]);
|
||||||
log(`\nMedium+ groups (rule, file): ${groups.length}`);
|
log(`\nMedium+ groups (per rule, max 40 locations): ${groups.length}`);
|
||||||
ghIssues(groups, a.apply, a.cap, log);
|
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).'); }
|
if (a.fixSmall) { if (a.apply) fixSmall(res.small, log); else log('fix-small needs --apply (dry-run).'); }
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
@ -27,13 +27,41 @@ test('shipped config loads and skips server.py bind', () => {
|
|||||||
const c = loadConfig();
|
const c = loadConfig();
|
||||||
assert.equal(classify(mk({ rule: 'python:S104', component: 'p:server.py' }), c).cls, 'skipped');
|
assert.equal(classify(mk({ rule: 'python:S104', component: 'p:server.py' }), c).cls, 'skipped');
|
||||||
});
|
});
|
||||||
test('grouping by rule+file and body marker', () => {
|
const P = (i) => ({ ...i, path: i.component.slice(2) });
|
||||||
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) })));
|
test('grouping per rule across files, marker keeps dedupe', () => {
|
||||||
|
const g = groupIssues([mk({ key: 'b' }), mk({ key: 'a' }), mk({ key: 'c', component: 'p:core/y.py' }), mk({ key: 'd', rule: 'python:S9' })].map(P));
|
||||||
assert.equal(g.length, 2);
|
assert.equal(g.length, 2);
|
||||||
const body = issueBody(g.find((x) => x.keys.length === 2));
|
const one = g.find((x) => x.rule === 'python:S1481');
|
||||||
assert.match(body, /<!-- sonar-keys: a,b -->/);
|
assert.equal(one.files, 2);
|
||||||
|
const body = issueBody(one);
|
||||||
|
assert.match(body, /<!-- sonar-keys: a,b,c -->/);
|
||||||
|
assert.match(body, /\| `core\/y.py` \| 3 \| c \|/);
|
||||||
assert.ok(hasMarker([body], ['b']));
|
assert.ok(hasMarker([body], ['b']));
|
||||||
assert.ok(!hasMarker([body], ['zzz']));
|
assert.ok(!hasMarker([body], ['zzz']));
|
||||||
|
assert.doesNotMatch(one.title, /part/);
|
||||||
|
});
|
||||||
|
test('rule with >40 locations is split into numbered parts', () => {
|
||||||
|
const many = Array.from({ length: 85 }, (_, n) => P(mk({ key: 'k' + String(n).padStart(3, '0'), component: `p:core/f${n % 7}.py`, line: n })));
|
||||||
|
const g = groupIssues(many);
|
||||||
|
assert.deepEqual(g.map((x) => x.issues.length), [40, 40, 5]);
|
||||||
|
assert.match(g[0].title, /part 1\/3/); assert.match(g[2].title, /part 3\/3/);
|
||||||
|
assert.equal(new Set(g.flatMap((x) => x.keys)).size, 85);
|
||||||
|
assert.doesNotMatch(issueBody(g[0]), /and \d+ more/);
|
||||||
|
});
|
||||||
|
test('body caps at 40 rows with "and N more" but marker lists all keys', () => {
|
||||||
|
const g = groupIssues(Array.from({ length: 45 }, (_, n) => P(mk({ key: 'k' + String(n).padStart(2, '0'), line: n }))), 100)[0];
|
||||||
|
const body = issueBody(g);
|
||||||
|
assert.equal(body.split('\n').filter((l) => l.startsWith('| `core/x.py`')).length, 40);
|
||||||
|
assert.match(body, /and 5 more/);
|
||||||
|
assert.ok(hasMarker([body], ['k44']));
|
||||||
|
});
|
||||||
|
test('per-run cap limits created issues (dry-run)', async () => {
|
||||||
|
const out = [];
|
||||||
|
const issues = Array.from({ length: 12 }, (_, n) => mk({ key: 'z' + n, rule: 'python:R' + n }));
|
||||||
|
await main(['--dry-run', '--all'], { token: 't', cfg, log: (s) => out.push(s), fetchFn: async () => page(issues) });
|
||||||
|
const txt = out.join('\n');
|
||||||
|
assert.match(txt, /Medium\+ groups \(per rule, max 40 locations\): 12/);
|
||||||
|
assert.match(txt, /10 of \d+ to create \(cap 10\)|gh unauthenticated/);
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = (issues) => ({ ok: true, json: async () => ({ issues, paging: { total: issues.length } }) });
|
const page = (issues) => ({ ok: true, json: async () => ({ issues, paging: { total: issues.length } }) });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user