@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/dist/index.js CHANGED
@@ -10,7 +10,17 @@ const SonarToolParams = Type.Object({
10
10
  action: StringEnum(["analyze", "issues", "open"]),
11
11
  path: Type.Optional(Type.String({ description: "Target alias or project directory to analyze or inspect" })),
12
12
  issueIndex: Type.Optional(Type.Number({ description: "1-based issue index to open" })),
13
+ severities: Type.Optional(Type.Array(Type.String({ description: "Issue severity to fetch (e.g. CRITICAL)" }))),
14
+ statuses: Type.Optional(Type.Array(Type.String({ description: "Issue status to fetch (e.g. OPEN)" }))),
15
+ types: Type.Optional(Type.Array(Type.String({ description: "Issue type to fetch (e.g. BUG)" }))),
16
+ rules: Type.Optional(Type.Array(Type.String({ description: "Rule keys to fetch" }))),
13
17
  });
18
+ const SONAR_COMMANDS = [
19
+ { value: "analyze", label: "analyze", description: "run analysis and fetch issues" },
20
+ { value: "issues", label: "issues", description: "browse the latest issues" },
21
+ { value: "open", label: "open", description: "preview a specific issue" },
22
+ { value: "init", label: "init", description: "configure a project target" },
23
+ ];
14
24
  // ── Helpers ───────────────────────────────────────────────────────────────────
