The measures step reported "Quality gate ERROR" and "Code smells 5", which is enough to notice debt and useless for clearing it. It now also prints the failing gate conditions, the open issues with their rule, file, line and effort, and the security hotspots awaiting review. #181 is why this matters. It assumed the five smells were the five eslint-plugin-sonarjs warnings, on the strength of the counts matching, and hedged that the server's rule set is not the plugin's. The hedge was right: those five warnings were fixed under #261, both workspaces lint at zero, and the analysis of the #261 merge still reported five smells and 24 minutes. They are a different five, and nothing short of the list settles which. The gate condition list matters for the same reason. "ERROR" sends a reader to a dashboard, which is the thing this script exists to avoid needing. Issues are capped at 25 rather than paged: past a couple of dozen the answer is not "read the list", and an unbounded fetch on every CI run is a cost with no reader. Verified against a stub SonarQube serving canned responses, so the parsing and formatting are exercised rather than reasoned about — failing conditions filtered from passing and NO_VALUE ones, components stripped of their project-key prefix, absent metrics rendered as a dash, and every path still exiting 0. The no-server and unreachable-server paths were exercised too; the step still cannot fail the job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
282 lines
12 KiB
JavaScript
282 lines
12 KiB
JavaScript
#!/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)}`);
|
|
});
|