@jonathan0823/pi-sonarqube 0.1.2 → 0.1.4

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
@@ -1,507 +1,74 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { stat } from "node:fs/promises";
4
+ import { basename } from "node:path";
1
5
  import { StringEnum } from "@earendil-works/pi-ai";
2
- import type {
3
- ExtensionAPI,
4
- ExtensionCommandContext,
5
- ExtensionContext,
6
- Theme,
7
- } from "@earendil-works/pi-coding-agent";
8
- import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
9
- import { Key, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
10
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
11
- import { basename, dirname, relative, resolve } from "node:path";
12
6
  import { Type } from "typebox";
13
-
14
- // ── Public types ──────────────────────────────────────────────────────────────
15
-
16
- export type SonarAction = "analyze" | "issues" | "open" | "init";
17
-
18
- export interface SonarIssue {
19
- key: string;
20
- rule: string;
21
- ruleName?: string;
22
- severity: string;
23
- message: string;
24
- filePath: string;
25
- line: number | null;
26
- status?: string;
27
- }
28
-
29
- export interface SonarAnalysisState {
30
- version: 1;
31
- analyzedAt: string;
32
- baseDir: string;
33
- serverUrl: string;
34
- projectKey: string;
35
- dashboardUrl?: string;
36
- ceTaskUrl?: string;
37
- analysisId?: string;
38
- totalIssues: number;
39
- issues: SonarIssue[];
40
- }
41
-
42
- export interface SonarProjectConfig {
43
- baseDir: string;
44
- serverUrl: string;
45
- projectKey: string;
46
- token?: string;
47
- hasProperties: boolean;
48
- }
49
-
50
- export interface SonarInitConfig {
51
- serverUrl: string;
52
- projectKey: string;
53
- token?: string;
54
- }
55
-
56
- // ── Internal types / state keys ───────────────────────────────────────────────
57
-
58
- const STATE_TYPE = "sonarqube-analysis-state";
7
+ import type {
8
+ SonarIssue,
9
+ SonarAnalysisState,
10
+ SonarIssueFetchOptions,
11
+ SonarInitConfig,
12
+ InitCommandOptions,
13
+ } from "./types.js";
14
+ import {
15
+ slugify,
16
+ normalizeServerUrl,
17
+ sonarProjectPropertiesPath,
18
+ projectConfigPath,
19
+ resolveInitTarget,
20
+ loadProjectConfig,
21
+ saveProjectConfig,
22
+ ensureDefaultSonarProjectProperties,
23
+ saveWorkspaceRegistry,
24
+ resolveConfig,
25
+ } from "./config.js";
26
+ import {
27
+ runScanner,
28
+ readReportTask,
29
+ waitForAnalysis,
30
+ normalizeIssueFilters,
31
+ fetchIssues,
32
+ createAnalysisState,
33
+ issueFilterLabel,
34
+ } from "./api.js";
35
+ import {
36
+ STATE_TYPE,
37
+ sonarArgumentCompletions,
38
+ parseCommandArgs,
39
+ splitSonarArgumentContext,
40
+ mergeAutocompleteSuggestions,
41
+ formatIssue,
42
+ formatSummary,
43
+ formatReport,
44
+ helpText,
45
+ targetLabel,
46
+ sonarErrorMessage,
47
+ } from "./commands.js";
48
+
49
+ import {
50
+ startAnalysisUi,
51
+ showIssueBrowser,
52
+ buildIssuePreview,
53
+ openIssuePreview,
54
+ resolveTargetState,
55
+ } from "./ui.js";
59
56
 
60
57
  const SonarToolParams = Type.Object({
61
58
  action: StringEnum(["analyze", "issues", "open"] as const),
62
59
  path: Type.Optional(Type.String({ description: "Target alias or project directory to analyze or inspect" })),
63
60
  issueIndex: Type.Optional(Type.Number({ description: "1-based issue index to open" })),
61
+ severities: Type.Optional(
62
+ Type.Array(Type.String({ description: "Issue severity to fetch (e.g. CRITICAL)" })),
63
+ ),
64
+ statuses: Type.Optional(
65
+ Type.Array(Type.String({ description: "Issue status to fetch (e.g. OPEN)" })),
66
+ ),
67
+ types: Type.Optional(Type.Array(Type.String({ description: "Issue type to fetch (e.g. BUG)" }))),
68
+ rules: Type.Optional(Type.Array(Type.String({ description: "Rule keys to fetch" }))),
64
69
  });
65
70
 
66
- // ── Helpers ───────────────────────────────────────────────────────────────────
67
-
68
- function nowIso(): string {
69
- return new Date().toISOString();
70
- }
71
-
72
- function slugify(input: string): string {
73
- return (
74
- input
75
- .toLowerCase()
76
- .replace(/[^a-z0-9]+/g, "-")
77
- .replace(/^-+|-+$/g, "")
78
- .slice(0, 80) || "sonarqube"
79
- );
80
- }
81
-
82
- function normalizePath(input: string, cwd: string): string {
83
- const trimmed = input.trim().replace(/^@/, "");
84
- return resolve(cwd, trimmed);
85
- }
86
-
87
- function parseProperties(text: string): Record<string, string> {
88
- const props: Record<string, string> = {};
89
- for (const rawLine of text.split(/\r?\n/)) {
90
- const line = rawLine.trim();
91
- if (!line || line.startsWith("#") || line.startsWith("!")) continue;
92
- const sep = line.indexOf("=");
93
- if (sep < 0) continue;
94
- const key = line.slice(0, sep).trim();
95
- const value = line.slice(sep + 1).trim();
96
- if (key) props[key] = value;
97
- }
98
- return props;
99
- }
100
-
101
- function normalizeServerUrl(url: string | undefined): string {
102
- const fallback = "http://localhost:9000";
103
- if (!url?.trim()) return fallback;
104
- return url.trim().replace(/\/+$/, "");
105
- }
106
-
107
- function resolveProjectKey(baseDir: string, props: Record<string, string>): string {
108
- const fromProps = props["sonar.projectKey"]?.trim();
109
- if (fromProps) return fromProps;
110
- return slugify(basename(baseDir));
111
- }
112
-
113
- async function readOptionalText(path: string): Promise<string | undefined> {
114
- try {
115
- return await readFile(path, "utf8");
116
- } catch {
117
- return undefined;
118
- }
119
- }
120
-
121
- async function readOptionalJson<T>(path: string): Promise<T | undefined> {
122
- try {
123
- const text = await readFile(path, "utf8");
124
- return JSON.parse(text) as T;
125
- } catch {
126
- return undefined;
127
- }
128
- }
129
-
130
- function projectConfigPath(baseDir: string): string {
131
- return resolve(baseDir, CONFIG_DIR_NAME, "sonarqube.json");
132
- }
133
-
134
- function sonarqubeConfigDir(baseDir: string): string {
135
- return resolve(baseDir, CONFIG_DIR_NAME);
136
- }
137
-
138
- /**
139
- * Load project-local config from `.pi/sonarqube.json`.
140
- * Returns undefined when the file doesn't exist or is invalid.
141
- */
142
- async function loadProjectConfig(baseDir: string): Promise<SonarInitConfig | undefined> {
143
- return readOptionalJson<SonarInitConfig>(projectConfigPath(baseDir));
144
- }
145
-
146
- /**
147
- * Save project-local config to `.pi/sonarqube.json`.
148
- */
149
- async function saveProjectConfig(baseDir: string, config: SonarInitConfig): Promise<void> {
150
- const dir = sonarqubeConfigDir(baseDir);
151
- await mkdir(dir, { recursive: true });
152
- await writeFile(projectConfigPath(baseDir), JSON.stringify(config, null, 2) + "\n", "utf8");
153
- }
154
-
155
- interface SonarWorkspaceRegistry {
156
- version: 1;
157
- workspaces: Record<string, string>;
158
- }
159
-
160
- interface ResolvedTarget {
161
- baseDir: string;
162
- repoRoot: string;
163
- alias?: string;
164
- }
165
-
166
- const WORKSPACE_REGISTRY_FILE = "sonarqube.workspaces.json";
167
-
168
- function workspaceRegistryPath(repoRoot: string): string {
169
- return resolve(repoRoot, CONFIG_DIR_NAME, WORKSPACE_REGISTRY_FILE);
170
- }
171
-
172
- async function hasGitRootMarker(dir: string): Promise<boolean> {
173
- try {
174
- const entry = await stat(resolve(dir, ".git"));
175
- return entry.isDirectory() || entry.isFile();
176
- } catch {
177
- return false;
178
- }
179
- }
180
-
181
- async function findRepoRoot(startDir: string): Promise<string> {
182
- let dir = resolve(startDir);
183
- for (;;) {
184
- if (await hasGitRootMarker(dir)) return dir;
185
- const parent = dirname(dir);
186
- if (parent === dir) return resolve(startDir);
187
- dir = parent;
188
- }
189
- }
190
-
191
- async function loadWorkspaceRegistry(startDir: string): Promise<{ repoRoot: string; registry: SonarWorkspaceRegistry }> {
192
- const repoRoot = await findRepoRoot(startDir);
193
- const raw = await readOptionalJson<Partial<SonarWorkspaceRegistry>>(workspaceRegistryPath(repoRoot));
194
- const workspaces = raw?.workspaces && typeof raw.workspaces === "object" ? { ...raw.workspaces } : {};
195
- return { repoRoot, registry: { version: 1, workspaces: workspaces as Record<string, string> } };
196
- }
197
-
198
- async function saveWorkspaceRegistry(startDir: string, alias: string, targetDir: string): Promise<void> {
199
- const { repoRoot, registry } = await loadWorkspaceRegistry(startDir);
200
- registry.workspaces[alias] = relative(repoRoot, targetDir) || ".";
201
- await mkdir(sonarqubeConfigDir(repoRoot), { recursive: true });
202
- await writeFile(workspaceRegistryPath(repoRoot), JSON.stringify(registry, null, 2) + "\n", "utf8");
203
- }
204
-
205
- function looksLikePath(token: string): boolean {
206
- return (
207
- token === "." ||
208
- token === ".." ||
209
- token.startsWith("./") ||
210
- token.startsWith("../") ||
211
- token.startsWith("/") ||
212
- token.startsWith("~") ||
213
- token.includes("/") ||
214
- token.includes("\\")
215
- );
216
- }
217
-
218
- function knownTargets(registry: SonarWorkspaceRegistry): string[] {
219
- return Object.keys(registry.workspaces).sort();
220
- }
221
-
222
- async function resolveTarget(ctx: ExtensionContext, targetInput?: string): Promise<ResolvedTarget> {
223
- const { repoRoot, registry } = await loadWorkspaceRegistry(ctx.cwd);
224
- if (!targetInput) {
225
- return { baseDir: ctx.cwd, repoRoot };
226
- }
227
-
228
- const aliasTarget = registry.workspaces[targetInput];
229
- if (aliasTarget) {
230
- const baseDir = resolve(repoRoot, aliasTarget);
231
- const baseDirStat = await stat(baseDir).catch(() => undefined);
232
- if (!baseDirStat?.isDirectory()) {
233
- throw new Error(`SonarQube target "${targetInput}" points to a missing directory: ${baseDir}`);
234
- }
235
- return { baseDir, repoRoot, alias: targetInput };
236
- }
237
-
238
- if (looksLikePath(targetInput)) {
239
- const baseDir = normalizePath(targetInput, ctx.cwd);
240
- const baseDirStat = await stat(baseDir).catch(() => undefined);
241
- if (!baseDirStat?.isDirectory()) {
242
- throw new Error(`SonarQube target path not found: ${baseDir}`);
243
- }
244
- return { baseDir, repoRoot };
245
- }
246
-
247
- const known = knownTargets(registry);
248
- if (known.length > 0) {
249
- throw new Error(
250
- `Unknown SonarQube target "${targetInput}". Known targets: ${known.join(", ")}. Use /sonarqube init <alias> <path> to add one.`,
251
- );
252
- }
253
-
254
- throw new Error(`Unknown SonarQube target "${targetInput}". Use /sonarqube init <alias> <path> to add one.`);
255
- }
256
-
257
- async function resolveInitTarget(
258
- ctx: ExtensionContext,
259
- alias?: string,
260
- targetInput?: string,
261
- ): Promise<ResolvedTarget> {
262
- if (targetInput) {
263
- const resolved = await resolveTarget(ctx, targetInput);
264
- return { ...resolved, alias };
265
- }
266
-
267
- const { repoRoot } = await loadWorkspaceRegistry(ctx.cwd);
268
- return { baseDir: ctx.cwd, repoRoot, alias };
269
- }
270
-
271
- export { projectConfigPath, loadProjectConfig, saveProjectConfig, sonarqubeConfigDir };
272
-
273
- // ── Config resolution (env > .pi/sonarqube.json > sonar-project.properties) ────
274
-
275
- async function resolveConfig(ctx: ExtensionContext, inputPath?: string): Promise<SonarProjectConfig> {
276
- const { baseDir } = await resolveTarget(ctx, inputPath);
277
- const propertiesPath = resolve(baseDir, "sonar-project.properties");
278
- const propertiesText = await readOptionalText(propertiesPath);
279
- const props = propertiesText ? parseProperties(propertiesText) : {};
280
- const projectCfg = await loadProjectConfig(baseDir);
281
-
282
- const serverUrl = normalizeServerUrl(
283
- process.env.SONARQUBE_URL ??
284
- process.env.SONAR_HOST_URL ??
285
- projectCfg?.serverUrl?.trim() ??
286
- props["sonar.host.url"],
287
- );
288
- const projectKey =
289
- process.env.SONAR_PROJECT_KEY?.trim() ||
290
- projectCfg?.projectKey?.trim() ||
291
- props["sonar.projectKey"]?.trim() ||
292
- resolveProjectKey(baseDir, props);
293
- const token =
294
- process.env.SONARQUBE_TOKEN?.trim() ||
295
- process.env.SONAR_TOKEN?.trim() ||
296
- projectCfg?.token?.trim() ||
297
- props["sonar.token"]?.trim();
298
-
299
- return {
300
- baseDir,
301
- serverUrl,
302
- projectKey,
303
- token: token || undefined,
304
- hasProperties: Boolean(propertiesText),
305
- };
306
- }
307
-
308
- // ── Auth / API helpers ────────────────────────────────────────────────────────
309
-
310
- function authHeader(token?: string): string | undefined {
311
- if (!token) return undefined;
312
- return `Basic ${Buffer.from(`${token}:`).toString("base64")}`;
313
- }
314
-
315
- async function fetchJson<T>(url: string, token?: string, signal?: AbortSignal): Promise<T> {
316
- const headers: Record<string, string> = { Accept: "application/json" };
317
- const auth = authHeader(token);
318
- if (auth) headers.Authorization = auth;
319
-
320
- const response = await fetch(url, { headers, signal });
321
- if (!response.ok) {
322
- const body = await response.text().catch(() => "");
323
- const suffix = body.trim() ? `: ${body.trim().slice(0, 240)}` : "";
324
- throw new Error(`SonarQube API request failed (${response.status} ${response.statusText}) for ${url}${suffix}`);
325
- }
326
-
327
- return (await response.json()) as T;
328
- }
329
-
330
- function toIssuePath(component: string, projectKey: string): string {
331
- const prefix = `${projectKey}:`;
332
- if (component.startsWith(prefix)) return component.slice(prefix.length);
333
- const colon = component.indexOf(":");
334
- return colon >= 0 ? component.slice(colon + 1) : component;
335
- }
336
-
337
- // ── Issue helpers ─────────────────────────────────────────────────────────────
338
-
339
- function buildIssuePreview(baseDir: string, issue: SonarIssue, radius = 3): Promise<string> {
340
- return (async () => {
341
- const filePath = resolve(baseDir, issue.filePath);
342
- const content = await readFile(filePath, "utf8");
343
- const lines = content.split(/\r?\n/);
344
- const target = issue.line ?? 1;
345
- const start = Math.max(1, target - radius);
346
- const end = Math.min(lines.length, target + radius);
347
- const width = String(end).length;
348
-
349
- const rendered: string[] = [];
350
- for (let line = start; line <= end; line++) {
351
- const marker = line === target ? ">" : " ";
352
- const num = String(line).padStart(width, " ");
353
- rendered.push(`${marker} ${num} | ${lines[line - 1] ?? ""}`);
354
- }
355
-
356
- return rendered.join("\n");
357
- })();
358
- }
359
-
360
- function formatIssue(issue: SonarIssue, index?: number): string {
361
- const loc = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
362
- const prefix = index !== undefined ? `${String(index).padStart(2, "0")}. ` : "";
363
- const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
364
- return `${prefix}${issue.severity} ${loc} — ${rule} — ${issue.message}`;
365
- }
366
-
367
- function formatSummary(state: SonarAnalysisState): string {
368
- const issueCount = state.totalIssues;
369
- const noIssues = issueCount === 0;
370
- return noIssues
371
- ? `SonarQube: no issues found for ${state.projectKey}`
372
- : `SonarQube: ${issueCount} issue${issueCount === 1 ? "" : "s"} found for ${state.projectKey}`;
373
- }
374
-
375
- function formatReport(state: SonarAnalysisState): string {
376
- const lines = [
377
- `Project: ${state.projectKey}`,
378
- `Server: ${state.serverUrl}`,
379
- `Base dir: ${state.baseDir}`,
380
- `Issues: ${state.totalIssues}`,
381
- ];
382
-
383
- if (state.issues.length === 0) {
384
- lines.push("No open issues found.");
385
- return lines.join("\n");
386
- }
387
-
388
- lines.push("");
389
- for (const [index, issue] of state.issues.slice(0, 10).entries()) {
390
- lines.push(formatIssue(issue, index + 1));
391
- }
392
- if (state.issues.length > 10) {
393
- lines.push(`... ${state.issues.length - 10} more`);
394
- }
395
- return lines.join("\n");
396
- }
397
-
398
-
399
-
400
- function severityColor(theme: Theme, severity: string): string {
401
- switch (severity.toLowerCase()) {
402
- case "blocker":
403
- case "critical":
404
- return theme.fg("error", severity);
405
- case "major":
406
- return theme.fg("warning", severity);
407
- case "minor":
408
- return theme.fg("accent", severity);
409
- default:
410
- return theme.fg("muted", severity);
411
- }
412
- }
413
-
414
- // ── Issue Browser UI component ────────────────────────────────────────────────
415
-
416
- class IssueBrowser {
417
- private selected = 0;
418
- private cachedWidth: number | undefined;
419
- private cachedLines: string[] | undefined;
420
-
421
- constructor(
422
- private readonly state: SonarAnalysisState,
423
- private readonly theme: Theme,
424
- private readonly done: (result: number | null) => void,
425
- ) {}
426
-
427
- invalidate(): void {
428
- this.cachedWidth = undefined;
429
- this.cachedLines = undefined;
430
- }
431
-
432
- handleInput(data: string): void {
433
- if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
434
- this.done(null);
435
- return;
436
- }
437
- if (matchesKey(data, Key.up)) {
438
- this.selected = Math.max(0, this.selected - 1);
439
- this.invalidate();
440
- return;
441
- }
442
- if (matchesKey(data, Key.down)) {
443
- this.selected = Math.min(this.state.issues.length - 1, this.selected + 1);
444
- this.invalidate();
445
- return;
446
- }
447
- if (matchesKey(data, Key.enter)) {
448
- this.done(this.selected);
449
- }
450
- }
451
-
452
- render(width: number): string[] {
453
- if (this.cachedLines && this.cachedWidth === width) {
454
- return this.cachedLines;
455
- }
456
-
457
- const lines: string[] = [];
458
- 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)`);
460
- lines.push(truncateToWidth(title, width));
461
- lines.push(truncateToWidth(subtitle, width));
462
- lines.push("");
463
-
464
- const pageSize = Math.min(20, this.state.issues.length);
465
- const halfWindow = Math.floor(pageSize / 2);
466
- const maxStart = Math.max(0, this.state.issues.length - pageSize);
467
- const start = Math.max(0, Math.min(this.selected - halfWindow, maxStart));
468
- const end = Math.min(this.state.issues.length, start + pageSize);
469
- const visibleIssues = this.state.issues.slice(start, end);
470
-
471
- if (visibleIssues.length === 0) {
472
- lines.push(truncateToWidth(this.theme.fg("success", "No open issues found."), width));
473
- } else {
474
- if (start > 0) {
475
- lines.push(truncateToWidth(this.theme.fg("dim", `... ${start} more above`), width));
476
- }
477
- visibleIssues.forEach((issue, index) => {
478
- const issueIndex = start + index;
479
- const selected = issueIndex === this.selected;
480
- const marker = selected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
481
- const location = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
482
- const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
483
- const severity = severityColor(this.theme, issue.severity);
484
- const summary = `${marker} ${String(issueIndex + 1).padStart(2, "0")}. ${severity} ${this.theme.fg(
485
- "accent",
486
- location,
487
- )} ${this.theme.fg("muted", rule)} ${this.theme.fg("text", `— ${issue.message}`)}`;
488
- lines.push(truncateToWidth(summary, width));
489
- });
490
- if (end < this.state.issues.length) {
491
- lines.push(truncateToWidth(this.theme.fg("dim", `... ${this.state.issues.length - end} more below`), width));
492
- }
493
- }
494
-
495
- lines.push("");
496
- lines.push(truncateToWidth(this.theme.fg("dim", "Up/Down to move, Enter to preview, Esc to close"), width));
497
-
498
- this.cachedWidth = width;
499
- this.cachedLines = lines;
500
- return lines;
501
- }
502
- }
503
-
504
- // ── State restoration ─────────────────────────────────────────────────────────
71
+ // ── State restoration ───────────────────────────────────────────────────────
505
72
 
506
73
  async function restoreState(
507
74
  pi: ExtensionAPI,
@@ -516,256 +83,17 @@ async function restoreState(
516
83
  statesByBaseDir.set(latest.baseDir, latest);
517
84
  }
518
85
  }
519
-
520
- if (latest) {
521
- ctx.ui.setStatus("sonarqube", formatSummary(latest));
522
- }
523
-
86
+ ctx.ui.setStatus("sonarqube", undefined);
524
87
  return latest;
525
88
  }
526
89
 
527
- // ── Error hints ───────────────────────────────────────────────────────────────
528
-
529
- function extractScannerHint(output: string, config: SonarProjectConfig): string {
530
- const lines = output.toLowerCase();
531
-
532
- if (/status code 401|status 401|unauthorized/i.test(lines)) {
533
- const defaultHint = config.serverUrl === "http://localhost:9000"
534
- ? " (default: http://localhost:9000)"
535
- : "";
536
- return [
537
- `Authentication failed for ${config.serverUrl}${defaultHint}.`,
538
- "Run `/sonarqube init` to set up your server URL and token, then retry.",
539
- "Or set SONARQUBE_TOKEN in your environment.",
540
- ].join(" ");
541
- }
542
-
543
- if (/status code 403|status 403|forbidden/i.test(lines)) {
544
- return [
545
- `Access denied by ${config.serverUrl}.`,
546
- 'Your token may not have the required permissions ("Execute Analysis").',
547
- "Run `/sonarqube init` to update the token or check server permissions.",
548
- ].join(" ");
549
- }
550
-
551
- if (/connect econnrefused|connect refused|connect timeout|enotfound|econnreset/i.test(lines)) {
552
- return [
553
- `Cannot reach SonarQube server at ${config.serverUrl}.`,
554
- "Is the server running? Run `/sonarqube init` to check the server URL.",
555
- "Default: http://localhost:9000",
556
- ].join(" ");
557
- }
558
-
559
- if (/not found|unknown url/i.test(lines) && (config.serverUrl === "http://localhost:9000" || /localhost/i.test(config.serverUrl))) {
560
- return [
561
- `SonarQube API at ${config.serverUrl} returned "not found".`,
562
- "The default server URL is http://localhost:9000. Run `/sonarqube init` to configure.",
563
- ].join(" ");
564
- }
565
-
566
- return "";
567
- }
568
-
569
- // ── SonarScanner interaction ─────────────────────────────────────────────────
570
-
571
- async function runScanner(pi: ExtensionAPI, config: SonarProjectConfig, signal?: AbortSignal): Promise<string> {
572
- const args = [
573
- `-Dsonar.projectKey=${config.projectKey}`,
574
- `-Dsonar.projectBaseDir=${config.baseDir}`,
575
- `-Dsonar.host.url=${config.serverUrl}`,
576
- ...(config.hasProperties ? [] : [`-Dsonar.sources=.`]),
577
- `-Dsonar.scm.disabled=true`,
578
- ];
579
- if (config.token) {
580
- args.push(`-Dsonar.token=${config.token}`);
581
- }
582
-
583
- const result = await pi.exec("sonar-scanner", args, {
584
- cwd: config.baseDir,
585
- signal,
586
- timeout: 30 * 60 * 1000,
587
- });
588
- if (result.code !== 0) {
589
- const output = [result.stdout, result.stderr]
590
- .filter(Boolean)
591
- .join("\n")
592
- .trim();
593
- const hint = extractScannerHint(output, config);
594
- const detail = output ? `\n\n${output.slice(-4000)}` : "";
595
- const message = hint
596
- ? `SonarQube scan failed for ${config.projectKey}. ${hint}`
597
- : `SonarQube scan failed for ${config.projectKey}${detail}`;
598
- throw new Error(message);
599
- }
600
-
601
- return result.stdout || result.stderr;
602
- }
603
-
604
- async function readReportTask(baseDir: string): Promise<Record<string, string>> {
605
- const path = resolve(baseDir, ".scannerwork", "report-task.txt");
606
- const text = await readFile(path, "utf8");
607
- return parseProperties(text);
608
- }
609
-
610
- async function waitForAnalysis(
611
- serverUrl: string,
612
- token: string | undefined,
613
- ceTaskUrl: string,
614
- signal?: AbortSignal,
615
- ): Promise<string | undefined> {
616
- for (let attempt = 0; attempt < 120; attempt++) {
617
- const result = await fetchJson<{
618
- task: { status: string; analysisId?: string; errorMessage?: string };
619
- }>(ceTaskUrl, token, signal);
620
- const status = result.task.status.toUpperCase();
621
- if (status === "SUCCESS") return result.task.analysisId;
622
- if (status === "FAILED") {
623
- throw new Error(`SonarQube analysis failed: ${result.task.errorMessage || "unknown error"}`);
624
- }
625
- if (status === "CANCELED") {
626
- throw new Error("SonarQube analysis was canceled by the server");
627
- }
628
- await new Promise((resolveWait) => setTimeout(resolveWait, 2000));
629
- }
630
-
631
- throw new Error(`Timed out waiting for SonarQube analysis at ${serverUrl}`);
632
- }
633
-
634
- async function fetchRuleName(
635
- serverUrl: string,
636
- token: string | undefined,
637
- ruleKey: string,
638
- signal?: AbortSignal,
639
- ): Promise<string | undefined> {
640
- try {
641
- const result = await fetchJson<{ rule: { name?: string } }>(
642
- `${serverUrl}/api/rules/show?key=${encodeURIComponent(ruleKey)}`,
643
- token,
644
- signal,
645
- );
646
- return result.rule.name;
647
- } catch {
648
- return undefined;
649
- }
650
- }
651
-
652
- async function fetchIssues(
653
- serverUrl: string,
654
- token: string | undefined,
655
- projectKey: string,
656
- signal?: AbortSignal,
657
- ): Promise<SonarIssue[]> {
658
- const issues: SonarIssue[] = [];
659
- const ruleNames = new Map<string, string | undefined>();
660
- let page = 1;
661
- let total = 0;
662
-
663
- for (;;) {
664
- const url = `${serverUrl}/api/issues/search?componentKeys=${encodeURIComponent(projectKey)}&resolved=false&ps=100&p=${page}`;
665
- const result = await fetchJson<{
666
- total: number;
667
- issues: Array<{
668
- key: string;
669
- rule: string;
670
- severity: string;
671
- message: string;
672
- component: string;
673
- line?: number;
674
- status?: string;
675
- }>;
676
- }>(url, token, signal);
677
-
678
- total = result.total;
679
- for (const raw of result.issues) {
680
- if (!ruleNames.has(raw.rule)) {
681
- ruleNames.set(raw.rule, await fetchRuleName(serverUrl, token, raw.rule, signal));
682
- }
683
- issues.push({
684
- key: raw.key,
685
- rule: raw.rule,
686
- ruleName: ruleNames.get(raw.rule),
687
- severity: raw.severity,
688
- message: raw.message,
689
- filePath: toIssuePath(raw.component, projectKey),
690
- line: typeof raw.line === "number" ? raw.line : null,
691
- status: raw.status,
692
- });
693
- }
694
-
695
- if (issues.length >= total || result.issues.length === 0) break;
696
- page += 1;
697
- }
698
-
699
- return issues;
700
- }
701
-
702
- function createAnalysisState(
703
- config: Pick<SonarProjectConfig, "baseDir" | "serverUrl" | "projectKey">,
704
- issues: SonarIssue[],
705
- extras: Partial<Pick<SonarAnalysisState, "dashboardUrl" | "ceTaskUrl" | "analysisId">> = {},
706
- ): SonarAnalysisState {
707
- return {
708
- version: 1,
709
- analyzedAt: nowIso(),
710
- baseDir: config.baseDir,
711
- serverUrl: config.serverUrl,
712
- projectKey: config.projectKey,
713
- dashboardUrl: extras.dashboardUrl,
714
- ceTaskUrl: extras.ceTaskUrl,
715
- analysisId: extras.analysisId,
716
- totalIssues: issues.length,
717
- issues,
718
- };
719
- }
720
-
721
- async function hasProjectAnalyses(
722
- serverUrl: string,
723
- token: string | undefined,
724
- projectKey: string,
725
- signal?: AbortSignal,
726
- ): Promise<boolean> {
727
- try {
728
- const result = await fetchJson<{ analyses?: Array<{ key?: string }> }>(
729
- `${serverUrl}/api/project_analyses/search?project=${encodeURIComponent(projectKey)}&ps=1`,
730
- token,
731
- signal,
732
- );
733
- return (result.analyses?.length ?? 0) > 0;
734
- } catch {
735
- return false;
736
- }
737
- }
738
-
739
- async function loadProjectIssuesFromApi(
740
- ctx: ExtensionContext,
741
- inputPath?: string,
742
- ): Promise<SonarAnalysisState> {
743
- const config = await resolveConfig(ctx, inputPath);
744
- const issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal);
745
- return createAnalysisState(config, issues);
746
- }
747
-
748
- async function resolveTargetState(
749
- ctx: ExtensionContext,
750
- statesByBaseDir: Map<string, SonarAnalysisState>,
751
- targetInput?: string,
752
- ): Promise<SonarAnalysisState | undefined> {
753
- const resolvedTarget = await resolveTarget(ctx, targetInput);
754
- try {
755
- return await loadProjectIssuesFromApi(ctx, targetInput);
756
- } catch (error) {
757
- const cached = statesByBaseDir.get(resolvedTarget.baseDir);
758
- if (cached) return cached;
759
- throw error;
760
- }
761
- }
762
-
763
- // ── Analysis orchestration ────────────────────────────────────────────────────
90
+ // ── Analysis orchestration ──────────────────────────────────────────────────
764
91
 
765
92
  async function analyzeProject(
766
93
  pi: ExtensionAPI,
767
94
  ctx: ExtensionContext,
768
95
  inputPath?: string,
96
+ filters?: SonarIssueFetchOptions,
769
97
  ): Promise<SonarAnalysisState> {
770
98
  const config = await resolveConfig(ctx, inputPath);
771
99
  const baseDirStat = await stat(config.baseDir).catch(() => undefined);
@@ -773,188 +101,170 @@ async function analyzeProject(
773
101
  throw new Error(`Project directory not found: ${config.baseDir}`);
774
102
  }
775
103
 
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}`);
104
+ const analysisUi = startAnalysisUi(ctx, config.projectKey);
782
105
 
