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:
parent
c2048ebc45
commit
2328852aab
@ -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.
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user