#!/usr/bin/env node // Prints the project's SonarQube measures 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 gateStatus = parse(gate.body)?.projectStatus?.status; lines.push('', `Quality gate ${gateStatus ?? 'unknown'}`, ''); console.log(lines.join('\n')); } 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)}`); });