#!/usr/bin/env node // Prints the project's SonarQube measures, its failing quality gate conditions, // its open issues and its unreviewed security hotspots into the job log. // // This exists because the numbers are otherwise unreachable. The server is // SonarQube 9.9 Community: it has no Bearer auth, so the official MCP cannot // connect, and the host URL is a CI secret, so there is no way to query it from // a developer's machine either. The measures lived only on a dashboard, which // made "reduce the debt" an instruction nobody could act on without a browser // open beside them. See #261. // // The scanner masks SONAR_HOST_URL and SONAR_TOKEN in the log. Measures are not // secret, so they print fine — only the URL they came from is redacted. // // Authentication is HTTP Basic with the token as the username and an empty // password. That is the documented scheme for 9.9; the `Authorization: Bearer` // form was added later and returns 401 here. // // Runs with `if: always()` and deliberately WITHOUT `continue-on-error`. A // cleanup report that can break a build is worse than no report, so instead of // being guarded this script simply never fails: every path exits 0 and problems // are described rather than thrown. Guarding it would also oblige it to be named // in the workflow's final gate — workflowGate.test.ts asserts that pairing — and // that gate exists to fail the job, which is the opposite of what is wanted for // a report. Same shape as the summarize-jest.js steps beside it. const https = require('https'); const http = require('http'); const fs = require('fs'); const path = require('path'); const HOST = process.env.SONAR_HOST_URL; const TOKEN = process.env.SONAR_TOKEN; const PROJECT = process.argv[2] || 'redefined-designs'; // Ordered so the four things the cleanup issue asks about come first, rather // than alphabetically. Each row is [metric key, label, formatter]. const METRICS = [ ['security_hotspots', 'Security hotspots', (v) => v], ['vulnerabilities', 'Vulnerabilities', (v) => v], ['bugs', 'Bugs', (v) => v], ['duplicated_lines_density', 'Duplicated lines', (v) => `${v}%`], ['duplicated_blocks', 'Duplicated blocks', (v) => v], ['sqale_index', 'Technical debt', formatDebt], ['code_smells', 'Code smells', (v) => v], ['coverage', 'Coverage', (v) => `${v}%`], ['line_coverage', 'Line coverage', (v) => `${v}%`], ['branch_coverage', 'Branch coverage', (v) => `${v}%`], ['uncovered_lines', 'Uncovered lines', (v) => v], ['ncloc', 'Lines of code', (v) => v], ['new_coverage', 'Coverage on new code', (v) => `${v}%`], ['new_duplicated_lines_density', 'Duplication on new code', (v) => `${v}%`], ['new_code_smells', 'Code smells on new code', (v) => v] ]; /** sqale_index is minutes. Days here are Sonar's 8-hour working days. */ function formatDebt(minutes) { const total = Number(minutes); if (!Number.isFinite(total)) return String(minutes); const days = Math.floor(total / (8 * 60)); const hours = Math.floor((total % (8 * 60)) / 60); const mins = total % 60; return `${total} min (${days}d ${hours}h ${mins}m)`; } function get(url) { return new Promise((resolve) => { const client = url.startsWith('https:') ? https : http; const request = client.get( url, // The empty password is deliberate: 9.9 expects `token:` rather than a // password, and omitting the colon sends the token as a username with no // password field at all, which it rejects. { auth: `${TOKEN}:`, timeout: 30000 }, (res) => { let body = ''; res.on('data', (chunk) => (body += chunk)); res.on('end', () => resolve({ status: res.statusCode, body })); } ); request.on('timeout', () => { request.destroy(); resolve({ status: 0, body: 'timed out after 30s' }); }); request.on('error', (err) => resolve({ status: 0, body: err.message })); }); } function parse(body) { try { return JSON.parse(body); } catch { return null; } } /** * Waits for the server to finish processing this run's analysis. * * The workflow does not set `sonar.qualitygate.wait`, so the scan step returns * as soon as the report is uploaded and the server computes measures in the * background. Querying immediately therefore returns the *previous* analysis, * which would be indistinguishable from the current one and quietly wrong — the * worst kind of number to publish into a cleanup report. * * The scanner writes the task id to .scannerwork/report-task.txt. Polling it * costs a few seconds and makes the difference between "these are this commit's * numbers" and "these are probably this commit's numbers". */ async function waitForAnalysis(base) { const reportPath = path.join(process.cwd(), '.scannerwork', 'report-task.txt'); if (!fs.existsSync(reportPath)) { return 'no report-task.txt — reporting the last completed analysis, which may predate this commit'; } const taskId = /^ceTaskId=(.+)$/m.exec(fs.readFileSync(reportPath, 'utf8'))?.[1]?.trim(); if (!taskId) { return 'no ceTaskId in report-task.txt — reporting the last completed analysis'; } // Twenty attempts at three seconds. A minute is far longer than this project // takes to process and short enough not to stretch the job noticeably. for (let attempt = 0; attempt < 20; attempt++) { const res = await get(`${base}/api/ce/task?id=${encodeURIComponent(taskId)}`); const status = parse(res.body)?.task?.status; if (status === 'SUCCESS') return null; if (status === 'FAILED' || status === 'CANCELED') { return `analysis ${status} on the server — reporting the last completed analysis instead`; } await new Promise((resolve) => setTimeout(resolve, 3000)); } return 'analysis still processing after 60s — reporting the last completed analysis'; } async function main() { if (!HOST || !TOKEN) { // Not an error. A fork or a run without the secrets configured reaches this // legitimately, and saying so is more useful than a stack trace. console.log('SonarQube measures: SONAR_HOST_URL or SONAR_TOKEN not set, skipping.'); return; } const base = HOST.replace(/\/+$/, ''); const staleness = await waitForAnalysis(base); const keys = METRICS.map(([key]) => key).join(','); const measuresUrl = `${base}/api/measures/component?component=${encodeURIComponent(PROJECT)}&metricKeys=${keys}`; const res = await get(measuresUrl); if (res.status !== 200) { console.log(`SonarQube measures: request failed (status ${res.status}). ${res.body.slice(0, 300)}`); return; } const json = parse(res.body); const measures = json?.component?.measures; if (!Array.isArray(measures)) { console.log('SonarQube measures: unexpected response shape.'); console.log(res.body.slice(0, 300)); return; } // Keyed by metric so the display order above is what prints, rather than // whatever order the API happened to return. const byKey = new Map(measures.map((m) => [m.metric, m])); const lines = ['', '=== SonarQube measures ===', '']; // Said plainly rather than omitted. A reader acting on these numbers needs to // know whether they describe this commit. if (staleness) lines.push(`NOTE: ${staleness}`, ''); for (const [key, label, format] of METRICS) { const measure = byKey.get(key); // A metric can be legitimately absent — new-code metrics do not exist until // a second analysis, and branch_coverage is missing when nothing branches. if (!measure) { lines.push(`${label.padEnd(26)} —`); continue; } const value = measure.period?.value ?? measure.value; lines.push(`${label.padEnd(26)} ${format(value)}`); } const gate = await get( `${base}/api/qualitygates/project_status?projectKey=${encodeURIComponent(PROJECT)}` ); const projectStatus = parse(gate.body)?.projectStatus; lines.push('', `Quality gate ${projectStatus?.status ?? 'unknown'}`); // Which conditions failed, not just that the gate did. "ERROR" on its own // sends a reader to the dashboard, which is the thing this script exists to // avoid needing. const failed = (projectStatus?.conditions ?? []).filter( (c) => c.status !== 'OK' && c.status !== 'NO_VALUE' ); if (failed.length > 0) { lines.push('', 'Failing gate conditions:'); for (const c of failed) { lines.push( ` ${c.metricKey}: ${c.actualValue} (${c.comparator} threshold ${c.errorThreshold})` ); } } lines.push(...(await describeIssues(base))); lines.push(...(await describeHotspots(base))); lines.push(''); console.log(lines.join('\n')); } /** * The open maintainability issues, named. * * A count is enough to notice debt and useless for clearing it. #181 assumed * the five smells were the five eslint-plugin-sonarjs warnings, because the * counts matched — they were fixed, and the count stayed five, so they were a * different five. Nothing short of the list settles that. * * Capped rather than paged: past a couple of dozen the answer is not "read the * list" anyway, and an unbounded fetch on every CI run is a cost with no reader. */ async function describeIssues(base) { const res = await get( `${base}/api/issues/search?componentKeys=${encodeURIComponent(PROJECT)}` + `&resolved=false&types=CODE_SMELL,BUG,VULNERABILITY&ps=25&s=SEVERITY&asc=false` ); if (res.status !== 200) return ['', `Open issues: could not fetch (status ${res.status}).`]; const json = parse(res.body); const issues = json?.issues; if (!Array.isArray(issues)) return ['', 'Open issues: unexpected response shape.']; if (issues.length === 0) return ['', 'Open issues: none.']; const total = json.total ?? issues.length; const out = ['', `Open issues (${issues.length} of ${total}):`]; for (const issue of issues) { // The component is "projectKey:path"; the key is noise in a log line. const where = String(issue.component ?? '').split(':').slice(1).join(':') || issue.component; const at = issue.line ? `:${issue.line}` : ''; out.push(` [${issue.severity}] ${where}${at}`); out.push(` ${issue.rule} — ${issue.message}`); out.push(` effort ${issue.effort ?? issue.debt ?? 'n/a'}`); } return out; } /** * Security hotspots awaiting review. * * A hotspot is not a defect — it is a place the scanner wants a human to say * whether the surrounding code is safe. That review happens in the dashboard * and cannot be done from here, so the point of listing them is to say what is * waiting and where, rather than to fix anything. */ async function describeHotspots(base) { const res = await get( `${base}/api/hotspots/search?projectKey=${encodeURIComponent(PROJECT)}` + `&status=TO_REVIEW&ps=25` ); if (res.status !== 200) return ['', `Security hotspots: could not fetch (status ${res.status}).`]; const hotspots = parse(res.body)?.hotspots; if (!Array.isArray(hotspots)) return ['', 'Security hotspots: unexpected response shape.']; if (hotspots.length === 0) return ['', 'Security hotspots awaiting review: none.']; const out = ['', `Security hotspots awaiting review (${hotspots.length}):`]; for (const h of hotspots) { const where = String(h.component ?? '').split(':').slice(1).join(':') || h.component; const at = h.line ? `:${h.line}` : ''; out.push(` ${where}${at}`); out.push(` ${h.ruleKey ?? h.securityCategory} — ${h.message}`); } return out; } main().catch((err) => { // Reported rather than thrown, for the reason in the header: this step must // not be able to fail the job. console.log(`SonarQube measures: ${err instanceof Error ? err.message : String(err)}`); });