diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b48e0b..9471993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,15 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ยท versioned wi --- ## [Unreleased] +### Added +- **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. - -### 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 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. ### Fixed +- Sonar: removed 43 small Sonar findings in 20 files: unused variables and dead helper functions (frontend), unused locals and a misread comment (backend); behaviour unchanged. 7 further small findings were deliberately left (used via globals, or entangled with other dead code). - Conversation Playground: Turn history rows (#n, time, error rows) were in the DOM but squeezed to zero height by the flex column once charts filled the Latency column; the list no longer shrinks. - 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. diff --git a/architecture/sonar-triage.md b/architecture/sonar-triage.md index e152eaa..11273f3 100644 --- a/architecture/sonar-triage.md +++ b/architecture/sonar-triage.md @@ -31,6 +31,8 @@ SonarQube /api/issues/search (GET, paged, OPEN/CONFIRMED/REOPENED) One per rule (all files); title `[sonar] : 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 `` 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 +Preferred: a multica **webhook autopilot** (Autopilots -> New autopilot -> Trigger: Webhook, assignee `sonar-fixer`, output mode Create issue, project Voice Creator Clone & Design 2). Multica shows the webhook URL after the autopilot is created; it carries its own secret, so save it only in `~/.config/multica-webhook` (chmod 600) or `MULTICA_WEBHOOK_URL`, never in the repo. After each GitHub issue the script POSTs JSON (`source, rule, title, github_issue_url, files, sonar_keys, effort_min, body`) to it, passing the URL to curl on stdin only. Fallback if no webhook is configured: the CLI command below. + After a GitHub issue is created the script runs `multica issue create --project --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. ## --fix-small diff --git a/scripts/sonar_triage.mjs b/scripts/sonar_triage.mjs index d6e9783..7bdddd3 100644 --- a/scripts/sonar_triage.mjs +++ b/scripts/sonar_triage.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Local SonarQube triage loop. Dry-run by default. See architecture/sonar-triage.md. -import { readFileSync, writeFileSync, mkdtempSync, existsSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdtempSync, existsSync, rmSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -125,6 +125,23 @@ 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(); + return /^https:\/\/[^\s"]+$/.test(u) ? u : ''; +} + +export function postWebhook(url, payload) { + const dir = mkdtempSync(join(tmpdir(), 'sonar-hook-')); + const f = join(dir, 'payload.json'); + writeFileSync(f, JSON.stringify(payload), { mode: 0o600 }); + const cfg = `url = "${url}"\nrequest = "POST"\nheader = "Content-Type: application/json"\ndata-binary = "@${f}"\nsilent\nmax-time = 30\noutput = "/dev/null"\nwrite-out = "%{http_code}"\n`; + const r = spawnSync('curl', ['-K', '-'], { input: cfg, encoding: 'utf8', timeout: 40000 }); + try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp dir cleanup is best effort */ } + const code = Number((r.stdout || '').trim()) || 0; + return { ok: code >= 200 && code < 300, code }; +} + function ghIssues(groups, apply, cap, log) { 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']); @@ -139,9 +156,16 @@ function ghIssues(groups, apply, cap, log) { if (r.status !== 0) { log(` gh create failed for ${g.title}`); continue; } const url = r.stdout.trim().split('\n').pop(); log(` created ${url}`); - // Mechanism: a multica issue (assigned to the agent) that points at the GitHub issue. - const m = run('multica', ['issue', 'create', '--title', g.title, '--project', MULTICA_PROJECT, '--assignee', MULTICA_AGENT, '--description-stdin'], `GitHub issue: ${url}\n\n${issueBody(g)}`); - log(m.status === 0 ? ` multica issue assigned to ${MULTICA_AGENT}` : ` multica hookup failed (create it manually): ${(m.stderr || '').split('\n')[0]}`); + // 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(); + 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`); + } else { + const m = run('multica', ['issue', 'create', '--title', g.title, '--project', MULTICA_PROJECT, '--assignee', MULTICA_AGENT, '--description-stdin'], `GitHub issue: ${url}\n\n${issueBody(g)}`); + log(m.status === 0 ? ` multica issue assigned to ${MULTICA_AGENT}` : ` multica hookup failed (create it manually): ${(m.stderr || '').split('\n')[0]}`); + } done.push(url); } return done; diff --git a/tests/sonar_triage.test.mjs b/tests/sonar_triage.test.mjs index c6338ae..90f72c4 100644 --- a/tests/sonar_triage.test.mjs +++ b/tests/sonar_triage.test.mjs @@ -83,3 +83,11 @@ test('dry-run makes only GET requests and never prints the token', async () => { assert.ok(out.join('\n').includes('SMALL (1)')); assert.ok(!out.join('\n').includes('SECRET-TOKEN-123')); }); + +test('webhookUrl accepts only https URLs and never invents one', async () => { + const { webhookUrl } = await import('../scripts/sonar_triage.mjs'); + assert.equal(webhookUrl({ MULTICA_WEBHOOK_URL: 'https://example.org/hook/abc' }, '/nonexistent'), 'https://example.org/hook/abc'); + assert.equal(webhookUrl({ MULTICA_WEBHOOK_URL: 'http://example.org/hook' }, '/nonexistent'), ''); + assert.equal(webhookUrl({ MULTICA_WEBHOOK_URL: 'https://a b"c' }, '/nonexistent'), ''); + assert.equal(webhookUrl({}, '/nonexistent'), ''); +});