@jonathan0823/pi-sonarqube 0.1.1 → 0.1.3
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/CONTRIBUTING.md +26 -0
- package/README.md +19 -11
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +352 -51
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/src/index.ts +411 -52
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { Key, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { AutocompleteItem, AutocompleteSuggestions } from "@earendil-works/pi-tui";
|
|
10
11
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
11
12
|
import { basename, dirname, relative, resolve } from "node:path";
|
|
12
13
|
import { Type } from "typebox";
|
|
@@ -35,6 +36,7 @@ export interface SonarAnalysisState {
|
|
|
35
36
|
dashboardUrl?: string;
|
|
36
37
|
ceTaskUrl?: string;
|
|
37
38
|
analysisId?: string;
|
|
39
|
+
filters?: SonarIssueFetchOptions;
|
|
38
40
|
totalIssues: number;
|
|
39
41
|
issues: SonarIssue[];
|
|
40
42
|
}
|
|
@@ -47,6 +49,13 @@ export interface SonarProjectConfig {
|
|
|
47
49
|
hasProperties: boolean;
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
export interface SonarIssueFetchOptions {
|
|
53
|
+
severities?: string[];
|
|
54
|
+
statuses?: string[];
|
|
55
|
+
types?: string[];
|
|
56
|
+
rules?: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
50
59
|
export interface SonarInitConfig {
|
|
51
60
|
serverUrl: string;
|
|
52
61
|
projectKey: string;
|
|
@@ -61,8 +70,19 @@ const SonarToolParams = Type.Object({
|
|
|
61
70
|
action: StringEnum(["analyze", "issues", "open"] as const),
|
|
62
71
|
path: Type.Optional(Type.String({ description: "Target alias or project directory to analyze or inspect" })),
|
|
63
72
|
issueIndex: Type.Optional(Type.Number({ description: "1-based issue index to open" })),
|
|
73
|
+
severities: Type.Optional(Type.Array(Type.String({ description: "Issue severity to fetch (e.g. CRITICAL)" }))),
|
|
74
|
+
statuses: Type.Optional(Type.Array(Type.String({ description: "Issue status to fetch (e.g. OPEN)" }))),
|
|
75
|
+
types: Type.Optional(Type.Array(Type.String({ description: "Issue type to fetch (e.g. BUG)" }))),
|
|
76
|
+
rules: Type.Optional(Type.Array(Type.String({ description: "Rule keys to fetch" }))),
|
|
64
77
|
});
|
|
65
78
|
|
|
79
|
+
const SONAR_COMMANDS = [
|
|
80
|
+
{ value: "analyze", label: "analyze", description: "run analysis and fetch issues" },
|
|
81
|
+
{ value: "issues", label: "issues", description: "browse the latest issues" },
|
|
82
|
+
{ value: "open", label: "open", description: "preview a specific issue" },
|
|
83
|
+
{ value: "init", label: "init", description: "configure a project target" },
|
|
84
|
+
] as const;
|
|
85
|
+
|
|
66
86
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
67
87
|
|
|
68
88
|
function nowIso(): string {
|
|
@@ -104,6 +124,99 @@ function normalizeServerUrl(url: string | undefined): string {
|
|
|
104
124
|
return url.trim().replace(/\/+$/, "");
|
|
105
125
|
}
|
|
106
126
|
|
|
127
|
+
const SONAR_SEVERITIES = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"] as const;
|
|
128
|
+
const SONAR_STATUSES = ["OPEN", "CONFIRMED", "REOPENED", "RESOLVED", "CLOSED"] as const;
|
|
129
|
+
const SONAR_TYPES = ["BUG", "VULNERABILITY", "CODE_SMELL"] as const;
|
|
130
|
+
|
|
131
|
+
function createAutocompleteItem(value: string, description?: string): AutocompleteItem {
|
|
132
|
+
return description ? { value, label: value, description } : { value, label: value };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function filterAutocompleteItems(
|
|
136
|
+
items: readonly { value: string; label: string; description?: string }[],
|
|
137
|
+
prefix: string,
|
|
138
|
+
): AutocompleteItem[] {
|
|
139
|
+
const query = prefix.trim().toLowerCase();
|
|
140
|
+
const filtered = query
|
|
141
|
+
? items.filter((item) => item.value.toLowerCase().startsWith(query) || item.label.toLowerCase().startsWith(query))
|
|
142
|
+
: [...items];
|
|
143
|
+
return filtered.map((item) => ({ value: item.value, label: item.label, description: item.description }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function mergeAutocompleteSuggestions(
|
|
147
|
+
prefix: string,
|
|
148
|
+
current: AutocompleteSuggestions | null,
|
|
149
|
+
extraItems: AutocompleteItem[],
|
|
150
|
+
): AutocompleteSuggestions | null {
|
|
151
|
+
const merged = new Map<string, AutocompleteItem>();
|
|
152
|
+
for (const item of current?.items ?? []) merged.set(item.value, item);
|
|
153
|
+
for (const item of extraItems) merged.set(item.value, item);
|
|
154
|
+
const items = [...merged.values()];
|
|
155
|
+
return items.length > 0 ? { prefix, items } : current;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function splitSonarArgumentContext(argumentText: string): { command: string; current: string; tokens: string[] } {
|
|
159
|
+
const trimmedLeft = argumentText.replace(/^\s+/, "");
|
|
160
|
+
if (!trimmedLeft) return { command: "", current: "", tokens: [] };
|
|
161
|
+
|
|
162
|
+
const tokens = trimmedLeft.split(/\s+/);
|
|
163
|
+
const current = /\s$/.test(argumentText) ? "" : tokens.pop() ?? "";
|
|
164
|
+
return { command: tokens[0] ?? current, current, tokens: tokens.length > 0 ? tokens : current ? [current] : [] };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function createFilterCompletionList(): AutocompleteItem[] {
|
|
168
|
+
return [
|
|
169
|
+
...SONAR_SEVERITIES.flatMap((value) => [
|
|
170
|
+
createAutocompleteItem(`severity:${value}`, "severity"),
|
|
171
|
+
createAutocompleteItem(value, "severity"),
|
|
172
|
+
]),
|
|
173
|
+
...SONAR_STATUSES.flatMap((value) => [
|
|
174
|
+
createAutocompleteItem(`status:${value}`, "status"),
|
|
175
|
+
createAutocompleteItem(value, "status"),
|
|
176
|
+
]),
|
|
177
|
+
...SONAR_TYPES.flatMap((value) => [
|
|
178
|
+
createAutocompleteItem(`type:${value}`, "type"),
|
|
179
|
+
createAutocompleteItem(value, "type"),
|
|
180
|
+
]),
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function sonarArgumentCompletions(argumentText: string, issues?: SonarIssue[]): AutocompleteItem[] | null {
|
|
185
|
+
const { command, current, tokens } = splitSonarArgumentContext(argumentText);
|
|
186
|
+
const lowerCommand = command.toLowerCase();
|
|
187
|
+
const commandMatches = SONAR_COMMANDS.filter((item) => item.value.startsWith(lowerCommand));
|
|
188
|
+
|
|
189
|
+
if (!command || commandMatches.length > 1 || !SONAR_COMMANDS.some((item) => item.value === lowerCommand)) {
|
|
190
|
+
return commandMatches.length > 0 ? commandMatches.map((item) => createAutocompleteItem(item.value, item.description)) : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (lowerCommand === "open") {
|
|
194
|
+
const issueItems = (issues ?? []).slice(0, 10).map((issue, index) =>
|
|
195
|
+
createAutocompleteItem(
|
|
196
|
+
String(index + 1),
|
|
197
|
+
`${issue.severity} ${issue.filePath}${issue.line ? `:${issue.line}` : ""}`,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
const suggestions = [...issueItems, ...createFilterCompletionList()];
|
|
201
|
+
const filtered = filterAutocompleteItems(suggestions, current);
|
|
202
|
+
return filtered.length > 0 ? filtered : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (lowerCommand === "analyze" || lowerCommand === "issues") {
|
|
206
|
+
const suggestions = createFilterCompletionList();
|
|
207
|
+
const filtered = filterAutocompleteItems(suggestions, current);
|
|
208
|
+
return filtered.length > 0 ? filtered : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (lowerCommand === "init") {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return tokens.length === 0 ? filterAutocompleteItems(SONAR_COMMANDS, current) : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
107
220
|
function resolveProjectKey(baseDir: string, props: Record<string, string>): string {
|
|
108
221
|
const fromProps = props["sonar.projectKey"]?.trim();
|
|
109
222
|
if (fromProps) return fromProps;
|
|
@@ -334,6 +447,140 @@ function toIssuePath(component: string, projectKey: string): string {
|
|
|
334
447
|
return colon >= 0 ? component.slice(colon + 1) : component;
|
|
335
448
|
}
|
|
336
449
|
|
|
450
|
+
function normalizeIssueList(values?: string[], uppercase = false): string[] | undefined {
|
|
451
|
+
const cleaned = values
|
|
452
|
+
?.map((value) => value.trim())
|
|
453
|
+
.filter(Boolean)
|
|
454
|
+
.map((value) => (uppercase ? value.toUpperCase() : value));
|
|
455
|
+
if (!cleaned?.length) return undefined;
|
|
456
|
+
return [...new Set(cleaned)];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function normalizeIssueFilters(filters?: SonarIssueFetchOptions): SonarIssueFetchOptions | undefined {
|
|
460
|
+
const normalized = {
|
|
461
|
+
severities: normalizeIssueList(filters?.severities, true),
|
|
462
|
+
statuses: normalizeIssueList(filters?.statuses, true),
|
|
463
|
+
types: normalizeIssueList(filters?.types, true),
|
|
464
|
+
rules: normalizeIssueList(filters?.rules),
|
|
465
|
+
};
|
|
466
|
+
return normalized.severities || normalized.statuses || normalized.types || normalized.rules ? normalized : undefined;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function mergeIssueFilters(...filters: Array<Partial<SonarIssueFetchOptions> | undefined>): SonarIssueFetchOptions | undefined {
|
|
470
|
+
const merged: SonarIssueFetchOptions = {};
|
|
471
|
+
for (const filter of filters) {
|
|
472
|
+
if (!filter) continue;
|
|
473
|
+
if (filter.severities?.length) merged.severities = [...(merged.severities ?? []), ...filter.severities];
|
|
474
|
+
if (filter.statuses?.length) merged.statuses = [...(merged.statuses ?? []), ...filter.statuses];
|
|
475
|
+
if (filter.types?.length) merged.types = [...(merged.types ?? []), ...filter.types];
|
|
476
|
+
if (filter.rules?.length) merged.rules = [...(merged.rules ?? []), ...filter.rules];
|
|
477
|
+
}
|
|
478
|
+
return normalizeIssueFilters(merged);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function splitFilterValues(value: string): string[] {
|
|
482
|
+
return value.split(",").map((part) => part.trim()).filter(Boolean);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function parseIssueFilterToken(token: string): Partial<SonarIssueFetchOptions> | undefined {
|
|
486
|
+
const trimmed = token.trim();
|
|
487
|
+
if (!trimmed) return undefined;
|
|
488
|
+
|
|
489
|
+
const explicit = trimmed.match(/^([^:=]+)[:=](.+)$/);
|
|
490
|
+
if (explicit) {
|
|
491
|
+
const key = explicit[1].trim().toLowerCase();
|
|
492
|
+
const values = splitFilterValues(explicit[2]);
|
|
493
|
+
if (values.length === 0) return undefined;
|
|
494
|
+
|
|
495
|
+
if (key === "severity" || key === "severities") return { severities: values.map((value) => value.toUpperCase()) };
|
|
496
|
+
if (key === "status" || key === "statuses") return { statuses: values.map((value) => value.toUpperCase()) };
|
|
497
|
+
if (key === "type" || key === "types") return { types: values.map((value) => value.toUpperCase()) };
|
|
498
|
+
if (key === "rule" || key === "rules") return { rules: values };
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const upper = trimmed.toUpperCase();
|
|
503
|
+
if (SONAR_SEVERITIES.includes(upper as (typeof SONAR_SEVERITIES)[number])) return { severities: [upper] };
|
|
504
|
+
if (SONAR_STATUSES.includes(upper as (typeof SONAR_STATUSES)[number])) return { statuses: [upper] };
|
|
505
|
+
if (SONAR_TYPES.includes(upper as (typeof SONAR_TYPES)[number])) return { types: [upper] };
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
interface ParsedSonarIssueArgs {
|
|
510
|
+
targetInput?: string;
|
|
511
|
+
issueIndex?: number;
|
|
512
|
+
filters?: SonarIssueFetchOptions;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function parseSonarIssueArgs(tokens: string[], allowIssueIndex = false): ParsedSonarIssueArgs {
|
|
516
|
+
const filters: Array<Partial<SonarIssueFetchOptions> | undefined> = [];
|
|
517
|
+
let targetInput: string | undefined;
|
|
518
|
+
let issueIndex: number | undefined;
|
|
519
|
+
|
|
520
|
+
for (const token of tokens) {
|
|
521
|
+
if (!token) continue;
|
|
522
|
+
if (allowIssueIndex && issueIndex === undefined && /^\d+$/.test(token)) {
|
|
523
|
+
issueIndex = Number(token);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const parsedFilter = parseIssueFilterToken(token);
|
|
528
|
+
if (parsedFilter) {
|
|
529
|
+
filters.push(parsedFilter);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!targetInput) {
|
|
534
|
+
targetInput = token;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
targetInput,
|
|
540
|
+
issueIndex,
|
|
541
|
+
filters: mergeIssueFilters(...filters),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function issueFilterLabel(filters?: SonarIssueFetchOptions): string {
|
|
546
|
+
if (!filters) return "";
|
|
547
|
+
const parts = [
|
|
548
|
+
filters.severities?.length ? `severities=${filters.severities.join(",")}` : "",
|
|
549
|
+
filters.statuses?.length ? `statuses=${filters.statuses.join(",")}` : "",
|
|
550
|
+
filters.types?.length ? `types=${filters.types.join(",")}` : "",
|
|
551
|
+
filters.rules?.length ? `rules=${filters.rules.join(",")}` : "",
|
|
552
|
+
].filter(Boolean);
|
|
553
|
+
return parts.join(" • ");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function severitySortRank(severity: string): number {
|
|
557
|
+
switch (severity.toUpperCase()) {
|
|
558
|
+
case "BLOCKER":
|
|
559
|
+
return 0;
|
|
560
|
+
case "CRITICAL":
|
|
561
|
+
return 1;
|
|
562
|
+
case "MAJOR":
|
|
563
|
+
return 2;
|
|
564
|
+
case "MINOR":
|
|
565
|
+
return 3;
|
|
566
|
+
case "INFO":
|
|
567
|
+
return 4;
|
|
568
|
+
default:
|
|
569
|
+
return 5;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function compareIssuesForContext(left: SonarIssue, right: SonarIssue): number {
|
|
574
|
+
const severityDiff = severitySortRank(left.severity) - severitySortRank(right.severity);
|
|
575
|
+
if (severityDiff !== 0) return severityDiff;
|
|
576
|
+
const fileDiff = left.filePath.localeCompare(right.filePath);
|
|
577
|
+
if (fileDiff !== 0) return fileDiff;
|
|
578
|
+
const leftLine = left.line ?? Number.POSITIVE_INFINITY;
|
|
579
|
+
const rightLine = right.line ?? Number.POSITIVE_INFINITY;
|
|
580
|
+
if (leftLine !== rightLine) return leftLine - rightLine;
|
|
581
|
+
return left.message.localeCompare(right.message);
|
|
582
|
+
}
|
|
583
|
+
|
|
337
584
|
// ── Issue helpers ─────────────────────────────────────────────────────────────
|
|
338
585
|
|
|
339
586
|
function buildIssuePreview(baseDir: string, issue: SonarIssue, radius = 3): Promise<string> {
|
|
@@ -367,9 +614,10 @@ function formatIssue(issue: SonarIssue, index?: number): string {
|
|
|
367
614
|
function formatSummary(state: SonarAnalysisState): string {
|
|
368
615
|
const issueCount = state.totalIssues;
|
|
369
616
|
const noIssues = issueCount === 0;
|
|
617
|
+
const filterLabel = state.filters ? ` (${issueFilterLabel(state.filters)})` : "";
|
|
370
618
|
return noIssues
|
|
371
|
-
? `SonarQube: no issues found for ${state.projectKey}`
|
|
372
|
-
: `SonarQube: ${issueCount} issue${issueCount === 1 ? "" : "s"} found for ${state.projectKey}`;
|
|
619
|
+
? `SonarQube: no issues found for ${state.projectKey}${filterLabel}`
|
|
620
|
+
: `SonarQube: ${issueCount} issue${issueCount === 1 ? "" : "s"} found for ${state.projectKey}${filterLabel}`;
|
|
373
621
|
}
|
|
374
622
|
|
|
375
623
|
function formatReport(state: SonarAnalysisState): string {
|
|
@@ -380,6 +628,10 @@ function formatReport(state: SonarAnalysisState): string {
|
|
|
380
628
|
`Issues: ${state.totalIssues}`,
|
|
381
629
|
];
|
|
382
630
|
|
|
631
|
+
if (state.filters) {
|
|
632
|
+
lines.push(`Filters: ${issueFilterLabel(state.filters)}`);
|
|
633
|
+
}
|
|
634
|
+
|
|
383
635
|
if (state.issues.length === 0) {
|
|
384
636
|
lines.push("No open issues found.");
|
|
385
637
|
return lines.join("\n");
|
|
@@ -411,6 +663,45 @@ function severityColor(theme: Theme, severity: string): string {
|
|
|
411
663
|
}
|
|
412
664
|
}
|
|
413
665
|
|
|
666
|
+
const ANALYSIS_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
667
|
+
const ANALYSIS_WIDGET_KEY = "sonarqube-analysis";
|
|
668
|
+
|
|
669
|
+
interface AnalysisUiHandle {
|
|
670
|
+
setPhase(phase: string): void;
|
|
671
|
+
stop(): void;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function startAnalysisUi(ctx: ExtensionContext, projectKey: string): AnalysisUiHandle {
|
|
675
|
+
let frameIndex = 0;
|
|
676
|
+
let phase = "Starting...";
|
|
677
|
+
const render = () => {
|
|
678
|
+
const frame = ANALYSIS_SPINNER[frameIndex];
|
|
679
|
+
ctx.ui.setWidget(ANALYSIS_WIDGET_KEY, [`${frame} SonarQube ${projectKey}`, phase]);
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
ctx.ui.setWorkingMessage(`Analyzing ${projectKey}...`);
|
|
683
|
+
ctx.ui.setWorkingIndicator({ frames: ANALYSIS_SPINNER as unknown as string[], intervalMs: 80 });
|
|
684
|
+
render();
|
|
685
|
+
|
|
686
|
+
const timer = setInterval(() => {
|
|
687
|
+
frameIndex = (frameIndex + 1) % ANALYSIS_SPINNER.length;
|
|
688
|
+
render();
|
|
689
|
+
}, 80);
|
|
690
|
+
|
|
691
|
+
return {
|
|
692
|
+
setPhase(nextPhase: string) {
|
|
693
|
+
phase = nextPhase;
|
|
694
|
+
render();
|
|
695
|
+
},
|
|
696
|
+
stop() {
|
|
697
|
+
clearInterval(timer);
|
|
698
|
+
ctx.ui.setWidget(ANALYSIS_WIDGET_KEY, undefined);
|
|
699
|
+
ctx.ui.setWorkingMessage();
|
|
700
|
+
ctx.ui.setWorkingIndicator();
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
414
705
|
// ── Issue Browser UI component ────────────────────────────────────────────────
|
|
415
706
|
|
|
416
707
|
class IssueBrowser {
|
|
@@ -456,7 +747,10 @@ class IssueBrowser {
|
|
|
456
747
|
|
|
457
748
|
const lines: string[] = [];
|
|
458
749
|
const title = this.theme.fg("accent", this.theme.bold(" SonarQube Issues "));
|
|
459
|
-
const
|
|
750
|
+
const subtitleText = this.state.filters
|
|
751
|
+
? `${this.state.projectKey} • ${this.state.totalIssues} issue(s) • ${issueFilterLabel(this.state.filters)}`
|
|
752
|
+
: `${this.state.projectKey} • ${this.state.totalIssues} issue(s)`;
|
|
753
|
+
const subtitle = this.theme.fg("dim", subtitleText);
|
|
460
754
|
lines.push(truncateToWidth(title, width));
|
|
461
755
|
lines.push(truncateToWidth(subtitle, width));
|
|
462
756
|
lines.push("");
|
|
@@ -517,10 +811,7 @@ async function restoreState(
|
|
|
517
811
|
}
|
|
518
812
|
}
|
|
519
813
|
|
|
520
|
-
|
|
521
|
-
ctx.ui.setStatus("sonarqube", formatSummary(latest));
|
|
522
|
-
}
|
|
523
|
-
|
|
814
|
+
ctx.ui.setStatus("sonarqube", undefined);
|
|
524
815
|
return latest;
|
|
525
816
|
}
|
|
526
817
|
|
|
@@ -654,14 +945,33 @@ async function fetchIssues(
|
|
|
654
945
|
token: string | undefined,
|
|
655
946
|
projectKey: string,
|
|
656
947
|
signal?: AbortSignal,
|
|
948
|
+
filters?: SonarIssueFetchOptions,
|
|
657
949
|
): Promise<SonarIssue[]> {
|
|
950
|
+
const normalizedFilters = normalizeIssueFilters(filters);
|
|
658
951
|
const issues: SonarIssue[] = [];
|
|
659
952
|
const ruleNames = new Map<string, string | undefined>();
|
|
660
953
|
let page = 1;
|
|
661
954
|
let total = 0;
|
|
662
955
|
|
|
663
956
|
for (;;) {
|
|
664
|
-
const url =
|
|
957
|
+
const url = new URL("/api/issues/search", serverUrl);
|
|
958
|
+
url.searchParams.set("projects", projectKey);
|
|
959
|
+
url.searchParams.set("resolved", "false");
|
|
960
|
+
url.searchParams.set("ps", "100");
|
|
961
|
+
url.searchParams.set("p", String(page));
|
|
962
|
+
if (normalizedFilters?.severities?.length) {
|
|
963
|
+
url.searchParams.set("severities", normalizedFilters.severities.join(","));
|
|
964
|
+
}
|
|
965
|
+
if (normalizedFilters?.statuses?.length) {
|
|
966
|
+
url.searchParams.set("statuses", normalizedFilters.statuses.join(","));
|
|
967
|
+
}
|
|
968
|
+
if (normalizedFilters?.types?.length) {
|
|
969
|
+
url.searchParams.set("types", normalizedFilters.types.join(","));
|
|
970
|
+
}
|
|
971
|
+
if (normalizedFilters?.rules?.length) {
|
|
972
|
+
url.searchParams.set("rules", normalizedFilters.rules.join(","));
|
|
973
|
+
}
|
|
974
|
+
|
|
665
975
|
const result = await fetchJson<{
|
|
666
976
|
total: number;
|
|
667
977
|
issues: Array<{
|
|
@@ -673,7 +983,7 @@ async function fetchIssues(
|
|
|
673
983
|
line?: number;
|
|
674
984
|
status?: string;
|
|
675
985
|
}>;
|
|
676
|
-
}>(url, token, signal);
|
|
986
|
+
}>(url.toString(), token, signal);
|
|
677
987
|
|
|
678
988
|
total = result.total;
|
|
679
989
|
for (const raw of result.issues) {
|
|
@@ -696,13 +1006,17 @@ async function fetchIssues(
|
|
|
696
1006
|
page += 1;
|
|
697
1007
|
}
|
|
698
1008
|
|
|
1009
|
+
if (normalizedFilters && issues.length > 1) {
|
|
1010
|
+
issues.sort(compareIssuesForContext);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
699
1013
|
return issues;
|
|
700
1014
|
}
|
|
701
1015
|
|
|
702
1016
|
function createAnalysisState(
|
|
703
1017
|
config: Pick<SonarProjectConfig, "baseDir" | "serverUrl" | "projectKey">,
|
|
704
1018
|
issues: SonarIssue[],
|
|
705
|
-
extras: Partial<Pick<SonarAnalysisState, "dashboardUrl" | "ceTaskUrl" | "analysisId">> = {},
|
|
1019
|
+
extras: Partial<Pick<SonarAnalysisState, "dashboardUrl" | "ceTaskUrl" | "analysisId" | "filters">> = {},
|
|
706
1020
|
): SonarAnalysisState {
|
|
707
1021
|
return {
|
|
708
1022
|
version: 1,
|
|
@@ -713,6 +1027,7 @@ function createAnalysisState(
|
|
|
713
1027
|
dashboardUrl: extras.dashboardUrl,
|
|
714
1028
|
ceTaskUrl: extras.ceTaskUrl,
|
|
715
1029
|
analysisId: extras.analysisId,
|
|
1030
|
+
filters: extras.filters,
|
|
716
1031
|
totalIssues: issues.length,
|
|
717
1032
|
issues,
|
|
718
1033
|
};
|
|
@@ -739,20 +1054,23 @@ async function hasProjectAnalyses(
|
|
|
739
1054
|
async function loadProjectIssuesFromApi(
|
|
740
1055
|
ctx: ExtensionContext,
|
|
741
1056
|
inputPath?: string,
|
|
1057
|
+
filters?: SonarIssueFetchOptions,
|
|
742
1058
|
): Promise<SonarAnalysisState> {
|
|
743
1059
|
const config = await resolveConfig(ctx, inputPath);
|
|
744
|
-
const
|
|
745
|
-
|
|
1060
|
+
const normalizedFilters = normalizeIssueFilters(filters);
|
|
1061
|
+
const issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal, normalizedFilters);
|
|
1062
|
+
return createAnalysisState(config, issues, { filters: normalizedFilters });
|
|
746
1063
|
}
|
|
747
1064
|
|
|
748
1065
|
async function resolveTargetState(
|
|
749
1066
|
ctx: ExtensionContext,
|
|
750
1067
|
statesByBaseDir: Map<string, SonarAnalysisState>,
|
|
751
1068
|
targetInput?: string,
|
|
1069
|
+
filters?: SonarIssueFetchOptions,
|
|
752
1070
|
): Promise<SonarAnalysisState | undefined> {
|
|
753
1071
|
const resolvedTarget = await resolveTarget(ctx, targetInput);
|
|
754
1072
|
try {
|
|
755
|
-
return await loadProjectIssuesFromApi(ctx, targetInput);
|
|
1073
|
+
return await loadProjectIssuesFromApi(ctx, targetInput, filters);
|
|
756
1074
|
} catch (error) {
|
|
757
1075
|
const cached = statesByBaseDir.get(resolvedTarget.baseDir);
|
|
758
1076
|
if (cached) return cached;
|
|
@@ -766,6 +1084,7 @@ async function analyzeProject(
|
|
|
766
1084
|
pi: ExtensionAPI,
|
|
767
1085
|
ctx: ExtensionContext,
|
|
768
1086
|
inputPath?: string,
|
|
1087
|
+
filters?: SonarIssueFetchOptions,
|
|
769
1088
|
): Promise<SonarAnalysisState> {
|
|
770
1089
|
const config = await resolveConfig(ctx, inputPath);
|
|
771
1090
|
const baseDirStat = await stat(config.baseDir).catch(() => undefined);
|
|
@@ -773,38 +1092,37 @@ async function analyzeProject(
|
|
|
773
1092
|
throw new Error(`Project directory not found: ${config.baseDir}`);
|
|
774
1093
|
}
|
|
775
1094
|
|
|
776
|
-
|
|
777
|
-
ctx.ui.setWorkingIndicator({
|
|
778
|
-
frames: [".", "..", "...", ".."],
|
|
779
|
-
intervalMs: 180,
|
|
780
|
-
});
|
|
781
|
-
ctx.ui.setStatus("sonarqube", `Analyzing ${config.projectKey}`);
|
|
1095
|
+
const analysisUi = startAnalysisUi(ctx, config.projectKey);
|
|
782
1096
|
|
|
783
1097
|
try {
|
|
1098
|
+
analysisUi.setPhase("Running sonar-scanner...");
|
|
784
1099
|
await runScanner(pi, config, ctx.signal);
|
|
1100
|
+
|
|
1101
|
+
analysisUi.setPhase("Reading scanner report...");
|
|
785
1102
|
const report = await readReportTask(config.baseDir);
|
|
786
1103
|
const ceTaskUrl = report["ceTaskUrl"];
|
|
787
1104
|
const dashboardUrl = report["dashboardUrl"];
|
|
788
1105
|
const analysisId = ceTaskUrl
|
|
789
|
-
? await waitForAnalysis(config.serverUrl, config.token, ceTaskUrl, ctx.signal)
|
|
1106
|
+
? (analysisUi.setPhase("Waiting for SonarQube analysis..."), await waitForAnalysis(config.serverUrl, config.token, ceTaskUrl, ctx.signal))
|
|
790
1107
|
: undefined;
|
|
791
|
-
|
|
1108
|
+
|
|
1109
|
+
analysisUi.setPhase("Fetching issues...");
|
|
1110
|
+
const normalizedFilters = normalizeIssueFilters(filters);
|
|
1111
|
+
const issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal, normalizedFilters);
|
|
792
1112
|
|
|
793
1113
|
const state = createAnalysisState(config, issues, {
|
|
794
1114
|
dashboardUrl,
|
|
795
1115
|
ceTaskUrl,
|
|
796
1116
|
analysisId,
|
|
1117
|
+
filters: normalizedFilters,
|
|
797
1118
|
});
|
|
798
1119
|
|
|
799
1120
|
pi.appendEntry(STATE_TYPE, state);
|
|
800
|
-
ctx.ui.setStatus("sonarqube", formatSummary(state));
|
|
801
1121
|
return state;
|
|
802
1122
|
} catch (error) {
|
|
803
|
-
ctx.ui.setStatus("sonarqube", undefined);
|
|
804
1123
|
throw error instanceof Error ? error : new Error(String(error));
|
|
805
1124
|
} finally {
|
|
806
|
-
|
|
807
|
-
ctx.ui.setWorkingIndicator();
|
|
1125
|
+
analysisUi.stop();
|
|
808
1126
|
}
|
|
809
1127
|
}
|
|
810
1128
|
|
|
@@ -827,7 +1145,6 @@ async function openIssuePreview(
|
|
|
827
1145
|
): Promise<void> {
|
|
828
1146
|
const preview = await buildIssuePreview(state.baseDir, issue);
|
|
829
1147
|
const title = `${issue.filePath}${issue.line ? `:${issue.line}` : ""}`;
|
|
830
|
-
ctx.ui.setStatus("sonarqube", title);
|
|
831
1148
|
if (ctx.mode === "tui") {
|
|
832
1149
|
await ctx.ui.editor(title, preview);
|
|
833
1150
|
return;
|
|
@@ -889,15 +1206,22 @@ function helpText(): string {
|
|
|
889
1206
|
return [
|
|
890
1207
|
"SonarQube commands:",
|
|
891
1208
|
"",
|
|
892
|
-
" /sonarqube init [alias] [path] configure
|
|
893
|
-
" /sonarqube analyze [target] run analysis for
|
|
894
|
-
" /sonarqube issues [target] browse
|
|
895
|
-
" /sonarqube open [target] <n> preview issue #n for target",
|
|
1209
|
+
" /sonarqube init [alias] [path] configure a project target",
|
|
1210
|
+
" /sonarqube analyze [target] run analysis for a target or path",
|
|
1211
|
+
" /sonarqube issues [target] browse issues for a target or path",
|
|
1212
|
+
" /sonarqube open [target] <n> preview issue #n for a target or path",
|
|
896
1213
|
" /sonarqube show this help",
|
|
897
1214
|
"",
|
|
1215
|
+
"Filters:",
|
|
1216
|
+
" /sonarqube issues be CRITICAL",
|
|
1217
|
+
" /sonarqube issues be severity:CRITICAL status:OPEN",
|
|
1218
|
+
"",
|
|
1219
|
+
"Autocomplete:",
|
|
1220
|
+
" type /sonarqube and press Tab to complete the subcommand or filters",
|
|
1221
|
+
"",
|
|
898
1222
|
"Defaults:",
|
|
899
1223
|
" config is stored in .pi/sonarqube.json",
|
|
900
|
-
"
|
|
1224
|
+
" use project paths directly (no alias needed)",
|
|
901
1225
|
].join("\n");
|
|
902
1226
|
}
|
|
903
1227
|
|
|
@@ -914,9 +1238,9 @@ function sonarErrorMessage(error: unknown): string {
|
|
|
914
1238
|
type ParsedSonarCommand =
|
|
915
1239
|
| { action: "help" }
|
|
916
1240
|
| { action: "init"; alias?: string; targetInput?: string }
|
|
917
|
-
| { action: "analyze"; targetInput?: string }
|
|
918
|
-
| { action: "issues"; targetInput?: string }
|
|
919
|
-
| { action: "open"; targetInput?: string; issueIndex?: number };
|
|
1241
|
+
| { action: "analyze"; targetInput?: string; filters?: SonarIssueFetchOptions }
|
|
1242
|
+
| { action: "issues"; targetInput?: string; filters?: SonarIssueFetchOptions }
|
|
1243
|
+
| { action: "open"; targetInput?: string; issueIndex?: number; filters?: SonarIssueFetchOptions };
|
|
920
1244
|
|
|
921
1245
|
function parseCommandArgs(args: string): ParsedSonarCommand {
|
|
922
1246
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
@@ -933,25 +1257,16 @@ function parseCommandArgs(args: string): ParsedSonarCommand {
|
|
|
933
1257
|
return { action: "init", alias: tokens[1], targetInput: tokens[2] };
|
|
934
1258
|
}
|
|
935
1259
|
if (head === "issues" || head === "view") {
|
|
936
|
-
return { action: "issues",
|
|
1260
|
+
return { action: "issues", ...parseSonarIssueArgs(tokens.slice(1)) };
|
|
937
1261
|
}
|
|
938
1262
|
if (head === "open") {
|
|
939
|
-
|
|
940
|
-
return { action: "open", issueIndex: Number(tokens[1]) };
|
|
941
|
-
}
|
|
942
|
-
const targetInput = tokens[1];
|
|
943
|
-
const maybeIndex = tokens[2];
|
|
944
|
-
return {
|
|
945
|
-
action: "open",
|
|
946
|
-
targetInput,
|
|
947
|
-
issueIndex: maybeIndex && /^\d+$/.test(maybeIndex) ? Number(maybeIndex) : undefined,
|
|
948
|
-
};
|
|
1263
|
+
return { action: "open", ...parseSonarIssueArgs(tokens.slice(1), true) };
|
|
949
1264
|
}
|
|
950
1265
|
if (head === "analyze" || head === "run") {
|
|
951
|
-
return { action: "analyze",
|
|
1266
|
+
return { action: "analyze", ...parseSonarIssueArgs(tokens.slice(1)) };
|
|
952
1267
|
}
|
|
953
1268
|
|
|
954
|
-
return { action: "analyze",
|
|
1269
|
+
return { action: "analyze", ...parseSonarIssueArgs(tokens) };
|
|
955
1270
|
}
|
|
956
1271
|
|
|
957
1272
|
// ── Extension entrypoint ──────────────────────────────────────────────────────
|
|
@@ -971,6 +1286,34 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
971
1286
|
|
|
972
1287
|
pi.on("session_start", async (_event, ctx) => {
|
|
973
1288
|
await restore(ctx);
|
|
1289
|
+
|
|
1290
|
+
if (ctx.hasUI) {
|
|
1291
|
+
ctx.ui.addAutocompleteProvider((current) => ({
|
|
1292
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
1293
|
+
const line = lines[cursorLine] ?? "";
|
|
1294
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
1295
|
+
const match = beforeCursor.match(/^\/sonarqube(?:\s+(.*))?$/);
|
|
1296
|
+
if (!match) {
|
|
1297
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const argumentText = match[1] ?? "";
|
|
1301
|
+
const { current: currentToken } = splitSonarArgumentContext(argumentText);
|
|
1302
|
+
const extraItems = sonarArgumentCompletions(argumentText, latestState?.issues) ?? [];
|
|
1303
|
+
const currentSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
1304
|
+
const merged = mergeAutocompleteSuggestions(currentToken, currentSuggestions, extraItems);
|
|
1305
|
+
return merged ?? currentSuggestions;
|
|
1306
|
+
},
|
|
1307
|
+
|
|
1308
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
1309
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
1310
|
+
},
|
|
1311
|
+
|
|
1312
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
1313
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
1314
|
+
},
|
|
1315
|
+
}));
|
|
1316
|
+
}
|
|
974
1317
|
});
|
|
975
1318
|
pi.on("session_tree", async (_event, ctx) => {
|
|
976
1319
|
await restore(ctx);
|
|
@@ -983,13 +1326,21 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
983
1326
|
promptSnippet: "Run local SonarQube analysis or inspect issue results",
|
|
984
1327
|
promptGuidelines: [
|
|
985
1328
|
"Use sonarqube when the user asks to run a local SonarQube scan, inspect issues, or open an issue's source location.",
|
|
1329
|
+
"Use the optional severity, status, type, and rule filters to fetch only the most relevant issues.",
|
|
986
1330
|
],
|
|
987
1331
|
parameters: SonarToolParams,
|
|
988
1332
|
|
|
989
1333
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
990
1334
|
try {
|
|
1335
|
+
const filters = normalizeIssueFilters({
|
|
1336
|
+
severities: params.severities,
|
|
1337
|
+
statuses: params.statuses,
|
|
1338
|
+
types: params.types,
|
|
1339
|
+
rules: params.rules,
|
|
1340
|
+
});
|
|
1341
|
+
|
|
991
1342
|
if (params.action === "analyze") {
|
|
992
|
-
const state = await analyzeProject(pi, ctx, params.path);
|
|
1343
|
+
const state = await analyzeProject(pi, ctx, params.path, filters);
|
|
993
1344
|
rememberState(state);
|
|
994
1345
|
return {
|
|
995
1346
|
content: [{ type: "text", text: formatReport(state) }],
|
|
@@ -997,7 +1348,7 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
997
1348
|
};
|
|
998
1349
|
}
|
|
999
1350
|
|
|
1000
|
-
const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path);
|
|
1351
|
+
const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path, filters);
|
|
1001
1352
|
if (!targetState) {
|
|
1002
1353
|
return {
|
|
1003
1354
|
content: [{ type: "text", text: "No SonarQube analysis has been run for this target yet." }],
|
|
@@ -1035,10 +1386,17 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
1035
1386
|
},
|
|
1036
1387
|
|
|
1037
1388
|
renderCall(args, theme, _context) {
|
|
1389
|
+
const filters = normalizeIssueFilters({
|
|
1390
|
+
severities: args.severities,
|
|
1391
|
+
statuses: args.statuses,
|
|
1392
|
+
types: args.types,
|
|
1393
|
+
rules: args.rules,
|
|
1394
|
+
});
|
|
1395
|
+
const filterText = filters ? ` ${theme.fg("muted", issueFilterLabel(filters))}` : "";
|
|
1038
1396
|
return new Text(
|
|
1039
1397
|
`${theme.fg("toolTitle", theme.bold("sonarqube"))} ${theme.fg("muted", args.action)}${
|
|
1040
1398
|
args.path ? ` ${theme.fg("accent", args.path)}` : ""
|
|
1041
|
-
}`,
|
|
1399
|
+
}${filterText}`,
|
|
1042
1400
|
0,
|
|
1043
1401
|
0,
|
|
1044
1402
|
);
|
|
@@ -1086,6 +1444,7 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
1086
1444
|
pi.registerCommand("sonarqube", {
|
|
1087
1445
|
description:
|
|
1088
1446
|
"Run a local SonarQube analysis, browse the latest issues, or init project config",
|
|
1447
|
+
getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues),
|
|
1089
1448
|
handler: async (args, ctx) => {
|
|
1090
1449
|
try {
|
|
1091
1450
|
const parsed = parseCommandArgs(args);
|
|
@@ -1106,7 +1465,7 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
1106
1465
|
|
|
1107
1466
|
// --- Analyze ---
|
|
1108
1467
|
if (parsed.action === "analyze") {
|
|
1109
|
-
const state = await analyzeProject(pi, ctx, parsed.targetInput);
|
|
1468
|
+
const state = await analyzeProject(pi, ctx, parsed.targetInput, parsed.filters);
|
|
1110
1469
|
rememberState(state);
|
|
1111
1470
|
if (ctx.hasUI) {
|
|
1112
1471
|
const scope = targetLabel(parsed.targetInput);
|
|
@@ -1115,7 +1474,7 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
1115
1474
|
return;
|
|
1116
1475
|
}
|
|
1117
1476
|
|
|
1118
|
-
const targetState = await resolveTargetState(ctx, statesByBaseDir, parsed.targetInput);
|
|
1477
|
+
const targetState = await resolveTargetState(ctx, statesByBaseDir, parsed.targetInput, parsed.filters);
|
|
1119
1478
|
if (!targetState) {
|
|
1120
1479
|
if (ctx.hasUI) {
|
|
1121
1480
|
ctx.ui.notify(
|