15
25
  function nowIso() {
16
26
  return new Date().toISOString();
@@ -48,6 +58,76 @@ function normalizeServerUrl(url) {
48
58
  return fallback;
49
59
  return url.trim().replace(/\/+$/, "");
50
60
  }
61
+ const SONAR_SEVERITIES = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"];
62
+ const SONAR_STATUSES = ["OPEN", "CONFIRMED", "REOPENED", "RESOLVED", "CLOSED"];
63
+ const SONAR_TYPES = ["BUG", "VULNERABILITY", "CODE_SMELL"];
64
+ function createAutocompleteItem(value, description) {
65
+ return description ? { value, label: value, description } : { value, label: value };
66
+ }
67
+ function filterAutocompleteItems(items, prefix) {
68
+ const query = prefix.trim().toLowerCase();
69
+ const filtered = query
70
+ ? items.filter((item) => item.value.toLowerCase().startsWith(query) || item.label.toLowerCase().startsWith(query))
71
+ : [...items];
72
+ return filtered.map((item) => ({ value: item.value, label: item.label, description: item.description }));
73
+ }
74
+ function mergeAutocompleteSuggestions(prefix, current, extraItems) {
75
+ const merged = new Map();
76
+ for (const item of current?.items ?? [])
77
+ merged.set(item.value, item);
78
+ for (const item of extraItems)
79
+ merged.set(item.value, item);
80
+ const items = [...merged.values()];
81
+ return items.length > 0 ? { prefix, items } : current;
82
+ }
83
+ function splitSonarArgumentContext(argumentText) {
84
+ const trimmedLeft = argumentText.replace(/^\s+/, "");
85
+ if (!trimmedLeft)
86
+ return { command: "", current: "", tokens: [] };
87
+ const tokens = trimmedLeft.split(/\s+/);
88
+ const current = /\s$/.test(argumentText) ? "" : tokens.pop() ?? "";
89
+ return { command: tokens[0] ?? current, current, tokens: tokens.length > 0 ? tokens : current ? [current] : [] };
90
+ }
91
+ function createFilterCompletionList() {
92
+ return [
93
+ ...SONAR_SEVERITIES.flatMap((value) => [
94
+ createAutocompleteItem(`severity:${value}`, "severity"),
95
+ createAutocompleteItem(value, "severity"),
96
+ ]),
97
+ ...SONAR_STATUSES.flatMap((value) => [
98
+ createAutocompleteItem(`status:${value}`, "status"),
99
+ createAutocompleteItem(value, "status"),
100
+ ]),
101
+ ...SONAR_TYPES.flatMap((value) => [
102
+ createAutocompleteItem(`type:${value}`, "type"),
103
+ createAutocompleteItem(value, "type"),
104
+ ]),
105
+ ];
106
+ }
107
+ function sonarArgumentCompletions(argumentText, issues) {
108
+ const { command, current, tokens } = splitSonarArgumentContext(argumentText);
109
+ const lowerCommand = command.toLowerCase();
110
+ const commandMatches = SONAR_COMMANDS.filter((item) => item.value.startsWith(lowerCommand));
111
+ if (!command || commandMatches.length > 1 || !SONAR_COMMANDS.some((item) => item.value === lowerCommand)) {
112
+ return commandMatches.length > 0 ? commandMatches.map((item) => createAutocompleteItem(item.value, item.description)) : null;
113
+ }
114
+ if (lowerCommand === "open") {
115
+ const issueItems = (issues ?? []).slice(0, 10).map((issue, index) => createAutocompleteItem(String(index + 1), `${issue.severity} ${issue.filePath}${issue.line ? `:${issue.line}` : ""}`));
116
+ const suggestions = [...issueItems, ...createFilterCompletionList()];
117
+ const filtered = filterAutocompleteItems(suggestions, current);
118
+ return filtered.length > 0 ? filtered : null;
119
+ }
120
+ if (lowerCommand === "analyze" || lowerCommand === "issues") {
121
+ const suggestions = createFilterCompletionList();
122
+ const filtered = filterAutocompleteItems(suggestions, current);
123
+ return filtered.length > 0 ? filtered : null;
124
+ }
125
+ if (lowerCommand === "init") {
126
+ return null;
127
+ }
128
+ return tokens.length === 0 ? filterAutocompleteItems(SONAR_COMMANDS, current) : null;
129
+ }
130
+ // ── Helpers ───────────────────────────────────────────────────────────────────
51
131
  function resolveProjectKey(baseDir, props) {
52
132
  const fromProps = props["sonar.projectKey"]?.trim();
53
133
  if (fromProps)
@@ -231,6 +311,138 @@ function toIssuePath(component, projectKey) {
231
311
  const colon = component.indexOf(":");
232
312
  return colon >= 0 ? component.slice(colon + 1) : component;
233
313
  }
314
+ function normalizeIssueList(values, uppercase = false) {
315
+ const cleaned = values
316
+ ?.map((value) => value.trim())
317
+ .filter(Boolean)
318
+ .map((value) => (uppercase ? value.toUpperCase() : value));
319
+ if (!cleaned?.length)
320
+ return undefined;
321
+ return [...new Set(cleaned)];
322
+ }
323
+ function normalizeIssueFilters(filters) {
324
+ const normalized = {
325
+ severities: normalizeIssueList(filters?.severities, true),
326
+ statuses: normalizeIssueList(filters?.statuses, true),
327
+ types: normalizeIssueList(filters?.types, true),
328
+ rules: normalizeIssueList(filters?.rules),
329
+ };
330
+ return normalized.severities || normalized.statuses || normalized.types || normalized.rules ? normalized : undefined;
331
+ }
332
+ function mergeIssueFilters(...filters) {
333
+ const merged = {};
334
+ for (const filter of filters) {
335
+ if (!filter)
336
+ continue;
337
+ if (filter.severities?.length)
338
+ merged.severities = [...(merged.severities ?? []), ...filter.severities];
339
+ if (filter.statuses?.length)
340
+ merged.statuses = [...(merged.statuses ?? []), ...filter.statuses];
341
+ if (filter.types?.length)
342
+ merged.types = [...(merged.types ?? []), ...filter.types];
343
+ if (filter.rules?.length)
344
+ merged.rules = [...(merged.rules ?? []), ...filter.rules];
345
+ }
346
+ return normalizeIssueFilters(merged);
347
+ }
348
+ function splitFilterValues(value) {
349
+ return value.split(",").map((part) => part.trim()).filter(Boolean);
350
+ }
351
+ function parseIssueFilterToken(token) {
352
+ const trimmed = token.trim();
353
+ if (!trimmed)
354
+ return undefined;
355
+ const explicit = trimmed.match(/^([^:=]+)[:=](.+)$/);
356
+ if (explicit) {
357
+ const key = explicit[1].trim().toLowerCase();
358
+ const values = splitFilterValues(explicit[2]);
359
+ if (values.length === 0)
360
+ return undefined;
361
+ if (key === "severity" || key === "severities")
362
+ return { severities: values.map((value) => value.toUpperCase()) };
363
+ if (key === "status" || key === "statuses")
364
+ return { statuses: values.map((value) => value.toUpperCase()) };
365
+ if (key === "type" || key === "types")
366
+ return { types: values.map((value) => value.toUpperCase()) };
367
+ if (key === "rule" || key === "rules")
368
+ return { rules: values };
369
+ return undefined;
370
+ }
371
+ const upper = trimmed.toUpperCase();
372
+ if (SONAR_SEVERITIES.includes(upper))
373
+ return { severities: [upper] };
374
+ if (SONAR_STATUSES.includes(upper))
375
+ return { statuses: [upper] };
376
+ if (SONAR_TYPES.includes(upper))
377
+ return { types: [upper] };
378
+ return undefined;
379
+ }
380
+ function parseSonarIssueArgs(tokens, allowIssueIndex = false) {
381
+ const filters = [];
382
+ let targetInput;
383
+ let issueIndex;
384
+ for (const token of tokens) {
385
+ if (!token)
386
+ continue;
387
+ if (allowIssueIndex && issueIndex === undefined && /^\d+$/.test(token)) {
388
+ issueIndex = Number(token);
389
+ continue;
390
+ }
391
+ const parsedFilter = parseIssueFilterToken(token);
392
+ if (parsedFilter) {
393
+ filters.push(parsedFilter);
394
+ continue;
395
+ }
396
+ if (!targetInput) {
397
+ targetInput = token;
398
+ }
399
+ }
400
+ return {
401
+ targetInput,
402
+ issueIndex,
403
+ filters: mergeIssueFilters(...filters),
404
+ };
405
+ }
406
+ function issueFilterLabel(filters) {
407
+ if (!filters)
408
+ return "";
409
+ const parts = [
410
+ filters.severities?.length ? `severities=${filters.severities.join(",")}` : "",
411
+ filters.statuses?.length ? `statuses=${filters.statuses.join(",")}` : "",
412
+ filters.types?.length ? `types=${filters.types.join(",")}` : "",
413
+ filters.rules?.length ? `rules=${filters.rules.join(",")}` : "",
414
+ ].filter(Boolean);
415
+ return parts.join(" • ");
416
+ }
417
+ function severitySortRank(severity) {
418
+ switch (severity.toUpperCase()) {
419
+ case "BLOCKER":
420
+ return 0;
421
+ case "CRITICAL":
422
+ return 1;
423
+ case "MAJOR":
424
+ return 2;
425
+ case "MINOR":
426
+ return 3;
427
+ case "INFO":
428
+ return 4;
429
+ default:
430
+ return 5;
431
+ }
432
+ }
433
+ function compareIssuesForContext(left, right) {
434
+ const severityDiff = severitySortRank(left.severity) - severitySortRank(right.severity);
435
+ if (severityDiff !== 0)
436
+ return severityDiff;
437
+ const fileDiff = left.filePath.localeCompare(right.filePath);
438
+ if (fileDiff !== 0)
439
+ return fileDiff;
440
+ const leftLine = left.line ?? Number.POSITIVE_INFINITY;
441
+ const rightLine = right.line ?? Number.POSITIVE_INFINITY;
442
+ if (leftLine !== rightLine)
443
+ return leftLine - rightLine;
444
+ return left.message.localeCompare(right.message);
445
+ }
234
446
  // ── Issue helpers ─────────────────────────────────────────────────────────────
235
447
  function buildIssuePreview(baseDir, issue, radius = 3) {
236
448
  return (async () => {
@@ -259,9 +471,10 @@ function formatIssue(issue, index) {
259
471
  function formatSummary(state) {
260
472
  const issueCount = state.totalIssues;
261
473
  const noIssues = issueCount === 0;
474
+ const filterLabel = state.filters ? ` (${issueFilterLabel(state.filters)})` : "";
262
475
  return noIssues
263
- ? `SonarQube: no issues found for ${state.projectKey}`
264
- : `SonarQube: ${issueCount} issue${issueCount === 1 ? "" : "s"} found for ${state.projectKey}`;
476
+ ? `SonarQube: no issues found for ${state.projectKey}${filterLabel}`
477
+ : `SonarQube: ${issueCount} issue${issueCount === 1 ? "" : "s"} found for ${state.projectKey}${filterLabel}`;
265
478
  }
266
479
  function formatReport(state) {
267
480
  const lines = [
@@ -270,6 +483,9 @@ function formatReport(state) {
270
483
  `Base dir: ${state.baseDir}`,
271
484
  `Issues: ${state.totalIssues}`,
272
485
  ];
486
+ if (state.filters) {
487
+ lines.push(`Filters: ${issueFilterLabel(state.filters)}`);
488
+ }
273
489
  if (state.issues.length === 0) {
274
490
  lines.push("No open issues found.");
275
491
  return lines.join("\n");
@@ -296,6 +512,35 @@ function severityColor(theme, severity) {
296
512
  return theme.fg("muted", severity);
297
513
  }
298
514
  }
515
+ const ANALYSIS_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
516
+ const ANALYSIS_WIDGET_KEY = "sonarqube-analysis";
517
+ function startAnalysisUi(ctx, projectKey) {
518
+ let frameIndex = 0;
519
+ let phase = "Starting...";
520
+ const render = () => {
521
+ const frame = ANALYSIS_SPINNER[frameIndex];
522
+ ctx.ui.setWidget(ANALYSIS_WIDGET_KEY, [`${frame} SonarQube ${projectKey}`, phase]);
523
+ };
524
+ ctx.ui.setWorkingMessage(`Analyzing ${projectKey}...`);
525
+ ctx.ui.setWorkingIndicator({ frames: ANALYSIS_SPINNER, intervalMs: 80 });
526
+ render();
527
+ const timer = setInterval(() => {
528
+ frameIndex = (frameIndex + 1) % ANALYSIS_SPINNER.length;
529
+ render();
530
+ }, 80);
531
+ return {
532
+ setPhase(nextPhase) {
533
+ phase = nextPhase;
534
+ render();
535
+ },
536
+ stop() {
537
+ clearInterval(timer);
538
+ ctx.ui.setWidget(ANALYSIS_WIDGET_KEY, undefined);
539
+ ctx.ui.setWorkingMessage();
540
+ ctx.ui.setWorkingIndicator();
541
+ },
542
+ };
543
+ }
299
544
  // ── Issue Browser UI component ────────────────────────────────────────────────
300
545
  class IssueBrowser {
301
546
  state;
@@ -338,7 +583,10 @@ class IssueBrowser {
338
583
  }
339
584
  const lines = [];
340
585
  const title = this.theme.fg("accent", this.theme.bold(" SonarQube Issues "));
341
- const subtitle = this.theme.fg("dim", `${this.state.projectKey} • ${this.state.totalIssues} issue(s)`);
586
+ const subtitleText = this.state.filters
587
+ ? `${this.state.projectKey} • ${this.state.totalIssues} issue(s) • ${issueFilterLabel(this.state.filters)}`
588
+ : `${this.state.projectKey} • ${this.state.totalIssues} issue(s)`;
589
+ const subtitle = this.theme.fg("dim", subtitleText);
342
590
  lines.push(truncateToWidth(title, width));
343
591
  lines.push(truncateToWidth(subtitle, width));
344
592
  lines.push("");
@@ -386,9 +634,7 @@ async function restoreState(pi, ctx, statesByBaseDir) {
386
634
  statesByBaseDir.set(latest.baseDir, latest);
387
635
  }
388
636
  }
389
- if (latest) {
390
- ctx.ui.setStatus("sonarqube", formatSummary(latest));
391
- }
637
+ ctx.ui.setStatus("sonarqube", undefined);
392
638
  return latest;
393
639
  }
394
640
  // ── Error hints ───────────────────────────────────────────────────────────────
@@ -487,14 +733,31 @@ async function fetchRuleName(serverUrl, token, ruleKey, signal) {
487
733
  return undefined;
488
734
  }
489
735
  }
490
- async function fetchIssues(serverUrl, token, projectKey, signal) {
736
+ async function fetchIssues(serverUrl, token, projectKey, signal, filters) {
737
+ const normalizedFilters = normalizeIssueFilters(filters);
491
738
  const issues = [];
492
739
  const ruleNames = new Map();
493
740
  let page = 1;
494
741
  let total = 0;
495
742
  for (;;) {
496
- const url = `${serverUrl}/api/issues/search?componentKeys=${encodeURIComponent(projectKey)}&resolved=false&ps=100&p=${page}`;
497
- const result = await fetchJson(url, token, signal);
743
+ const url = new URL("/api/issues/search", serverUrl);
744
+ url.searchParams.set("projects", projectKey);
745
+ url.searchParams.set("resolved", "false");
746
+ url.searchParams.set("ps", "100");
747
+ url.searchParams.set("p", String(page));
748
+ if (normalizedFilters?.severities?.length) {
749
+ url.searchParams.set("severities", normalizedFilters.severities.join(","));
750
+ }
751
+ if (normalizedFilters?.statuses?.length) {
752
+ url.searchParams.set("statuses", normalizedFilters.statuses.join(","));
753
+ }
754
+ if (normalizedFilters?.types?.length) {
755
+ url.searchParams.set("types", normalizedFilters.types.join(","));
756
+ }
757
+ if (normalizedFilters?.rules?.length) {
758
+ url.searchParams.set("rules", normalizedFilters.rules.join(","));
759
+ }
760
+ const result = await fetchJson(url.toString(), token, signal);
498
761
  total = result.total;
499
762
  for (const raw of result.issues) {
500
763
  if (!ruleNames.has(raw.rule)) {
@@ -515,6 +778,9 @@ async function fetchIssues(serverUrl, token, projectKey, signal) {
515
778
  break;
516
779
  page += 1;
517
780
  }
781
+ if (normalizedFilters && issues.length > 1) {
782
+ issues.sort(compareIssuesForContext);
783
+ }
518
784
  return issues;
519
785
  }
520
786
  function createAnalysisState(config, issues, extras = {}) {
@@ -527,6 +793,7 @@ function createAnalysisState(config, issues, extras = {}) {
527
793
  dashboardUrl: extras.dashboardUrl,
528
794
  ceTaskUrl: extras.ceTaskUrl,
529
795
  analysisId: extras.analysisId,
796
+ filters: extras.filters,
530
797
  totalIssues: issues.length,
531
798
  issues,
532
799
  };
@@ -540,15 +807,16 @@ async function hasProjectAnalyses(serverUrl, token, projectKey, signal) {
540
807
  return false;
541
808
  }
542
809
  }
543
- async function loadProjectIssuesFromApi(ctx, inputPath) {
810
+ async function loadProjectIssuesFromApi(ctx, inputPath, filters) {
544
811
  const config = await resolveConfig(ctx, inputPath);
545
- const issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal);
546
- return createAnalysisState(config, issues);
812
+ const normalizedFilters = normalizeIssueFilters(filters);
813
+ const issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal, normalizedFilters);
814
+ return createAnalysisState(config, issues, { filters: normalizedFilters });
547
815
  }
548
- async function resolveTargetState(ctx, statesByBaseDir, targetInput) {
816
+ async function resolveTargetState(ctx, statesByBaseDir, targetInput, filters) {
549
817
  const resolvedTarget = await resolveTarget(ctx, targetInput);
550
818
  try {
551
- return await loadProjectIssuesFromApi(ctx, targetInput);
819
+ return await loadProjectIssuesFromApi(ctx, targetInput, filters);
552
820
  }
553
821
  catch (error) {
554
822
  const cached = statesByBaseDir.get(resolvedTarget.baseDir);
@@ -558,43 +826,40 @@ async function resolveTargetState(ctx, statesByBaseDir, targetInput) {
558
826
  }
559
827
  }
560
828
  // ── Analysis orchestration ────────────────────────────────────────────────────
561
- async function analyzeProject(pi, ctx, inputPath) {
829
+ async function analyzeProject(pi, ctx, inputPath, filters) {
562
830
  const config = await resolveConfig(ctx, inputPath);
563
831
  const baseDirStat = await stat(config.baseDir).catch(() => undefined);
564
832
  if (!baseDirStat?.isDirectory()) {
565
833
  throw new Error(`Project directory not found: ${config.baseDir}`);
566
834
  }
567
- ctx.ui.setWorkingMessage(`Running SonarQube analysis for ${config.projectKey}`);
568
- ctx.ui.setWorkingIndicator({
569
- frames: [".", "..", "...", ".."],
570
- intervalMs: 180,
571
- });
572
- ctx.ui.setStatus("sonarqube", `Analyzing ${config.projectKey}`);
835
+ const analysisUi = startAnalysisUi(ctx, config.projectKey);
573
836
  try {
837
+ analysisUi.setPhase("Running sonar-scanner...");
574
838
  await runScanner(pi, config, ctx.signal);
839
+ analysisUi.setPhase("Reading scanner report...");
575
840
  const report = await readReportTask(config.baseDir);
576
841
  const ceTaskUrl = report["ceTaskUrl"];
577
842
  const dashboardUrl = report["dashboardUrl"];
578
843
  const analysisId = ceTaskUrl
579
- ? await waitForAnalysis(config.serverUrl, config.token, ceTaskUrl, ctx.signal)
844
+ ? (analysisUi.setPhase("Waiting for SonarQube analysis..."), await waitForAnalysis(config.serverUrl, config.token, ceTaskUrl, ctx.signal))
580
845
  : undefined;
581
- const issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal);
846
+ analysisUi.setPhase("Fetching issues...");
847
+ const normalizedFilters = normalizeIssueFilters(filters);
848
+ const issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal, normalizedFilters);
582
849
  const state = createAnalysisState(config, issues, {
583
850
  dashboardUrl,
584
851
  ceTaskUrl,
585
852
  analysisId,
853
+ filters: normalizedFilters,
586
854
  });
587
855
  pi.appendEntry(STATE_TYPE, state);
588
- ctx.ui.setStatus("sonarqube", formatSummary(state));
589
856
  return state;
590
857
  }
591
858
  catch (error) {
592
- ctx.ui.setStatus("sonarqube", undefined);
593
859
  throw error instanceof Error ? error : new Error(String(error));
594
860
  }
595
861
  finally {
596
- ctx.ui.setWorkingMessage();
597
- ctx.ui.setWorkingIndicator();
862
+ analysisUi.stop();
598
863
  }
599
864
  }
600
865
  // ── UI helpers ────────────────────────────────────────────────────────────────
@@ -608,7 +873,6 @@ async function showIssueBrowser(ctx, state) {
608
873
  async function openIssuePreview(ctx, state, issue) {
609
874
  const preview = await buildIssuePreview(state.baseDir, issue);
610
875
  const title = `${issue.filePath}${issue.line ? `:${issue.line}` : ""}`;
611
- ctx.ui.setStatus("sonarqube", title);
612
876
  if (ctx.mode === "tui") {
613
877
  await ctx.ui.editor(title, preview);
614
878
  return;
@@ -655,15 +919,22 @@ function helpText() {
655
919
  return [
656
920
  "SonarQube commands:",
657
921
  "",
658
- " /sonarqube init [alias] [path] configure target and optional alias",
659
- " /sonarqube analyze [target] run analysis for alias or path",
660
- " /sonarqube issues [target] browse latest issues for target",
661
- " /sonarqube open [target] <n> preview issue #n for target",
922
+ " /sonarqube init [alias] [path] configure a project target",
923
+ " /sonarqube analyze [target] run analysis for a target or path",
924
+ " /sonarqube issues [target] browse issues for a target or path",
925
+ " /sonarqube open [target] <n> preview issue #n for a target or path",
662
926
  " /sonarqube show this help",
663
927
  "",
928
+ "Filters:",
929
+ " /sonarqube issues be CRITICAL",
930
+ " /sonarqube issues be severity:CRITICAL status:OPEN",
931
+ "",
932
+ "Autocomplete:",
933
+ " type /sonarqube and press Tab to complete the subcommand or filters",
934
+ "",
664
935
  "Defaults:",
665
936
  " config is stored in .pi/sonarqube.json",
666
- " monorepo aliases live in .pi/sonarqube.workspaces.json",
937
+ " use project paths directly (no alias needed)",
667
938
  ].join("\n");
668
939
  }
669
940
  function targetLabel(targetInput) {
@@ -688,24 +959,15 @@ function parseCommandArgs(args) {
688
959
  return { action: "init", alias: tokens[1], targetInput: tokens[2] };
689
960
  }
690
961
  if (head === "issues" || head === "view") {
691
- return { action: "issues", targetInput: tokens[1] };
962
+ return { action: "issues", ...parseSonarIssueArgs(tokens.slice(1)) };
692
963
  }
693
964
  if (head === "open") {
694
- if (tokens.length === 2 && /^\d+$/.test(tokens[1])) {
695
- return { action: "open", issueIndex: Number(tokens[1]) };
696
- }
697
- const targetInput = tokens[1];
698
- const maybeIndex = tokens[2];
699
- return {
700
- action: "open",
701
- targetInput,
702
- issueIndex: maybeIndex && /^\d+$/.test(maybeIndex) ? Number(maybeIndex) : undefined,
703
- };
965
+ return { action: "open", ...parseSonarIssueArgs(tokens.slice(1), true) };
704
966
  }
705
967
  if (head === "analyze" || head === "run") {
706
- return { action: "analyze", targetInput: tokens[1] };
968
+ return { action: "analyze", ...parseSonarIssueArgs(tokens.slice(1)) };
707
969
  }
708
- return { action: "analyze", targetInput: tokens[0] };
970
+ return { action: "analyze", ...parseSonarIssueArgs(tokens) };
709
971
  }
710
972
  // ── Extension entrypoint ──────────────────────────────────────────────────────
711
973
  export default function sonarqube(pi) {
@@ -720,6 +982,30 @@ export default function sonarqube(pi) {
720
982
  };
721
983
  pi.on("session_start", async (_event, ctx) => {
722
984
  await restore(ctx);
985
+ if (ctx.hasUI) {
986
+ ctx.ui.addAutocompleteProvider((current) => ({
987
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
988
+ const line = lines[cursorLine] ?? "";
989
+ const beforeCursor = line.slice(0, cursorCol);
990
+ const match = beforeCursor.match(/^\/sonarqube(?:\s+(.*))?$/);
991
+ if (!match) {
992
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
993
+ }
994
+ const argumentText = match[1] ?? "";
995
+ const { current: currentToken } = splitSonarArgumentContext(argumentText);
996
+ const extraItems = sonarArgumentCompletions(argumentText, latestState?.issues) ?? [];
997
+ const currentSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
998
+ const merged = mergeAutocompleteSuggestions(currentToken, currentSuggestions, extraItems);
999
+ return merged ?? currentSuggestions;
1000
+ },
1001
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
1002
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
1003
+ },
1004
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
1005
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
1006
+ },
1007
+ }));
1008
+ }
723
1009
  });
724
1010
  pi.on("session_tree", async (_event, ctx) => {
725
1011
  await restore(ctx);
@@ -731,19 +1017,26 @@ export default function sonarqube(pi) {
731
1017
  promptSnippet: "Run local SonarQube analysis or inspect issue results",
732
1018
  promptGuidelines: [
733
1019
  "Use sonarqube when the user asks to run a local SonarQube scan, inspect issues, or open an issue's source location.",
1020
+ "Use the optional severity, status, type, and rule filters to fetch only the most relevant issues.",
734
1021
  ],
735
1022
  parameters: SonarToolParams,
736
1023
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
737
1024
  try {
1025
+ const filters = normalizeIssueFilters({
1026
+ severities: params.severities,
1027
+ statuses: params.statuses,
1028
+ types: params.types,
1029
+ rules: params.rules,
1030
+ });
738
1031
  if (params.action === "analyze") {
739
- const state = await analyzeProject(pi, ctx, params.path);
1032
+ const state = await analyzeProject(pi, ctx, params.path, filters);
740
1033
  rememberState(state);
741
1034
  return {
742
1035
  content: [{ type: "text", text: formatReport(state) }],
743
1036
  details: state,
744
1037
  };
745
1038
  }
746
- const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path);
1039
+ const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path, filters);
747
1040
  if (!targetState) {
748
1041
  return {
749
1042
  content: [{ type: "text", text: "No SonarQube analysis has been run for this target yet." }],
@@ -777,7 +1070,14 @@ export default function sonarqube(pi) {
777
1070
  }
778
1071
  },
779
1072
  renderCall(args, theme, _context) {
780
- return new Text(`${theme.fg("toolTitle", theme.bold("sonarqube"))} ${theme.fg("muted", args.action)}${args.path ? ` ${theme.fg("accent", args.path)}` : ""}`, 0, 0);
1073
+ const filters = normalizeIssueFilters({
1074
+ severities: args.severities,
1075
+ statuses: args.statuses,
1076
+ types: args.types,
1077
+ rules: args.rules,
1078
+ });
1079
+ const filterText = filters ? ` ${theme.fg("muted", issueFilterLabel(filters))}` : "";
1080
+ return new Text(`${theme.fg("toolTitle", theme.bold("sonarqube"))} ${theme.fg("muted", args.action)}${args.path ? ` ${theme.fg("accent", args.path)}` : ""}${filterText}`, 0, 0);
781
1081
  },
782
1082
  renderResult(result, { expanded }, theme, _context) {
783
1083
  const state = result.details;
@@ -811,6 +1111,7 @@ export default function sonarqube(pi) {
811
1111
  });
812
1112
  pi.registerCommand("sonarqube", {
813
1113
  description: "Run a local SonarQube analysis, browse the latest issues, or init project config",
1114
+ getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues),
814
1115
  handler: async (args, ctx) => {
815
1116
  try {
816
1117
  const parsed = parseCommandArgs(args);
@@ -828,7 +1129,7 @@ export default function sonarqube(pi) {
828
1129
  }
829
1130
  // --- Analyze ---
830
1131
  if (parsed.action === "analyze") {
831
- const state = await analyzeProject(pi, ctx, parsed.targetInput);
1132
+ const state = await analyzeProject(pi, ctx, parsed.targetInput, parsed.filters);
832
1133
  rememberState(state);
833
1134
  if (ctx.hasUI) {
834
1135
  const scope = targetLabel(parsed.targetInput);
@@ -836,7 +1137,7 @@ export default function sonarqube(pi) {
836
1137
  }
837
1138
  return;
838
1139
  }
839
- const targetState = await resolveTargetState(ctx, statesByBaseDir, parsed.targetInput);
1140
+ const targetState = await resolveTargetState(ctx, statesByBaseDir, parsed.targetInput, parsed.filters);
840
1141
  if (!targetState) {
841
1142
  if (ctx.hasUI) {
842
1143
  ctx.ui.notify(`No SonarQube analysis found for this target. Run /sonarqube analyze${targetLabel(parsed.targetInput)} first.`, "warning");