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:
parent
402fd52f40
commit
ccbd834eea
@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### 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.
|
- **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
|
### 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.
|
- 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.
|
||||||
|
|||||||
@ -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.
|
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
|
## --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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@ -179,6 +179,43 @@ function ghIssues(groups, apply, cap, log, lane = 'fix') {
|
|||||||
return done;
|
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) {
|
function fixSmall(small, log) {
|
||||||
const dirty = () => run('git', ['status', '--porcelain', '--untracked-files=no']).stdout.trim();
|
const dirty = () => run('git', ['status', '--porcelain', '--untracked-files=no']).stdout.trim();
|
||||||
if (dirty()) { log('fix-small: refusing, tracked working tree is dirty.'); return; }
|
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 === '--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 === '--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 === '--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 if (f === '--exclude-rules') a.exclude = (argv[++i] || '').split(',').filter(Boolean);
|
||||||
else throw new Error(`unknown flag ${f}`);
|
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();
|
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; }
|
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 tokFile = join(homedir(), '.config', 'sonar-token');
|
||||||
|
|||||||
@ -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.length, 2);
|
||||||
assert.equal(g[0].title, '[sonar] python:S3776: complexity 23 -> 15 in core/a.py:10');
|
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'));
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user