feat(qa): refactor lane for cognitive-complexity findings (own webhook, one issue per function)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-06 04:19:27 +02:00
parent c2048ebc45
commit 2328852aab
3 changed files with 43 additions and 11 deletions

View File

@ -16,6 +16,13 @@
## Lanes
Only one agent may edit the tree at a time. The batch driver runs the `sonar-fixer` lane first; the refactor lane is started only when no `sonar` issue is open and the tracked tree is clean. Each lane's issues carry a label: `agent-fix` (fixer) or `agent-refactor` (refactorer).
## Running the lane
```
npm run sonar:triage -- --all --lane refactor # dry-run: lists the next 3 functions
npm run sonar:triage -- --all --lane refactor --apply # files 3 issues, fires the refactor webhook
```
`--lane refactor` selects only `python:S3776` and `javascript:S3776`, groups one issue per function, labels them `sonar` and `agent-refactor`, defaults the cap to 3 and posts to the URL in `~/.config/multica-webhook-refactor` (or `MULTICA_WEBHOOK_REFACTOR_URL`). Start it only when no `sonar` issue is open and the tracked tree is clean.
## Issue shape for this lane
One GitHub issue per function (one Sonar location), not per rule: a complexity refactor is reviewed and reverted per function. Title `[sonar] <rule>: complexity N -> 15 in <file>:<function>`. Cap: 3 issues per batch.

View File

