@jonathan0823/pi-sonarqube 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -10,6 +10,8 @@ import type {
10
10
  SonarIssueFetchOptions,
11
11
  SonarInitConfig,
12
12
  InitCommandOptions,
13
+ FileDuplication,
14
+ SonarProjectConfig,
13
15
  } from "./types.js";
14
16
  import {
15
17
  slugify,
@@ -29,6 +31,10 @@ import {
29
31
  waitForAnalysis,
30
32
  normalizeIssueFilters,
31
33
  fetchIssues,
34
+ fetchDuplicationMeasures,
35
+ fetchIssueSeverityCounts,
36
+ fetchFileDuplications,
37
+ fetchFileDuplicationBlocks,
32
38
  createAnalysisState,
33
39
  issueFilterLabel,
34
40
  } from "./api.js";
@@ -40,6 +46,9 @@ import {
40
46
  mergeAutocompleteSuggestions,
41
47
  formatIssue,
42
48
  formatSummary,
49
+ formatMetricsOutput,
50
+ formatDuplicationsList,
51
+ formatDuplicationBlockDetail,
43
52
  formatReport,
44
53
  helpText,
45
54
  targetLabel,
@@ -52,10 +61,11 @@ import {
52
61
  buildIssuePreview,
53
62
  openIssuePreview,
54
63
  resolveTargetState,
64
+ showDuplicationBrowser,
55
65
  } from "./ui.js";
56
66
 
57
67
  const SonarToolParams = Type.Object({
58
- action: StringEnum(["analyze", "issues", "open"] as const),
68
+ action: StringEnum(["analyze", "issues", "open", "metrics", "duplications"] as const),
59
69
  path: Type.Optional(Type.String({ description: "Target alias or project directory to analyze or inspect" })),
60
70
  issueIndex: Type.Optional(Type.Number({ description: "1-based issue index to open" })),
61
71
  severities: Type.Optional(
@@ -126,11 +136,20 @@ async function analyzeProject(
126
136
  normalizedFilters,
127
137
  );
128
138
 
139
+ analysisUi.setPhase("Fetching metrics...");
140
+ const measures = await fetchDuplicationMeasures(
141
+ config.serverUrl,
142
+ config.token,
143
+ config.projectKey,
144
+ ctx.signal,
145
+ );
146
+
129
147
  const state = createAnalysisState(config, issues, {
130
148
  dashboardUrl,
131
149
  ceTaskUrl,
132
150
  analysisId,
133
151
  filters: normalizedFilters,
152
+ measures,
134
153
  });
135
154
 
136
155
  pi.appendEntry(STATE_TYPE, state);
@@ -249,12 +268,25 @@ function renderIssueListResult(
249
268
  theme: Theme,
250
269
  ): Text {
251
270
  const summary = formatSummary(state);
252
- if (state.issues.length === 0) {
271
+ if (state.issues.length === 0 && !state.measures) {
253
272
  return new Text(theme.fg("success", summary), 0, 0);
254
273
  }
255
274
 
275
+ const lines: string[] = [];
276
+ lines.push(theme.fg("accent", summary));
277
+ if (state.measures) {
278
+ const density = state.measures.duplicatedLinesDensity.toFixed(1);
279
+ const detail = state.measures.duplicatedBlocks > 0
280
+ ? `Duplication: ${density}% lines=${state.measures.duplicatedLines} blocks=${state.measures.duplicatedBlocks} files=${state.measures.duplicatedFiles}`
281
+ : `Duplication: ${density}% (no duplications detected)`;
282
+ lines.push(theme.fg("dim", detail));
283
+ }
284
+
285
+ if (state.issues.length === 0) {
286
+ return new Text(lines.join("\n"), 0, 0);
287
+ }
288
+
256
289
  const visible = expanded ? state.issues : state.issues.slice(0, 5);
257
- const lines: string[] = [theme.fg("accent", summary)];
258
290
  for (const [i, issue] of visible.entries()) {
259
291
  lines.push(theme.fg("muted", formatIssue(issue, i + 1)));
260
292
  }
@@ -339,6 +371,9 @@ export default function sonarqube(pi: ExtensionAPI) {
339
371
  return { content: [{ type: "text", text: formatReport(state) }], details: state };
340
372
  }
341
373
 
374
+ if (params.action === "duplications") return toolDuplications(ctx, params.path);
375
+ if (params.action === "metrics") return toolMetrics(ctx, params.path);
376
+
342
377
  const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path, filters);
343
378
  if (!targetState) {
344
379
  return {
@@ -437,6 +472,12 @@ export default function sonarqube(pi: ExtensionAPI) {
437
472
  await commandAnalyze(pi, ctx, parsed.targetInput, parsed.filters, rememberState);
438
473
  return;
439
474
  }
475
+ case "metrics":
476
+ await commandMetrics(ctx, parsed.targetInput);
477
+ return;
478
+ case "duplications":
479
+ await commandDuplications(pi, ctx, parsed.targetInput, parsed.issueIndex);
480
+ return;
440
481
  default:
441
482
  await commandIssuesOrOpen(pi, ctx, statesByBaseDir, parsed, rememberState);
442
483
  }
@@ -469,6 +510,106 @@ async function commandAnalyze(
469
510
  }
470
511
  }
471
512
 
513
+ async function commandMetrics(
514
+ ctx: ExtensionCommandContext,
515
+ targetInput?: string,
516
+ ): Promise<void> {
517
+ const config = await resolveConfig(ctx, targetInput);
518
+ const [measures, issueCounts] = await Promise.all([
519
+ fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
520
+ fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
521
+ ]);
522
+ if (!measures && !issueCounts) {
523
+ if (ctx.hasUI) {
524
+ ctx.ui.notify(`Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`, "warning");
525
+ }
526
+ return;
527
+ }
528
+ const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts });
529
+ if (ctx.hasUI) {
530
+ ctx.ui.notify(text, "info");
531
+ }
532
+ }
533
+
534
+ async function commandDuplications(
535
+ pi: ExtensionAPI,
536
+ ctx: ExtensionCommandContext,
537
+ targetInput?: string,
538
+ fileIndex?: number,
539
+ ): Promise<void> {
540
+ const config = await resolveConfig(ctx, targetInput);
541
+ let files: FileDuplication[];
542
+ try {
543
+ files = await fetchFileDuplications(config.serverUrl, config.token, config.projectKey, ctx.signal);
544
+ } catch (error) {
545
+ const msg = sonarErrorMessage(error);
546
+ if (msg.includes("403") || msg.includes("Insufficient privileges")) {
547
+ if (ctx.hasUI) {
548
+ ctx.ui.notify("SonarQube token needs 'See Source Code' permission to list duplicated files. Update the token permissions in SonarQube and run /sonarqube init to update the token.", "warning");
549
+ }
550
+ return;
551
+ }
552
+ throw error;
553
+ }
554
+ if (files.length === 0) {
555
+ // Fallback: check project-level metrics for confirmation
556
+ const projectMeasures = await fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal).catch(() => undefined);
557
+ if (projectMeasures && projectMeasures.duplicatedFiles > 0) {
558
+ if (ctx.hasUI) {
559
+ ctx.ui.notify(
560
+ `Cannot list individual files — your SonarQube token lacks "See Source Code" permission. Grant it in SonarQube project permissions, then run /sonarqube init to update the token.`,
561
+ "warning",
562
+ );
563
+ }
564
+ } else if (ctx.hasUI) {
565
+ ctx.ui.notify("No duplications found.", "info");
566
+ }
567
+ return;
568
+ }
569
+
570
+ if (fileIndex !== undefined) {
571
+ await showDuplicationDrillDown(ctx, config, files, fileIndex);
572
+ return;
573
+ }
574
+ await showDuplicationListOrBrowser(ctx, config, files);
575
+ }
576
+
577
+ async function showDuplicationDrillDown(
578
+ ctx: ExtensionCommandContext,
579
+ config: SonarProjectConfig,
580
+ files: FileDuplication[],
581
+ fileIndex: number,
582
+ ): Promise<void> {
583
+ const file = files[fileIndex - 1];
584
+ if (!file) {
585
+ if (ctx.hasUI) ctx.ui.notify(`File #${fileIndex} not found.`, "error");
586
+ return;
587
+ }
588
+ const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
589
+ const text = formatDuplicationBlockDetail(file.filePath, groups);
590
+ if (ctx.mode === "tui") {
591
+ await ctx.ui.editor(`Duplications in ${file.filePath}`, text);
592
+ } else if (ctx.hasUI) {
593
+ ctx.ui.notify(text, "info");
594
+ }
595
+ }
596
+
597
+ async function showDuplicationListOrBrowser(
598
+ ctx: ExtensionCommandContext,
599
+ config: SonarProjectConfig,
600
+ files: FileDuplication[],
601
+ ): Promise<void> {
602
+ if (ctx.mode !== "tui") {
603
+ ctx.ui.notify(formatDuplicationsList(files), "info");
604
+ return;
605
+ }
606
+ const choice = await showDuplicationBrowser(ctx, files);
607
+ if (choice == null) return;
608
+ const file = files[choice];
609
+ const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
610
+ await ctx.ui.editor(`Duplications in ${file.filePath}`, formatDuplicationBlockDetail(file.filePath, groups));
611
+ }
612
+
472
613
  async function commandIssuesOrOpen(
473
614
  pi: ExtensionAPI,
474
615
  ctx: ExtensionCommandContext,
@@ -518,6 +659,68 @@ async function commandShowIssues(
518
659
  }
519
660
  }
520
661
 
662
+ // ── Tool helper functions ────────────────────────────────────────────────────
663
+
664
+ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
665
+ let config;
666
+ try {
667
+ config = await resolveConfig(ctx, path);
668
+ } catch {
669
+ return {
670
+ content: [{ type: "text", text: "Project not configured. Run /sonarqube init first." }],
671
+ details: { error: "Project not configured." },
672
+ };
673
+ }
674
+ let files: FileDuplication[];
675
+ try {
676
+ files = await fetchFileDuplications(config.serverUrl, config.token, config.projectKey, ctx.signal);
677
+ } catch (error) {
678
+ const msg = sonarErrorMessage(error);
679
+ if (msg.includes("403") || msg.includes("Insufficient privileges")) {
680
+ return {
681
+ content: [{ type: "text", text: "SonarQube token needs 'See Source Code' permission to list duplicated files. Grant it in SonarQube project permissions, then update the token via /sonarqube init." }],
682
+ details: { error: "Token missing 'See Source Code' permission." },
683
+ };
684
+ }
685
+ return {
686
+ content: [{ type: "text", text: msg }],
687
+ details: { error: msg },
688
+ };
689
+ }
690
+ if (files.length === 0) {
691
+ return {
692
+ content: [{ type: "text", text: `No duplications found for ${config.projectKey}.` }],
693
+ details: { error: `No duplications found for ${config.projectKey}.` },
694
+ };
695
+ }
696
+ const text = formatDuplicationsList(files);
697
+ return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, files } };
698
+ }
699
+
700
+ async function toolMetrics(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
701
+ let config;
702
+ try {
703
+ config = await resolveConfig(ctx, path);
704
+ } catch {
705
+ return {
706
+ content: [{ type: "text", text: "Project not configured. Run /sonarqube init first." }],
707
+ details: { error: "Project not configured." },
708
+ };
709
+ }
710
+ const [measures, issueCounts] = await Promise.all([
711
+ fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
712
+ fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
713
+ ]);
714
+ if (!measures && !issueCounts) {
715
+ return {
716
+ content: [{ type: "text", text: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` }],
717
+ details: { error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` },
718
+ };
719
+ }
720
+ const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts });
721
+ return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts } };
722
+ }
723
+
521
724
  // ── Re-exports for public API ───────────────────────────────────────────────
