@jonathan0823/pi-sonarqube 0.8.2 → 0.9.1

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,
@@ -930,47 +1048,63 @@ async function showDuplicationListOrBrowser(
930
1048
  ctx.ui.notify("Duplication loaded into editor — press Enter to send", "info");
931
1049
  }
932
1050
 
933
- async function commandIssuesOrOpen(
934
- 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(
935
1060
  ctx: ExtensionCommandContext,
936
1061
  statesByBaseDir: Map<string, SonarAnalysisState>,
937
- parsed: {
938
- targetInput?: string;
939
- filters?: SonarIssueFetchOptions;
940
- action: string;
941
- issueIndex?: number;
942
- },
943
- rememberState: (s: SonarAnalysisState) => void,
944
- ): Promise<void> {
945
- let { targetInput } = parsed;
1062
+ targetInput: string | undefined,
1063
+ filters: SonarIssueFetchOptions | undefined,
1064
+ ): Promise<SonarAnalysisState | undefined> {
1065
+ const originalTargetInput = targetInput;
946
1066
  if (!targetInput) {
947
1067
  const pickResult = await pickOrResolveTarget(ctx);
948
- if (pickResult === null) return;
1068
+ if (pickResult === null) return undefined;
949
1069
  targetInput = pickResult;
950
1070
  }
951
1071
  const targetState = await resolveTargetState(
952
1072
  ctx,
953
1073
  statesByBaseDir,
954
1074
  targetInput,
955
- parsed.filters,
1075
+ filters,
956
1076
  );
957
- if (!targetState) {
958
- 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) {
959
1100
  ctx.ui.notify(
960
- `No SonarQube analysis found for this target. Run /sonarqube analyze${targetLabel(parsed.targetInput)} first.`,
961
- "warning",
1101
+ result.text,
1102
+ result.missingIssueKeys.length ? "warning" : "info",
962
1103
  );
963
1104
  }
964
1105
  return;
965
1106
  }
966
- rememberState(targetState);
967
-
968
- if (parsed.action === "issues") {
969
- await commandShowIssues(ctx, targetState);
970
- return;
971
- }
972
1107
 
973
- // "open"
974
1108
  const index = parsed.issueIndex ?? 1;
975
1109
  const issue = targetState.issues[index - 1];
976
1110
  if (!issue) {
@@ -980,6 +1114,29 @@ async function commandIssuesOrOpen(
980
1114
  await openIssuePreview(ctx, targetState, issue);
981
1115
  }
982
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
+
983
1140
  async function commandShowIssues(
984
1141
  ctx: ExtensionCommandContext,
985
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