@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/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 subtitle = this.theme.fg("dim", `${this.state.projectKey} • ${this.state.totalIssues} issue(s)`);
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
- if (latest) {
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 = `${serverUrl}/api/issues/search?componentKeys=${encodeURIComponent(projectKey)}&resolved=false&ps=100&p=${page}`;
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 issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal);
745
- return createAnalysisState(config, issues);
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
- ctx.ui.setWorkingMessage(`Running SonarQube analysis for ${config.projectKey}`);
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
- const issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal);
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
- ctx.ui.setWorkingMessage();
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 target and optional alias",
893
- " /sonarqube analyze [target] run analysis for alias or path",
894
- " /sonarqube issues [target] browse latest issues for target",
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
- " monorepo aliases live in .pi/sonarqube.workspaces.json",
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", targetInput: tokens[1] };
1260
+ return { action: "issues", ...parseSonarIssueArgs(tokens.slice(1)) };
937
1261
  }
938
1262
  if (head === "open") {
939
- if (tokens.length === 2 && /^\d+$/.test(tokens[1])) {
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", targetInput: tokens[1] };
1266
+ return { action: "analyze", ...parseSonarIssueArgs(tokens.slice(1)) };
952
1267
  }
953
1268
 
954
- return { action: "analyze", targetInput: tokens[0] };
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(