feat(qa): sonar triage --drain with delivery check and one resend

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-06 11:31:55 +02:00
parent 402fd52f40
commit ccbd834eea
4 changed files with 73 additions and 0 deletions

View File

@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Added
- **Sonar triage `--drain`:** works through open agent issues one at a time, fires the multica webhook, waits for the agent's answer on GitHub and resends once if nothing arrives; stops on a stalled issue.
- **Sonar triage can fire a multica webhook autopilot** after each GitHub issue (URL from `~/.config/multica-webhook` or `MULTICA_WEBHOOK_URL`, passed to curl on stdin only); the multica CLI stays as fallback.
### 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.

View File

@ -35,6 +35,9 @@ Preferred: a multica **webhook autopilot** (Autopilots -> New autopilot -> Trigg
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.
## --drain (one issue at a time, with delivery check)
`npm run sonar:triage -- --apply --drain [--lane refactor] [--timeout 60] [--resends 1]` takes the open GitHub issues labelled `sonar` + `agent-fix` (or `agent-refactor`), oldest first, and for each one: fires the webhook, then polls GitHub every minute until the agent answers (a new comment, the label `fixed-local`, or the issue closed). If nothing arrives within `--timeout` minutes it resends once (`--resends`), and if it still stays silent it STOPS and reports the stalled issue, so nothing else queues behind it. Reason: multica has accepted webhook calls (HTTP 200) without creating an issue, so a 200 is not proof of delivery. One issue at a time also respects the agents' concurrency of 1.
## --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.

View File

@ -179,6 +179,43 @@ function ghIssues(groups, apply, cap, log, lane = 'fix') {
return done;
}
// Work through the open agent issues ONE AT A TIME: fire the webhook, wait for the agent to answer (comment, label or close),
// resend at most `resends` times when nothing arrives within `timeoutMin`, then stop and report. A webhook 200 alone proves nothing:
// multica has been seen to accept a call without creating an issue.
export async function drain(log, { lane = 'fix', timeoutMin = 60, resends = 1, pollSec = 60, deps = {} } = {}) {
const sh = deps.run || run;
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
const hook = deps.webhookUrl ? deps.webhookUrl() : webhookUrl(process.env, undefined, lane);
const post = deps.postWebhook || postWebhook;
if (!hook) { log('no webhook configured for this lane'); return { done: [], stalled: null }; }
const label = lane === 'refactor' ? 'agent-refactor' : 'agent-fix';
const listed = sh('gh', ['issue', 'list', '--state', 'open', '--label', 'sonar', '--label', label, '--limit', '100', '--json', 'number,title,body,url']);
if (listed.status !== 0) { log('gh issue list failed'); return { done: [], stalled: null }; }
const todo = JSON.parse(listed.stdout).sort((x, y) => x.number - y.number);
const done = [];
const view = (n) => JSON.parse(sh('gh', ['issue', 'view', String(n), '--json', 'state,comments,labels']).stdout || '{}');
for (const it of todo) {
const base = (view(it.number).comments || []).length;
let sent = 0, answered = false;
while (!answered && sent <= resends) {
const w = post(hook, { source: 'sonar-triage', title: it.title, github_issue_url: it.url, body: it.body });
sent++;
log(`#${it.number}: webhook ${w.ok ? 'HTTP ' + w.code : 'FAILED'} (send ${sent}/${resends + 1})`);
const deadline = Date.now() + timeoutMin * 60000;
while (Date.now() < deadline) {
await sleep(pollSec * 1000);
const v = view(it.number);
if (v.state === 'CLOSED' || (v.comments || []).length > base || (v.labels || []).some((l) => l.name === 'fixed-local')) { answered = true; break; }
}
if (!answered) log(`#${it.number}: no answer within ${timeoutMin} min`);
}
if (!answered) { log(`#${it.number}: stalled after ${sent} send(s); stopping so nothing else queues behind it.`); return { done, stalled: it.number }; }
log(`#${it.number}: answered`);
done.push(it.number);
}
return { done, stalled: null };
}
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; }
@ -205,9 +242,16 @@ export async function main(argv, deps = {}) {
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]; 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 === '--drain') a.drain = true;
else if (f === '--timeout') a.timeout = +argv[++i];
else if (f === '--resends') a.resends = +argv[++i];
else if (f === '--exclude-rules') a.exclude = (argv[++i] || '').split(',').filter(Boolean);
else throw new Error(`unknown flag ${f}`);
}
if (a.drain) {
if (!a.apply) { log('--drain needs --apply (it fires webhooks).'); return {}; }
return drain(log, { lane: a.lane || 'fix', timeoutMin: a.timeout || 60, resends: a.resends ?? 1, deps: deps.drainDeps });
}
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');

View File

@ -110,3 +110,28 @@ test('refactor lane: own webhook, one group per function with location in the ti
assert.equal(g.length, 2);
assert.equal(g[0].title, '[sonar] python:S3776: complexity 23 -> 15 in core/a.py:10');
});
test('drain: one issue at a time, resends once when nothing arrives, stops on a stalled issue', async () => {
const { drain } = await import('../scripts/sonar_triage.mjs');
const issues = [{ number: 8, title: 't8', body: 'b', url: 'u8' }, { number: 9, title: 't9', body: 'b', url: 'u9' }];
const mkRun = (answerAfter) => {
const polls = {};
return (cmd, args) => {
if (args[1] === 'list') return { status: 0, stdout: JSON.stringify(issues) };
const n = Number(args[2]); polls[n] = (polls[n] || 0) + 1;
const closed = polls[n] > (answerAfter[n] ?? 1e9);
return { status: 0, stdout: JSON.stringify({ state: closed ? 'CLOSED' : 'OPEN', comments: [], labels: [] }) };
};
};
const posts = [];
const base = { webhookUrl: () => 'https://example.org/h', sleep: async () => {}, postWebhook: (u, p) => { posts.push(p.github_issue_url); return { ok: true, code: 200 }; } };
const logs = []; const log = (m) => logs.push(m);
// both answer: sent once each, in order
let r = await drain(log, { timeoutMin: 0.0001, pollSec: 0, deps: { ...base, run: mkRun({ 8: 2, 9: 2 }) } });
assert.deepEqual(posts, ['u8', 'u9']); assert.deepEqual(r.done, [8, 9]); assert.equal(r.stalled, null);
// #8 never answers: sent twice (one resend), then stops and never touches #9
posts.length = 0;
r = await drain(log, { timeoutMin: 0.0001, pollSec: 0, resends: 1, deps: { ...base, run: mkRun({}) } });
assert.deepEqual(posts, ['u8', 'u8']); assert.equal(r.stalled, 8); assert.deepEqual(r.done, []);
assert.ok(!logs.join('\n').includes('https://example.org/h'));
});