@jonathan0823/pi-sonarqube 0.8.1 → 0.9.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
@@ -35,6 +35,7 @@ import {
35
35
  readReportTask,
36
36
  waitForAnalysis,
37
37
  normalizeIssueFilters,
38
+ normalizeIssueList,
38
39
  fetchIssues,
39
40
  fetchDuplicationMeasures,
40
41
  fetchIssueSeverityCounts,
@@ -60,6 +61,7 @@ import {
60
61
  helpText,
61
62
  targetLabel,
62
63
  sonarErrorMessage,
64
+ assertIssueSelection,
63
65
  } from "./commands.js";
64
66
 
65
67
  import {
@@ -83,11 +85,22 @@ const SonarToolParams = Type.Object({
83
85
  ] as const),
84
86
  path: Type.Optional(
85
87
  Type.String({
86
- description: "Target alias or project directory to analyze or inspect",
88
+ description:
89
+ "Configured target alias or SonarQube project root directory. Selects the project/configuration; never use it to filter by source file or subdirectory. Use pathScope for that.",
87
90
  }),
88
91
  ),
89
92
  issueIndex: Type.Optional(
90
- Type.Number({ description: "1-based issue index to open" }),
93
+ Type.Number({
94
+ description:
95
+ "Legacy 1-based issue position in this call's query results. Prefer issueKeys for stable opening.",
96
+ }),
97
+ ),
98
+ issueKeys: Type.Optional(
99
+ Type.Array(Type.String({ description: "Stable SonarQube issue key" }), {
100
+ description: "One to 10 stable issue keys to open in one batch",
101
+ minItems: 1,
102
+ maxItems: 10,
103
+ }),
91
104
  ),
92
105
  severities: Type.Optional(
93
106
  Type.Array(
@@ -120,7 +133,7 @@ const SonarToolParams = Type.Object({
120
133
  pathScope: Type.Optional(
121
134
  Type.String({
122
135
  description:
123
- "Scope issues to a specific file or directory path (e.g. src/api.ts or src/). Use this instead of the `path` parameter for file-level filtering.",
136
+ "File or directory inside the selected SonarQube project, relative to its project root (e.g. src/api.ts or src/). Filters issues only; use path to select the project.",
124
137
  }),
125
138
  ),
126
139
  });
@@ -356,15 +369,78 @@ function renderEmptyResult(summary: string, theme: Theme): Text {
356
369
  function renderIssueResult(
357
370
  issue: SonarIssue,
358
371
  contentText: string | undefined,
359
- expanded: boolean,
360
372
  theme: Theme,
361
373
  ): Text {
362
374
  const text = contentText ?? formatIssue(issue);
363
- const preview = expanded ? text.split("\n").slice(1).join("\n") : "";
364
- const label = theme.fg("accent", formatIssue(issue));
375
+ const preview = text.split("\n").slice(1).join("\n");
376
+ const label = theme.fg("accent", formatIssue(issue, undefined, false));
365
377
  return new Text(preview ? `${label}\n${preview}` : label, 0, 0);
366
378
  }
367
379
 
380
+ async function buildIssueKeyPreviews(
381
+ baseDir: string,
382
+ issueKeys: string[],
383
+ issues: SonarIssue[],
384
+ ): Promise<{
385
+ text: string;
386
+ selectedIssues: SonarIssue[];
387
+ missingIssueKeys: string[];
388
+ }> {
389
+ const issuesByKey = new Map(issues.map((issue) => [issue.key, issue]));
390
+ const selectedIssues: SonarIssue[] = [];
391
+ const missingIssueKeys: string[] = [];
392
+ const sections = await Promise.all(
393
+ issueKeys.map(async (key) => {
394
+ const issue = issuesByKey.get(key);
395
+ if (!issue) {
396
+ missingIssueKeys.push(key);
397
+ return `Issue key "${key}" was not found.`;
398
+ }
399
+ selectedIssues.push(issue);
400
+ try {
401
+ const preview = await buildIssuePreview(baseDir, issue);
402
+ return `${formatIssue(issue)}\n\n${preview}`;
403
+ } catch (error) {
404
+ return `${formatIssue(issue)}\n\n${sonarErrorMessage(error)}`;
405
+ }
406
+ }),
407
+ );
408
+ return { text: sections.join("\n\n"), selectedIssues, missingIssueKeys };
409
+ }
410
+
411
+ function renderIssueBatchResult(
412
+ issues: SonarIssue[],
413
+ missingIssueKeys: string[],
414
+ contentText: string | undefined,
415
+ theme: Theme,
416
+ ): Text {
417
+ if (contentText) {
418
+ const hiddenKeys = issues.reduce(
419
+ (text, issue) =>
420
+ text.replace(formatIssue(issue), formatIssue(issue, undefined, false)),
421
+ contentText,
422
+ );
423
+ const hiddenMissingKeys = missingIssueKeys.reduce(
424
+ (text, key) =>
425
+ text.replace(
426
+ `Issue key "${key}" was not found.`,
427
+ "Requested issue was not found.",
428
+ ),
429
+ hiddenKeys,
430
+ );
431
+ return new Text(hiddenMissingKeys, 0, 0);
432
+ }
433
+ const lines = issues.map((issue) =>
434
+ theme.fg("accent", formatIssue(issue, undefined, false)),
435
+ );
436
+ if (missingIssueKeys.length > 0) {
437
+ lines.push(
438
+ theme.fg("error", "One or more requested issues were not found."),
439
+ );
440
+ }
441
+ return new Text(lines.join("\n"), 0, 0);
442
+ }
443
+
368
444
  function renderIssueListResult(
369
445
  state: SonarAnalysisState,
370
446
  expanded: boolean,
@@ -392,7 +468,7 @@ function renderIssueListResult(
392
468
 
393
469
  const visible = expanded ? state.issues : state.issues.slice(0, 5);
394
470
  for (const [i, issue] of visible.entries()) {
395
- lines.push(theme.fg("muted", formatIssue(issue, i + 1)));
471
+ lines.push(theme.fg("muted", formatIssue(issue, i + 1, false)));
396
472
  }
397
473
  if (!expanded && state.issues.length > visible.length) {
398
474
  lines.push(
@@ -424,6 +500,18 @@ async function cachedServerConfig(cwd: string): Promise<typeof _cachedConfig> {
424
500
  return _cachedConfig;
425
501
  }
426
502
 
503
+ const SONARQUBE_COMMAND_PREFIX = "/sonarqube";
504
+
505
+ /** Text after "/sonarqube " in an autocomplete prefix, or undefined if it isn't a /sonarqube command. */
506
+ export function sonarqubeArgumentText(prefix: string): string | undefined {
507
+ if (!prefix.startsWith(SONARQUBE_COMMAND_PREFIX)) return undefined;
508
+ const rest = prefix.slice(SONARQUBE_COMMAND_PREFIX.length);
509
+ if (rest === "") return "";
510
+ const trimmed = rest.trimStart();
511
+ if (trimmed === rest) return undefined; // no whitespace after the command
512
+ return trimmed;
513
+ }
514
+
427
515
  export default function sonarqube(pi: ExtensionAPI) {
428
516
  const statesByBaseDir = new Map<string, SonarAnalysisState>();
429
517
  let latestState: SonarAnalysisState | undefined;
@@ -445,16 +533,14 @@ export default function sonarqube(pi: ExtensionAPI) {
445
533
  async getSuggestions(lines, cursorLine, cursorCol, options) {
446
534
  const line = lines[cursorLine] ?? "";
447
535
  const beforeCursor = line.slice(0, cursorCol);
448
- const match = /^\/sonarqube(?:\s+(.*))?$/.exec(beforeCursor);
449
- if (!match)
536
+ const argumentText = sonarqubeArgumentText(beforeCursor);
537
+ if (argumentText === undefined)
450
538
  return current.getSuggestions(
451
539
  lines,
452
540
  cursorLine,
453
541
  cursorCol,
454
542
  options,
455
543
  );
456
-
457
- const argumentText = match[1] ?? "";
458
544
  const { current: currentToken } =
459
545
  splitSonarArgumentContext(argumentText);
460
546
  const cfg = await cachedServerConfig(ctx.cwd);
@@ -516,8 +602,10 @@ export default function sonarqube(pi: ExtensionAPI) {
516
602
  promptSnippet: "Run local SonarQube analysis or inspect issue results",
517
603
  promptGuidelines: [
518
604
  "Use sonarqube when the user asks to run a local SonarQube scan, inspect issues, or open an issue's source location.",
605
+ "The `path` parameter selects a configured target alias or SonarQube project root. Never pass a source file or subdirectory as `path`.",
606
+ "Use `pathScope` for a file or directory inside that project. Example: path: 'apps/web', pathScope: 'src/components/'.",
519
607
  "Use the optional severity, status, type, rule, quality, and impact severity filters to fetch only the most relevant issues.",
520
- "To scope issues to a specific file or directory, use the `pathScope` parameter (e.g. pathScope: 'src/api.ts') — do not use the `path` parameter for file filtering.",
608
+ "Prefer stable `issueKeys` over positional `issueIndex` when opening issue source. Multiple keys can be opened in one call.",
521
609
  ],
522
610
  parameters: SonarToolParams,
523
611
 
@@ -533,6 +621,11 @@ export default function sonarqube(pi: ExtensionAPI) {
533
621
  pathScope: params.pathScope,
534
622
  });
535
623
  assertFiltersNotAmbiguous(filters);
624
+ const issueKeys = normalizeIssueList(params.issueKeys);
625
+ assertIssueSelection(params.issueIndex, issueKeys);
626
+ if (issueKeys?.length && params.action !== "open") {
627
+ throw new Error("issueKeys can only be used with the open action.");
628
+ }
536
629
 
537
630
  if (params.action === "analyze") {
538
631
  const state = await analyzeProject(pi, ctx, params.path, filters);
@@ -551,7 +644,7 @@ export default function sonarqube(pi: ExtensionAPI) {
551
644
  ctx,
552
645
  statesByBaseDir,
553
646
  params.path,
554
- filters,
647
+ normalizeIssueFilters({ ...filters, issueKeys }),
555
648
  );
556
649
  if (!targetState) {
557
650
  return {
@@ -576,6 +669,18 @@ export default function sonarqube(pi: ExtensionAPI) {
576
669
  };
577
670
  }
578
671
 
672
+ if (issueKeys?.length) {
673
+ const result = await buildIssueKeyPreviews(
674
+ targetState.baseDir,
675
+ issueKeys,
676
+ targetState.issues,
677
+ );
678
+ return {
679
+ content: [{ type: "text", text: result.text }],
680
+ details: result,
681
+ };
682
+ }
683
+
579
684
  const index = params.issueIndex ?? 1;
580
685
  const issue = targetState.issues[index - 1];
581
686
  if (!issue) {
@@ -612,6 +717,8 @@ export default function sonarqube(pi: ExtensionAPI) {
612
717
  rules: args.rules,
613
718
  softwareQualities: args.softwareQualities,
614
719
  impactSeverities: args.impactSeverities,
720
+ issueKeys: args.issueKeys,
721
+ pathScope: args.pathScope,
615
722
  });
616
723
  const filterText = filters
617
724
  ? ` ${theme.fg("muted", issueFilterLabel(filters))}`
@@ -628,7 +735,12 @@ export default function sonarqube(pi: ExtensionAPI) {
628
735
  renderResult(result, { expanded }, theme, _context) {
629
736
  const state = result.details as
630
737
  | SonarAnalysisState
631
- | { error?: string; selectedIssue?: SonarIssue }
738
+ | {
739
+ error?: string;
740
+ selectedIssue?: SonarIssue;
741
+ selectedIssues?: SonarIssue[];
742
+ missingIssueKeys?: string[];
743
+ }
632
744
  | undefined;
633
745
 
634
746
  if (!state) {
@@ -640,13 +752,20 @@ export default function sonarqube(pi: ExtensionAPI) {
640
752
  return renderErrorResult(state.error, theme);
641
753
  }
642
754
 
755
+ const contentText =
756
+ result.content[0]?.type === "text" ? result.content[0].text : undefined;
757
+
643
758
  if ("selectedIssue" in state && state.selectedIssue) {
644
- const issue = state.selectedIssue;
645
- const contentText =
646
- result.content[0]?.type === "text"
647
- ? result.content[0].text
648
- : undefined;
649
- return renderIssueResult(issue, contentText, expanded, theme);
759
+ return renderIssueResult(state.selectedIssue, contentText, theme);
760
+ }
761
+
762
+ if ("selectedIssues" in state && state.selectedIssues) {
763
+ return renderIssueBatchResult(
764
+ state.selectedIssues,
765
+ state.missingIssueKeys ?? [],
766
+ contentText,
767
+ theme,
768
+ );
650
769
  }
651
770
 
652
771
  if ("issues" in state) {
@@ -710,7 +829,6 @@ export default function sonarqube(pi: ExtensionAPI) {
710
829
  return;
711
830
  default:
712
831
  await commandIssuesOrOpen(
713
- pi,
714
832
  ctx,
715
833
  statesByBaseDir,
716
834
  parsed,
@@ -921,59 +1039,72 @@ async function showDuplicationListOrBrowser(
921
1039
  config.projectKey,
922
1040
  ctx.signal,
923
1041
  );
924
- const lines = [`Duplications in ${file.filePath}`];
925
- for (const [i, group] of groups.entries()) {
926
- lines.push("", `Block ${i + 1}:`);
927
- for (const block of group.blocks) {
928
- const end = block.from + block.size - 1;
929
- lines.push(` ${block.filePath}:${block.from}-${end}`);
930
- }
931
- }
932
- ctx.ui.setEditorText(lines.join("\n"));
1042
+ const text = await buildDuplicationPreview(
1043
+ config.baseDir,
1044
+ file.filePath,
1045
+ groups,
1046
+ );
1047
+ ctx.ui.setEditorText(text);
933
1048
  ctx.ui.notify("Duplication loaded into editor — press Enter to send", "info");
934
1049
  }
935
1050
 
936
- async function commandIssuesOrOpen(
937
- pi: ExtensionAPI,
1051
+ type IssueCommandArgs = {
1052
+ targetInput?: string;
1053
+ filters?: SonarIssueFetchOptions;
1054
+ action: string;
1055
+ issueIndex?: number;
1056
+ issueKeys?: string[];
1057
+ };
1058
+
1059
+ async function resolveIssueCommandState(
938
1060
  ctx: ExtensionCommandContext,
939
1061
  statesByBaseDir: Map<string, SonarAnalysisState>,
940
- parsed: {
941
- targetInput?: string;
942
- filters?: SonarIssueFetchOptions;
943
- action: string;
944
- issueIndex?: number;
945
- },
946
- rememberState: (s: SonarAnalysisState) => void,
947
- ): Promise<void> {
948
- let { targetInput } = parsed;
1062
+ targetInput: string | undefined,
1063
+ filters: SonarIssueFetchOptions | undefined,
1064
+ ): Promise<SonarAnalysisState | undefined> {
1065
+ const originalTargetInput = targetInput;
949
1066
  if (!targetInput) {
950
1067
  const pickResult = await pickOrResolveTarget(ctx);
951
- if (pickResult === null) return;
1068
+ if (pickResult === null) return undefined;
952
1069
  targetInput = pickResult;
953
1070
  }
954
1071
  const targetState = await resolveTargetState(
955
1072
  ctx,
956
1073
  statesByBaseDir,
957
1074
  targetInput,
958
- parsed.filters,
1075
+ filters,
959
1076
  );
960
- if (!targetState) {
961
- if (ctx.hasUI) {
1077
+ if (!targetState && ctx.hasUI) {
1078
+ ctx.ui.notify(
1079
+ `No SonarQube analysis found for this target. Run /sonarqube analyze${targetLabel(originalTargetInput)} first.`,
1080
+ "warning",
1081
+ );
1082
+ }
1083
+ return targetState;
1084
+ }
1085
+
1086
+ async function openIssueCommand(
1087
+ ctx: ExtensionCommandContext,
1088
+ targetState: SonarAnalysisState,
1089
+ parsed: IssueCommandArgs,
1090
+ ): Promise<void> {
1091
+ if (parsed.issueKeys?.length) {
1092
+ const result = await buildIssueKeyPreviews(
1093
+ targetState.baseDir,
1094
+ parsed.issueKeys,
1095
+ targetState.issues,
1096
+ );
1097
+ if (ctx.mode === "tui") {
1098
+ await ctx.ui.editor("SonarQube issue previews", result.text);
1099
+ } else if (ctx.hasUI) {
962
1100
  ctx.ui.notify(
963
- `No SonarQube analysis found for this target. Run /sonarqube analyze${targetLabel(parsed.targetInput)} first.`,
964
- "warning",
1101
+ result.text,
1102
+ result.missingIssueKeys.length ? "warning" : "info",
965
1103
  );
966
1104
  }
967
1105
  return;
968
1106
  }
969
- rememberState(targetState);
970
-
971
- if (parsed.action === "issues") {
972
- await commandShowIssues(ctx, targetState);
973
- return;
974
- }
975
1107
 
976
- // "open"
977
1108
  const index = parsed.issueIndex ?? 1;
978
1109
  const issue = targetState.issues[index - 1];
979
1110
  if (!issue) {
@@ -983,6 +1114,29 @@ async function commandIssuesOrOpen(
983
1114
  await openIssuePreview(ctx, targetState, issue);
984
1115
  }
985
1116
 
1117
+ async function commandIssuesOrOpen(
1118
+ ctx: ExtensionCommandContext,
1119
+ statesByBaseDir: Map<string, SonarAnalysisState>,
1120
+ parsed: IssueCommandArgs,
1121
+ rememberState: (s: SonarAnalysisState) => void,
1122
+ ): Promise<void> {
1123
+ assertIssueSelection(parsed.issueIndex, parsed.issueKeys);
1124
+ const targetState = await resolveIssueCommandState(
1125
+ ctx,
1126
+ statesByBaseDir,
1127
+ parsed.targetInput,
1128
+ normalizeIssueFilters({ ...parsed.filters, issueKeys: parsed.issueKeys }),
1129
+ );
1130
+ if (!targetState) return;
1131
+
1132
+ rememberState(targetState);
1133
+ if (parsed.action === "issues") {
1134
+ await commandShowIssues(ctx, targetState);
1135
+ return;
1136
+ }
1137
+ await openIssueCommand(ctx, targetState, parsed);
1138
+ }
1139
+
986
1140
  async function commandShowIssues(
987
1141
  ctx: ExtensionCommandContext,
988
1142
  targetState: SonarAnalysisState,
package/src/types.ts CHANGED
@@ -86,6 +86,7 @@ export interface SonarIssueFetchOptions {
86
86
  rules?: string[];
87
87
  softwareQualities?: string[];
88
88
  impactSeverities?: string[];
89
+ issueKeys?: string[];
89
90
  pathScope?: string;
90
91
  componentKeys?: string[];
91
92
  }
@@ -114,6 +115,7 @@ export interface ResolvedTarget {
114
115
  export interface ParsedSonarIssueArgs {
115
116
  targetInput?: string;
116
117
  issueIndex?: number;
118
+ issueKeys?: string[];
117
119
  filters?: SonarIssueFetchOptions;
118
120
  }
119
121