@ -74,6 +74,7 @@ export function triage(issues, cfg) {
const countBy = (xs, f) => xs.reduce((m, x) => (m[f(x)] = (m[f(x)] || 0) + 1, m), {});
export const MAX_LOCATIONS = 40;
export const REFACTOR_RULES = new Set(['python:S3776', 'javascript:S3776']); // cognitive complexity: own lane and agent
// One group per rule; a rule with more than `size` locations is split into numbered parts.
export function groupIssues(items, size = MAX_LOCATIONS) {
@ -88,7 +89,9 @@ export function groupIssues(items, size = MAX_LOCATIONS) {
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) });
title: (size === 1
? `[sonar] ${rule}: complexity ${(/from (\d+)/.exec(is[0].message || '') || [])[1] || '?'} -> 15 in ${is[0].path}:${is[0].line ?? '?'}`
: `[sonar] ${rule}: ${(is[0].message || '').slice(0, 60).replace(/\s+/g, ' ')} in ${files} file(s)${tag}`).slice(0, 200) });
}
}
// Most valuable first, so a capped run files bugs before style findings.
@ -126,8 +129,10 @@ export function hasMarker(existingBodies, keys) {
const run = (cmd, args, input) => spawnSync(cmd, args, { input, encoding: 'utf8', cwd: ROOT, timeout: 600000 });
// The webhook URL carries its own secret: read it from env/file, pass it to curl on stdin only (never argv, never logged).
export function webhookUrl(env = process.env, file = join(homedir(), '.config', 'multica-webhook')) {
const u = (env.MULTICA_WEBHOOK_URL || (existsSync(file) ? readFileSync(file, 'utf8') : '')).trim();
export function webhookUrl(env = process.env, file, lane = 'fix') {
const refactor = lane === 'refactor';
file = file || join(homedir(), '.config', refactor ? 'multica-webhook-refactor' : 'multica-webhook');
const u = (env[refactor ? 'MULTICA_WEBHOOK_REFACTOR_URL' : 'MULTICA_WEBHOOK_URL'] || (existsSync(file) ? readFileSync(file, 'utf8') : '')).trim();
return /^https:\/\/[^\s"]+$/.test(u) ? u : '';
}
@ -142,7 +147,8 @@ export function postWebhook(url, payload) {
return { ok: code >= 200 && code < 300, code };
}
function ghIssues(groups, apply, cap, log) {
function ghIssues(groups, apply, cap, log, lane = 'fix') {
const agentLabel = lane === 'refactor' ? 'agent-refactor' : 'agent-fix';
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']);
// closed fixed-local issues also suppress (Sonar keys stay open until a re-scan)
@ -151,16 +157,16 @@ function ghIssues(groups, apply, cap, log) {
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']);
if (apply) for (const l of ['sonar', agentLabel]) 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));
const r = run('gh', ['issue', 'create', '--title', g.title, '--label', 'sonar', '--label', agentLabel, '--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 1: a multica autopilot webhook (URL is a secret: env MULTICA_WEBHOOK_URL or ~/.config/multica-webhook).
// Mechanism 2 (fallback): the multica CLI, which refuses to run in an agent context.
const hook = webhookUrl();
const hook = webhookUrl(process.env, undefined, lane);
if (hook) {
const w = postWebhook(hook, { source: 'sonar-triage', rule: g.rule, title: g.title, github_issue_url: url, files: g.files, sonar_keys: g.keys, effort_min: g.effort, body: issueBody(g) });
log(w.ok ? ` multica autopilot fired (HTTP ${w.code})` : ` multica webhook failed (HTTP ${w.code || 'none'}): assign the issue manually`);
@ -197,7 +203,8 @@ export async function main(argv, deps = {}) {
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 if (f === '--cap') { a.cap = +argv[++i]; a.capSet = true; }
else if (f === '--lane') { a.lane = argv[++i]; if (!['fix', 'refactor'].includes(a.lane)) throw new Error('--lane must be fix or refactor'); }
else if (f === '--exclude-rules') a.exclude = (argv[++i] || '').split(',').filter(Boolean);
else throw new Error(`unknown flag ${f}`);
}
@ -215,9 +222,15 @@ export async function main(argv, deps = {}) {
}
if (a.json) writeFileSync(a.json, JSON.stringify(res, null, 2));
if (deps.noActions) return res;
const groups = groupIssues([...res.medium, ...res.large]).filter((g) => !(a.exclude || []).includes(g.rule));
log(`\nMedium+ groups (per rule, max 40 locations): ${groups.length}`);
ghIssues(groups, a.apply, a.cap, log);
const lane = a.lane || 'fix';
// refactor lane: cognitive-complexity findings only, one GitHub issue per function (reviewed and reverted per function), cap 3.
const all = [...res.medium, ...res.large];
const groups = (lane === 'refactor'
? groupIssues(all.filter((i) => REFACTOR_RULES.has(i.rule)), 1)
: groupIssues(all)).filter((g) => !(a.exclude || []).includes(g.rule));
const cap = lane === 'refactor' && !a.capSet ? 3 : a.cap;
log(`\n${lane === 'refactor' ? 'Refactor' : 'Medium+'} groups (${lane === 'refactor' ? 'one per function' : 'per rule, max 40 locations'}): ${groups.length}`);
ghIssues(groups, a.apply, cap, log, lane);
if (a.fixSmall) { if (a.apply) fixSmall(res.small, log); else log('fix-small needs --apply (dry-run).'); }
return res;
}

View File

@ -98,3 +98,15 @@ test('webhookUrl accepts only https URLs and never invents one', async () => {
assert.equal(webhookUrl({ MULTICA_WEBHOOK_URL: 'https://a b"c' }, '/nonexistent'), '');
assert.equal(webhookUrl({}, '/nonexistent'), '');
});
test('refactor lane: own webhook, one group per function with location in the title', async () => {
const { webhookUrl, groupIssues, REFACTOR_RULES } = await import('../scripts/sonar_triage.mjs');
assert.equal(webhookUrl({ MULTICA_WEBHOOK_REFACTOR_URL: 'https://example.org/r' }, '/nonexistent', 'refactor'), 'https://example.org/r');
assert.equal(webhookUrl({ MULTICA_WEBHOOK_URL: 'https://example.org/f' }, '/nonexistent', 'refactor'), '');
assert.equal(webhookUrl({ MULTICA_WEBHOOK_URL: 'https://example.org/f' }, '/nonexistent'), 'https://example.org/f');
assert.ok(REFACTOR_RULES.has('python:S3776') && REFACTOR_RULES.has('javascript:S3776'));
const mkc = (path, line) => ({ key: path + line, rule: 'python:S3776', severity: 'CRITICAL', type: 'CODE_SMELL', effort: '10min', path, line, message: 'Refactor this function to reduce its Cognitive Complexity from 23 to the 15 allowed.' });
const g = groupIssues([mkc('core/a.py', 10), mkc('core/a.py', 99)], 1);
assert.equal(g.length, 2);
assert.equal(g[0].title, '[sonar] python:S3776: complexity 23 -> 15 in core/a.py:10');
});