@jonathan0823/pi-sonarqube 0.4.0 → 0.6.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 +2 -2
- package/dist/api.d.ts +12 -2
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +116 -26
- package/dist/api.js.map +1 -1
- package/dist/commands.d.ts +2 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +285 -30
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +13 -2
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +182 -52
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +26 -4
- package/dist/types.js.map +1 -1
- package/dist/ui.d.ts +29 -13
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +160 -70
- package/dist/ui.js.map +1 -1
- package/package.json +1 -1
- package/src/api.ts +310 -59
- package/src/commands.ts +454 -66
- package/src/config.ts +84 -23
- package/src/index.ts +462 -94
- package/src/types.ts +29 -4
- package/src/ui.ts +271 -85
package/src/index.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
Theme,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
7
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
8
|
import { stat } from "node:fs/promises";
|
|
4
9
|
import { basename } from "node:path";
|
|
@@ -68,22 +73,54 @@ import {
|
|
|
68
73
|
} from "./ui.js";
|
|
69
74
|
|
|
70
75
|
const SonarToolParams = Type.Object({
|
|
71
|
-
action: StringEnum([
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
action: StringEnum([
|
|
77
|
+
"analyze",
|
|
78
|
+
"issues",
|
|
79
|
+
"open",
|
|
80
|
+
"metrics",
|
|
81
|
+
"duplications",
|
|
82
|
+
] as const),
|
|
83
|
+
path: Type.Optional(
|
|
84
|
+
Type.String({
|
|
85
|
+
description: "Target alias or project directory to analyze or inspect",
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
issueIndex: Type.Optional(
|
|
89
|
+
Type.Number({ description: "1-based issue index to open" }),
|
|
90
|
+
),
|
|
74
91
|
severities: Type.Optional(
|
|
75
|
-
Type.Array(
|
|
92
|
+
Type.Array(
|
|
93
|
+
Type.String({ description: "Issue severity to fetch (e.g. CRITICAL)" }),
|
|
94
|
+
),
|
|
76
95
|
),
|
|
77
96
|
statuses: Type.Optional(
|
|
78
|
-
Type.Array(
|
|
97
|
+
Type.Array(
|
|
98
|
+
Type.String({ description: "Issue status to fetch (e.g. OPEN)" }),
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
types: Type.Optional(
|
|
102
|
+
Type.Array(Type.String({ description: "Issue type to fetch (e.g. BUG)" })),
|
|
103
|
+
),
|
|
104
|
+
rules: Type.Optional(
|
|
105
|
+
Type.Array(Type.String({ description: "Rule keys to fetch" })),
|
|
79
106
|
),
|
|
80
|
-
types: Type.Optional(Type.Array(Type.String({ description: "Issue type to fetch (e.g. BUG)" }))),
|
|
81
|
-
rules: Type.Optional(Type.Array(Type.String({ description: "Rule keys to fetch" }))),
|
|
82
107
|
softwareQualities: Type.Optional(
|
|
83
|
-
Type.Array(
|
|
108
|
+
Type.Array(
|
|
109
|
+
Type.String({
|
|
110
|
+
description: "Software quality to fetch (e.g. MAINTAINABILITY)",
|
|
111
|
+
}),
|
|
112
|
+
),
|
|
84
113
|
),
|
|
85
114
|
impactSeverities: Type.Optional(
|
|
86
|
-
Type.Array(
|
|
115
|
+
Type.Array(
|
|
116
|
+
Type.String({ description: "Impact severity to fetch (e.g. HIGH)" }),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
pathScope: Type.Optional(
|
|
120
|
+
Type.String({
|
|
121
|
+
description:
|
|
122
|
+
"Scope issues to a specific file or directory path (e.g. src/api.ts or src/). Use this instead of the `path` parameter for file-level filtering.",
|
|
123
|
+
}),
|
|
87
124
|
),
|
|
88
125
|
});
|
|
89
126
|
|
|
@@ -97,7 +134,11 @@ async function restoreState(
|
|
|
97
134
|
statesByBaseDir.clear();
|
|
98
135
|
let latest: SonarAnalysisState | undefined;
|
|
99
136
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
100
|
-
if (
|
|
137
|
+
if (
|
|
138
|
+
entry.type === "custom" &&
|
|
139
|
+
entry.customType === STATE_TYPE &&
|
|
140
|
+
entry.data
|
|
141
|
+
) {
|
|
101
142
|
latest = entry.data as SonarAnalysisState;
|
|
102
143
|
statesByBaseDir.set(latest.baseDir, latest);
|
|
103
144
|
}
|
|
@@ -115,6 +156,7 @@ async function analyzeProject(
|
|
|
115
156
|
filters?: SonarIssueFetchOptions,
|
|
116
157
|
): Promise<SonarAnalysisState> {
|
|
117
158
|
const config = await resolveConfig(ctx, inputPath);
|
|
159
|
+
_cachedConfig = { serverUrl: config.serverUrl, projectKey: config.projectKey, token: config.token };
|
|
118
160
|
const baseDirStat = await stat(config.baseDir).catch(() => undefined);
|
|
119
161
|
if (!baseDirStat?.isDirectory()) {
|
|
120
162
|
throw new Error(`Project directory not found: ${config.baseDir}`);
|
|
@@ -132,7 +174,12 @@ async function analyzeProject(
|
|
|
132
174
|
const dashboardUrl = report["dashboardUrl"];
|
|
133
175
|
const analysisId = ceTaskUrl
|
|
134
176
|
? (analysisUi.setPhase("Waiting for SonarQube analysis..."),
|
|
135
|
-
await waitForAnalysis(
|
|
177
|
+
await waitForAnalysis(
|
|
178
|
+
config.serverUrl,
|
|
179
|
+
config.token,
|
|
180
|
+
ceTaskUrl,
|
|
181
|
+
ctx.signal,
|
|
182
|
+
))
|
|
136
183
|
: undefined;
|
|
137
184
|
|
|
138
185
|
analysisUi.setPhase("Fetching issues...");
|
|
@@ -143,6 +190,7 @@ async function analyzeProject(
|
|
|
143
190
|
config.projectKey,
|
|
144
191
|
ctx.signal,
|
|
145
192
|
normalizedFilters,
|
|
193
|
+
config.baseDir,
|
|
146
194
|
);
|
|
147
195
|
|
|
148
196
|
analysisUi.setPhase("Fetching metrics...");
|
|
@@ -154,7 +202,11 @@ async function analyzeProject(
|
|
|
154
202
|
);
|
|
155
203
|
|
|
156
204
|
analysisUi.setPhase("Detecting mode...");
|
|
157
|
-
const cleanCodeMode = await fetchCleanCodeMode(
|
|
205
|
+
const cleanCodeMode = await fetchCleanCodeMode(
|
|
206
|
+
config.serverUrl,
|
|
207
|
+
config.token,
|
|
208
|
+
ctx.signal,
|
|
209
|
+
);
|
|
158
210
|
|
|
159
211
|
const state = createAnalysisState(config, issues, {
|
|
160
212
|
dashboardUrl,
|
|
@@ -200,26 +252,50 @@ async function collectInitInputs(
|
|
|
200
252
|
ctx: ExtensionCommandContext,
|
|
201
253
|
existing: SonarInitConfig | undefined,
|
|
202
254
|
baseDir: string,
|
|
203
|
-
): Promise<
|
|
204
|
-
|
|
255
|
+
): Promise<
|
|
256
|
+
| { serverUrl: string; projectKey: string; token: string | undefined }
|
|
257
|
+
| undefined
|
|
258
|
+
> {
|
|
259
|
+
const serverUrlInput = await ctx.ui.editor(
|
|
260
|
+
"SonarQube server URL",
|
|
261
|
+
existing?.serverUrl ?? "http://localhost:9000",
|
|
262
|
+
);
|
|
205
263
|
if (serverUrlInput === undefined) return undefined;
|
|
206
|
-
const serverUrl = normalizeServerUrl(
|
|
264
|
+
const serverUrl = normalizeServerUrl(
|
|
265
|
+
serverUrlInput || existing?.serverUrl || "http://localhost:9000",
|
|
266
|
+
);
|
|
207
267
|
|
|
208
|
-
const projectKeyInput = await ctx.ui.input(
|
|
268
|
+
const projectKeyInput = await ctx.ui.input(
|
|
269
|
+
"SonarQube project key",
|
|
270
|
+
existing?.projectKey ?? basename(baseDir),
|
|
271
|
+
);
|
|
209
272
|
if (projectKeyInput === undefined) return undefined;
|
|
210
|
-
const projectKey =
|
|
273
|
+
const projectKey =
|
|
274
|
+
projectKeyInput || existing?.projectKey || slugify(basename(baseDir));
|
|
211
275
|
|
|
212
|
-
const tokenInput = await ctx.ui.input(
|
|
276
|
+
const tokenInput = await ctx.ui.input(
|
|
277
|
+
"SonarQube token (optional, press Enter to skip)",
|
|
278
|
+
existing?.token ?? "",
|
|
279
|
+
);
|
|
213
280
|
if (tokenInput === undefined) return undefined;
|
|
214
281
|
|
|
215
282
|
return { serverUrl, projectKey, token: tokenInput.trim() || undefined };
|
|
216
283
|
}
|
|
217
284
|
|
|
218
|
-
async function initCommandHandler(
|
|
219
|
-
|
|
285
|
+
async function initCommandHandler(
|
|
286
|
+
ctx: ExtensionCommandContext,
|
|
287
|
+
options: InitCommandOptions = {},
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const { baseDir, repoRoot } = await resolveInitTarget(
|
|
290
|
+
ctx,
|
|
291
|
+
options.alias,
|
|
292
|
+
options.targetInput,
|
|
293
|
+
);
|
|
220
294
|
const existing = await loadProjectConfig(baseDir);
|
|
221
295
|
|
|
222
|
-
const confirmed = existing
|
|
296
|
+
const confirmed = existing
|
|
297
|
+
? await confirmOverwrite(ctx, baseDir, existing)
|
|
298
|
+
: true;
|
|
223
299
|
if (!confirmed) return;
|
|
224
300
|
|
|
225
301
|
const inputs = await collectInitInputs(ctx, existing, baseDir);
|
|
@@ -250,7 +326,10 @@ function notifyInitResult(
|
|
|
250
326
|
} else if (propertiesState === "updated") {
|
|
251
327
|
note = ` and merged ${sonarProjectPropertiesPath(baseDir)}`;
|
|
252
328
|
}
|
|
253
|
-
ctx.ui.notify(
|
|
329
|
+
ctx.ui.notify(
|
|
330
|
+
`SonarQube config saved${label} to ${projectConfigPath(baseDir)}${note}`,
|
|
331
|
+
"info",
|
|
332
|
+
);
|
|
254
333
|
}
|
|
255
334
|
|
|
256
335
|
// ── Tool result rendering helpers (reduces renderResult complexity) ─────────
|
|
@@ -289,9 +368,10 @@ function renderIssueListResult(
|
|
|
289
368
|
lines.push(theme.fg("accent", summary));
|
|
290
369
|
if (state.measures) {
|
|
291
370
|
const density = state.measures.duplicatedLinesDensity.toFixed(1);
|
|
292
|
-
const detail =
|
|
293
|
-
|
|
294
|
-
|
|
371
|
+
const detail =
|
|
372
|
+
state.measures.duplicatedBlocks > 0
|
|
373
|
+
? `Duplication: ${density}% lines=${state.measures.duplicatedLines} blocks=${state.measures.duplicatedBlocks} files=${state.measures.duplicatedFiles}`
|
|
374
|
+
: `Duplication: ${density}% (no duplications detected)`;
|
|
295
375
|
lines.push(theme.fg("dim", detail));
|
|
296
376
|
}
|
|
297
377
|
|
|
@@ -304,13 +384,30 @@ function renderIssueListResult(
|
|
|
304
384
|
lines.push(theme.fg("muted", formatIssue(issue, i + 1)));
|
|
305
385
|
}
|
|
306
386
|
if (!expanded && state.issues.length > visible.length) {
|
|
307
|
-
lines.push(
|
|
387
|
+
lines.push(
|
|
388
|
+
theme.fg("dim", `... ${state.issues.length - visible.length} more`),
|
|
389
|
+
);
|
|
308
390
|
}
|
|
309
391
|
return new Text(lines.join("\n"), 0, 0);
|
|
310
392
|
}
|
|
311
393
|
|
|
312
394
|
// ── Extension entrypoint ────────────────────────────────────────────────────
|
|
313
395
|
|
|
396
|
+
// ponytail: lazy config cache so rule autocomplete can fetch from the server
|
|
397
|
+
let _cachedConfig: { serverUrl: string; projectKey: string; token?: string } | undefined;
|
|
398
|
+
|
|
399
|
+
async function cachedServerConfig(cwd: string): Promise<typeof _cachedConfig> {
|
|
400
|
+
if (!_cachedConfig) {
|
|
401
|
+
try {
|
|
402
|
+
const cfg = await resolveConfig({ cwd });
|
|
403
|
+
_cachedConfig = { serverUrl: cfg.serverUrl, projectKey: cfg.projectKey, token: cfg.token };
|
|
404
|
+
} catch {
|
|
405
|
+
// fall through
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return _cachedConfig;
|
|
409
|
+
}
|
|
410
|
+
|
|
314
411
|
export default function sonarqube(pi: ExtensionAPI) {
|
|
315
412
|
const statesByBaseDir = new Map<string, SonarAnalysisState>();
|
|
316
413
|
let latestState: SonarAnalysisState | undefined;
|
|
@@ -333,22 +430,60 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
333
430
|
const line = lines[cursorLine] ?? "";
|
|
334
431
|
const beforeCursor = line.slice(0, cursorCol);
|
|
335
432
|
const match = /^\/sonarqube(?:\s+(.*))?$/.exec(beforeCursor);
|
|
336
|
-
if (!match)
|
|
433
|
+
if (!match)
|
|
434
|
+
return current.getSuggestions(
|
|
435
|
+
lines,
|
|
436
|
+
cursorLine,
|
|
437
|
+
cursorCol,
|
|
438
|
+
options,
|
|
439
|
+
);
|
|
337
440
|
|
|
338
441
|
const argumentText = match[1] ?? "";
|
|
339
|
-
const { current: currentToken } =
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
const
|
|
442
|
+
const { current: currentToken } =
|
|
443
|
+
splitSonarArgumentContext(argumentText);
|
|
444
|
+
const cfg = await cachedServerConfig(ctx.cwd);
|
|
445
|
+
const extraItems =
|
|
446
|
+
(await sonarArgumentCompletions(
|
|
447
|
+
argumentText,
|
|
448
|
+
latestState?.issues,
|
|
449
|
+
latestState?.cleanCodeMode,
|
|
450
|
+
undefined,
|
|
451
|
+
cfg?.serverUrl,
|
|
452
|
+
cfg?.token,
|
|
453
|
+
cfg?.projectKey,
|
|
454
|
+
)) ?? [];
|
|
455
|
+
const currentSuggestions = await current.getSuggestions(
|
|
456
|
+
lines,
|
|
457
|
+
cursorLine,
|
|
458
|
+
cursorCol,
|
|
459
|
+
options,
|
|
460
|
+
);
|
|
461
|
+
const merged = mergeAutocompleteSuggestions(
|
|
462
|
+
currentToken,
|
|
463
|
+
currentSuggestions,
|
|
464
|
+
extraItems,
|
|
465
|
+
);
|
|
343
466
|
return merged ?? currentSuggestions;
|
|
344
467
|
},
|
|
345
468
|
|
|
346
469
|
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
347
|
-
return current.applyCompletion(
|
|
470
|
+
return current.applyCompletion(
|
|
471
|
+
lines,
|
|
472
|
+
cursorLine,
|
|
473
|
+
cursorCol,
|
|
474
|
+
item,
|
|
475
|
+
prefix,
|
|
476
|
+
);
|
|
348
477
|
},
|
|
349
478
|
|
|
350
479
|
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
351
|
-
return
|
|
480
|
+
return (
|
|
481
|
+
current.shouldTriggerFileCompletion?.(
|
|
482
|
+
lines,
|
|
483
|
+
cursorLine,
|
|
484
|
+
cursorCol,
|
|
485
|
+
) ?? true
|
|
486
|
+
);
|
|
352
487
|
},
|
|
353
488
|
}));
|
|
354
489
|
}
|
|
@@ -366,6 +501,7 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
366
501
|
promptGuidelines: [
|
|
367
502
|
"Use sonarqube when the user asks to run a local SonarQube scan, inspect issues, or open an issue's source location.",
|
|
368
503
|
"Use the optional severity, status, type, rule, quality, and impact severity filters to fetch only the most relevant issues.",
|
|
504
|
+
"To scope issues to a specific file or directory, use the `pathScope` parameter (e.g. pathScope: 'src/api.ts') — do not use the `path` parameter for file filtering.",
|
|
369
505
|
],
|
|
370
506
|
parameters: SonarToolParams,
|
|
371
507
|
|
|
@@ -378,30 +514,50 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
378
514
|
rules: params.rules,
|
|
379
515
|
softwareQualities: params.softwareQualities,
|
|
380
516
|
impactSeverities: params.impactSeverities,
|
|
517
|
+
pathScope: params.pathScope,
|
|
381
518
|
});
|
|
382
519
|
assertFiltersNotAmbiguous(filters);
|
|
383
520
|
|
|
384
521
|
if (params.action === "analyze") {
|
|
385
522
|
const state = await analyzeProject(pi, ctx, params.path, filters);
|
|
386
523
|
rememberState(state);
|
|
387
|
-
return {
|
|
524
|
+
return {
|
|
525
|
+
content: [{ type: "text", text: formatReport(state) }],
|
|
526
|
+
details: state,
|
|
527
|
+
};
|
|
388
528
|
}
|
|
389
529
|
|
|
390
|
-
if (params.action === "duplications")
|
|
530
|
+
if (params.action === "duplications")
|
|
531
|
+
return toolDuplications(ctx, params.path);
|
|
391
532
|
if (params.action === "metrics") return toolMetrics(ctx, params.path);
|
|
392
533
|
|
|
393
|
-
const targetState = await resolveTargetState(
|
|
534
|
+
const targetState = await resolveTargetState(
|
|
535
|
+
ctx,
|
|
536
|
+
statesByBaseDir,
|
|
537
|
+
params.path,
|
|
538
|
+
filters,
|
|
539
|
+
);
|
|
394
540
|
if (!targetState) {
|
|
395
541
|
return {
|
|
396
|
-
content: [
|
|
397
|
-
|
|
542
|
+
content: [
|
|
543
|
+
{
|
|
544
|
+
type: "text",
|
|
545
|
+
text: "No SonarQube analysis has been run for this target yet.",
|
|
546
|
+
},
|
|
547
|
+
],
|
|
548
|
+
details: {
|
|
549
|
+
error: "No SonarQube analysis has been run for this target yet.",
|
|
550
|
+
},
|
|
398
551
|
};
|
|
399
552
|
}
|
|
400
553
|
|
|
401
554
|
rememberState(targetState);
|
|
402
555
|
|
|
403
556
|
if (params.action === "issues") {
|
|
404
|
-
return {
|
|
557
|
+
return {
|
|
558
|
+
content: [{ type: "text", text: formatReport(targetState) }],
|
|
559
|
+
details: targetState,
|
|
560
|
+
};
|
|
405
561
|
}
|
|
406
562
|
|
|
407
563
|
const index = params.issueIndex ?? 1;
|
|
@@ -415,12 +571,20 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
415
571
|
|
|
416
572
|
const preview = await buildIssuePreview(targetState.baseDir, issue);
|
|
417
573
|
return {
|
|
418
|
-
content: [
|
|
574
|
+
content: [
|
|
575
|
+
{
|
|
576
|
+
type: "text",
|
|
577
|
+
text: `${formatIssue(issue, index)}\n\n${preview}`,
|
|
578
|
+
},
|
|
579
|
+
],
|
|
419
580
|
details: { ...targetState, selectedIssue: issue },
|
|
420
581
|
};
|
|
421
582
|
} catch (error) {
|
|
422
583
|
const msg = sonarErrorMessage(error);
|
|
423
|
-
return {
|
|
584
|
+
return {
|
|
585
|
+
content: [{ type: "text", text: msg }],
|
|
586
|
+
details: { error: msg },
|
|
587
|
+
};
|
|
424
588
|
}
|
|
425
589
|
},
|
|
426
590
|
|
|
@@ -433,7 +597,9 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
433
597
|
softwareQualities: args.softwareQualities,
|
|
434
598
|
impactSeverities: args.impactSeverities,
|
|
435
599
|
});
|
|
436
|
-
const filterText = filters
|
|
600
|
+
const filterText = filters
|
|
601
|
+
? ` ${theme.fg("muted", issueFilterLabel(filters))}`
|
|
602
|
+
: "";
|
|
437
603
|
return new Text(
|
|
438
604
|
`${theme.fg("toolTitle", theme.bold("sonarqube"))} ${theme.fg("muted", args.action)}${
|
|
439
605
|
args.path ? ` ${theme.fg("accent", args.path)}` : ""
|
|
@@ -460,7 +626,10 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
460
626
|
|
|
461
627
|
if ("selectedIssue" in state && state.selectedIssue) {
|
|
462
628
|
const issue = state.selectedIssue;
|
|
463
|
-
const contentText =
|
|
629
|
+
const contentText =
|
|
630
|
+
result.content[0]?.type === "text"
|
|
631
|
+
? result.content[0].text
|
|
632
|
+
: undefined;
|
|
464
633
|
return renderIssueResult(issue, contentText, expanded, theme);
|
|
465
634
|
}
|
|
466
635
|
|
|
@@ -474,8 +643,20 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
474
643
|
});
|
|
475
644
|
|
|
476
645
|
pi.registerCommand("sonarqube", {
|
|
477
|
-
description:
|
|
478
|
-
|
|
646
|
+
description:
|
|
647
|
+
"Run a local SonarQube analysis, browse the latest issues, or init project config",
|
|
648
|
+
getArgumentCompletions: async (argumentPrefix) => {
|
|
649
|
+
const cfg = await cachedServerConfig(process.cwd());
|
|
650
|
+
return sonarArgumentCompletions(
|
|
651
|
+
argumentPrefix,
|
|
652
|
+
latestState?.issues,
|
|
653
|
+
latestState?.cleanCodeMode,
|
|
654
|
+
process.cwd(),
|
|
655
|
+
cfg?.serverUrl,
|
|
656
|
+
cfg?.token,
|
|
657
|
+
cfg?.projectKey,
|
|
658
|
+
);
|
|
659
|
+
},
|
|
479
660
|
handler: async (args, ctx) => {
|
|
480
661
|
try {
|
|
481
662
|
const parsed = parseCommandArgs(args);
|
|
@@ -484,20 +665,41 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
484
665
|
if (ctx.hasUI) ctx.ui.notify(helpText(), "info");
|
|
485
666
|
return;
|
|
486
667
|
case "init":
|
|
487
|
-
await initCommandHandler(ctx, {
|
|
668
|
+
await initCommandHandler(ctx, {
|
|
669
|
+
alias: parsed.alias,
|
|
670
|
+
targetInput: parsed.targetInput,
|
|
671
|
+
});
|
|
488
672
|
return;
|
|
489
673
|
case "analyze": {
|
|
490
|
-
await commandAnalyze(
|
|
674
|
+
await commandAnalyze(
|
|
675
|
+
pi,
|
|
676
|
+
ctx,
|
|
677
|
+
parsed.targetInput,
|
|
678
|
+
parsed.filters,
|
|
679
|
+
rememberState,
|
|
680
|
+
);
|
|
491
681
|
return;
|
|
492
682
|
}
|
|
493
683
|
case "metrics":
|
|
494
684
|
await commandMetrics(ctx, parsed.targetInput);
|
|
495
685
|
return;
|
|
496
686
|
case "duplications":
|
|
497
|
-
await commandDuplications(
|
|
687
|
+
await commandDuplications(
|
|
688
|
+
pi,
|
|
689
|
+
ctx,
|
|
690
|
+
parsed.targetInput,
|
|
691
|
+
parsed.issueIndex,
|
|
692
|
+
parsed.filters,
|
|
693
|
+
);
|
|
498
694
|
return;
|
|
499
695
|
default:
|
|
500
|
-
await commandIssuesOrOpen(
|
|
696
|
+
await commandIssuesOrOpen(
|
|
697
|
+
pi,
|
|
698
|
+
ctx,
|
|
699
|
+
statesByBaseDir,
|
|
700
|
+
parsed,
|
|
701
|
+
rememberState,
|
|
702
|
+
);
|
|
501
703
|
}
|
|
502
704
|
} catch (error) {
|
|
503
705
|
if (ctx.hasUI) {
|
|
@@ -528,24 +730,67 @@ async function commandAnalyze(
|
|
|
528
730
|
}
|
|
529
731
|
}
|
|
530
732
|
|
|
733
|
+
async function loadMetricsData(
|
|
734
|
+
config: SonarProjectConfig,
|
|
735
|
+
signal?: AbortSignal,
|
|
736
|
+
): Promise<{
|
|
737
|
+
cleanCodeMode: Awaited<ReturnType<typeof fetchCleanCodeMode>>;
|
|
738
|
+
measures: Awaited<ReturnType<typeof fetchDuplicationMeasures>>;
|
|
739
|
+
issueCounts: Awaited<ReturnType<typeof fetchIssueSeverityCounts>>;
|
|
740
|
+
qualityCounts: Awaited<ReturnType<typeof fetchIssueQualityCounts>>;
|
|
741
|
+
} | null> {
|
|
742
|
+
const cleanCodeMode = await fetchCleanCodeMode(
|
|
743
|
+
config.serverUrl,
|
|
744
|
+
config.token,
|
|
745
|
+
signal,
|
|
746
|
+
);
|
|
747
|
+
const [measures, issueCounts, qualityCounts] = await Promise.all([
|
|
748
|
+
fetchDuplicationMeasures(
|
|
749
|
+
config.serverUrl,
|
|
750
|
+
config.token,
|
|
751
|
+
config.projectKey,
|
|
752
|
+
signal,
|
|
753
|
+
),
|
|
754
|
+
fetchIssueSeverityCounts(
|
|
755
|
+
config.serverUrl,
|
|
756
|
+
config.token,
|
|
757
|
+
config.projectKey,
|
|
758
|
+
signal,
|
|
759
|
+
cleanCodeMode,
|
|
760
|
+
),
|
|
761
|
+
fetchIssueQualityCounts(
|
|
762
|
+
config.serverUrl,
|
|
763
|
+
config.token,
|
|
764
|
+
config.projectKey,
|
|
765
|
+
signal,
|
|
766
|
+
),
|
|
767
|
+
]);
|
|
768
|
+
if (!measures && !issueCounts && !qualityCounts) return null;
|
|
769
|
+
return { cleanCodeMode, measures, issueCounts, qualityCounts };
|
|
770
|
+
}
|
|
771
|
+
|
|
531
772
|
async function commandMetrics(
|
|
532
773
|
ctx: ExtensionCommandContext,
|
|
533
774
|
targetInput?: string,
|
|
534
775
|
): Promise<void> {
|
|
535
776
|
const config = await resolveConfig(ctx, targetInput);
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
539
|
-
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal, cleanCodeMode),
|
|
540
|
-
fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
541
|
-
]);
|
|
542
|
-
if (!measures && !issueCounts && !qualityCounts) {
|
|
777
|
+
const metrics = await loadMetricsData(config, ctx.signal);
|
|
778
|
+
if (!metrics) {
|
|
543
779
|
if (ctx.hasUI) {
|
|
544
|
-
ctx.ui.notify(
|
|
780
|
+
ctx.ui.notify(
|
|
781
|
+
`Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`,
|
|
782
|
+
"warning",
|
|
783
|
+
);
|
|
545
784
|
}
|
|
546
785
|
return;
|
|
547
786
|
}
|
|
548
|
-
const text = formatMetricsOutput({
|
|
787
|
+
const text = formatMetricsOutput({
|
|
788
|
+
projectKey: config.projectKey,
|
|
789
|
+
measures: metrics.measures,
|
|
790
|
+
issueCounts: metrics.issueCounts,
|
|
791
|
+
issueQualityCounts: metrics.qualityCounts,
|
|
792
|
+
cleanCodeMode: metrics.cleanCodeMode,
|
|
793
|
+
});
|
|
549
794
|
if (ctx.hasUI) {
|
|
550
795
|
ctx.ui.notify(text, "info");
|
|
551
796
|
}
|
|
@@ -556,16 +801,27 @@ async function commandDuplications(
|
|
|
556
801
|
ctx: ExtensionCommandContext,
|
|
557
802
|
targetInput?: string,
|
|
558
803
|
fileIndex?: number,
|
|
804
|
+
filters?: SonarIssueFetchOptions,
|
|
559
805
|
): Promise<void> {
|
|
560
806
|
const config = await resolveConfig(ctx, targetInput);
|
|
561
807
|
let files: FileDuplication[];
|
|
562
808
|
try {
|
|
563
|
-
files = await fetchFileDuplications(
|
|
809
|
+
files = await fetchFileDuplications(
|
|
810
|
+
config.serverUrl,
|
|
811
|
+
config.token,
|
|
812
|
+
config.projectKey,
|
|
813
|
+
ctx.signal,
|
|
814
|
+
filters?.pathScope,
|
|
815
|
+
config.baseDir,
|
|
816
|
+
);
|
|
564
817
|
} catch (error) {
|
|
565
818
|
const msg = sonarErrorMessage(error);
|
|
566
819
|
if (msg.includes("403") || msg.includes("Insufficient privileges")) {
|
|
567
820
|
if (ctx.hasUI) {
|
|
568
|
-
ctx.ui.notify(
|
|
821
|
+
ctx.ui.notify(
|
|
822
|
+
"SonarQube token needs 'See Source Code' permission to list duplicated files. Update the token permissions in SonarQube and run /sonarqube init to update the token.",
|
|
823
|
+
"warning",
|
|
824
|
+
);
|
|
569
825
|
}
|
|
570
826
|
return;
|
|
571
827
|
}
|
|
@@ -580,7 +836,7 @@ async function commandDuplications(
|
|
|
580
836
|
await showDuplicationDrillDown(ctx, config, files, fileIndex);
|
|
581
837
|
return;
|
|
582
838
|
}
|
|
583
|
-
await showDuplicationListOrBrowser(ctx, config, files);
|
|
839
|
+
await showDuplicationListOrBrowser(ctx, config, files, filters);
|
|
584
840
|
}
|
|
585
841
|
|
|
586
842
|
async function showDuplicationDrillDown(
|
|
@@ -594,8 +850,18 @@ async function showDuplicationDrillDown(
|
|
|
594
850
|
if (ctx.hasUI) ctx.ui.notify(`File #${fileIndex} not found.`, "error");
|
|
595
851
|
return;
|
|
596
852
|
}
|
|
597
|
-
const groups = await fetchFileDuplicationBlocks(
|
|
598
|
-
|
|
853
|
+
const groups = await fetchFileDuplicationBlocks(
|
|
854
|
+
config.serverUrl,
|
|
855
|
+
config.token,
|
|
856
|
+
file.fileKey,
|
|
857
|
+
config.projectKey,
|
|
858
|
+
ctx.signal,
|
|
859
|
+
);
|
|
860
|
+
const text = await buildDuplicationPreview(
|
|
861
|
+
config.baseDir,
|
|
862
|
+
file.filePath,
|
|
863
|
+
groups,
|
|
864
|
+
);
|
|
599
865
|
if (ctx.mode === "tui") {
|
|
600
866
|
await ctx.ui.editor(`Duplications in ${file.filePath}`, text);
|
|
601
867
|
} else if (ctx.hasUI) {
|
|
@@ -607,15 +873,23 @@ async function showDuplicationListOrBrowser(
|
|
|
607
873
|
ctx: ExtensionCommandContext,
|
|
608
874
|
config: SonarProjectConfig,
|
|
609
875
|
files: FileDuplication[],
|
|
876
|
+
filters?: SonarIssueFetchOptions,
|
|
610
877
|
): Promise<void> {
|
|
611
878
|
if (ctx.mode !== "tui") {
|
|
612
879
|
ctx.ui.notify(formatDuplicationsList(files), "info");
|
|
613
880
|
return;
|
|
614
881
|
}
|
|
615
|
-
const
|
|
882
|
+
const scopeLabel = filters?.pathScope ? issueFilterLabel(filters) : undefined;
|
|
883
|
+
const choice = await showDuplicationBrowser(ctx, files, scopeLabel);
|
|
616
884
|
if (choice == null) return;
|
|
617
885
|
const file = files[choice];
|
|
618
|
-
const groups = await fetchFileDuplicationBlocks(
|
|
886
|
+
const groups = await fetchFileDuplicationBlocks(
|
|
887
|
+
config.serverUrl,
|
|
888
|
+
config.token,
|
|
889
|
+
file.fileKey,
|
|
890
|
+
config.projectKey,
|
|
891
|
+
ctx.signal,
|
|
892
|
+
);
|
|
619
893
|
const lines = [`Duplications in ${file.filePath}`];
|
|
620
894
|
for (const [i, group] of groups.entries()) {
|
|
621
895
|
lines.push("", `Block ${i + 1}:`);
|
|
@@ -632,10 +906,20 @@ async function commandIssuesOrOpen(
|
|
|
632
906
|
pi: ExtensionAPI,
|
|
633
907
|
ctx: ExtensionCommandContext,
|
|
634
908
|
statesByBaseDir: Map<string, SonarAnalysisState>,
|
|
635
|
-
parsed: {
|
|
909
|
+
parsed: {
|
|
910
|
+
targetInput?: string;
|
|
911
|
+
filters?: SonarIssueFetchOptions;
|
|
912
|
+
action: string;
|
|
913
|
+
issueIndex?: number;
|
|
914
|
+
},
|
|
636
915
|
rememberState: (s: SonarAnalysisState) => void,
|
|
637
916
|
): Promise<void> {
|
|
638
|
-
const targetState = await resolveTargetState(
|
|
917
|
+
const targetState = await resolveTargetState(
|
|
918
|
+
ctx,
|
|
919
|
+
statesByBaseDir,
|
|
920
|
+
parsed.targetInput,
|
|
921
|
+
parsed.filters,
|
|
922
|
+
);
|
|
639
923
|
if (!targetState) {
|
|
640
924
|
if (ctx.hasUI) {
|
|
641
925
|
ctx.ui.notify(
|
|
@@ -675,8 +959,12 @@ async function commandShowIssues(
|
|
|
675
959
|
const issue = targetState.issues[choice];
|
|
676
960
|
if (issue) {
|
|
677
961
|
const line = issue.line ? `:${issue.line}` : "";
|
|
678
|
-
const rule = issue.ruleName
|
|
679
|
-
|
|
962
|
+
const rule = issue.ruleName
|
|
963
|
+
? `${issue.rule} (${issue.ruleName})`
|
|
964
|
+
: issue.rule;
|
|
965
|
+
ctx.ui.setEditorText(
|
|
966
|
+
`${issue.severity} ${issue.filePath}${line} — ${rule} — ${issue.message}`,
|
|
967
|
+
);
|
|
680
968
|
ctx.ui.notify("Issue loaded into editor — press Enter to send", "info");
|
|
681
969
|
}
|
|
682
970
|
}
|
|
@@ -684,24 +972,45 @@ async function commandShowIssues(
|
|
|
684
972
|
|
|
685
973
|
// ── Tool helper functions ────────────────────────────────────────────────────
|
|
686
974
|
|
|
687
|
-
async function toolDuplications(
|
|
975
|
+
async function toolDuplications(
|
|
976
|
+
ctx: any,
|
|
977
|
+
path: string | undefined,
|
|
978
|
+
): Promise<{
|
|
979
|
+
content: Array<{ type: "text"; text: string }>;
|
|
980
|
+
details: Record<string, unknown>;
|
|
981
|
+
}> {
|
|
688
982
|
let config;
|
|
689
983
|
try {
|
|
690
984
|
config = await resolveConfig(ctx, path);
|
|
691
985
|
} catch {
|
|
692
986
|
return {
|
|
693
|
-
content: [
|
|
987
|
+
content: [
|
|
988
|
+
{
|
|
989
|
+
type: "text",
|
|
990
|
+
text: "Project not configured. Run /sonarqube init first.",
|
|
991
|
+
},
|
|
992
|
+
],
|
|
694
993
|
details: { error: "Project not configured." },
|
|
695
994
|
};
|
|
696
995
|
}
|
|
697
996
|
let files: FileDuplication[];
|
|
698
997
|
try {
|
|
699
|
-
files = await fetchFileDuplications(
|
|
998
|
+
files = await fetchFileDuplications(
|
|
999
|
+
config.serverUrl,
|
|
1000
|
+
config.token,
|
|
1001
|
+
config.projectKey,
|
|
1002
|
+
ctx.signal,
|
|
1003
|
+
);
|
|
700
1004
|
} catch (error) {
|
|
701
1005
|
const msg = sonarErrorMessage(error);
|
|
702
1006
|
if (msg.includes("403") || msg.includes("Insufficient privileges")) {
|
|
703
1007
|
return {
|
|
704
|
-
content: [
|
|
1008
|
+
content: [
|
|
1009
|
+
{
|
|
1010
|
+
type: "text",
|
|
1011
|
+
text: "SonarQube token needs 'See Source Code' permission to list duplicated files. Grant it in SonarQube project permissions, then update the token via /sonarqube init.",
|
|
1012
|
+
},
|
|
1013
|
+
],
|
|
705
1014
|
details: { error: "Token missing 'See Source Code' permission." },
|
|
706
1015
|
};
|
|
707
1016
|
}
|
|
@@ -712,7 +1021,12 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
|
|
|
712
1021
|
}
|
|
713
1022
|
if (files.length === 0) {
|
|
714
1023
|
return {
|
|
715
|
-
content: [
|
|
1024
|
+
content: [
|
|
1025
|
+
{
|
|
1026
|
+
type: "text",
|
|
1027
|
+
text: `No duplications found for ${config.projectKey}.`,
|
|
1028
|
+
},
|
|
1029
|
+
],
|
|
716
1030
|
details: { error: `No duplications found for ${config.projectKey}.` },
|
|
717
1031
|
};
|
|
718
1032
|
}
|
|
@@ -721,12 +1035,23 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
|
|
|
721
1035
|
const topContexts = await Promise.all(
|
|
722
1036
|
topFiles.map(async (file) => {
|
|
723
1037
|
try {
|
|
724
|
-
const groups = await fetchFileDuplicationBlocks(
|
|
725
|
-
|
|
1038
|
+
const groups = await fetchFileDuplicationBlocks(
|
|
1039
|
+
config.serverUrl,
|
|
1040
|
+
config.token,
|
|
1041
|
+
file.fileKey,
|
|
1042
|
+
config.projectKey,
|
|
1043
|
+
ctx.signal,
|
|
1044
|
+
);
|
|
1045
|
+
const preview = await buildDuplicationPreview(
|
|
1046
|
+
config.baseDir,
|
|
1047
|
+
file.filePath,
|
|
1048
|
+
groups,
|
|
1049
|
+
);
|
|
726
1050
|
return {
|
|
727
1051
|
filePath: file.filePath,
|
|
728
1052
|
duplicatedBlocks: file.duplicatedBlocks,
|
|
729
1053
|
duplicatedLines: file.duplicatedLines,
|
|
1054
|
+
duplicatedLinesDensity: file.duplicatedLinesDensity,
|
|
730
1055
|
preview,
|
|
731
1056
|
};
|
|
732
1057
|
} catch (error) {
|
|
@@ -734,6 +1059,7 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
|
|
|
734
1059
|
filePath: file.filePath,
|
|
735
1060
|
duplicatedBlocks: file.duplicatedBlocks,
|
|
736
1061
|
duplicatedLines: file.duplicatedLines,
|
|
1062
|
+
duplicatedLinesDensity: file.duplicatedLinesDensity,
|
|
737
1063
|
preview: `Duplications in ${file.filePath}\n\n<failed to load duplicated lines: ${sonarErrorMessage(error)}>`,
|
|
738
1064
|
};
|
|
739
1065
|
}
|
|
@@ -741,12 +1067,22 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
|
|
|
741
1067
|
);
|
|
742
1068
|
const remaining = files.length - topContexts.length;
|
|
743
1069
|
const content: Array<{ type: "text"; text: string }> = [
|
|
744
|
-
{
|
|
1070
|
+
{
|
|
1071
|
+
type: "text",
|
|
1072
|
+
text: `Duplicated files for ${config.projectKey}: ${files.length}. Showing top ${topContexts.length}.`,
|
|
1073
|
+
},
|
|
745
1074
|
...topContexts.map((item, index) => ({
|
|
746
1075
|
type: "text" as const,
|
|
747
|
-
text: `#${index + 1} ${item.filePath} blocks=${item.duplicatedBlocks} lines=${item.duplicatedLines}\n${item.preview}`,
|
|
1076
|
+
text: `#${index + 1} ${item.filePath} dup%=${item.duplicatedLinesDensity.toFixed(1)} blocks=${item.duplicatedBlocks} lines=${item.duplicatedLines}\n${item.preview}`,
|
|
748
1077
|
})),
|
|
749
|
-
...(remaining > 0
|
|
1078
|
+
...(remaining > 0
|
|
1079
|
+
? [
|
|
1080
|
+
{
|
|
1081
|
+
type: "text" as const,
|
|
1082
|
+
text: `+ ${remaining} more file(s) not shown`,
|
|
1083
|
+
},
|
|
1084
|
+
]
|
|
1085
|
+
: []),
|
|
750
1086
|
];
|
|
751
1087
|
return {
|
|
752
1088
|
content,
|
|
@@ -759,30 +1095,58 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
|
|
|
759
1095
|
};
|
|
760
1096
|
}
|
|
761
1097
|
|
|
762
|
-
async function toolMetrics(
|
|
1098
|
+
async function toolMetrics(
|
|
1099
|
+
ctx: any,
|
|
1100
|
+
path: string | undefined,
|
|
1101
|
+
): Promise<{
|
|
1102
|
+
content: Array<{ type: "text"; text: string }>;
|
|
1103
|
+
details: Record<string, unknown>;
|
|
1104
|
+
}> {
|
|
763
1105
|
let config;
|
|
764
1106
|
try {
|
|
765
1107
|
config = await resolveConfig(ctx, path);
|
|
766
1108
|
} catch {
|
|
767
1109
|
return {
|
|
768
|
-
content: [
|
|
1110
|
+
content: [
|
|
1111
|
+
{
|
|
1112
|
+
type: "text",
|
|
1113
|
+
text: "Project not configured. Run /sonarqube init first.",
|
|
1114
|
+
},
|
|
1115
|
+
],
|
|
769
1116
|
details: { error: "Project not configured." },
|
|
770
1117
|
};
|
|
771
1118
|
}
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
775
|
-
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal, cleanCodeMode),
|
|
776
|
-
fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
777
|
-
]);
|
|
778
|
-
if (!measures && !issueCounts && !qualityCounts) {
|
|
1119
|
+
const metrics = await loadMetricsData(config, ctx.signal);
|
|
1120
|
+
if (!metrics) {
|
|
779
1121
|
return {
|
|
780
|
-
content: [
|
|
781
|
-
|
|
1122
|
+
content: [
|
|
1123
|
+
{
|
|
1124
|
+
type: "text",
|
|
1125
|
+
text: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`,
|
|
1126
|
+
},
|
|
1127
|
+
],
|
|
1128
|
+
details: {
|
|
1129
|
+
error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`,
|
|
1130
|
+
},
|
|
782
1131
|
};
|
|
783
1132
|
}
|
|
784
|
-
const text = formatMetricsOutput({
|
|
785
|
-
|
|
1133
|
+
const text = formatMetricsOutput({
|
|
1134
|
+
projectKey: config.projectKey,
|
|
1135
|
+
measures: metrics.measures,
|
|
1136
|
+
issueCounts: metrics.issueCounts,
|
|
1137
|
+
issueQualityCounts: metrics.qualityCounts,
|
|
1138
|
+
cleanCodeMode: metrics.cleanCodeMode,
|
|
1139
|
+
});
|
|
1140
|
+
return {
|
|
1141
|
+
content: [{ type: "text", text }],
|
|
1142
|
+
details: {
|
|
1143
|
+
projectKey: config.projectKey,
|
|
1144
|
+
measures: metrics.measures,
|
|
1145
|
+
issueCounts: metrics.issueCounts,
|
|
1146
|
+
issueQualityCounts: metrics.qualityCounts,
|
|
1147
|
+
cleanCodeMode: metrics.cleanCodeMode,
|
|
1148
|
+
},
|
|
1149
|
+
};
|
|
786
1150
|
}
|
|
787
1151
|
|
|
788
1152
|
// ── Re-exports for public API ───────────────────────────────────────────────
|
|
@@ -797,4 +1161,8 @@ export type {
|
|
|
797
1161
|
IssueQualityCounts,
|
|
798
1162
|
} from "./types.js";
|
|
799
1163
|
|
|
800
|
-
export {
|
|
1164
|
+
export {
|
|
1165
|
+
projectConfigPath,
|
|
1166
|
+
loadProjectConfig,
|
|
1167
|
+
saveProjectConfig,
|
|
1168
|
+
} from "./config.js";
|