chore(sonar): name the findings instead of counting them (#181)

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>
This commit is contained in:
2026-09-02 17:21:44 -05:00
co-authored by Claude Opus 5
parent 15beda1f0b
commit d75c45cf91
+88 -3
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
// Prints the project's SonarQube measures into the job log.
// 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
@@ -183,12 +184,96 @@ async function main() {
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'}`, '');
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.