783
106
  try {
107
+ analysisUi.setPhase("Running sonar-scanner...");
784
108
  await runScanner(pi, config, ctx.signal);
109
+
110
+ analysisUi.setPhase("Reading scanner report...");
785
111
  const report = await readReportTask(config.baseDir);
786
112
  const ceTaskUrl = report["ceTaskUrl"];
787
113
  const dashboardUrl = report["dashboardUrl"];
788
114
  const analysisId = ceTaskUrl
789
- ? await waitForAnalysis(config.serverUrl, config.token, ceTaskUrl, ctx.signal)
115
+ ? (analysisUi.setPhase("Waiting for SonarQube analysis..."),
116
+ await waitForAnalysis(config.serverUrl, config.token, ceTaskUrl, ctx.signal))
790
117
  : undefined;
791
- const issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal);
118
+
119
+ analysisUi.setPhase("Fetching issues...");
120
+ const normalizedFilters = normalizeIssueFilters(filters);
121
+ const issues = await fetchIssues(
122
+ config.serverUrl,
123
+ config.token,
124
+ config.projectKey,
125
+ ctx.signal,
126
+ normalizedFilters,
127
+ );
792
128
 
793
129
  const state = createAnalysisState(config, issues, {
794
130
  dashboardUrl,
795
131
  ceTaskUrl,
796
132
  analysisId,
133
+ filters: normalizedFilters,
797
134
  });