522
725
 
523
726
  export type {
package/src/types.ts CHANGED
@@ -13,6 +13,38 @@ export interface SonarIssue {
13
13
  status?: string;
14
14
  }
15
15
 
16
+ export interface SonarDuplicationMeasures {
17
+ duplicatedLinesDensity: number;
18
+ duplicatedLines: number;
19
+ duplicatedBlocks: number;
20
+ duplicatedFiles: number;
21
+ }
22
+
23
+ export interface IssueSeverityCounts {
24
+ blocker: number;
25
+ critical: number;
26
+ major: number;
27
+ minor: number;
28
+ info: number;
29
+ }
30
+
31
+ export interface FileDuplication {
32
+ filePath: string;
33
+ fileKey: string;
34
+ duplicatedLines: number;
35
+ duplicatedBlocks: number;
36
+ }
37
+
38
+ export interface DuplicationBlock {
39
+ from: number;
40
+ size: number;
41
+ filePath: string;
42
+ }
43
+
44
+ export interface DuplicationBlockGroup {
45
+ blocks: DuplicationBlock[];
46
+ }
47
+
16
48
  export interface SonarAnalysisState {
17
49
  version: 1;
18
50
  analyzedAt: string;
@@ -23,6 +55,7 @@ export interface SonarAnalysisState {
23
55
  ceTaskUrl?: string;
24
56
  analysisId?: string;
25
57
  filters?: SonarIssueFetchOptions;
58
+ measures?: SonarDuplicationMeasures;
26
59
  totalIssues: number;
27
60
  issues: SonarIssue[];
28
61
  }
package/src/ui.ts CHANGED
@@ -2,7 +2,7 @@ import type { Theme, ExtensionContext, ExtensionCommandContext } from "@earendil
2
2
  import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { resolve } from "node:path";
5
- import type { SonarIssue, SonarAnalysisState, SonarIssueFetchOptions } from "./types.js";
5
+ import type { SonarIssue, SonarAnalysisState, SonarIssueFetchOptions, FileDuplication } from "./types.js";
6
6
  import { issueFilterLabel, fetchIssues, createAnalysisState, normalizeIssueFilters } from "./api.js";
7
7
  import { resolveConfig, resolveTarget } from "./config.js";
8
8
 
@@ -71,8 +71,6 @@ export function startAnalysisUi(ctx: ExtensionContext, projectKey: string): Anal
71
71
 
72
72
  export class IssueBrowser {
73
73
  private selected = 0;
74
- private cachedWidth: number | undefined;
75
- private cachedLines: string[] | undefined;
76
74
 
77
75
  constructor(
78
76
  private readonly state: SonarAnalysisState,
@@ -80,10 +78,7 @@ export class IssueBrowser {
80
78
  private readonly done: (result: number | null) => void,
81
79
  ) {}
82
80
 
83
- invalidate(): void {
84
- this.cachedWidth = undefined;
85
- this.cachedLines = undefined;
86
- }
81
+ invalidate(): void {} // NOSONAR - required by TUI interface
87
82
 
88
83
  handleInput(data: string): void {
89
84
  if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
@@ -92,12 +87,10 @@ export class IssueBrowser {
92
87
  }
93
88
  if (matchesKey(data, Key.up)) {
94
89
  this.selected = Math.max(0, this.selected - 1);
95
- this.invalidate();
96
90
  return;
97
91
  }
98
92
  if (matchesKey(data, Key.down)) {
99
93
  this.selected = Math.min(this.state.issues.length - 1, this.selected + 1);
100
- this.invalidate();
101
94
  return;
102
95
  }
103
96
  if (matchesKey(data, Key.enter)) {
@@ -106,10 +99,6 @@ export class IssueBrowser {
106
99
  }
107
100
 
108
101
  render(width: number): string[] {
109
- if (this.cachedLines && this.cachedWidth === width) {
110
- return this.cachedLines;
111
- }
112
-
113
102
  const lines: string[] = [];
114
103
  const title = this.theme.fg("accent", this.theme.bold(" SonarQube Issues "));
115
104
  const filterSuffix = this.state.filters ? ` • ${issueFilterLabel(this.state.filters)}` : "";
@@ -131,8 +120,15 @@ export class IssueBrowser {
131
120
  const visibleIssues = this.state.issues.slice(start, end);
132
121
 
133
122
  if (visibleIssues.length === 0) {
134
- lines.push(truncateToWidth(this.theme.fg("success", "No open issues found."), width));
135
- return finalize(lines, width, this.theme, this.cachedWidth, this.cachedLines);
123
+ lines.push(
124
+ truncateToWidth(this.theme.fg("success", "No open issues found."), width),
125
+ "",
126
+ truncateToWidth(
127
+ this.theme.fg("dim", "Up/Down to move, Enter to preview, Esc to close"),
128
+ width,
129
+ ),
130
+ );
131
+ return lines;
136
132
  }
137
133
 
138
134
  if (start > 0) {
@@ -173,27 +169,10 @@ export class IssueBrowser {
173
169
  ),
174
170
  );
175
171
 
176
- this.cachedWidth = width;
177
- this.cachedLines = lines;
178
172
  return lines;
179
173
  }
180
174
  }
181
175
 
182
- function finalize(
183
- lines: string[],
184
- width: number,
185
- theme: Theme,
186
- cachedWidth: number | undefined,
187
- cachedLines: string[] | undefined,
188
- ): string[] {
189
- lines.push(
190
- "",
191
- truncateToWidth(theme.fg("dim", "Up/Down to move, Enter to preview, Esc to close"), width),
192
- );
193
- const result = cachedLines ?? lines;
194
- return result;
195
- }
196
-
197
176
  // ── Severity colors ─────────────────────────────────────────────────────────
198
177
 
199
178
  function severityColor(theme: Theme, severity: string): string {
@@ -210,6 +189,76 @@ function severityColor(theme: Theme, severity: string): string {
210
189
  }
211
190
  }
212
191
 
192
+ // ── Duplication browser (used by index.ts) ─────────────────────────────────
193
+
194
+ export class DuplicationBrowser {
195
+ private selected = 0;
196
+
197
+ constructor(
198
+ private readonly files: FileDuplication[],
199
+ private readonly theme: Theme,
200
+ private readonly done: (result: number | null) => void,
201
+ ) {}
202
+
203
+ invalidate(): void {} // NOSONAR - required by TUI interface
204
+
205
+ handleInput(data: string): void {
206
+ if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
207
+ this.done(null);
208
+ return;
209
+ }
210
+ if (matchesKey(data, Key.up)) {
211
+ this.selected = Math.max(0, this.selected - 1);
212
+ return;
213
+ }
214
+ if (matchesKey(data, Key.down)) {
215
+ this.selected = Math.min(this.files.length - 1, this.selected + 1);
216
+ return;
217
+ }
218
+ if (matchesKey(data, Key.enter)) {
219
+ this.done(this.selected);
220
+ }
221
+ }
222
+
223
+ render(width: number): string[] {
224
+ const lines: string[] = [
225
+ this.theme.fg("accent", this.theme.bold(" SonarQube Duplications ")),
226
+ this.theme.fg("dim", `${this.files.length} file(s) with duplicate code`),
227
+ "",
228
+ ];
229
+
230
+ const pageSize = Math.min(15, this.files.length);
231
+ const halfWindow = Math.floor(pageSize / 2);
232
+ const maxStart = Math.max(0, this.files.length - pageSize);
233
+ const start = Math.max(0, Math.min(this.selected - halfWindow, maxStart));
234
+ const end = Math.min(this.files.length, start + pageSize);
235
+ const visible = this.files.slice(start, end);
236
+
237
+ for (const [offset, file] of visible.entries()) {
238
+ const index = start + offset;
239
+ const isSelected = index === this.selected;
240
+ const marker = isSelected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
241
+ const detail = `blocks=${file.duplicatedBlocks} lines=${file.duplicatedLines}`;
242
+ const line = `${marker} ${String(index + 1).padStart(2, " ")}. ${this.theme.fg("accent", file.filePath)} ${this.theme.fg("dim", detail)}`;
243
+ lines.push(truncateToWidth(line, width));
244
+ }
245
+
246
+ lines.push(
247
+ "",
248
+ this.theme.fg("dim", "Up/Down to move, Enter for details, Esc to close"),
249
+ );
250
+ return lines;
251
+ }
252
+ }
253
+
254
+ export async function showDuplicationBrowser(
255
+ ctx: ExtensionCommandContext,
256
+ files: FileDuplication[],
257
+ ): Promise<number | null> {
258
+ if (ctx.mode !== "tui" || files.length === 0) return null;
259
+ return await ctx.ui.custom<number | null>((_tui, theme, _kb, done) => new DuplicationBrowser(files, theme, done));
260
+ }
261
+
213
262
  // ── Issue browser / preview helpers (used by index.ts) ──────────────────────
214
263
 
215
264
  export async function showIssueBrowser(