@jonathan0823/pi-sonarqube 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -3
- package/dist/api.d.ts +39 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +443 -0
- package/dist/api.js.map +1 -0
- package/dist/commands.d.ts +69 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +248 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +275 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -54
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +315 -975
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +92 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +40 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +234 -0
- package/dist/ui.js.map +1 -0
- package/package.json +1 -1
- package/src/api.ts +587 -0
- package/src/commands.ts +314 -0
- package/src/config.ts +336 -0
- package/src/index.ts +477 -1262
- package/src/types.ts +112 -0
- package/src/ui.ts +320 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
SonarIssue,
|
|
6
|
+
SonarAnalysisState,
|
|
7
|
+
SonarProjectConfig,
|
|
8
|
+
SonarIssueFetchOptions,
|
|
9
|
+
SonarDuplicationMeasures,
|
|
10
|
+
IssueSeverityCounts,
|
|
11
|
+
FileDuplication,
|
|
12
|
+
DuplicationBlockGroup,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
import { SONAR_SEVERITIES, SONAR_STATUSES, SONAR_TYPES } from "./types.js";
|
|
15
|
+
import { parseProperties } from "./config.js";
|
|
16
|
+
|
|
17
|
+
// ── Auth / HTTP helpers ─────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export function authHeader(token?: string): string | undefined {
|
|
20
|
+
if (!token) return undefined;
|
|
21
|
+
const credentials = `${token}:`;
|
|
22
|
+
return `Basic ${Buffer.from(credentials).toString("base64")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function fetchJson<T>(url: string, token?: string, signal?: AbortSignal): Promise<T> {
|
|
26
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
27
|
+
const auth = authHeader(token);
|
|
28
|
+
if (auth) headers.Authorization = auth;
|
|
29
|
+
|
|
30
|
+
const response = await fetch(url, { headers, signal });
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
const body = await response.text().catch(() => "");
|
|
33
|
+
const suffix = body.trim() ? `: ${body.trim().slice(0, 240)}` : "";
|
|
34
|
+
throw new Error(`SonarQube API request failed (${response.status} ${response.statusText}) for ${url}${suffix}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (await response.json()) as T;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function toIssuePath(component: string, projectKey: string): string {
|
|
41
|
+
const prefix = `${projectKey}:`;
|
|
42
|
+
if (component.startsWith(prefix)) return component.slice(prefix.length);
|
|
43
|
+
const colon = component.indexOf(":");
|
|
44
|
+
return colon >= 0 ? component.slice(colon + 1) : component;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Issue filter helpers ────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export function normalizeIssueList(values?: string[], uppercase = false): string[] | undefined {
|
|
50
|
+
const cleaned = values
|
|
51
|
+
?.map((v) => v.trim())
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.map((v) => (uppercase ? v.toUpperCase() : v));
|
|
54
|
+
if (!cleaned?.length) return undefined;
|
|
55
|
+
return [...new Set(cleaned)];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function normalizeIssueFilters(filters?: SonarIssueFetchOptions): SonarIssueFetchOptions | undefined {
|
|
59
|
+
const normalized = {
|
|
60
|
+
severities: normalizeIssueList(filters?.severities, true),
|
|
61
|
+
statuses: normalizeIssueList(filters?.statuses, true),
|
|
62
|
+
types: normalizeIssueList(filters?.types, true),
|
|
63
|
+
rules: normalizeIssueList(filters?.rules),
|
|
64
|
+
};
|
|
65
|
+
return normalized.severities || normalized.statuses || normalized.types || normalized.rules ? normalized : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const EXPLICIT_FILTER_RE = /^([^:=]+)[:=](.+)$/;
|
|
69
|
+
|
|
70
|
+
export function parseIssueFilterToken(token: string): Partial<SonarIssueFetchOptions> | undefined {
|
|
71
|
+
const trimmed = token.trim();
|
|
72
|
+
if (!trimmed) return undefined;
|
|
73
|
+
|
|
74
|
+
const explicit = EXPLICIT_FILTER_RE.exec(trimmed);
|
|
75
|
+
if (explicit) {
|
|
76
|
+
const key = explicit[1].trim().toLowerCase();
|
|
77
|
+
const values = explicit[2].split(",").map((p) => p.trim()).filter(Boolean);
|
|
78
|
+
if (values.length === 0) return undefined;
|
|
79
|
+
|
|
80
|
+
if (key === "severity" || key === "severities") return { severities: values.map((v) => v.toUpperCase()) };
|
|
81
|
+
if (key === "status" || key === "statuses") return { statuses: values.map((v) => v.toUpperCase()) };
|
|
82
|
+
if (key === "type" || key === "types") return { types: values.map((v) => v.toUpperCase()) };
|
|
83
|
+
if (key === "rule" || key === "rules") return { rules: values };
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const upper = trimmed.toUpperCase();
|
|
88
|
+
if (SONAR_SEVERITIES.includes(upper as (typeof SONAR_SEVERITIES)[number])) return { severities: [upper] };
|
|
89
|
+
if (SONAR_STATUSES.includes(upper as (typeof SONAR_STATUSES)[number])) return { statuses: [upper] };
|
|
90
|
+
if (SONAR_TYPES.includes(upper as (typeof SONAR_TYPES)[number])) return { types: [upper] };
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseSonarIssueArgs(
|
|
95
|
+
tokens: string[],
|
|
96
|
+
allowIssueIndex = false,
|
|
97
|
+
): { targetInput?: string; issueIndex?: number; filters?: SonarIssueFetchOptions } {
|
|
98
|
+
const filters: Array<Partial<SonarIssueFetchOptions> | undefined> = [];
|
|
99
|
+
let targetInput: string | undefined;
|
|
100
|
+
let issueIndex: number | undefined;
|
|
101
|
+
|
|
102
|
+
for (const token of tokens) {
|
|
103
|
+
if (!token) continue;
|
|
104
|
+
if (allowIssueIndex && issueIndex === undefined && /^\d+$/.test(token)) {
|
|
105
|
+
issueIndex = Number(token);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const parsedFilter = parseIssueFilterToken(token);
|
|
110
|
+
if (parsedFilter) {
|
|
111
|
+
filters.push(parsedFilter);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!targetInput) {
|
|
116
|
+
targetInput = token;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { targetInput, issueIndex, filters: mergeFilters(...filters) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mergeFilters(
|
|
124
|
+
...filters: Array<Partial<SonarIssueFetchOptions> | undefined>
|
|
125
|
+
): SonarIssueFetchOptions | undefined {
|
|
126
|
+
const merged: SonarIssueFetchOptions = {};
|
|
127
|
+
for (const filter of filters) {
|
|
128
|
+
if (!filter) continue;
|
|
129
|
+
if (filter.severities?.length) merged.severities = [...(merged.severities ?? []), ...filter.severities];
|
|
130
|
+
if (filter.statuses?.length) merged.statuses = [...(merged.statuses ?? []), ...filter.statuses];
|
|
131
|
+
if (filter.types?.length) merged.types = [...(merged.types ?? []), ...filter.types];
|
|
132
|
+
if (filter.rules?.length) merged.rules = [...(merged.rules ?? []), ...filter.rules];
|
|
133
|
+
}
|
|
134
|
+
return normalizeIssueFilters(merged);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function issueFilterLabel(filters?: SonarIssueFetchOptions): string {
|
|
138
|
+
if (!filters) return "";
|
|
139
|
+
const parts = [
|
|
140
|
+
filters.severities?.length ? `severities=${filters.severities.join(",")}` : "",
|
|
141
|
+
filters.statuses?.length ? `statuses=${filters.statuses.join(",")}` : "",
|
|
142
|
+
filters.types?.length ? `types=${filters.types.join(",")}` : "",
|
|
143
|
+
filters.rules?.length ? `rules=${filters.rules.join(",")}` : "",
|
|
144
|
+
].filter(Boolean);
|
|
145
|
+
return parts.join(" • ");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function severitySortRank(severity: string): number {
|
|
149
|
+
switch (severity.toUpperCase()) {
|
|
150
|
+
case "BLOCKER":
|
|
151
|
+
return 0;
|
|
152
|
+
case "CRITICAL":
|
|
153
|
+
return 1;
|
|
154
|
+
case "MAJOR":
|
|
155
|
+
return 2;
|
|
156
|
+
case "MINOR":
|
|
157
|
+
return 3;
|
|
158
|
+
case "INFO":
|
|
159
|
+
return 4;
|
|
160
|
+
default:
|
|
161
|
+
return 5;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function compareIssuesForContext(left: SonarIssue, right: SonarIssue): number {
|
|
166
|
+
const severityDiff = severitySortRank(left.severity) - severitySortRank(right.severity);
|
|
167
|
+
if (severityDiff !== 0) return severityDiff;
|
|
168
|
+
const fileDiff = left.filePath.localeCompare(right.filePath);
|
|
169
|
+
if (fileDiff !== 0) return fileDiff;
|
|
170
|
+
const leftLine = left.line ?? Number.POSITIVE_INFINITY;
|
|
171
|
+
const rightLine = right.line ?? Number.POSITIVE_INFINITY;
|
|
172
|
+
if (leftLine !== rightLine) return leftLine - rightLine;
|
|
173
|
+
return left.message.localeCompare(right.message);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Error hints ─────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
export function sonarScannerInstallHint(): string {
|
|
179
|
+
return [
|
|
180
|
+
"SonarScanner is not installed or not on PATH.",
|
|
181
|
+
"Please install SonarScanner and make sure `sonar-scanner` is available, then retry.",
|
|
182
|
+
].join(" ");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function extractScannerHint(output: string, config: SonarProjectConfig, exitCode?: number): string {
|
|
186
|
+
const lines = output.toLowerCase();
|
|
187
|
+
|
|
188
|
+
const isNotFoundOnPath =
|
|
189
|
+
exitCode === 127 ||
|
|
190
|
+
/spawn .*enoent|enoent|command not found|is not recognized/i.test(lines);
|
|
191
|
+
if (isNotFoundOnPath) {
|
|
192
|
+
return sonarScannerInstallHint();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const isUnauthorized = /status code 401|status 401|unauthorized/i.test(lines);
|
|
196
|
+
if (isUnauthorized) {
|
|
197
|
+
const defaultHint = config.serverUrl === "http://localhost:9000" ? " (default: http://localhost:9000)" : "";
|
|
198
|
+
return [
|
|
199
|
+
`Authentication failed for ${config.serverUrl}${defaultHint}.`,
|
|
200
|
+
"Run `/sonarqube init` to set up your server URL and token, then retry.",
|
|
201
|
+
"Or set SONARQUBE_TOKEN in your environment.",
|
|
202
|
+
].join(" ");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const isForbidden = /status code 403|status 403|forbidden/i.test(lines);
|
|
206
|
+
if (isForbidden) {
|
|
207
|
+
return [
|
|
208
|
+
`Access denied by ${config.serverUrl}.`,
|
|
209
|
+
'Your token may not have the required permissions ("Execute Analysis").',
|
|
210
|
+
"Run `/sonarqube init` to update the token or check server permissions.",
|
|
211
|
+
].join(" ");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const isConnectionError = /connect econnrefused|connect refused|connect timeout|enotfound|econnreset/i.test(lines);
|
|
215
|
+
if (isConnectionError) {
|
|
216
|
+
return [
|
|
217
|
+
`Cannot reach SonarQube server at ${config.serverUrl}.`,
|
|
218
|
+
"Is the server running? Run `/sonarqube init` to check the server URL.",
|
|
219
|
+
"Default: http://localhost:9000",
|
|
220
|
+
].join(" ");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (/not found|unknown url/i.test(lines) && (config.serverUrl === "http://localhost:9000" || /localhost/i.test(config.serverUrl))) {
|
|
224
|
+
return [
|
|
225
|
+
`SonarQube API at ${config.serverUrl} returned "not found".`,
|
|
226
|
+
"The default server URL is http://localhost:9000. Run `/sonarqube init` to configure.",
|
|
227
|
+
].join(" ");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Scanner interaction ─────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
export async function runScanner(pi: ExtensionAPI, config: SonarProjectConfig, signal?: AbortSignal): Promise<string> {
|
|
236
|
+
const scannerCheck =
|
|
237
|
+
process.platform === "win32"
|
|
238
|
+
? await pi.exec("cmd", ["/d", "/s", "/c", "where", "sonar-scanner"], {
|
|
239
|
+
cwd: config.baseDir,
|
|
240
|
+
signal,
|
|
241
|
+
timeout: 10_000,
|
|
242
|
+
})
|
|
243
|
+
: await pi.exec("sh", ["-lc", "command -v sonar-scanner"], {
|
|
244
|
+
cwd: config.baseDir,
|
|
245
|
+
signal,
|
|
246
|
+
timeout: 10_000,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (scannerCheck.code !== 0 || !scannerCheck.stdout.trim()) {
|
|
250
|
+
throw new Error(sonarScannerInstallHint());
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const args = [
|
|
254
|
+
`-Dsonar.projectKey=${config.projectKey}`,
|
|
255
|
+
`-Dsonar.projectBaseDir=${config.baseDir}`,
|
|
256
|
+
`-Dsonar.host.url=${config.serverUrl}`,
|
|
257
|
+
...(config.hasProperties ? [] : [`-Dsonar.sources=.`]),
|
|
258
|
+
`-Dsonar.scm.disabled=true`,
|
|
259
|
+
];
|
|
260
|
+
if (config.token) {
|
|
261
|
+
args.push(`-Dsonar.token=${config.token}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const result = await pi.exec("sonar-scanner", args, {
|
|
265
|
+
cwd: config.baseDir,
|
|
266
|
+
signal,
|
|
267
|
+
timeout: 30 * 60 * 1000,
|
|
268
|
+
});
|
|
269
|
+
if (result.code !== 0) {
|
|
270
|
+
const output = [result.stdout, result.stderr]
|
|
271
|
+
.filter(Boolean)
|
|
272
|
+
.join("\n")
|
|
273
|
+
.trim();
|
|
274
|
+
const hint = extractScannerHint(output, config, result.code);
|
|
275
|
+
const detail = output ? `\n\n${output.slice(-4000)}` : "";
|
|
276
|
+
const message = hint
|
|
277
|
+
? `SonarQube scan failed for ${config.projectKey}. ${hint}`
|
|
278
|
+
: `SonarQube scan failed for ${config.projectKey}${detail}`;
|
|
279
|
+
throw new Error(message);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return result.stdout || result.stderr;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function readReportTask(baseDir: string): Promise<Record<string, string>> {
|
|
286
|
+
const path = resolve(baseDir, ".scannerwork", "report-task.txt");
|
|
287
|
+
const text = await readFile(path, "utf8");
|
|
288
|
+
return parseProperties(text);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function waitForAnalysis(
|
|
292
|
+
serverUrl: string,
|
|
293
|
+
token: string | undefined,
|
|
294
|
+
ceTaskUrl: string,
|
|
295
|
+
signal?: AbortSignal,
|
|
296
|
+
): Promise<string | undefined> {
|
|
297
|
+
for (let attempt = 0; attempt < 120; attempt++) {
|
|
298
|
+
const result = await fetchJson<{
|
|
299
|
+
task: { status: string; analysisId?: string; errorMessage?: string };
|
|
300
|
+
}>(ceTaskUrl, token, signal);
|
|
301
|
+
const status = result.task.status.toUpperCase();
|
|
302
|
+
if (status === "SUCCESS") return result.task.analysisId;
|
|
303
|
+
if (status === "FAILED") {
|
|
304
|
+
throw new Error(`SonarQube analysis failed: ${result.task.errorMessage || "unknown error"}`);
|
|
305
|
+
}
|
|
306
|
+
if (status === "CANCELED") {
|
|
307
|
+
throw new Error("SonarQube analysis was canceled by the server");
|
|
308
|
+
}
|
|
309
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
310
|
+
}
|
|
311
|
+
throw new Error(`Timed out waiting for SonarQube analysis at ${serverUrl}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Issue fetching ──────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
function buildIssueSearchUrl(
|
|
317
|
+
serverUrl: string,
|
|
318
|
+
projectKey: string,
|
|
319
|
+
page: number,
|
|
320
|
+
filters?: SonarIssueFetchOptions,
|
|
321
|
+
): string {
|
|
322
|
+
const url = new URL("/api/issues/search", serverUrl);
|
|
323
|
+
url.searchParams.set("projects", projectKey);
|
|
324
|
+
url.searchParams.set("resolved", "false");
|
|
325
|
+
url.searchParams.set("ps", "100");
|
|
326
|
+
url.searchParams.set("p", String(page));
|
|
327
|
+
if (filters?.severities?.length) url.searchParams.set("severities", filters.severities.join(","));
|
|
328
|
+
if (filters?.statuses?.length) url.searchParams.set("statuses", filters.statuses.join(","));
|
|
329
|
+
if (filters?.types?.length) url.searchParams.set("types", filters.types.join(","));
|
|
330
|
+
if (filters?.rules?.length) url.searchParams.set("rules", filters.rules.join(","));
|
|
331
|
+
return url.toString();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function mapRawIssue(
|
|
335
|
+
raw: {
|
|
336
|
+
key: string;
|
|
337
|
+
rule: string;
|
|
338
|
+
severity: string;
|
|
339
|
+
message: string;
|
|
340
|
+
component: string;
|
|
341
|
+
line?: number;
|
|
342
|
+
status?: string;
|
|
343
|
+
},
|
|
344
|
+
projectKey: string,
|
|
345
|
+
ruleNames: Map<string, string | undefined>,
|
|
346
|
+
): SonarIssue {
|
|
347
|
+
return {
|
|
348
|
+
key: raw.key,
|
|
349
|
+
rule: raw.rule,
|
|
350
|
+
ruleName: ruleNames.get(raw.rule),
|
|
351
|
+
severity: raw.severity,
|
|
352
|
+
message: raw.message,
|
|
353
|
+
filePath: toIssuePath(raw.component, projectKey),
|
|
354
|
+
line: typeof raw.line === "number" ? raw.line : null,
|
|
355
|
+
status: raw.status,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function fetchIssuePage(
|
|
360
|
+
serverUrl: string,
|
|
361
|
+
token: string | undefined,
|
|
362
|
+
projectKey: string,
|
|
363
|
+
page: number,
|
|
364
|
+
signal?: AbortSignal,
|
|
365
|
+
filters?: SonarIssueFetchOptions,
|
|
366
|
+
): Promise<{ total: number; issueRows: Array<{ key: string; rule: string; severity: string; message: string; component: string; line?: number; status?: string }> }> {
|
|
367
|
+
const url = buildIssueSearchUrl(serverUrl, projectKey, page, filters);
|
|
368
|
+
const result = await fetchJson<{
|
|
369
|
+
total: number;
|
|
370
|
+
issues: Array<{
|
|
371
|
+
key: string;
|
|
372
|
+
rule: string;
|
|
373
|
+
severity: string;
|
|
374
|
+
message: string;
|
|
375
|
+
component: string;
|
|
376
|
+
line?: number;
|
|
377
|
+
status?: string;
|
|
378
|
+
}>;
|
|
379
|
+
}>(url, token, signal);
|
|
380
|
+
return { total: result.total, issueRows: result.issues };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function fetchIssues(
|
|
384
|
+
serverUrl: string,
|
|
385
|
+
token: string | undefined,
|
|
386
|
+
projectKey: string,
|
|
387
|
+
signal?: AbortSignal,
|
|
388
|
+
filters?: SonarIssueFetchOptions,
|
|
389
|
+
): Promise<SonarIssue[]> {
|
|
390
|
+
const normalizedFilters = normalizeIssueFilters(filters);
|
|
391
|
+
const issues: SonarIssue[] = [];
|
|
392
|
+
const ruleNames = new Map<string, string | undefined>();
|
|
393
|
+
let page = 1;
|
|
394
|
+
let total = 0;
|
|
395
|
+
|
|
396
|
+
for (;;) {
|
|
397
|
+
const { total: pageTotal, issueRows } = await fetchIssuePage(
|
|
398
|
+
serverUrl, token, projectKey, page, signal, normalizedFilters,
|
|
399
|
+
);
|
|
400
|
+
total = pageTotal;
|
|
401
|
+
|
|
402
|
+
for (const raw of issueRows) {
|
|
403
|
+
if (!ruleNames.has(raw.rule)) {
|
|
404
|
+
ruleNames.set(raw.rule, await fetchRuleName(serverUrl, token, raw.rule, signal));
|
|
405
|
+
}
|
|
406
|
+
issues.push(mapRawIssue(raw, projectKey, ruleNames));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (issues.length >= total || issueRows.length === 0) break;
|
|
410
|
+
page += 1;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (normalizedFilters && issues.length > 1) {
|
|
414
|
+
issues.sort(compareIssuesForContext);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return issues;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function fetchRuleName(
|
|
421
|
+
serverUrl: string,
|
|
422
|
+
token: string | undefined,
|
|
423
|
+
ruleKey: string,
|
|
424
|
+
signal?: AbortSignal,
|
|
425
|
+
): Promise<string | undefined> {
|
|
426
|
+
try {
|
|
427
|
+
const result = await fetchJson<{ rule: { name?: string } }>(
|
|
428
|
+
`${serverUrl}/api/rules/show?key=${encodeURIComponent(ruleKey)}`,
|
|
429
|
+
token,
|
|
430
|
+
signal,
|
|
431
|
+
);
|
|
432
|
+
return result.rule.name;
|
|
433
|
+
} catch {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Analysis state ──────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
export function createAnalysisState(
|
|
441
|
+
config: Pick<SonarProjectConfig, "baseDir" | "serverUrl" | "projectKey">,
|
|
442
|
+
issues: SonarIssue[],
|
|
443
|
+
extras: Partial<Pick<SonarAnalysisState, "dashboardUrl" | "ceTaskUrl" | "analysisId" | "filters" | "measures">> = {},
|
|
444
|
+
): SonarAnalysisState {
|
|
445
|
+
return {
|
|
446
|
+
version: 1,
|
|
447
|
+
analyzedAt: new Date().toISOString(),
|
|
448
|
+
baseDir: config.baseDir,
|
|
449
|
+
serverUrl: config.serverUrl,
|
|
450
|
+
projectKey: config.projectKey,
|
|
451
|
+
dashboardUrl: extras.dashboardUrl,
|
|
452
|
+
ceTaskUrl: extras.ceTaskUrl,
|
|
453
|
+
analysisId: extras.analysisId,
|
|
454
|
+
filters: extras.filters,
|
|
455
|
+
totalIssues: issues.length,
|
|
456
|
+
measures: extras.measures,
|
|
457
|
+
issues,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── Measures ──────────────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
export async function fetchDuplicationMeasures(
|
|
464
|
+
serverUrl: string,
|
|
465
|
+
token: string | undefined,
|
|
466
|
+
projectKey: string,
|
|
467
|
+
signal?: AbortSignal,
|
|
468
|
+
): Promise<SonarDuplicationMeasures | undefined> {
|
|
469
|
+
try {
|
|
470
|
+
const url = `${serverUrl}/api/measures/component?component=${encodeURIComponent(projectKey)}&metricKeys=duplicated_lines_density,duplicated_lines,duplicated_blocks,duplicated_files`;
|
|
471
|
+
const result = await fetchJson<{
|
|
472
|
+
component: { measures: Array<{ metric: string; value: string }> };
|
|
473
|
+
}>(url, token, signal);
|
|
474
|
+
const measures = result.component.measures;
|
|
475
|
+
const getValue = (key: string): number => {
|
|
476
|
+
const m = measures.find((m) => m.metric === key);
|
|
477
|
+
return m ? Number.parseFloat(m.value) : 0;
|
|
478
|
+
};
|
|
479
|
+
return {
|
|
480
|
+
duplicatedLinesDensity: getValue("duplicated_lines_density"),
|
|
481
|
+
duplicatedLines: getValue("duplicated_lines"),
|
|
482
|
+
duplicatedBlocks: getValue("duplicated_blocks"),
|
|
483
|
+
duplicatedFiles: getValue("duplicated_files"),
|
|
484
|
+
};
|
|
485
|
+
} catch {
|
|
486
|
+
return undefined;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export async function fetchIssueSeverityCounts(
|
|
491
|
+
serverUrl: string,
|
|
492
|
+
token: string | undefined,
|
|
493
|
+
projectKey: string,
|
|
494
|
+
signal?: AbortSignal,
|
|
495
|
+
): Promise<IssueSeverityCounts | undefined> {
|
|
496
|
+
try {
|
|
497
|
+
const url = `${serverUrl}/api/issues/search?projects=${encodeURIComponent(projectKey)}&resolved=false&ps=1&facets=severities`;
|
|
498
|
+
const result = await fetchJson<{
|
|
499
|
+
facets: Array<{ property: string; values: Array<{ val: string; count: number }> }>;
|
|
500
|
+
}>(url, token, signal);
|
|
501
|
+
const facet = result.facets?.find((f) => f.property === "severities");
|
|
502
|
+
if (!facet) return undefined;
|
|
503
|
+
const getCount = (severity: string): number => {
|
|
504
|
+
const entry = facet.values.find((v) => v.val === severity);
|
|
505
|
+
return entry?.count ?? 0;
|
|
506
|
+
};
|
|
507
|
+
return {
|
|
508
|
+
blocker: getCount("BLOCKER"),
|
|
509
|
+
critical: getCount("CRITICAL"),
|
|
510
|
+
major: getCount("MAJOR"),
|
|
511
|
+
minor: getCount("MINOR"),
|
|
512
|
+
info: getCount("INFO"),
|
|
513
|
+
};
|
|
514
|
+
} catch {
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ── File duplications ─────────────────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
export async function fetchFileDuplications(
|
|
522
|
+
serverUrl: string,
|
|
523
|
+
token: string | undefined,
|
|
524
|
+
projectKey: string,
|
|
525
|
+
signal?: AbortSignal,
|
|
526
|
+
): Promise<FileDuplication[]> {
|
|
527
|
+
// Get file keys from issues search facets
|
|
528
|
+
const facetUrl = `${serverUrl}/api/issues/search?projects=${encodeURIComponent(projectKey)}&ps=1&facets=files`;
|
|
529
|
+
const facetResult = await fetchJson<{
|
|
530
|
+
facets: Array<{ property: string; values: Array<{ val: string; count: number }> }>;
|
|
531
|
+
}>(facetUrl, token, signal);
|
|
532
|
+
|
|
533
|
+
const fileFacet = facetResult.facets?.find((f) => f.property === "files");
|
|
534
|
+
if (!fileFacet?.values?.length) return [];
|
|
535
|
+
|
|
536
|
+
// Query per-file duplication measures. CATCH errors per file (404/403 = inaccessible)
|
|
537
|
+
const results: Array<FileDuplication | null> = await Promise.all(
|
|
538
|
+
fileFacet.values.map(async (entry) => {
|
|
539
|
+
try {
|
|
540
|
+
const fileKey = `${projectKey}:${entry.val}`;
|
|
541
|
+
const url = `${serverUrl}/api/measures/component?component=${encodeURIComponent(fileKey)}&metricKeys=duplicated_lines,duplicated_blocks`;
|
|
542
|
+
const res = await fetchJson<{
|
|
543
|
+
component: { measures?: Array<{ metric: string; value: string }> };
|
|
544
|
+
}>(url, token, signal);
|
|
545
|
+
const measures = res.component.measures ?? [];
|
|
546
|
+
const getValue = (metric: string): number => {
|
|
547
|
+
const m = measures.find((m) => m.metric === metric);
|
|
548
|
+
return m ? Number.parseInt(m.value, 10) : 0;
|
|
549
|
+
};
|
|
550
|
+
return {
|
|
551
|
+
filePath: entry.val,
|
|
552
|
+
fileKey: `${projectKey}:${entry.val}`,
|
|
553
|
+
duplicatedLines: getValue("duplicated_lines"),
|
|
554
|
+
duplicatedBlocks: getValue("duplicated_blocks"),
|
|
555
|
+
};
|
|
556
|
+
} catch {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
}),
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
return results.filter((f): f is FileDuplication => f !== null && f.duplicatedBlocks > 0);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export async function fetchFileDuplicationBlocks(
|
|
566
|
+
serverUrl: string,
|
|
567
|
+
token: string | undefined,
|
|
568
|
+
fileKey: string,
|
|
569
|
+
projectKey: string,
|
|
570
|
+
signal?: AbortSignal,
|
|
571
|
+
): Promise<DuplicationBlockGroup[]> {
|
|
572
|
+
const url = `${serverUrl}/api/duplications/show?key=${encodeURIComponent(fileKey)}`;
|
|
573
|
+
const result = await fetchJson<{
|
|
574
|
+
duplications: Array<{
|
|
575
|
+
blocks: Array<{ from: number; size: number; component: string }>;
|
|
576
|
+
}>;
|
|
577
|
+
}>(url, token, signal);
|
|
578
|
+
return result.duplications.map((d) => ({
|
|
579
|
+
blocks: d.blocks.map((b) => ({
|
|
580
|
+
from: b.from,
|
|
581
|
+
size: b.size,
|
|
582
|
+
filePath: toIssuePath(b.component, projectKey),
|
|
583
|
+
})),
|
|
584
|
+
}));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
|