798
135
 
799
136
  pi.appendEntry(STATE_TYPE, state);
800
- ctx.ui.setStatus("sonarqube", formatSummary(state));
801
137
  return state;
802
138
  } catch (error) {
803
- ctx.ui.setStatus("sonarqube", undefined);
804
139
  throw error instanceof Error ? error : new Error(String(error));
805
140
  } finally {
806
- ctx.ui.setWorkingMessage();
807
- ctx.ui.setWorkingIndicator();
141
+ analysisUi.stop();
808
142
  }
809
143
  }
810
144
 
811
- // ── UI helpers ────────────────────────────────────────────────────────────────
145
+ // ── Init command helpers (extracted to reduce cognitive complexity) ─────────
812
146
 
813
- async function showIssueBrowser(
147
+ async function confirmOverwrite(
814
148
  ctx: ExtensionCommandContext,
815
- state: SonarAnalysisState,
816
- ): Promise<number | null> {
817
- if (ctx.mode !== "tui") return null;
818
- if (state.issues.length === 0) return null;
819
-
820
- return await ctx.ui.custom<number | null>((_tui, theme, _kb, done) => new IssueBrowser(state, theme, done));
149
+ baseDir: string,
150
+ existing: SonarInitConfig,
151
+ ): Promise<boolean> {
152
+ const ok = await ctx.ui.confirm(
153
+ "Overwrite?",
154
+ [
155
+ `SonarQube config already exists at ${baseDir}:`,
156
+ ` Server: ${existing.serverUrl}`,
157
+ ` Project: ${existing.projectKey}`,
158
+ "",
159
+ "Overwrite?",
160
+ ].join("\n"),
161
+ );
162
+ if (ok) return true;
163
+ if (ctx.hasUI) ctx.ui.notify("Init cancelled.", "info");
164
+ return false;
821
165
  }
822
166
 
823
- async function openIssuePreview(
167
+ async function collectInitInputs(
824
168
  ctx: ExtensionCommandContext,
825
- state: SonarAnalysisState,
826
- issue: SonarIssue,
827
- ): Promise<void> {
828
- const preview = await buildIssuePreview(state.baseDir, issue);
829
- const title = `${issue.filePath}${issue.line ? `:${issue.line}` : ""}`;
830
- ctx.ui.setStatus("sonarqube", title);
831
- if (ctx.mode === "tui") {
832
- await ctx.ui.editor(title, preview);
833
- return;
834
- }
835
- ctx.ui.notify(`${title}\n${preview}`, "info");
836
- }
169
+ existing: SonarInitConfig | undefined,
170
+ baseDir: string,
171
+ ): Promise<{ serverUrl: string; projectKey: string; token: string | undefined } | undefined> {
172
+ const serverUrlInput = await ctx.ui.editor("SonarQube server URL", existing?.serverUrl ?? "http://localhost:9000");
173
+ if (serverUrlInput === undefined) return undefined;
174
+ const serverUrl = normalizeServerUrl(serverUrlInput || existing?.serverUrl || "http://localhost:9000");
175
+
176
+ const projectKeyInput = await ctx.ui.input("SonarQube project key", existing?.projectKey ?? basename(baseDir));
177
+ if (projectKeyInput === undefined) return undefined;
178
+ const projectKey = projectKeyInput || existing?.projectKey || slugify(basename(baseDir));
837
179
 
838
- // ── Init command ──────────────────────────────────────────────────────────────
180
+ const tokenInput = await ctx.ui.input("SonarQube token (optional, press Enter to skip)", existing?.token ?? "");
181
+ if (tokenInput === undefined) return undefined;
839
182
 
840
- interface InitCommandOptions {
841
- alias?: string;
842
- targetInput?: string;
183
+ return { serverUrl, projectKey, token: tokenInput.trim() || undefined };
843
184
  }
844
185
 
845
186
  async function initCommandHandler(ctx: ExtensionCommandContext, options: InitCommandOptions = {}): Promise<void> {
846
187
  const { baseDir, repoRoot } = await resolveInitTarget(ctx, options.alias, options.targetInput);
847
188
  const existing = await loadProjectConfig(baseDir);
848
189
 
849
- if (existing) {
850
- const ok = await ctx.ui.confirm(
851
- "Overwrite?",
852
- `SonarQube config already exists at ${baseDir}:\n Server: ${existing.serverUrl}\n Project: ${existing.projectKey}\n\nOverwrite?`,
853
- );
854
- if (!ok) {
855
- if (ctx.hasUI) ctx.ui.notify("Init cancelled.", "info");
856
- return;
857
- }
858
- }
190
+ const confirmed = existing ? await confirmOverwrite(ctx, baseDir, existing) : true;
191
+ if (!confirmed) return;
859
192
 
860
- const serverUrlInput = await ctx.ui.editor("SonarQube server URL", existing?.serverUrl ?? "http://localhost:9000");
861
- if (serverUrlInput === undefined) return;
862
- const serverUrl = normalizeServerUrl(serverUrlInput || existing?.serverUrl || "http://localhost:9000");
193
+ const inputs = await collectInitInputs(ctx, existing, baseDir);
194
+ if (!inputs) return;
863
195
 
864
- const projectKeyInput = await ctx.ui.input("SonarQube project key", existing?.projectKey ?? basename(baseDir));
865
- if (projectKeyInput === undefined) return;
866
- const projectKey = projectKeyInput || existing?.projectKey || slugify(basename(baseDir));
867
-
868
- const tokenInput = await ctx.ui.input("SonarQube token (optional, press Enter to skip)", existing?.token ?? "");
869
- if (tokenInput === undefined) return;
870
- const token = tokenInput.trim() || undefined;
871
-
872
- await saveProjectConfig(baseDir, {
873
- serverUrl,
874
- projectKey,
875
- token,
876
- });
196
+ await saveProjectConfig(baseDir, inputs);
197
+ const propertiesState = await ensureDefaultSonarProjectProperties(baseDir);
877
198
 
878
199
  if (options.alias) {
879
200
  await saveWorkspaceRegistry(repoRoot, options.alias, baseDir);
880
201
  }
881
202
 
882
203
  if (ctx.hasUI) {
883
- const targetLabel = options.alias ? ` (${options.alias})` : "";
884
- ctx.ui.notify(`SonarQube config saved${targetLabel} to ${projectConfigPath(baseDir)}`, "info");
204
+ notifyInitResult(ctx, baseDir, propertiesState, options.alias);
885
205
  }
886
206
  }
887
207
 
888
- function helpText(): string {
889
- return [
890
- "SonarQube commands:",
891
- "",
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",
896
- " /sonarqube show this help",
897
- "",
898
- "Defaults:",
899
- " config is stored in .pi/sonarqube.json",
900
- " monorepo aliases live in .pi/sonarqube.workspaces.json",
901
- ].join("\n");
208
+ function notifyInitResult(
209
+ ctx: ExtensionCommandContext,
210
+ baseDir: string,
211
+ propertiesState: "created" | "updated" | "unchanged",
212
+ alias?: string,
213
+ ): void {
214
+ const label = alias ? ` (${alias})` : "";
215
+ let note = "";
216
+ if (propertiesState === "created") {
217
+ note = ` and created ${sonarProjectPropertiesPath(baseDir)}`;
218
+ } else if (propertiesState === "updated") {
219
+ note = ` and merged ${sonarProjectPropertiesPath(baseDir)}`;
220
+ }
221
+ ctx.ui.notify(`SonarQube config saved${label} to ${projectConfigPath(baseDir)}${note}`, "info");
902
222
  }
903
223
 
904
- function targetLabel(targetInput?: string): string {
905
- return targetInput ? ` ${targetInput}` : "";
224
+ // ── Tool result rendering helpers (reduces renderResult complexity) ─────────
225
+
226
+ function renderErrorResult(errorMsg: string, theme: Theme): Text {
227
+ return new Text(theme.fg("error", errorMsg), 0, 0);
906
228
  }
907
229
 
908
- function sonarErrorMessage(error: unknown): string {
909
- return error instanceof Error ? error.message : String(error);
230
+ function renderEmptyResult(summary: string, theme: Theme): Text {
231
+ return new Text(theme.fg("success", summary), 0, 0);
910
232
  }
911
233
 
912
- // ── Command arg parsing ───────────────────────────────────────────────────────
913
-
914
- type ParsedSonarCommand =
915
- | { action: "help" }
916
- | { action: "init"; alias?: string; targetInput?: string }
917
- | { action: "analyze"; targetInput?: string }
918
- | { action: "issues"; targetInput?: string }
919
- | { action: "open"; targetInput?: string; issueIndex?: number };
920
-
921
- function parseCommandArgs(args: string): ParsedSonarCommand {
922
- const tokens = args.trim().split(/\s+/).filter(Boolean);
923
- if (tokens.length === 0) return { action: "help" };
924
-
925
- const head = tokens[0].toLowerCase();
926
- if (head === "init") {
927
- if (tokens.length === 1) return { action: "init" };
928
- if (tokens.length === 2) {
929
- return looksLikePath(tokens[1])
930
- ? { action: "init", targetInput: tokens[1] }
931
- : { action: "init", alias: tokens[1] };
932
- }
933
- return { action: "init", alias: tokens[1], targetInput: tokens[2] };
934
- }
935
- if (head === "issues" || head === "view") {
936
- return { action: "issues", targetInput: tokens[1] };
234
+ function renderIssueResult(
235
+ issue: SonarIssue,
236
+ contentText: string | undefined,
237
+ expanded: boolean,
238
+ theme: Theme,
239
+ ): Text {
240
+ const text = contentText ?? formatIssue(issue);
241
+ const preview = expanded ? text.split("\n").slice(1).join("\n") : "";
242
+ const label = theme.fg("accent", formatIssue(issue));
243
+ return new Text(preview ? `${label}\n${preview}` : label, 0, 0);
244
+ }
245
+
246
+ function renderIssueListResult(
247
+ state: SonarAnalysisState,
248
+ expanded: boolean,
249
+ theme: Theme,
250
+ ): Text {
251
+ const summary = formatSummary(state);
252
+ if (state.issues.length === 0) {
253
+ return new Text(theme.fg("success", summary), 0, 0);
937
254
  }
938
- 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
- };
255
+
256
+ const visible = expanded ? state.issues : state.issues.slice(0, 5);
257
+ const lines: string[] = [theme.fg("accent", summary)];
258
+ for (const [i, issue] of visible.entries()) {
259
+ lines.push(theme.fg("muted", formatIssue(issue, i + 1)));
949
260
  }
950
- if (head === "analyze" || head === "run") {
951
- return { action: "analyze", targetInput: tokens[1] };
261
+ if (!expanded && state.issues.length > visible.length) {
262
+ lines.push(theme.fg("dim", `... ${state.issues.length - visible.length} more`));
952
263
  }
953
-
954
- return { action: "analyze", targetInput: tokens[0] };
264
+ return new Text(lines.join("\n"), 0, 0);
955
265
  }
956
266
 
957
- // ── Extension entrypoint ──────────────────────────────────────────────────────
267
+ // ── Extension entrypoint ────────────────────────────────────────────────────
958
268
 
959
269
  export default function sonarqube(pi: ExtensionAPI) {
960
270
  const statesByBaseDir = new Map<string, SonarAnalysisState>();
@@ -971,7 +281,34 @@ export default function sonarqube(pi: ExtensionAPI) {
971
281
 
972
282
  pi.on("session_start", async (_event, ctx) => {
973
283
  await restore(ctx);
284
+
285
+ if (ctx.hasUI) {
286
+ ctx.ui.addAutocompleteProvider((current) => ({
287
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
288
+ const line = lines[cursorLine] ?? "";
289
+ const beforeCursor = line.slice(0, cursorCol);
290
+ const match = /^\/sonarqube(?:\s+(.*))?$/.exec(beforeCursor);
291
+ if (!match) return current.getSuggestions(lines, cursorLine, cursorCol, options);
292
+
293
+ const argumentText = match[1] ?? "";
294
+ const { current: currentToken } = splitSonarArgumentContext(argumentText);
295
+ const extraItems = sonarArgumentCompletions(argumentText, latestState?.issues) ?? [];
296
+ const currentSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
297
+ const merged = mergeAutocompleteSuggestions(currentToken, currentSuggestions, extraItems);
298
+ return merged ?? currentSuggestions;
299
+ },
300
+
301
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
302
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
303
+ },
304
+
305
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
306
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
307
+ },
308
+ }));
309
+ }
974
310
  });
311
+
975
312
  pi.on("session_tree", async (_event, ctx) => {
976
313
  await restore(ctx);
977
314
  });
@@ -983,21 +320,26 @@ export default function sonarqube(pi: ExtensionAPI) {
983
320
  promptSnippet: "Run local SonarQube analysis or inspect issue results",
984
321
  promptGuidelines: [
985
322
  "Use sonarqube when the user asks to run a local SonarQube scan, inspect issues, or open an issue's source location.",
323
+ "Use the optional severity, status, type, and rule filters to fetch only the most relevant issues.",
986
324
  ],
987
325
  parameters: SonarToolParams,
988
326
 
989
327
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
990
328
  try {
329
+ const filters = normalizeIssueFilters({
330
+ severities: params.severities,
331
+ statuses: params.statuses,
332
+ types: params.types,
333
+ rules: params.rules,
334
+ });
335
+
991
336
  if (params.action === "analyze") {
992
- const state = await analyzeProject(pi, ctx, params.path);
337
+ const state = await analyzeProject(pi, ctx, params.path, filters);
993
338
  rememberState(state);
994
- return {
995
- content: [{ type: "text", text: formatReport(state) }],
996
- details: state,
997
- };
339
+ return { content: [{ type: "text", text: formatReport(state) }], details: state };
998
340
  }
999
341
 
1000
- const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path);
342
+ const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path, filters);
1001
343
  if (!targetState) {
1002
344
  return {
1003
345
  content: [{ type: "text", text: "No SonarQube analysis has been run for this target yet." }],
@@ -1008,10 +350,7 @@ export default function sonarqube(pi: ExtensionAPI) {
1008
350
  rememberState(targetState);
1009
351
 
1010
352
  if (params.action === "issues") {
1011
- return {
1012
- content: [{ type: "text", text: formatReport(targetState) }],
1013
- details: targetState,
1014
- };
353
+ return { content: [{ type: "text", text: formatReport(targetState) }], details: targetState };
1015
354
  }
1016
355
 
1017
356
  const index = params.issueIndex ?? 1;
@@ -1029,53 +368,51 @@ export default function sonarqube(pi: ExtensionAPI) {
1029
368
  details: { ...targetState, selectedIssue: issue },
1030
369
  };
1031
370
  } catch (error) {
1032
- const message = sonarErrorMessage(error);
1033
- return { content: [{ type: "text", text: message }], details: { error: message } };
371
+ const msg = sonarErrorMessage(error);
372
+ return { content: [{ type: "text", text: msg }], details: { error: msg } };
1034
373
  }
1035
374
  },
1036
375
 
1037
376
  renderCall(args, theme, _context) {
377
+ const filters = normalizeIssueFilters({
378
+ severities: args.severities,
379
+ statuses: args.statuses,
380
+ types: args.types,
381
+ rules: args.rules,
382
+ });
383
+ const filterText = filters ? ` ${theme.fg("muted", issueFilterLabel(filters))}` : "";
1038
384
  return new Text(
1039
385
  `${theme.fg("toolTitle", theme.bold("sonarqube"))} ${theme.fg("muted", args.action)}${
1040
386
  args.path ? ` ${theme.fg("accent", args.path)}` : ""
1041
- }`,
387
+ }${filterText}`,
1042
388
  0,
1043
389
  0,
1044
390
  );
1045
391
  },
1046
392
 
1047
393
  renderResult(result, { expanded }, theme, _context) {
1048
- const state =
1049
- result.details as
1050
- | SonarAnalysisState
1051
- | { error?: string; selectedIssue?: SonarIssue }
1052
- | undefined;
1053
- if (state && "error" in state && state.error) {
1054
- return new Text(theme.fg("error", state.error), 0, 0);
394
+ const state = result.details as
395
+ | SonarAnalysisState
396
+ | { error?: string; selectedIssue?: SonarIssue }
397
+ | undefined;
398
+
399
+ if (!state) {
400
+ const text = result.content[0];
401
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
1055
402
  }
1056
403
 
1057
- if (state && "selectedIssue" in state && state.selectedIssue) {
1058
- const issue = state.selectedIssue;
1059
- const text = result.content[0]?.type === "text" ? result.content[0].text : formatIssue(issue);
1060
- const preview = expanded ? text.split("\n").slice(1).join("\n") : "";
1061
- return new Text(`${theme.fg("accent", formatIssue(issue))}${preview ? `\n${preview}` : ""}`, 0, 0);
404
+ if ("error" in state && state.error) {
405
+ return renderErrorResult(state.error, theme);
1062
406
  }
1063
407
 
1064
- if (state && "issues" in state) {
1065
- const summary = formatSummary(state);
1066
- if (state.issues.length === 0) {
1067
- return new Text(theme.fg("success", summary), 0, 0);
1068
- }
408
+ if ("selectedIssue" in state && state.selectedIssue) {
409
+ const issue = state.selectedIssue;
410
+ const contentText = result.content[0]?.type === "text" ? result.content[0].text : undefined;
411
+ return renderIssueResult(issue, contentText, expanded, theme);
412
+ }
1069
413
 
1070
- const visible = expanded ? state.issues : state.issues.slice(0, 5);
1071
- const lines = [theme.fg("accent", summary)];
1072
- visible.forEach((issue, index) => {
1073
- lines.push(theme.fg("muted", formatIssue(issue, index + 1)));
1074
- });
1075
- if (!expanded && state.issues.length > visible.length) {
1076
- lines.push(theme.fg("dim", `... ${state.issues.length - visible.length} more`));
1077
- }
1078
- return new Text(lines.join("\n"), 0, 0);
414
+ if ("issues" in state) {
415
+ return renderIssueListResult(state, expanded, theme);
1079
416
  }
1080
417
 
1081
418
  const text = result.content[0];
@@ -1084,71 +421,25 @@ export default function sonarqube(pi: ExtensionAPI) {
1084
421
  });
1085
422
 
1086
423
  pi.registerCommand("sonarqube", {
1087
- description:
1088
- "Run a local SonarQube analysis, browse the latest issues, or init project config",
424
+ description: "Run a local SonarQube analysis, browse the latest issues, or init project config",
425
+ getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues),
1089
426
  handler: async (args, ctx) => {
1090
427
  try {
1091
428
  const parsed = parseCommandArgs(args);
1092
-
1093
- // --- Help ---
1094
- if (parsed.action === "help") {
1095
- if (ctx.hasUI) {
1096
- ctx.ui.notify(helpText(), "info");
1097
- }
1098
- return;
1099
- }
1100
-
1101
- // --- Init ---
1102
- if (parsed.action === "init") {
1103
- await initCommandHandler(ctx, { alias: parsed.alias, targetInput: parsed.targetInput });
1104
- return;
1105
- }
1106
-
1107
- // --- Analyze ---
1108
- if (parsed.action === "analyze") {
1109
- const state = await analyzeProject(pi, ctx, parsed.targetInput);
1110
- rememberState(state);
1111
- if (ctx.hasUI) {
1112
- const scope = targetLabel(parsed.targetInput);
1113
- ctx.ui.notify(`${formatSummary(state)}. Use /sonarqube issues${scope} to browse.`, state.issues.length === 0 ? "info" : "warning");
1114
- }
1115
- return;
1116
- }
1117
-
1118
- const targetState = await resolveTargetState(ctx, statesByBaseDir, parsed.targetInput);
1119
- if (!targetState) {
1120
- if (ctx.hasUI) {
1121
- ctx.ui.notify(
1122
- `No SonarQube analysis found for this target. Run /sonarqube analyze${targetLabel(parsed.targetInput)} first.`,
1123
- "warning",
1124
- );
1125
- }
1126
- return;
1127
- }
1128
- rememberState(targetState);
1129
-
1130
- // --- Issues ---
1131
- if (parsed.action === "issues") {
1132
- if (targetState.issues.length === 0) {
1133
- if (ctx.hasUI) ctx.ui.notify(formatSummary(targetState), "info");
429
+ switch (parsed.action) {
430
+ case "help":
431
+ if (ctx.hasUI) ctx.ui.notify(helpText(), "info");
432
+ return;
433
+ case "init":
434
+ await initCommandHandler(ctx, { alias: parsed.alias, targetInput: parsed.targetInput });
435
+ return;
436
+ case "analyze": {
437
+ await commandAnalyze(pi, ctx, parsed.targetInput, parsed.filters, rememberState);
1134
438
  return;
1135
439
  }
1136
- const choice = await showIssueBrowser(ctx, targetState);
1137
- if (choice !== null && choice !== undefined) {
1138
- const issue = targetState.issues[choice];
1139
- if (issue) await openIssuePreview(ctx, targetState, issue);
1140
- }
1141
- return;
440
+ default:
441
+ await commandIssuesOrOpen(pi, ctx, statesByBaseDir, parsed, rememberState);
1142
442
  }
1143
-
1144
- // --- Open ---
1145
- const index = parsed.issueIndex ?? 1;
1146
- const issue = targetState.issues[index - 1];
1147
- if (!issue) {
1148
- if (ctx.hasUI) ctx.ui.notify(`Issue #${index} was not found.`, "error");
1149
- return;
1150
- }
1151
- await openIssuePreview(ctx, targetState, issue);
1152
443
  } catch (error) {
1153
444
  if (ctx.hasUI) {
1154
445
  ctx.ui.notify(sonarErrorMessage(error), "error");
@@ -1159,3 +450,83 @@ export default function sonarqube(pi: ExtensionAPI) {
1159
450
  },
1160
451
  });
1161
452
  }
453
+
454
+ async function commandAnalyze(
455
+ pi: ExtensionAPI,
456
+ ctx: ExtensionContext,
457
+ targetInput: string | undefined,
458
+ filters: SonarIssueFetchOptions | undefined,
459
+ rememberState: (s: SonarAnalysisState) => void,
460
+ ): Promise<void> {
461
+ const state = await analyzeProject(pi, ctx, targetInput, filters);
462
+ rememberState(state);
463
+ if (ctx.hasUI) {
464
+ const scope = targetLabel(targetInput);
465
+ ctx.ui.notify(
466
+ `${formatSummary(state)}. Use /sonarqube issues${scope} to browse.`,
467
+ state.issues.length === 0 ? "info" : "warning",
468
+ );
469
+ }
470
+ }
471
+
472
+ async function commandIssuesOrOpen(
473
+ pi: ExtensionAPI,
474
+ ctx: ExtensionCommandContext,
475
+ statesByBaseDir: Map<string, SonarAnalysisState>,
476
+ parsed: { targetInput?: string; filters?: SonarIssueFetchOptions; action: string; issueIndex?: number },
477
+ rememberState: (s: SonarAnalysisState) => void,
478
+ ): Promise<void> {
479
+ const targetState = await resolveTargetState(ctx, statesByBaseDir, parsed.targetInput, parsed.filters);
480
+ if (!targetState) {
481
+ if (ctx.hasUI) {
482
+ ctx.ui.notify(
483
+ `No SonarQube analysis found for this target. Run /sonarqube analyze${targetLabel(parsed.targetInput)} first.`,
484
+ "warning",
485
+ );
486
+ }
487
+ return;
488
+ }
489
+ rememberState(targetState);
490
+
491
+ if (parsed.action === "issues") {
492
+ await commandShowIssues(ctx, targetState);
493
+ return;
494
+ }
495
+
496
+ // "open"
497
+ const index = parsed.issueIndex ?? 1;
498
+ const issue = targetState.issues[index - 1];
499
+ if (!issue) {
500
+ if (ctx.hasUI) ctx.ui.notify(`Issue #${index} was not found.`, "error");
501
+ return;
502
+ }
503
+ await openIssuePreview(ctx, targetState, issue);
504
+ }
505
+
506
+ async function commandShowIssues(
507
+ ctx: ExtensionCommandContext,
508
+ targetState: SonarAnalysisState,
509
+ ): Promise<void> {
510
+ if (targetState.issues.length === 0) {
511
+ if (ctx.hasUI) ctx.ui.notify(formatSummary(targetState), "info");
512
+ return;
513
+ }
514
+ const choice = await showIssueBrowser(ctx, targetState);
515
+ if (choice != null) {
516
+ const issue = targetState.issues[choice];
517
+ if (issue) await openIssuePreview(ctx, targetState, issue);
518
+ }
519
+ }
520
+
521
+ // ── Re-exports for public API ───────────────────────────────────────────────
522
+
523
+ export type {
524
+ SonarAction,
525
+ SonarIssue,
526
+ SonarAnalysisState,
527
+ SonarProjectConfig,
528
+ SonarIssueFetchOptions,
529
+ SonarInitConfig,
530
+ } from "./types.js";
531
+
532
+ export { projectConfigPath, loadProjectConfig, saveProjectConfig } from "./config.js";